8878ee8c54
- Attachment chips in the mail reader are now clickable: new InboxAdapter downloadAttachment() resolves a short-lived URL, opened in a new tab. - Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100% chain to flex fill so a long thread scrolls within the pane instead of being clipped. MockInboxAdapter implements download. Bump to 0.1.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useInboxAdapter } from './provider';
|
|
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
|
|
|
|
export interface InboxData {
|
|
items: InboxItem[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
transition: (id: string, state: InboxState) => Promise<void>;
|
|
refetch: () => void;
|
|
}
|
|
|
|
/** The unified inbox for a given filter state. Refetches when the filter changes. */
|
|
export function useInbox(state?: InboxState): InboxData {
|
|
const adapter = useInboxAdapter();
|
|
const [items, setItems] = useState<InboxItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [nonce, setNonce] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
adapter
|
|
.listInbox(state)
|
|
.then((list) => {
|
|
if (!alive) return;
|
|
setItems(list);
|
|
setError(null);
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!alive) return;
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
setItems([]);
|
|
})
|
|
.finally(() => {
|
|
if (alive) setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [adapter, state, nonce]);
|
|
|
|
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
|
const transition = useCallback(
|
|
async (id: string, next: InboxState) => {
|
|
await adapter.transition(id, next);
|
|
setNonce((n) => n + 1);
|
|
},
|
|
[adapter],
|
|
);
|
|
|
|
return { items, loading, error, transition, refetch };
|
|
}
|
|
|
|
export interface MailThreadState {
|
|
messages: MailMessage[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
|
|
canAttach: boolean;
|
|
upload: (file: File) => Promise<MailAttachment>;
|
|
canDownload: boolean;
|
|
download: (attachment: MailAttachment) => Promise<string>;
|
|
refetch: () => void;
|
|
}
|
|
|
|
/** One mail thread: history + reply. */
|
|
export function useMailThread(threadId: string | null): MailThreadState {
|
|
const adapter = useInboxAdapter();
|
|
const [messages, setMessages] = useState<MailMessage[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [nonce, setNonce] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!threadId) {
|
|
setMessages([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
setLoading(true);
|
|
adapter
|
|
.mailHistory(threadId)
|
|
.then((m) => {
|
|
if (!alive) return;
|
|
setMessages(m);
|
|
setError(null);
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (alive) setError(e instanceof Error ? e.message : String(e));
|
|
})
|
|
.finally(() => {
|
|
if (alive) setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [adapter, threadId, nonce]);
|
|
|
|
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
|
const reply = useCallback(
|
|
async (content: string, attachment?: MailAttachment) => {
|
|
if (!threadId) return;
|
|
await adapter.mailReply(threadId, content, attachment);
|
|
setNonce((n) => n + 1);
|
|
},
|
|
[adapter, threadId],
|
|
);
|
|
const upload = useCallback(
|
|
async (file: File) => {
|
|
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
|
return adapter.uploadAttachment(file);
|
|
},
|
|
[adapter],
|
|
);
|
|
const download = useCallback(
|
|
async (attachment: MailAttachment) => {
|
|
if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter');
|
|
return adapter.downloadAttachment(attachment);
|
|
},
|
|
[adapter],
|
|
);
|
|
|
|
return {
|
|
messages,
|
|
loading,
|
|
error,
|
|
reply,
|
|
canAttach: typeof adapter.uploadAttachment === 'function',
|
|
upload,
|
|
canDownload: typeof adapter.downloadAttachment === 'function',
|
|
download,
|
|
refetch,
|
|
};
|
|
}
|
|
|
|
export interface ComposeState {
|
|
supported: boolean;
|
|
directory: MailPerson[];
|
|
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
|
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
|
|
canAttach: boolean;
|
|
upload: (file: File) => Promise<MailAttachment>;
|
|
}
|
|
|
|
/** Compose a new message — in-app (to a person) or external (to an email). */
|
|
export function useCompose(): ComposeState {
|
|
const adapter = useInboxAdapter();
|
|
const supported = typeof adapter.composeInternal === 'function';
|
|
const [directory, setDirectory] = useState<MailPerson[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!adapter.directory) return;
|
|
let alive = true;
|
|
adapter
|
|
.directory()
|
|
.then((d) => {
|
|
if (alive) setDirectory(d);
|
|
})
|
|
.catch(() => {
|
|
if (alive) setDirectory([]);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [adapter]);
|
|
|
|
const sendInternal = useCallback(
|
|
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
|
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
|
|
await adapter.composeInternal(recipientUserId, subject, text, attachments);
|
|
},
|
|
[adapter],
|
|
);
|
|
const sendExternal = useCallback(
|
|
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
|
|
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
|
|
await adapter.composeExternal(target, subject, text, attachments);
|
|
},
|
|
[adapter],
|
|
);
|
|
const upload = useCallback(
|
|
async (file: File) => {
|
|
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
|
|
return adapter.uploadAttachment(file);
|
|
},
|
|
[adapter],
|
|
);
|
|
|
|
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
|
|
}
|