feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)
Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components: - InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply + optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter - hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose - <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a sandboxed iframe + reply), compose modal (in-app / email); themeable styles - MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads + directory) shipped from ./adapters/mock-inbox - 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useInboxAdapter } from './provider';
|
||||
import type { InboxItem, InboxState, 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) => Promise<void>;
|
||||
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) => {
|
||||
if (!threadId) return;
|
||||
await adapter.mailReply(threadId, content);
|
||||
setNonce((n) => n + 1);
|
||||
},
|
||||
[adapter, threadId],
|
||||
);
|
||||
|
||||
return { messages, loading, error, reply, refetch };
|
||||
}
|
||||
|
||||
export interface ComposeState {
|
||||
supported: boolean;
|
||||
directory: MailPerson[];
|
||||
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>;
|
||||
sendExternal: (target: string, subject: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeInternal(recipientUserId, subject, text);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
const sendExternal = useCallback(
|
||||
async (target: string, subject: string, text: string) => {
|
||||
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
|
||||
await adapter.composeExternal(target, subject, text);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
return { supported, directory, sendInternal, sendExternal };
|
||||
}
|
||||
Reference in New Issue
Block a user