Repository navigation
Expand file tree
/
Copy pathMailThread.tsx
More file actions
180 lines (167 loc) · 6.69 KB
/
Copy pathMailThread.tsx
File metadata and controls
180 lines (167 loc) · 6.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
/**
* React: a real thread, wired the way a mail client actually loads one.
*
* Reference code — copy it into an app rather than running it here; it needs a
* bundler and a React root. Everything it does is deliberate:
*
* - metadata for the whole thread renders immediately, bodies stream in;
* - the bodies fetched FIRST are the ones on screen, not the first 200 in the
* list — that is what `onVisibleRangeChange` is for;
* - the transform is memoized and cached, so one arriving body re-cleans one
* message rather than the whole thread;
* - every side effect (opening a link, saving an attachment, retrying) is the
* host's, because a component that reached for `window.open` or an Electron
* IPC channel of its own would work in exactly one application;
* - so is remembering whose images the reader trusts: the view reports a
* "Load images" click, and the host decides what it means from then on.
*/
import { useCallback, useMemo, useState } from 'react';
import {
createBodyCache,
MailChatView,
mailsToMessages,
type Attachment,
type ChatMessage,
type Mail,
type VisibleRange,
} from '@sarv-in/email-chat-view';
import '@sarv-in/email-chat-view/style.css';
export interface MailThreadProps {
/** Whatever your mail store gives you, in any order. */
mails: readonly Mail[];
/** The reader — used for `isFromMe` and excluded from identity colouring. */
myAddress: string;
/** Is there older history left to page in? */
hasOlder: boolean;
/** Fetch the next page of older messages. */
onLoadOlder: () => void;
/** Fetch these bodies next — the ones the reader is looking at. */
prioritizeBodies: (ids: readonly string[]) => void;
/** Re-fetch one body that failed. */
refetchBody: (id: string) => void;
/** Senders whose remote images load without asking, lower-cased. */
imageSenders: ReadonlySet<string>;
/** Remember one — persist it however your store does. */
allowImagesFrom: (address: string) => void;
}
export function MailThread({
mails,
myAddress,
hasOlder,
onLoadOlder,
prioritizeBodies,
refetchBody,
imageSenders,
allowImagesFrom,
}: MailThreadProps) {
const [loadingOlder, setLoadingOlder] = useState(false);
/**
* One cache for the life of the component, NOT one per render.
*
* It is keyed on `(id, body)`, so a body arriving for message 3 changes only
* message 3's key: that one is re-cleaned and the other 199 are served from
* the cache. A cache recreated each render would re-clean all 200 every time
* a body lands, which on a large thread is the whole cost of the feature.
*
* `useState` with a lazy initialiser rather than a ref: the function runs
* once, the value never changes, and nothing reads a ref during render.
*/
const [cache] = useState(createBodyCache);
const messages = useMemo(
() =>
mailsToMessages(mails, {
currentUserAddress: myAddress,
// Declared, never guessed. IMAP stores usually hand you epoch SECONDS,
// and a silent factor-of-1000 error does not crash — it puts every
// message in 1970 and sorts the thread wrongly.
dateUnit: 's',
cache,
}),
[mails, myAddress, cache],
);
const handleLoadOlder = useCallback(async () => {
setLoadingOlder(true);
try {
await onLoadOlder();
} finally {
setLoadingOlder(false);
}
}, [onLoadOlder]);
/**
* Which messages are on screen. Only the view knows this and only you can
* fetch, which is why the library reports it instead of hiding it: fetching
* the visible bodies before the 180 the reader scrolled past is the
* difference between a thread that feels instant and one that fills in from
* the top while the reader waits at the bottom.
*/
const handleVisibleRange = useCallback(
({ ids }: VisibleRange) => {
const byId = new Map(messages.map((message) => [message.id, message]));
const pending = ids.filter((id) => byId.get(id)?.bodyPending);
if (pending.length) prioritizeBodies(pending);
},
[messages, prioritizeBodies],
);
// A mail client opens links in the browser, not inside the message. The view
// intercepts the click and hands you the URL; where it goes is your call.
const handleOpenLink = useCallback((url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleDownload = useCallback((attachment: Attachment) => {
// Your IPC call, your signed URL — the library never fetches the bytes.
console.log('download', attachment.filename);
}, []);
/**
* Remote images: blocked unless the sender is on your allowlist. Called for
* every rendered message, so it reads a `Set` rather than scanning anything.
*/
const blockImages = useCallback(
(message: ChatMessage) => !imageSenders.has(message.fromAddress.toLowerCase()),
[imageSenders],
);
/**
* The reader clicked "Load images" on a bubble. That bubble has already
* loaded its images; remembering the sender is what makes their NEXT mail
* load on its own — and, once `imageSenders` updates, every other bubble of
* theirs on screen loads too, because `blockImages` answers differently.
*/
const handleLoadImages = useCallback(
(message: ChatMessage) => allowImagesFrom(message.fromAddress.toLowerCase()),
[allowImagesFrom],
);
return (
<MailChatView
messages={messages}
currentUserAddress={myAddress}
hasOlder={hasOlder}
onLoadOlder={handleLoadOlder}
loadingOlder={loadingOlder}
onVisibleRangeChange={handleVisibleRange}
// DOM ceiling: older bubbles are held behind a "show earlier" button
// rather than kept in the document forever.
maxRendered={50}
onOpenLink={handleOpenLink}
onRetryBody={(message) => refetchBody(message.id)}
onDownloadAttachment={handleDownload}
blockRemoteImages={blockImages}
onLoadRemoteImages={handleLoadImages}
// Placeholder bubbles while the thread's metadata is still arriving —
// they say what a spinner cannot: that a conversation is coming, roughly
// this long, laid out this way.
loading={mails.length === 0}
// Every string the view renders is overridable; dates are not here,
// because those go through `Intl` in the reader's own locale.
labels={{ today: 'Today' }}
className="my-thread"
/>
);
}
/**
* Restyling is one variable on any ancestor — no `!important`, no fork:
*
* .my-thread { --sec-bubble-mine-bg: #0b57d0; }
*
* Every value the components use is a `--sec-*` token that resolves through
* your own design token of the same meaning first, so an app that already has a
* design system inherits it and one that does not gets sensible defaults.
*/