@marktiderman/genesis-chat 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -6
- package/dist/{chunk-VEPZROQB.js → chunk-BTG4JWLF.js} +5 -4
- package/dist/chunk-BTG4JWLF.js.map +1 -0
- package/dist/{chunk-AZS3HNLM.js → chunk-IEFULAC2.js} +25 -3
- package/dist/chunk-IEFULAC2.js.map +1 -0
- package/dist/{chunk-JU2FAPBI.js → chunk-XQ2ULNHV.js} +2 -2
- package/dist/{chunk-JU2FAPBI.js.map → chunk-XQ2ULNHV.js.map} +1 -1
- package/dist/native.d.ts +158 -6
- package/dist/native.js +404 -181
- package/dist/native.js.map +1 -1
- package/dist/push.d.ts +2 -1
- package/dist/push.js +1 -1
- package/dist/react.d.ts +6 -2
- package/dist/react.js +1 -1
- package/dist/server.js +1 -1
- package/dist/web.d.ts +6 -2
- package/dist/web.js +6 -18
- package/dist/web.js.map +1 -1
- package/package.json +9 -9
- package/src/native/__tests__/attachment-slot.test.tsx +353 -0
- package/src/native/__tests__/organise-screens.test.tsx +12 -0
- package/src/native/__tests__/push-ids.test.ts +18 -0
- package/src/native/__tests__/push.test.ts +10 -4
- package/src/native/attachment-cards.tsx +249 -11
- package/src/native/copy.ts +15 -0
- package/src/native/index.ts +28 -4
- package/src/native/media.tsx +89 -2
- package/src/native/message-bubble.tsx +78 -12
- package/src/native/push.ts +5 -2
- package/src/native/search-screen.tsx +77 -69
- package/src/native/thread-screen.tsx +23 -6
- package/src/react/error-boundary.tsx +46 -0
- package/src/react/index.tsx +6 -2
- package/src/web/cards.tsx +2 -2
- package/src/web/error-boundary.tsx +2 -30
- package/dist/chunk-AZS3HNLM.js.map +0 -1
- package/dist/chunk-VEPZROQB.js.map +0 -1
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
* are also VoiceOver / TalkBack actions, so nothing needs a gesture. The
|
|
9
9
|
* bubble is one accessible element (its parts would otherwise be read one by
|
|
10
10
|
* one), which hides the controls inside it, so it carries theirs as actions
|
|
11
|
-
* too: play or pause a voice note and change its speed, open each photo
|
|
11
|
+
* too: play or pause a voice note and change its speed, open each photo, an
|
|
12
|
+
* app card's action. App attachment kinds draw as cards, or through the app's
|
|
13
|
+
* `attachmentSlot` (see attachment-cards.tsx); a voice note's transcript is
|
|
14
|
+
* part of the bubble's label.
|
|
12
15
|
*/
|
|
13
|
-
import { memo, useMemo, useRef } from "react";
|
|
16
|
+
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
14
17
|
import { Animated, PanResponder, Pressable, View } from "react-native";
|
|
15
18
|
import { NativeAvatar, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
16
19
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
17
|
-
import {
|
|
20
|
+
import { summarizeReactions } from "../core/thread";
|
|
18
21
|
import type { ChatLinkPreview, ChatMessage, PendingFile } from "../core/types";
|
|
19
22
|
import type { ChatAttachmentKind } from "../react/index";
|
|
20
|
-
import { cardAction, cardTitle,
|
|
23
|
+
import { AppAttachment, attachmentKindOf, attachmentPreview, cardAction, cardTitle, splitCardAttachments, useCardRuns, type ChatAttachmentSlot, type OpenAttachment } from "./attachment-cards";
|
|
21
24
|
import { ChatLinkPreviewCard, linkHost, openChatLink } from "./link-preview";
|
|
22
25
|
import { cycleVideoRate, fullScreenVideo, toggleVideo, toggleVideoMute, usePlayingVideo } from "./video-player";
|
|
23
26
|
import { fill } from "./copy";
|
|
@@ -61,6 +64,14 @@ export interface MessageBubbleProps {
|
|
|
61
64
|
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
62
65
|
/** A card's button, when its kind gives no `onPress` of its own. */
|
|
63
66
|
onOpenAttachment?: OpenAttachment;
|
|
67
|
+
/**
|
|
68
|
+
* Draws the app's own attachment kinds (a task with a checkbox, a worksheet with its progress)
|
|
69
|
+
* instead of the card: every kind the package doesn't draw, registered or not. `null` hides the
|
|
70
|
+
* kinds that aren't registered. Define it at module scope (or memoize it) so rows stay memoized.
|
|
71
|
+
*/
|
|
72
|
+
attachmentSlot?: ChatAttachmentSlot | null;
|
|
73
|
+
/** How long an app card's action may run before it reads as failed; 30 s by default, 0 for never. */
|
|
74
|
+
cardActionTimeoutMs?: number;
|
|
64
75
|
testID: string;
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -94,10 +105,24 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
94
105
|
const senderName = mine ? copy.you : (sender?.name ?? "");
|
|
95
106
|
|
|
96
107
|
const kinds = props.attachmentKinds;
|
|
108
|
+
const slot = props.attachmentSlot;
|
|
97
109
|
const { cards, rest: plain } = useMemo(
|
|
98
|
-
() => splitCardAttachments(message && !message.deletedAt ? message.attachments : [], kinds),
|
|
99
|
-
[message, kinds],
|
|
110
|
+
() => splitCardAttachments(message && !message.deletedAt ? message.attachments : [], kinds, slot !== undefined),
|
|
111
|
+
[message, kinds, slot],
|
|
100
112
|
);
|
|
113
|
+
const runs = useCardRuns({ failedText: copy.cardActionFailed, timedOutText: copy.cardActionTimedOut, timeoutMs: props.cardActionTimeoutMs });
|
|
114
|
+
// App attachments that drew nothing (the slot returned null): left out of the label and actions.
|
|
115
|
+
const [empty, setEmpty] = useState<ReadonlySet<string>>(() => new Set());
|
|
116
|
+
const onEmpty = useCallback((id: string, isEmpty: boolean) => {
|
|
117
|
+
setEmpty((s) => {
|
|
118
|
+
if (s.has(id) === isEmpty) return s;
|
|
119
|
+
const next = new Set(s);
|
|
120
|
+
if (isEmpty) next.add(id);
|
|
121
|
+
else next.delete(id);
|
|
122
|
+
return next;
|
|
123
|
+
});
|
|
124
|
+
}, []);
|
|
125
|
+
const shownCards = cards.filter((a) => !empty.has(a.id));
|
|
101
126
|
const attachments: BubbleAttachment[] = useMemo(() => {
|
|
102
127
|
if (message) return message.deletedAt ? [] : plain.map((a) => fromServer(a, urls));
|
|
103
128
|
// What it already carries (a hosted video) comes first, as the outbox sends it.
|
|
@@ -113,7 +138,13 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
113
138
|
const photos = attachments.filter((a) => a.kind === "image" && a.uri && props.onOpenImage);
|
|
114
139
|
const videos = attachments.filter((a) => a.kind === "video" && modules.video && (a.uri || a.playbackId));
|
|
115
140
|
const playingVideo = usePlayingVideo(videos.map((a) => a.key));
|
|
116
|
-
const cardActions = message
|
|
141
|
+
const cardActions = message
|
|
142
|
+
? cards.map((a) => {
|
|
143
|
+
if (empty.has(a.id)) return null;
|
|
144
|
+
const c = cardAction(attachmentKindOf(kinds, a.kind), a, message, props.onOpenAttachment, { mine });
|
|
145
|
+
return c ? { label: c.label, run: () => void runs.run(a.id, c.run) } : null;
|
|
146
|
+
})
|
|
147
|
+
: [];
|
|
117
148
|
const playingVoice = usePlayingVoiceNote(voices.map((a) => a.key));
|
|
118
149
|
|
|
119
150
|
// ---- Swipe to reply ----
|
|
@@ -165,8 +196,23 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
165
196
|
const label = [
|
|
166
197
|
senderName,
|
|
167
198
|
message?.deletedAt ? copy.deleted : displayBody(body),
|
|
168
|
-
attachments
|
|
169
|
-
|
|
199
|
+
attachments
|
|
200
|
+
.map((a) =>
|
|
201
|
+
a.kind === "image"
|
|
202
|
+
? copy.photo
|
|
203
|
+
: a.kind === "voice"
|
|
204
|
+
? [copy.voice, a.transcript?.trim() ? fill(copy.transcriptLabel, { text: a.transcript.trim() }) : null].filter(Boolean).join(", ")
|
|
205
|
+
: a.kind === "video"
|
|
206
|
+
? copy.video
|
|
207
|
+
: copy.file,
|
|
208
|
+
)
|
|
209
|
+
.join(", "),
|
|
210
|
+
shownCards
|
|
211
|
+
.map((a) => {
|
|
212
|
+
const s = runs.stateOf(a.id);
|
|
213
|
+
return [cardTitle(attachmentKindOf(kinds, a.kind), a, { mine }), s.error].filter(Boolean).join(", ");
|
|
214
|
+
})
|
|
215
|
+
.join(", "),
|
|
170
216
|
preview ? fill(copy.linkPreview, { title: preview.title?.trim() || linkHost(preview.url), site: preview.siteName?.trim() || linkHost(preview.url) }) : null,
|
|
171
217
|
time,
|
|
172
218
|
message?.editedAt ? copy.edited : null,
|
|
@@ -221,12 +267,19 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
221
267
|
|
|
222
268
|
const quote = message?.replyToId ?? pending?.input.replyToId ?? null;
|
|
223
269
|
const quoted = quote && repliedTo ? repliedTo : null;
|
|
270
|
+
// A message that is only app attachments the slot drew nothing for has nothing to show or read.
|
|
271
|
+
const invisible = Boolean(message && !message.deletedAt && !body && !attachments.length && !preview && !quote && !moderation && cards.length && !shownCards.length);
|
|
224
272
|
const bubbleColor = mine ? colors.tint : colors.surface;
|
|
225
273
|
const fg = mine ? "text-primary-foreground" : undefined;
|
|
226
274
|
const textStyle = mine ? { fontSize: 17, lineHeight: 22 } : { fontSize: 17, lineHeight: 22, color: colors.label };
|
|
227
275
|
|
|
228
276
|
return (
|
|
229
|
-
<View
|
|
277
|
+
<View
|
|
278
|
+
testID={testID}
|
|
279
|
+
accessibilityElementsHidden={invisible || undefined}
|
|
280
|
+
importantForAccessibility={invisible ? "no-hide-descendants" : undefined}
|
|
281
|
+
style={{ paddingHorizontal: 8, paddingTop: row.groupStart ? 8 : 2, backgroundColor: props.highlighted ? colors.fill : undefined, display: invisible ? "none" : undefined }}
|
|
282
|
+
>
|
|
230
283
|
{row.showName && sender?.name ? (
|
|
231
284
|
<NativeText preset="caption" style={{ marginLeft: group ? 48 : 12, marginBottom: 2, color: colors.secondaryLabel }}>
|
|
232
285
|
{sender.name}
|
|
@@ -269,14 +322,27 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
269
322
|
{quoted ? (quoted.senderId === me ? copy.you : (person(quoted.senderId)?.name ?? "")) : ""}
|
|
270
323
|
</NativeText>
|
|
271
324
|
<NativeText preset="caption" numberOfLines={2} className={fg} style={mine ? undefined : { color: colors.secondaryLabel }}>
|
|
272
|
-
{quoted ?
|
|
325
|
+
{quoted ? attachmentPreview(displayBody(quoted.body), quoted.attachments[0], kinds, Boolean(quoted.deletedAt)) : "…"}
|
|
273
326
|
</NativeText>
|
|
274
327
|
</View>
|
|
275
328
|
) : null}
|
|
276
329
|
{attachments.length ? <MessageAttachments attachments={attachments} mine={mine} onOpenImage={props.onOpenImage} testID={testID} /> : null}
|
|
277
330
|
{message && cards.length
|
|
278
331
|
? cards.map((a, i) => (
|
|
279
|
-
<
|
|
332
|
+
<AppAttachment
|
|
333
|
+
key={a.id}
|
|
334
|
+
attachment={a}
|
|
335
|
+
message={message}
|
|
336
|
+
kind={attachmentKindOf(kinds, a.kind)}
|
|
337
|
+
url={(a.path ? urls[a.path] : null) ?? a.url ?? null}
|
|
338
|
+
mine={mine}
|
|
339
|
+
slot={slot}
|
|
340
|
+
onOpenAttachment={props.onOpenAttachment}
|
|
341
|
+
onEmpty={onEmpty}
|
|
342
|
+
run={(task) => void runs.run(a.id, task)}
|
|
343
|
+
state={runs.stateOf(a.id)}
|
|
344
|
+
testID={`${testID}-card-${i}`}
|
|
345
|
+
/>
|
|
280
346
|
))
|
|
281
347
|
: null}
|
|
282
348
|
{message?.deletedAt ? (
|
package/src/native/push.ts
CHANGED
|
@@ -29,7 +29,10 @@ import type { Outbox, OutboxStore, QueuedMessage } from "../core/outbox";
|
|
|
29
29
|
|
|
30
30
|
export const CHAT_PUSH_CATEGORY = "messages";
|
|
31
31
|
export const CHAT_REPLY_ACTION = "reply";
|
|
32
|
-
|
|
32
|
+
/** The same id as genesis-notifications' `MESSAGE_ACTIONS` (device ids take no "-" or ":"). */
|
|
33
|
+
export const CHAT_MARK_READ_ACTION = "mark_read";
|
|
34
|
+
/** The id before 0.2.2: a notification already on a phone still carries it. */
|
|
35
|
+
const LEGACY_MARK_READ_ACTION = "mark-read";
|
|
33
36
|
|
|
34
37
|
export interface ChatPushCopy {
|
|
35
38
|
title: string;
|
|
@@ -212,7 +215,7 @@ export async function handleChatPushResponse(
|
|
|
212
215
|
waitMs?: number;
|
|
213
216
|
} & ChatPushOutboxDeps,
|
|
214
217
|
): Promise<ChatPushOutcome> {
|
|
215
|
-
const action = response.actionIdentifier;
|
|
218
|
+
const action = response.actionIdentifier === LEGACY_MARK_READ_ACTION ? CHAT_MARK_READ_ACTION : response.actionIdentifier;
|
|
216
219
|
if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return "ignored";
|
|
217
220
|
const data = response.notification.request.content.data ?? {};
|
|
218
221
|
const conversationId = conversationIdFromData(data);
|
|
@@ -88,79 +88,87 @@ export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, de
|
|
|
88
88
|
useEffect(() => {
|
|
89
89
|
if (announce) AccessibilityInfo.announceForAccessibility?.(announce);
|
|
90
90
|
}, [announce]);
|
|
91
|
+
const field = (
|
|
92
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
|
93
|
+
<NativeSearchBar
|
|
94
|
+
testID={`${testID}-field`}
|
|
95
|
+
value={value}
|
|
96
|
+
onChange={setValue}
|
|
97
|
+
placeholder={conversationId ? copy.searchThreadPlaceholder : copy.searchPlaceholder}
|
|
98
|
+
cancelLabel={copy.cancel}
|
|
99
|
+
showCancel={false}
|
|
100
|
+
/>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
const empty = short ? (
|
|
104
|
+
<NativeText preset="caption" style={{ paddingHorizontal: 20, color: colors.secondaryLabel }} testID={`${testID}-hint`}>
|
|
105
|
+
{copy.searchHint}
|
|
106
|
+
</NativeText>
|
|
107
|
+
) : isSearching ? (
|
|
108
|
+
<View testID={`${testID}-loading`} style={{ padding: 16, gap: 10 }}>
|
|
109
|
+
<NativeSkeleton height={52} className="w-full" />
|
|
110
|
+
<NativeSkeleton height={52} className="w-full" />
|
|
111
|
+
</View>
|
|
112
|
+
) : (
|
|
113
|
+
<NativeEmptyState variant="no-results" title={copy.searchNoResults} body={fill(copy.searchNoResultsBody, { query })} testID={`${testID}-empty`} />
|
|
114
|
+
);
|
|
115
|
+
// One list, with the field as its header and the hint, loading and empty states as its empty
|
|
116
|
+
// content: under a large-title or see-through (Liquid Glass) header only a scroll view's automatic
|
|
117
|
+
// content inset moves the content below the bar, as InboxPage does. A plain View at the top of the
|
|
118
|
+
// SafeAreaView drew the field behind the navigation bar, where it couldn't be tapped.
|
|
91
119
|
return (
|
|
92
120
|
<SafeAreaView testID={testID} edges={["left", "right"]} style={{ flex: 1, backgroundColor: colors.groupedBackground }}>
|
|
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
|
-
keyboardShouldPersistTaps="handled"
|
|
120
|
-
keyboardDismissMode="on-drag"
|
|
121
|
-
renderItem={({ item: h }) => {
|
|
122
|
-
const who = nameOf(h.senderId);
|
|
123
|
-
const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
|
|
124
|
-
const when = formatInboxTime(new Date(h.createdAt), now());
|
|
125
|
-
return (
|
|
126
|
-
<HitRow
|
|
127
|
-
testID={`${testID}-hit-${h.messageId}`}
|
|
128
|
-
accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
|
|
129
|
-
onPress={() => onOpen(h)}
|
|
130
|
-
>
|
|
131
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
132
|
-
<NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
133
|
-
{who}
|
|
121
|
+
<FlatList
|
|
122
|
+
testID={`${testID}-results`}
|
|
123
|
+
data={short ? [] : hits}
|
|
124
|
+
keyExtractor={(h) => `${h.messageId}:${h.inTranscript}`}
|
|
125
|
+
contentInsetAdjustmentBehavior="automatic"
|
|
126
|
+
keyboardShouldPersistTaps="handled"
|
|
127
|
+
keyboardDismissMode="on-drag"
|
|
128
|
+
ListHeaderComponent={field}
|
|
129
|
+
ListEmptyComponent={empty}
|
|
130
|
+
renderItem={({ item: h }) => {
|
|
131
|
+
const who = nameOf(h.senderId);
|
|
132
|
+
const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
|
|
133
|
+
const when = formatInboxTime(new Date(h.createdAt), now());
|
|
134
|
+
return (
|
|
135
|
+
<HitRow
|
|
136
|
+
testID={`${testID}-hit-${h.messageId}`}
|
|
137
|
+
accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
|
|
138
|
+
onPress={() => onOpen(h)}
|
|
139
|
+
>
|
|
140
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
141
|
+
<NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
142
|
+
{who}
|
|
143
|
+
</NativeText>
|
|
144
|
+
{where ? (
|
|
145
|
+
<NativeText preset="caption" numberOfLines={1} style={{ flexShrink: 1, color: colors.secondaryLabel }}>
|
|
146
|
+
{where}
|
|
134
147
|
</NativeText>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
</NativeBadge>
|
|
154
|
-
) : null}
|
|
155
|
-
<View style={{ flex: 1 }}>
|
|
156
|
-
<SearchSnippet snippet={h.snippet} />
|
|
157
|
-
</View>
|
|
148
|
+
) : null}
|
|
149
|
+
<NativeText preset="caption" style={{ marginLeft: "auto", color: colors.tertiaryLabel }}>
|
|
150
|
+
{when}
|
|
151
|
+
</NativeText>
|
|
152
|
+
</View>
|
|
153
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6 }}>
|
|
154
|
+
{h.inTranscript ? (
|
|
155
|
+
<NativeBadge variant="muted" testID={`${testID}-transcript-${h.messageId}`}>
|
|
156
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
|
|
157
|
+
<NativeIcon name="mic" size={11} color={colors.secondaryLabel} />
|
|
158
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
|
|
159
|
+
{copy.transcript}
|
|
160
|
+
</NativeText>
|
|
161
|
+
</View>
|
|
162
|
+
</NativeBadge>
|
|
163
|
+
) : null}
|
|
164
|
+
<View style={{ flex: 1 }}>
|
|
165
|
+
<SearchSnippet snippet={h.snippet} />
|
|
158
166
|
</View>
|
|
159
|
-
</
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
</View>
|
|
168
|
+
</HitRow>
|
|
169
|
+
);
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
164
172
|
</SafeAreaView>
|
|
165
173
|
);
|
|
166
174
|
}
|
|
@@ -27,11 +27,11 @@ import { AccessibilityInfo, FlatList, KeyboardAvoidingView, Platform, Pressable,
|
|
|
27
27
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
28
28
|
import { NativeEmptyState, NativeSkeleton, NativeText, useGenesisModules, useToast } from "@marktiderman/genesis-ui-native/components";
|
|
29
29
|
import { useReducedMotion, useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
30
|
-
import { activityLine
|
|
30
|
+
import { activityLine } from "../core/thread";
|
|
31
31
|
import type { ChatMessage, PendingFile } from "../core/types";
|
|
32
32
|
import { useChat, useConversation, useFileUrls, useLinkPreviews, useMessageActions, usePeople, usePins, useTopics } from "../react/index";
|
|
33
33
|
import { recordingLimitMs } from "../core/media";
|
|
34
|
-
import type
|
|
34
|
+
import { attachmentPreview, type ChatAttachmentSlot, type OpenAttachment } from "./attachment-cards";
|
|
35
35
|
import { linkPreviewIds, previewToShow } from "./link-preview";
|
|
36
36
|
import { ChatVideoRecorder } from "./video-recorder";
|
|
37
37
|
import { VideoUploadTray } from "./video-upload-tray";
|
|
@@ -80,6 +80,14 @@ export interface ChatThreadScreenProps {
|
|
|
80
80
|
availability?: boolean;
|
|
81
81
|
/** An app attachment card's button (a form, a check-in), when its kind gives no `onPress`. */
|
|
82
82
|
onOpenAttachment?: OpenAttachment;
|
|
83
|
+
/**
|
|
84
|
+
* Swap parts, as the web `ChatThread` does. `attachment` draws the app's own attachment kinds
|
|
85
|
+
* (a task with a checkbox): every kind the package doesn't draw, with `fallback` for the card it
|
|
86
|
+
* would have been; `null` hides kinds that aren't registered. Define it at module scope.
|
|
87
|
+
*/
|
|
88
|
+
slots?: ChatThreadScreenSlots;
|
|
89
|
+
/** How long an app card's action may run before it reads as failed; 30 s by default, 0 for never. */
|
|
90
|
+
cardActionTimeoutMs?: number;
|
|
83
91
|
/**
|
|
84
92
|
* Whether the screen is the one shown (the navigator's `useIsFocused()`). When it turns false a
|
|
85
93
|
* video or voice note of this thread that is playing pauses: switching tab keeps the screen
|
|
@@ -89,6 +97,11 @@ export interface ChatThreadScreenProps {
|
|
|
89
97
|
testID?: string;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
export interface ChatThreadScreenSlots {
|
|
101
|
+
/** Draws the app's own attachment kinds; the card by default. */
|
|
102
|
+
attachment?: ChatAttachmentSlot | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
/** The list props chat passes; FlashList v2 and FlatList both take them. */
|
|
93
106
|
interface ChatListProps {
|
|
94
107
|
ref?: unknown;
|
|
@@ -143,6 +156,8 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
143
156
|
const { conversationId, threadRootId = null, keyboardVerticalOffset, onOpenAttachment, jumpTo, topic: topicProp, showTopics = false, scheduling = true, availability = true, focused = true, testID = "chat-thread" } = props;
|
|
144
157
|
const chat = useChat();
|
|
145
158
|
const { userId, videoUpload, attachmentKinds, outbox } = chat;
|
|
159
|
+
const attachmentSlot = props.slots?.attachment;
|
|
160
|
+
const cardActionTimeoutMs = props.cardActionTimeoutMs;
|
|
146
161
|
// Off unless the app turns it on: an og:image fetched from the phone tells its site who reads it.
|
|
147
162
|
const linkPreviewImages = (chat as { linkPreviewImages?: boolean }).linkPreviewImages === true;
|
|
148
163
|
const topicsState = useTopics(threadRootId ? null : conversationId);
|
|
@@ -242,9 +257,9 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
242
257
|
|
|
243
258
|
const reply = useCallback(
|
|
244
259
|
(m: ChatMessage) => {
|
|
245
|
-
setReply(replyTargetFor(m, nameOf(m.senderId),
|
|
260
|
+
setReply(replyTargetFor(m, nameOf(m.senderId), attachmentPreview(displayBody(m.body), m.attachments[0], attachmentKinds, Boolean(m.deletedAt))));
|
|
246
261
|
},
|
|
247
|
-
[setReply, nameOf],
|
|
262
|
+
[setReply, nameOf, attachmentKinds],
|
|
248
263
|
);
|
|
249
264
|
|
|
250
265
|
const onAction = useCallback(
|
|
@@ -355,7 +370,7 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
355
370
|
},
|
|
356
371
|
onLanded: (id) => {
|
|
357
372
|
const m = byId.get(id);
|
|
358
|
-
if (m) AccessibilityInfo.announceForAccessibility?.(fill(copy.jumpedTo, { name: nameOf(m.senderId), text:
|
|
373
|
+
if (m) AccessibilityInfo.announceForAccessibility?.(fill(copy.jumpedTo, { name: nameOf(m.senderId), text: attachmentPreview(displayBody(m.body), m.attachments[0], attachmentKinds, Boolean(m.deletedAt)) }));
|
|
359
374
|
},
|
|
360
375
|
onGone: () => {
|
|
361
376
|
toast.show({ title: copy.messageGone });
|
|
@@ -395,11 +410,13 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
395
410
|
linkPreviewImages={linkPreviewImages}
|
|
396
411
|
attachmentKinds={attachmentKinds}
|
|
397
412
|
onOpenAttachment={onOpenAttachment}
|
|
413
|
+
attachmentSlot={attachmentSlot}
|
|
414
|
+
cardActionTimeoutMs={cardActionTimeoutMs}
|
|
398
415
|
testID={`${testID}-message-${m?.id ?? row.key}`}
|
|
399
416
|
/>
|
|
400
417
|
);
|
|
401
418
|
},
|
|
402
|
-
[testID, copy.newMessages, userId, urls, byId, group, reducedMotion, onLongPress, allowed, reply, onSwipeReply, onRetry, onDiscard, react, onOpenImage, picker, reattachable, onReattach, previews, linkPreviewImages, attachmentKinds, onOpenAttachment, observer, highlightedId],
|
|
419
|
+
[testID, copy.newMessages, userId, urls, byId, group, reducedMotion, onLongPress, allowed, reply, onSwipeReply, onRetry, onDiscard, react, onOpenImage, picker, reattachable, onReattach, previews, linkPreviewImages, attachmentKinds, onOpenAttachment, attachmentSlot, cardActionTimeoutMs, observer, highlightedId],
|
|
403
420
|
);
|
|
404
421
|
|
|
405
422
|
// ---- Video messages ----
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* `ChatErrorBoundary`: what one message (or one app attachment card) draws can't take the whole
|
|
4
|
+
* thread down. Messages carry what other people sent, and app renderers get sender-controlled
|
|
5
|
+
* data, so a render that throws shows its fallback instead, and the error goes to `onError`
|
|
6
|
+
* (console.error by default) so it is never silent. `resetKeys` lets it try again once what it
|
|
7
|
+
* draws changes (the app's data arrived).
|
|
8
|
+
*/
|
|
9
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
export interface ChatErrorBoundaryProps {
|
|
12
|
+
/** Drawn instead of the children once they throw. */
|
|
13
|
+
fallback: ReactNode;
|
|
14
|
+
/** Told about the error; console.error by default. */
|
|
15
|
+
onError?: (error: unknown, info: ErrorInfo) => void;
|
|
16
|
+
/** When any of these changes (by identity), a boundary showing its fallback tries the children again. */
|
|
17
|
+
resetKeys?: readonly unknown[];
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function changed(a: readonly unknown[] | undefined, b: readonly unknown[] | undefined): boolean {
|
|
22
|
+
if (a === b) return false;
|
|
23
|
+
if (!a || !b || a.length !== b.length) return true;
|
|
24
|
+
return a.some((v, i) => !Object.is(v, b[i]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {
|
|
28
|
+
override state = { failed: false };
|
|
29
|
+
|
|
30
|
+
static getDerivedStateFromError(): { failed: boolean } {
|
|
31
|
+
return { failed: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override componentDidUpdate(prev: ChatErrorBoundaryProps) {
|
|
35
|
+
if (this.state.failed && changed(prev.resetKeys, this.props.resetKeys)) this.setState({ failed: false });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override componentDidCatch(error: unknown, info: ErrorInfo) {
|
|
39
|
+
if (this.props.onError) this.props.onError(error, info);
|
|
40
|
+
else console.error("genesis-chat: a message couldn't be drawn", error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override render() {
|
|
44
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/react/index.tsx
CHANGED
|
@@ -130,8 +130,12 @@ export interface ChatCardContext {
|
|
|
130
130
|
export interface ChatAttachmentKind {
|
|
131
131
|
title(attachment: ChatAttachment, ctx?: ChatCardContext): string;
|
|
132
132
|
subtitle?(attachment: ChatAttachment, ctx?: ChatCardContext): string | null | undefined;
|
|
133
|
-
/**
|
|
134
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The card's button: a link (`href`) or a handler (`onPress`). On native the button is also the
|
|
135
|
+
* bubble's VoiceOver action, and a handler that returns a promise shows the card as busy until
|
|
136
|
+
* it settles (and a short error if it rejects).
|
|
137
|
+
*/
|
|
138
|
+
action?(attachment: ChatAttachment, ctx?: ChatCardContext): { label: string; href?: string; onPress?: () => void | Promise<unknown> } | null | undefined;
|
|
135
139
|
/** What previews call it (the inbox's last message, "Check-in"); the title by default. */
|
|
136
140
|
label?: string;
|
|
137
141
|
}
|
package/src/web/cards.tsx
CHANGED
|
@@ -96,7 +96,7 @@ export interface ChatAttachmentCardProps {
|
|
|
96
96
|
/** An icon component (lucide); a clipboard by default. */
|
|
97
97
|
icon?: ComponentType<{ className?: string }>;
|
|
98
98
|
/** One button: a link (`href`, safe schemes only) or a handler. */
|
|
99
|
-
action?: { label: string; href?: string; onPress?: () => void } | null;
|
|
99
|
+
action?: { label: string; href?: string; onPress?: () => void | Promise<unknown> } | null;
|
|
100
100
|
tone?: "default" | "primary";
|
|
101
101
|
className?: string;
|
|
102
102
|
}
|
|
@@ -126,7 +126,7 @@ export function ChatAttachmentCard({ title, subtitle, icon: Icon = ClipboardList
|
|
|
126
126
|
</a>
|
|
127
127
|
</Button>
|
|
128
128
|
) : (
|
|
129
|
-
<Button type="button" size="sm" variant={primary ? "secondary" : "default"} className="shrink-0" onClick={action.onPress}>
|
|
129
|
+
<Button type="button" size="sm" variant={primary ? "secondary" : "default"} className="shrink-0" onClick={() => void Promise.resolve(action.onPress?.()).catch((e) => console.error("genesis-chat: a card's action failed", e))}>
|
|
130
130
|
{action.label}
|
|
131
131
|
</Button>
|
|
132
132
|
)
|
|
@@ -1,33 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @feat:chat
|
|
3
|
-
* `ChatErrorBoundary
|
|
4
|
-
* thread down. Messages carry what other people sent, and app renderers get sender-controlled
|
|
5
|
-
* data, so a render that throws shows its fallback instead, and the error goes to `onError`
|
|
6
|
-
* (console.error by default) so it is never silent.
|
|
3
|
+
* `ChatErrorBoundary` lives in `/react` (no DOM), so the native renderer shares it.
|
|
7
4
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export interface ChatErrorBoundaryProps {
|
|
11
|
-
/** Drawn instead of the children once they throw. */
|
|
12
|
-
fallback: ReactNode;
|
|
13
|
-
/** Told about the error; console.error by default. */
|
|
14
|
-
onError?: (error: unknown, info: ErrorInfo) => void;
|
|
15
|
-
children: ReactNode;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {
|
|
19
|
-
override state = { failed: false };
|
|
20
|
-
|
|
21
|
-
static getDerivedStateFromError(): { failed: boolean } {
|
|
22
|
-
return { failed: true };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
override componentDidCatch(error: unknown, info: ErrorInfo) {
|
|
26
|
-
if (this.props.onError) this.props.onError(error, info);
|
|
27
|
-
else console.error("genesis-chat: a message couldn't be drawn", error);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
override render() {
|
|
31
|
-
return this.state.failed ? this.props.fallback : this.props.children;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
5
|
+
export { ChatErrorBoundary, type ChatErrorBoundaryProps } from "../react/error-boundary";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react/previews.ts"],"names":[],"mappings":";;;AAWO,IAAM,kBAAA,GAAqB,CAA4C,IAAA,EAAsC,GAAA,EAAQ,CAAA,EAAmB,GAAA,KAC5I,IAAA,GAAO,GAAG,CAAA,EAAoG,IAAA,CAAK,IAAA,EAAO,CAAA,EAAG,GAAG;AAK5H,SAAS,UAAU,IAAA,EAAsC,CAAA,EAAmB,MAAuB,EAAE,IAAA,EAAM,OAAM,EAAW;AACjI,EAAA,IAAI;AACF,IAAA,OAAO,kBAAA,CAAmB,MAAM,OAAA,EAAS,CAAA,EAAG,GAAG,CAAA,IAAK,CAAA,CAAE,QAAQ,CAAA,CAAE,IAAA;AAAA,EAClE,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,CAAA,CAAE,QAAQ,CAAA,CAAE,IAAA;AAAA,EACrB;AACF;AAGA,SAAS,eAAe,IAAA,EAA8B;AACpD,EAAA,OAAO,EAAE,IAAI,EAAA,EAAI,SAAA,EAAW,IAAI,QAAA,EAAU,CAAA,EAAG,MAAM,GAAA,EAAK,IAAI,QAAA,EAAU,MAAA,EAAQ,MAAM,IAAA,EAAM,UAAA,EAAY,MAAM,UAAA,EAAY,IAAA,EAAM,KAAK,IAAA,EAAM,MAAA,EAAQ,SAAS,IAAA,EAAM,IAAA,EAAM,MAAM,IAAA,EAAM,KAAA,EAAO,MAAM,KAAA,EAAO,IAAA,EAAM,QAAQ,IAAA,EAAM,UAAA,EAAY,MAAM,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA,EAAM,SAAA,EAAW,MAAM,UAAA,EAAY,IAAA,EAAM,kBAAkB,IAAA,EAAK;AAC1U;AAOO,SAAS,iBAAA,CAAkB,IAAA,EAAc,UAAA,EAAwD,KAAA,EAAuD,UAAU,KAAA,EAAe;AACtL,EAAA,MAAM,IAAA,GAAO,OAAO,UAAA,KAAe,QAAA,GAAW,aAAa,UAAA,EAAY,IAAA;AACvE,EAAA,MAAM,IAAA,GAAO,WAAA,CAAY,IAAA,EAAM,IAAA,EAAM,OAAO,CAAA;AAE5C,EAAA,IAAI,WAAW,CAAC,IAAA,IAAQ,CAAC,KAAA,IAAS,CAAC,MAAA,CAAO,SAAA,CAAU,cAAA,CAAe,IAAA,CAAK,OAAO,IAAI,CAAA,IAAK,YAAY,IAAA,EAAM,IAAI,GAAG,OAAO,IAAA;AACxH,EAAA,MAAM,IAAI,OAAO,UAAA,KAAe,QAAA,GAAW,cAAA,CAAe,UAAU,CAAA,GAAI,UAAA;AACxE,EAAA,MAAM,CAAA,GAAI,MAAM,IAAI,CAAA;AACpB,EAAA,MAAM,KAAA,GAAQ,OAAO,CAAA,EAAG,KAAA,KAAU,YAAY,CAAA,CAAE,KAAA,CAAM,IAAA,EAAK,GAAI,EAAE,KAAA,CAAM,IAAA,EAAK,GAAI,SAAA,CAAU,GAAG,CAAC,CAAA;AAC9F,EAAA,OAAO,KAAA,IAAS,KAAA,KAAU,IAAA,GAAO,KAAA,GAAQ,IAAA;AAC3C;AAGO,SAAS,cAAA,CAAe,SAAsB,KAAA,EAA+D;AAClH,EAAA,OAAO,iBAAA,CAAkB,OAAA,CAAQ,IAAA,EAAM,OAAA,CAAQ,WAAA,CAAY,CAAC,CAAA,EAAG,KAAA,EAAO,OAAA,CAAQ,OAAA,CAAQ,SAAS,CAAC,CAAA;AAClG","file":"chunk-AZS3HNLM.js","sourcesContent":["/**\n * @feat:chat\n * One-line previews of a message (the inbox, the long-press sheet) where an app attachment kind\n * reads as its `label`, else its title (\"Check-in\"), not \"Attachment\". No React or DOM: web and\n * native both draw from it.\n */\nimport { previewText } from \"../core/thread\";\nimport type { ChatAttachment, ChatMessage } from \"../core/types\";\nimport type { ChatAttachmentKind, ChatCardContext } from \"./index\";\n\n/** Calls a kind's `title`, `subtitle` or `action` with the card context. */\nexport const callAttachmentKind = <K extends \"title\" | \"subtitle\" | \"action\">(kind: ChatAttachmentKind | undefined, key: K, a: ChatAttachment, ctx: ChatCardContext) =>\n (kind?.[key] as ((this: ChatAttachmentKind, a: ChatAttachment, ctx: ChatCardContext) => unknown) | undefined)?.call(kind!, a, ctx) as\n | ReturnType<NonNullable<ChatAttachmentKind[K]>>\n | undefined;\n\n/** A card's title (the kind's, else the file's name, else the kind); an app kind that throws reads as its name. */\nexport function cardTitle(kind: ChatAttachmentKind | undefined, a: ChatAttachment, ctx: ChatCardContext = { mine: false }): string {\n try {\n return callAttachmentKind(kind, \"title\", a, ctx) || a.name || a.kind;\n } catch {\n return a.name || a.kind;\n }\n}\n\n/** An attachment with only its kind, for a preview that knows no more (the inbox's last message). */\nfunction bareAttachment(kind: string): ChatAttachment {\n return { id: \"\", messageId: \"\", position: 0, kind, ref: {}, provider: \"none\", path: null, providerId: null, playbackId: null, url: null, status: \"ready\", name: null, mime: null, bytes: null, width: null, height: null, durationMs: null, waveform: null, posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null };\n}\n\n/**\n * A message's one-line preview: its text, else what it carries, where an app kind reads as its\n * title (\"Check-in\") instead of \"Attachment\". `attachment` is the kind alone (the inbox) or the\n * attachment itself.\n */\nexport function attachmentPreview(body: string, attachment: string | ChatAttachment | null | undefined, kinds: Record<string, ChatAttachmentKind> | undefined, deleted = false): string {\n const kind = typeof attachment === \"string\" ? attachment : attachment?.kind;\n const text = previewText(body, kind, deleted);\n // Text that reads as something (not only markdown marks) is the preview, as previewText has it.\n if (deleted || !kind || !kinds || !Object.prototype.hasOwnProperty.call(kinds, kind) || previewText(body, null)) return text;\n const a = typeof attachment === \"string\" ? bareAttachment(attachment) : attachment!;\n const k = kinds[kind];\n const title = typeof k?.label === \"string\" && k.label.trim() ? k.label.trim() : cardTitle(k, a);\n return title && title !== kind ? title : text;\n}\n\n/** The long-press sheet's caption for a message. */\nexport function messagePreview(message: ChatMessage, kinds: Record<string, ChatAttachmentKind> | undefined): string {\n return attachmentPreview(message.body, message.attachments[0], kinds, Boolean(message.deletedAt));\n}\n\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/native/push.ts"],"names":[],"mappings":"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","file":"chunk-VEPZROQB.js","sourcesContent":["/**\n * @feat:chat\n * Push glue for genesis-native's push block, data only: the `messages`\n * category (a switch in Notification settings and an Android channel), its\n * notification actions (Reply with a text field, Mark read), what a tap opens\n * (`data.url` = `/messages/<conversationId>`), the reply and mark-read\n * handlers for the block's `onResponse`, and the match that keeps a thread's\n * own pushes out of the foreground while it is open (the block's `suppress`).\n *\n * const push = createPush({ …, categories: [...yours, chatPushCategory()],\n * onResponse: (r) => void handleChatPushResponse(r, { client: chatClient, notifications: Notifications }) });\n * void registerChatNotificationActions(Notifications); // once, at start\n * useSuppressNotifications(chatThreadMatch(conversationId)); // in the thread screen\n *\n * The sender sets `categoryId: \"messages\"` (iOS) / the `messages` channel\n * (Android) and `data: { url: \"/messages/<id>\", conversationId, seq, messageId }`;\n * `chatPushPayload` builds those and the fields iOS rich push reads (the\n * sender's name and photo, the group, a photo), and it is server safe: import\n * it from `@marktiderman/genesis-chat/push`.\n *\n * A reply goes through the outbox, so a phone that is offline keeps it: pass\n * the live one (`useChat().outbox`) when the chat screens are mounted, and the\n * persisted store and user id (`ChatProvider outboxStore`) for when they\n * aren't. A reply that can't be sent then waits there, failed, with Retry in\n * its thread.\n */\nimport type { ChatClient } from \"../core/client\";\nimport type { Outbox, OutboxStore, QueuedMessage } from \"../core/outbox\";\n\nexport const CHAT_PUSH_CATEGORY = \"messages\";\nexport const CHAT_REPLY_ACTION = \"reply\";\nexport const CHAT_MARK_READ_ACTION = \"mark-read\";\n\nexport interface ChatPushCopy {\n title: string;\n description: string;\n reply: string;\n replySubmit: string;\n replyPlaceholder: string;\n markRead: string;\n}\n\nexport const chatPushCopy: ChatPushCopy = {\n title: \"Messages\",\n description: \"New messages in your conversations.\",\n reply: \"Reply\",\n replySubmit: \"Send\",\n replyPlaceholder: \"Message\",\n markRead: \"Mark read\",\n};\n\n/** The path a message's push opens. */\nexport function chatPath(conversationId: string): string {\n return `/messages/${conversationId}`;\n}\n\n/** The category for createPush's `categories` (genesis-native's NotificationCategory). */\nexport function chatPushCategory(copy: Partial<ChatPushCopy> = {}) {\n const c = { ...chatPushCopy, ...copy };\n return { id: CHAT_PUSH_CATEGORY, title: c.title, description: c.description, importance: \"high\" as const };\n}\n\n/** expo-notifications' `setNotificationCategoryAsync`, the one call this needs. */\nexport interface NotificationCategoriesLike {\n setNotificationCategoryAsync(\n identifier: string,\n actions: {\n identifier: string;\n buttonTitle: string;\n textInput?: { submitButtonTitle: string; placeholder: string };\n options?: { opensAppToForeground?: boolean; isDestructive?: boolean; isAuthenticationRequired?: boolean };\n }[],\n options?: Record<string, unknown>,\n ): Promise<unknown>;\n dismissNotificationAsync?(identifier: string): Promise<void>;\n}\n\n/** The actions on a message's notification: Reply (a text field) and Mark read, neither opening the app. */\nexport function chatNotificationActions(copy: Partial<ChatPushCopy> = {}) {\n const c = { ...chatPushCopy, ...copy };\n return [\n {\n identifier: CHAT_REPLY_ACTION,\n buttonTitle: c.reply,\n textInput: { submitButtonTitle: c.replySubmit, placeholder: c.replyPlaceholder },\n options: { opensAppToForeground: false },\n },\n { identifier: CHAT_MARK_READ_ACTION, buttonTitle: c.markRead, options: { opensAppToForeground: false } },\n ];\n}\n\n/** Registers the actions with the OS (once, at start; it is idempotent). */\nexport function registerChatNotificationActions(\n notifications: NotificationCategoriesLike,\n copy?: Partial<ChatPushCopy>,\n onError?: (error: unknown) => void,\n): Promise<unknown> {\n // Never throws, at module load least of all: a module without categories (web, a test's mock) is reported.\n try {\n if (typeof notifications?.setNotificationCategoryAsync !== \"function\") {\n onError?.(new Error(\"chat push: setNotificationCategoryAsync is missing, so Reply and Mark read aren't on message notifications\"));\n return Promise.resolve(undefined);\n }\n return Promise.resolve(notifications.setNotificationCategoryAsync(CHAT_PUSH_CATEGORY, chatNotificationActions(copy))).catch((error: unknown) => {\n onError?.(error);\n });\n } catch (error) {\n onError?.(error);\n return Promise.resolve(undefined);\n }\n}\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** The conversation a push is about: `data.conversationId`, else the id in `data.url`. */\nexport function conversationIdFromData(data: Record<string, unknown> | null | undefined): string | null {\n if (!data) return null;\n const direct = typeof data.conversationId === \"string\" ? data.conversationId : typeof data.conversation_id === \"string\" ? data.conversation_id : null;\n if (direct && UUID.test(direct)) return direct.toLowerCase();\n const url = typeof data.url === \"string\" ? data.url : null;\n const m = url ? /^\\/messages\\/([0-9a-f-]{36})(?:[/?#]|$)/i.exec(url) : null;\n return m && UUID.test(m[1]!) ? m[1]!.toLowerCase() : null;\n}\n\n/**\n * The client id a reply from this notification sends with: the notification's\n * own id, as a uuid (it already is one on iOS; anything else is hashed into\n * one), so the same reply delivered twice posts once.\n */\nexport function replyClientId(notificationId: string): string {\n if (UUID.test(notificationId)) return notificationId.toLowerCase();\n // Four 32-bit FNV-1a hashes with different seeds: 128 stable bits.\n const words = [0x811c9dc5, 0x01000193, 0x050c5d1f, 0x6f4f2a3b].map((seed) => {\n let h = seed >>> 0;\n for (let i = 0; i < notificationId.length; i++) {\n h ^= notificationId.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16).padStart(8, \"0\");\n });\n const hex = words.join(\"\");\n // Version 8 (custom) and the RFC 4122 variant, so it is a valid uuid.\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;\n}\n\n/**\n * The client id for one reply: from the notification's id, the message it is\n * about (`data.messageId`) and the reply's text. The same reply delivered\n * twice posts once; two replies to one conversation never share an id, even\n * when iOS reuses the notification's id for a collapsed push.\n */\nexport function replyClientIdFor(notificationId: string, data: Record<string, unknown> | null | undefined, body: string): string {\n const messageId = typeof data?.messageId === \"string\" ? data.messageId : typeof data?.message_id === \"string\" ? data.message_id : \"\";\n return replyClientId(`${notificationId}|m:${messageId.toLowerCase()}|t:${body}`);\n}\n\n/** The part of a notification response this reads (expo-notifications' `NotificationResponse`). */\nexport interface ChatPushResponseLike {\n actionIdentifier: string;\n /** The text typed into Reply. */\n userText?: string;\n notification: { request: { identifier: string; content: { data?: Record<string, unknown> | null } } };\n}\n\n/** \"queued\": the reply couldn't go now and waits in the outbox, failed, for Retry. */\nexport type ChatPushOutcome = \"replied\" | \"queued\" | \"marked-read\" | \"ignored\" | \"failed\";\n\ntype LiveOutbox = Pick<Outbox, \"send\" | \"pending\"> & Partial<Pick<Outbox, \"load\">>;\n\n/** How long a reply queued in the live outbox is waited for before it counts as queued, not sent. */\nexport const PUSH_REPLY_WAIT_MS = 15_000;\n\n/** Where a reply waits when it can't go now. */\nexport interface ChatPushOutboxDeps {\n /** The live outbox (`useChat().outbox`), or a getter for it (read again after a slow failure): the reply is queued there and sent in order. */\n outbox?: LiveOutbox | null | (() => LiveOutbox | null | undefined);\n /** Without a live outbox: the persisted store (`ChatProvider outboxStore`) a reply that failed is added to. */\n store?: OutboxStore | null;\n /** Whose store it is (the signed-in user's id). */\n userId?: string | null;\n}\n\n/** A reply that couldn't be sent, as the outbox keeps it: shown failed in its thread, with Retry. */\nexport function failedReply(input: { conversationId: string; clientId: string; body: string }, userId: string, error = \"Not sent. Check your connection.\"): QueuedMessage {\n return {\n clientId: input.clientId,\n input: { conversationId: input.conversationId, clientId: input.clientId, body: input.body },\n senderId: userId,\n createdAt: new Date().toISOString(),\n status: \"failed\",\n error,\n previews: {},\n files: [],\n pendingFiles: [],\n attempts: 1,\n };\n}\n\n/**\n * Handles Reply and Mark read from a message's notification (pass it as the\n * push block's `onResponse`). Taps on the notification itself are the block's\n * routing, not this. Never throws: a failure is the outcome \"failed\" (and\n * `onError`).\n */\nexport async function handleChatPushResponse(\n response: ChatPushResponseLike,\n deps: {\n client: Pick<ChatClient, \"send\" | \"markRead\">;\n notifications?: Pick<NotificationCategoriesLike, \"dismissNotificationAsync\">;\n onError?: (error: unknown) => void;\n /** How long to wait for a reply queued in the live outbox to go. Default PUSH_REPLY_WAIT_MS. */\n waitMs?: number;\n } & ChatPushOutboxDeps,\n): Promise<ChatPushOutcome> {\n const action = response.actionIdentifier;\n if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return \"ignored\";\n const data = response.notification.request.content.data ?? {};\n const conversationId = conversationIdFromData(data);\n if (!conversationId) return \"ignored\";\n const notificationId = response.notification.request.identifier;\n try {\n let outcome: ChatPushOutcome;\n if (action === CHAT_REPLY_ACTION) {\n const body = (response.userText ?? \"\").trim();\n if (!body) return \"ignored\";\n const input = { conversationId, clientId: replyClientIdFor(notificationId, data, body), body };\n outcome = await sendReply(input, deps);\n // A reply reads the conversation too, as answering in the app would.\n if (outcome === \"replied\") await deps.client.markRead(conversationId).catch(() => undefined);\n } else {\n const seq = typeof data.seq === \"number\" ? data.seq : typeof data.seq === \"string\" ? Number(data.seq) : undefined;\n await deps.client.markRead(conversationId, Number.isFinite(seq) ? seq : undefined);\n outcome = \"marked-read\";\n }\n await deps.notifications?.dismissNotificationAsync?.(notificationId).catch(() => undefined);\n return outcome;\n } catch (error) {\n deps.onError?.(error);\n return \"failed\";\n }\n}\n\nasync function sendReply(\n input: { conversationId: string; clientId: string; body: string },\n deps: { client: Pick<ChatClient, \"send\">; onError?: (error: unknown) => void; waitMs?: number } & ChatPushOutboxDeps,\n): Promise<ChatPushOutcome> {\n const liveOutbox = () => (typeof deps.outbox === \"function\" ? deps.outbox() : deps.outbox) ?? null;\n const live = liveOutbox();\n if (live) return queueLive(live, input, deps.waitMs);\n try {\n await deps.client.send(input);\n return \"replied\";\n } catch (error) {\n // The chat screens may have opened while the send failed: their outbox owns the saved copy now.\n const opened = liveOutbox();\n if (opened) return queueLive(opened, input, 0);\n if (!deps.store || !deps.userId) throw error;\n deps.onError?.(error);\n const queue = await deps.store.load();\n if (!queue.some((q) => q.clientId === input.clientId)) await deps.store.save([...queue, failedReply(input, deps.userId)]);\n return \"queued\";\n }\n}\n\n/**\n * Queues the reply in the live outbox (once) and waits, briefly, for it to go: \"replied\" once it\n * has left the queue, \"queued\" while it is still there or failed (it waits there for Retry).\n */\nasync function queueLive(live: LiveOutbox, input: { conversationId: string; clientId: string; body: string }, waitMs = PUSH_REPLY_WAIT_MS): Promise<ChatPushOutcome> {\n await live.load?.();\n const mine = () => live.pending(input.conversationId).find((p) => p.clientId === input.clientId);\n if (!mine()) await live.send(input);\n const until = Date.now() + waitMs;\n for (;;) {\n const p = mine();\n if (!p) return \"replied\";\n if (p.status === \"failed\" || Date.now() >= until) return \"queued\";\n await new Promise((r) => setTimeout(r, 100));\n }\n}\n\n/** For the push block's `suppress` / `useSuppressNotifications`: this conversation's pushes, while its thread is open. */\nexport function chatThreadMatch(conversationId: string | null | undefined): ((data: Record<string, unknown>) => boolean) | null {\n if (!conversationId) return null;\n const id = conversationId.toLowerCase();\n return (data) => conversationIdFromData(data) === id;\n}\n\n/** What a message's push says about itself, for `chatPushPayload`. */\nexport interface ChatPushPayloadInput {\n conversationId: string;\n messageId: string;\n seq?: number | null | undefined;\n /** Who sent it: an opaque id (never an email or phone), the name to show and an https photo. */\n sender?: { id: string; name: string; avatarUrl?: string | null | undefined } | null | undefined;\n /** The conversation's name when it is a group; leave it out for a direct conversation. */\n groupName?: string | null | undefined;\n /** An https photo in the message, attached to the notification. */\n imageUrl?: string | null | undefined;\n /**\n * The recipient hides previews: the push carries the link and ids only (no\n * sender, group or photo) and the service extension doesn't run. Send neutral\n * title and body text with it.\n */\n hidePreviews?: boolean | undefined;\n}\n\n/** The `data` of a message's push. Flat strings and numbers, as genesis-notifications' `PushMessage.data` takes. */\nexport interface ChatPushData {\n [key: string]: string | number;\n url: string;\n conversationId: string;\n messageId: string;\n}\n\n/** The fields to spread into an Expo push message (with `to`, `title`, `body`, `badge`, `channelId`). */\nexport interface ChatPushPayload {\n categoryId: typeof CHAT_PUSH_CATEGORY;\n /** Groups the conversation's notifications, and is the conversation iOS files a Communication Notification under. */\n threadId: string;\n /** iOS runs genesis-native's Notification Service Extension only when this is true (`mutable-content: 1`). */\n mutableContent: boolean;\n data: ChatPushData;\n}\n\n/** The extension fetches only https URLs (no credentials, at most 1024 characters): genesis-native's `isRichPushUrl`. */\nfunction httpsUrl(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length > 1024) return null;\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" && url.hostname.length > 0 && !url.username && !url.password ? value : null;\n } catch {\n return null;\n }\n}\n\nfunction shortText(value: unknown): string | null {\n if (typeof value !== \"string\") return null;\n const t = value.trim();\n return t ? t.slice(0, 200) : null;\n}\n\n/**\n * The category, thread, `mutableContent` and `data` of a message's push, for\n * the server that sends it:\n *\n * const message = { to, title, body, badge, channelId: \"messages\", ...chatPushPayload({ conversationId, messageId, seq, sender, groupName, imageUrl, hidePreviews }) };\n *\n * On iOS, with the rich push plugin, a push with a sender is a Communication\n * Notification (the sender's photo; Focus treats it as a message) and `imageUrl`\n * is attached. Fields that aren't usable (a URL that isn't https, an empty\n * name) are left out rather than sent.\n */\nexport function chatPushPayload(input: ChatPushPayloadInput): ChatPushPayload {\n const conversationId = input.conversationId.toLowerCase();\n if (!UUID.test(conversationId)) throw new Error(\"chatPushPayload: conversationId must be a uuid\");\n const data: ChatPushData = { url: chatPath(conversationId), conversationId, messageId: input.messageId };\n if (typeof input.seq === \"number\" && Number.isFinite(input.seq)) data.seq = input.seq;\n if (input.hidePreviews) {\n return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: false, data };\n }\n const senderName = shortText(input.sender?.name);\n const senderId = shortText(input.sender?.id);\n if (senderName && senderId) {\n data.senderId = senderId;\n data.senderName = senderName;\n const avatar = httpsUrl(input.sender?.avatarUrl);\n if (avatar) data.senderAvatar = avatar;\n }\n const groupName = shortText(input.groupName);\n if (groupName) data.groupName = groupName;\n const image = httpsUrl(input.imageUrl);\n if (image) data.image = image;\n return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: true, data };\n}\n"]}
|