Files
iios/packages/iios-messaging-ui/src/inbox/hooks.ts
T
maaz519 8878ee8c54 feat(messaging-ui): mail attachment download + scrollable reader (0.1.1)
- 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>
2026-07-23 00:53:08 +05:30

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 };
}