Skip to content
File

Blob: src/client/hooks/useInbox.ts

typescript244 lines
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import {
3 EMAIL_PAGE_SIZE,
4 deleteEmail as deleteEmailRequest,
5 getEmail,
6 getErrorMessage,
7 getInbox,
8 listEmails,
9 type AuthDescriptor,
10 type EmailDetail,
11 type EmailSummary,
12 type InboxInfo,
13} from "../lib/api";
14 
15interface SessionTarget {
16 address: string;
17 auth: AuthDescriptor;
18}
19 
20interface UseInboxOptions {
21 markReadOnOpen?: boolean;
22}
23 
24export function useInbox(session: SessionTarget | null, options: UseInboxOptions = {}) {
25 const address = session?.address ?? null;
26 const sessionAuth = session?.auth ?? null;
27 const markReadOnOpen = options.markReadOnOpen ?? true;
28 
29 // Stable auth identity across renders so useCallback deps do not
30 // retrigger when the parent rebuilds the descriptor.
31 const authMode = sessionAuth?.mode ?? "none";
32 const userToken = sessionAuth?.mode === "user" ? sessionAuth.token : "";
33 const auth = useMemo<AuthDescriptor | null>(() => {
34 if (authMode === "user") return { mode: "user", token: userToken };
35 if (authMode === "admin") return { mode: "admin" };
36 return null;
37 }, [authMode, userToken]);
38 
39 const [inbox, setInbox] = useState<InboxInfo | null>(null);
40 const [emails, setEmails] = useState<EmailSummary[]>([]);
41 const [selectedEmail, setSelectedEmail] = useState<EmailDetail | null>(null);
42 const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null);
43 const [loading, setLoading] = useState(false);
44 const [emailLoading, setEmailLoading] = useState(false);
45 const [error, setError] = useState<string | null>(null);
46 const selectedEmailIdRef = useRef(selectedEmailId);
47 const selectedEmailRef = useRef(selectedEmail);
48 selectedEmailIdRef.current = selectedEmailId;
49 selectedEmailRef.current = selectedEmail;
50 
51 const markEmailRead = useCallback(
52 (emailId: string) => {
53 if (!markReadOnOpen) {
54 return;
55 }
56 
57 setEmails((current) => current.map((item) => (item.id === emailId ? { ...item, isRead: true } : item)));
58 },
59 [markReadOnOpen],
60 );
61 
62 const loadEmailDetail = useCallback(
63 async (emailId: string) => {
64 if (!address || !auth || !emailId) {
65 setSelectedEmail(null);
66 return null;
67 }
68 
69 setEmailLoading(true);
70 
71 try {
72 const detail = await getEmail(address, emailId, auth);
73 setSelectedEmail(detail);
74 markEmailRead(emailId);
75 return detail;
76 } finally {
77 setEmailLoading(false);
78 }
79 },
80 [address, auth, markEmailRead],
81 );
82 
83 const refreshInbox = useCallback(async () => {
84 if (!address || !auth) {
85 setInbox(null);
86 return null;
87 }
88 
89 const inboxInfo = await getInbox(address, auth);
90 setInbox(inboxInfo);
91 return inboxInfo;
92 }, [address, auth]);
93 
94 const refreshEmails = useCallback(
95 async ({
96 includeTotal = false,
97 refreshSelected = false,
98 }: { includeTotal?: boolean; refreshSelected?: boolean } = {}) => {
99 if (!address || !auth) {
100 setEmails([]);
101 setSelectedEmail(null);
102 setSelectedEmailId(null);
103 return null;
104 }
105 
106 const emailPage = await listEmails(address, auth, {
107 includeTotal,
108 });
109 
110 setEmails(emailPage.emails);
111 
112 const previousId = selectedEmailIdRef.current;
113 const nextSelectedId = emailPage.emails.some((item) => item.id === previousId)
114 ? previousId
115 : (emailPage.emails[0]?.id ?? null);
116 
117 setSelectedEmailId(nextSelectedId);
118 
119 if (!nextSelectedId) {
120 setSelectedEmail(null);
121 return emailPage;
122 }
123 
124 const shouldRefreshSelected =
125 refreshSelected || nextSelectedId !== previousId || selectedEmailRef.current?.id !== nextSelectedId;
126 
127 if (shouldRefreshSelected) {
128 await loadEmailDetail(nextSelectedId);
129 }
130 
131 return emailPage;
132 },
133 [address, auth, loadEmailDetail],
134 );
135 
136 const selectEmail = useCallback(
137 async (emailId: string) => {
138 if (!address || !auth) {
139 return;
140 }
141 
142 if (!emailId) {
143 setSelectedEmailId(null);
144 setSelectedEmail(null);
145 return;
146 }
147 
148 setSelectedEmailId(emailId);
149 setError(null);
150 
151 try {
152 await loadEmailDetail(emailId);
153 } catch (nextError) {
154 setError(getErrorMessage(nextError));
155 }
156 },
157 [address, auth, loadEmailDetail],
158 );
159 
160 const refresh = useCallback(async () => {
161 if (!address || !auth) {
162 setInbox(null);
163 setEmails([]);
164 setSelectedEmail(null);
165 setSelectedEmailId(null);
166 return;
167 }
168 
169 setLoading(true);
170 setError(null);
171 
172 try {
173 await refreshInbox();
174 await refreshEmails({
175 refreshSelected: true,
176 });
177 } catch (nextError) {
178 setError(getErrorMessage(nextError));
179 } finally {
180 setLoading(false);
181 setEmailLoading(false);
182 }
183 }, [address, auth, refreshEmails, refreshInbox]);
184 
185 const deleteEmail = useCallback(
186 async (emailId: string) => {
187 if (!address || !auth) {
188 return;
189 }
190 
191 setError(null);
192 
193 try {
194 await deleteEmailRequest(address, emailId, auth);
195 await refreshEmails({ refreshSelected: selectedEmailIdRef.current === emailId });
196 } catch (nextError) {
197 setError(getErrorMessage(nextError));
198 }
199 },
200 [address, auth, refreshEmails],
201 );
202 
203 const applyIncomingEmail = useCallback(
204 async (email: EmailSummary) => {
205 setEmails((current) => {
206 const withoutDuplicate = current.filter((item) => item.id !== email.id);
207 return [email, ...withoutDuplicate].slice(0, Math.max(current.length, EMAIL_PAGE_SIZE));
208 });
209 
210 if (!selectedEmailIdRef.current) {
211 setSelectedEmailId(email.id);
212 
213 try {
214 setError(null);
215 await loadEmailDetail(email.id);
216 } catch (nextError) {
217 setError(getErrorMessage(nextError));
218 }
219 }
220 },
221 [loadEmailDetail],
222 );
223 
224 useEffect(() => {
225 void refresh();
226 }, [refresh]);
227 
228 return {
229 inbox,
230 emails,
231 selectedEmail,
232 selectedEmailId,
233 loading,
234 emailLoading,
235 error,
236 refreshInbox,
237 refreshEmails,
238 refresh,
239 selectEmail,
240 deleteEmail,
241 applyIncomingEmail,
242 };
243}