@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,9 +8,16 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The kind's `title`, `subtitle` and `action` get a second argument, `{ mine }` (`ChatCardContext`):
|
|
10
10
|
* true on the sender's own card, so a kind can drop its button there (you don't answer your own
|
|
11
|
-
* check-in). Previews (the inbox, the long-press sheet) name a card by its kind's
|
|
12
|
-
* its title, not "Attachment".
|
|
11
|
+
* check-in). Previews (the inbox, the long-press sheet, a reply's quote) name a card by its kind's
|
|
12
|
+
* `label`, else its title, not "Attachment".
|
|
13
|
+
*
|
|
14
|
+
* For more than a card (a task with a checkbox, a worksheet with its progress) the app draws its
|
|
15
|
+
* kinds itself through the `attachment` slot (`ChatThreadScreen slots`, `MessageBubble
|
|
16
|
+
* attachmentSlot`), the native twin of the web slot: the same props (`attachment`, `message`,
|
|
17
|
+
* `url`, `mine`) plus the kind, its action, `run` for the app's own actions with `pending` and
|
|
18
|
+
* `error`, and `fallback` (the card it would have been). A slot that throws shows the fallback.
|
|
13
19
|
*/
|
|
20
|
+
import { useCallback, useLayoutEffect, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from "react";
|
|
14
21
|
import { Linking, View } from "react-native";
|
|
15
22
|
import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
16
23
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
@@ -20,18 +27,31 @@ import { attachmentPreview, callAttachmentKind as call, cardTitle, messagePrevie
|
|
|
20
27
|
|
|
21
28
|
export type { ChatCardContext };
|
|
22
29
|
export { attachmentPreview, cardTitle, messagePreview };
|
|
30
|
+
import { ChatErrorBoundary } from "../react/error-boundary";
|
|
23
31
|
import { fill } from "./copy";
|
|
24
32
|
import { useChatNative } from "./context";
|
|
25
33
|
import { ON_TINT_FILL, ON_TINT_FILL_STRONG } from "./media-colors";
|
|
26
34
|
|
|
27
35
|
export type OpenAttachment = (attachment: ChatAttachment, message: ChatMessage) => void;
|
|
28
36
|
|
|
29
|
-
/**
|
|
30
|
-
export
|
|
31
|
-
|
|
37
|
+
/** The kinds the package draws itself; every other kind is the app's. */
|
|
38
|
+
export const BUILT_IN_KINDS: ReadonlySet<string> = new Set(["image", "voice", "video", "file", "link"]);
|
|
39
|
+
|
|
40
|
+
/** The app's registered kind for this attachment kind (own keys only, never `constructor` and the like). */
|
|
41
|
+
export function attachmentKindOf(kinds: Record<string, ChatAttachmentKind> | undefined, kind: string): ChatAttachmentKind | undefined {
|
|
42
|
+
return kinds && Object.prototype.hasOwnProperty.call(kinds, kind) ? kinds[kind] : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A message's attachments split into the app's cards and the rest (photos, voice, video, files).
|
|
47
|
+
* A card is a registered kind; with `slot` (the app draws its kinds) also any kind the package
|
|
48
|
+
* doesn't draw itself, as on the web.
|
|
49
|
+
*/
|
|
50
|
+
export function splitCardAttachments<T extends { kind: string }>(attachments: readonly T[], kinds: Record<string, ChatAttachmentKind> | undefined, slot = false): { cards: T[]; rest: T[] } {
|
|
51
|
+
if (!kinds && !slot) return { cards: [], rest: [...attachments] };
|
|
32
52
|
const cards: T[] = [];
|
|
33
53
|
const rest: T[] = [];
|
|
34
|
-
for (const a of attachments) (
|
|
54
|
+
for (const a of attachments) (attachmentKindOf(kinds, a.kind) || (slot && !BUILT_IN_KINDS.has(a.kind)) ? cards : rest).push(a);
|
|
35
55
|
return { cards, rest };
|
|
36
56
|
}
|
|
37
57
|
|
|
@@ -71,16 +91,143 @@ export function cardAction(
|
|
|
71
91
|
return run ? { label: action.label, run } : null;
|
|
72
92
|
}
|
|
73
93
|
|
|
94
|
+
/** Where one card's app action stands: running, or failed with a short message. */
|
|
95
|
+
export interface CardRunState {
|
|
96
|
+
pending: boolean;
|
|
97
|
+
error: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const IDLE: CardRunState = { pending: false, error: null };
|
|
101
|
+
|
|
102
|
+
/** How long a card's action may run before it reads as failed (`useCardRuns`, `cardActionTimeoutMs`). */
|
|
103
|
+
export const CARD_ACTION_TIMEOUT_MS = 30_000;
|
|
104
|
+
|
|
105
|
+
export interface CardRunOptions {
|
|
106
|
+
/** The error once an action threw or rejected (copy `cardActionFailed`). */
|
|
107
|
+
failedText: string;
|
|
108
|
+
/** The error once an action ran longer than `timeoutMs` (copy `cardActionTimedOut`). */
|
|
109
|
+
timedOutText?: string;
|
|
110
|
+
/** Milliseconds before a running action reads as failed; 30 s by default, 0 for never. */
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs the app's actions for a message's cards (tick a task, answer a check-in) and keeps each
|
|
116
|
+
* card's state by attachment id: `pending` until the action settles, then `error` (the copy's
|
|
117
|
+
* `cardActionFailed`) if it threw or rejected. Only a promise makes a card busy; a second run
|
|
118
|
+
* while one is pending is ignored. One that hasn't settled after `timeoutMs` (30 s) stops being
|
|
119
|
+
* busy and reads as failed (`cardActionTimedOut`); what it settles to later is ignored.
|
|
120
|
+
*/
|
|
121
|
+
export function useCardRuns(options: CardRunOptions | string): { stateOf(id: string): CardRunState; run(id: string, task: () => unknown): Promise<void> } {
|
|
122
|
+
const { failedText, timedOutText = failedText, timeoutMs = CARD_ACTION_TIMEOUT_MS } = typeof options === "string" ? { failedText: options } : options;
|
|
123
|
+
const [states, setStates] = useState<Record<string, CardRunState>>({});
|
|
124
|
+
const busy = useRef(new Set<string>());
|
|
125
|
+
const run = useCallback(
|
|
126
|
+
async (id: string, task: () => unknown) => {
|
|
127
|
+
if (busy.current.has(id)) return;
|
|
128
|
+
const failed = (e: unknown, text = failedText) => {
|
|
129
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) console.warn("[genesis-chat] an attachment card's action failed:", e);
|
|
130
|
+
setStates((s) => ({ ...s, [id]: { pending: false, error: text } }));
|
|
131
|
+
};
|
|
132
|
+
let result: unknown;
|
|
133
|
+
try {
|
|
134
|
+
result = task();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
failed(e);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Only an action that returns a promise is busy; a plain handler is done when it returns.
|
|
140
|
+
if (!result || typeof (result as PromiseLike<unknown>).then !== "function") {
|
|
141
|
+
setStates((s) => (s[id] ? { ...s, [id]: IDLE } : s));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
busy.current.add(id);
|
|
145
|
+
setStates((s) => ({ ...s, [id]: { pending: true, error: null } }));
|
|
146
|
+
const TIMED_OUT = Symbol("timed out");
|
|
147
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
148
|
+
const limit = timeoutMs > 0 ? new Promise<typeof TIMED_OUT>((resolve) => (timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs))) : null;
|
|
149
|
+
try {
|
|
150
|
+
const settled = await (limit ? Promise.race([result as PromiseLike<unknown>, limit]) : result);
|
|
151
|
+
if (settled === TIMED_OUT) {
|
|
152
|
+
failed(new Error(`timed out after ${timeoutMs} ms`), timedOutText);
|
|
153
|
+
// Swallow what the abandoned action settles to.
|
|
154
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
155
|
+
} else setStates((s) => ({ ...s, [id]: IDLE }));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
failed(e);
|
|
158
|
+
} finally {
|
|
159
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
160
|
+
busy.current.delete(id);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[failedText, timedOutText, timeoutMs],
|
|
164
|
+
);
|
|
165
|
+
const stateOf = useCallback((id: string) => states[id] ?? IDLE, [states]);
|
|
166
|
+
return { stateOf, run };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What the `attachment` slot gets: the web slot's props (`attachment`, `message`, `url`, `mine`)
|
|
171
|
+
* and what a native card needs besides. The bubble is one VoiceOver element, so a control drawn
|
|
172
|
+
* here (a checkbox) is reached through the kind's `action`, which the bubble offers as its own
|
|
173
|
+
* action: give the kind one, and call `action.run` from the control so both stay the same.
|
|
174
|
+
*/
|
|
175
|
+
export interface ChatAttachmentSlotProps {
|
|
176
|
+
attachment: ChatAttachment;
|
|
177
|
+
message: ChatMessage;
|
|
178
|
+
/**
|
|
179
|
+
* Its signed URL when it has a file (else its `url`), only when it is http(s): the attachment's
|
|
180
|
+
* `url` is whatever the sender put there, so any other scheme (javascript:, file:) is null.
|
|
181
|
+
*/
|
|
182
|
+
url: string | null;
|
|
183
|
+
/** On a message you sent (drawn on the tint). */
|
|
184
|
+
mine: boolean;
|
|
185
|
+
/** The app's kind for it (`ChatProvider attachmentKinds`), when registered. */
|
|
186
|
+
kind: ChatAttachmentKind | undefined;
|
|
187
|
+
/** The kind's `action`, ready to call: it runs through `run`, so `pending` and `error` follow it. Null without one. */
|
|
188
|
+
action: { label: string; run: () => void } | null;
|
|
189
|
+
/** Runs an app action (tick a task): `pending` until it settles, `error` if it throws or rejects. */
|
|
190
|
+
run(task: () => unknown): void;
|
|
191
|
+
pending: boolean;
|
|
192
|
+
/** A short message once an action failed; cleared by the next run. */
|
|
193
|
+
error: string | null;
|
|
194
|
+
/** The card the package would draw: return it for kinds the slot doesn't draw. */
|
|
195
|
+
fallback: ReactElement;
|
|
196
|
+
testID: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Draws the app's own attachment kinds; `null` hides the kinds that aren't registered. A slot that
|
|
201
|
+
* returns null draws nothing for that attachment (a bubble left with nothing is not shown).
|
|
202
|
+
*/
|
|
203
|
+
export type ChatAttachmentSlot = ComponentType<ChatAttachmentSlotProps>;
|
|
204
|
+
|
|
205
|
+
/** A URL a slot may be given: http or https only, else null. */
|
|
206
|
+
export function slotUrl(url: string | null | undefined): string | null {
|
|
207
|
+
if (!url) return null;
|
|
208
|
+
try {
|
|
209
|
+
const u = new URL(url);
|
|
210
|
+
return (u.protocol === "https:" || u.protocol === "http:") && u.hostname ? url : null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
74
216
|
export interface ChatAttachmentCardProps {
|
|
75
217
|
attachment: ChatAttachment;
|
|
76
218
|
message: ChatMessage;
|
|
77
|
-
kind
|
|
219
|
+
/** The app's kind; without one the card shows the attachment's name (or kind) and no button. */
|
|
220
|
+
kind?: ChatAttachmentKind;
|
|
78
221
|
mine?: boolean;
|
|
79
222
|
onOpenAttachment?: OpenAttachment;
|
|
223
|
+
/** Runs the button's action (the bubble passes its own, for busy and error state); calls it straight away by default. */
|
|
224
|
+
run?: (task: () => unknown) => void;
|
|
225
|
+
/** Busy while the action runs; the error under the card once it failed. */
|
|
226
|
+
state?: CardRunState;
|
|
80
227
|
testID?: string;
|
|
81
228
|
}
|
|
82
229
|
|
|
83
|
-
export function ChatAttachmentCard({ attachment, message, kind, mine = false, onOpenAttachment, testID = "chat-attachment-card" }: ChatAttachmentCardProps) {
|
|
230
|
+
export function ChatAttachmentCard({ attachment, message, kind, mine = false, onOpenAttachment, run, state = IDLE, testID = "chat-attachment-card" }: ChatAttachmentCardProps) {
|
|
84
231
|
const { copy } = useChatNative();
|
|
85
232
|
const colors = useThemeRoles();
|
|
86
233
|
let title = "";
|
|
@@ -97,9 +244,9 @@ export function ChatAttachmentCard({ attachment, message, kind, mine = false, on
|
|
|
97
244
|
const action = cardAction(kind, attachment, message, onOpenAttachment, ctx);
|
|
98
245
|
const fg = mine ? "text-primary-foreground" : undefined;
|
|
99
246
|
return (
|
|
247
|
+
<View testID={testID} style={{ gap: 4, minWidth: 220 }}>
|
|
100
248
|
<View
|
|
101
|
-
|
|
102
|
-
style={{ flexDirection: "row", alignItems: "center", gap: 10, padding: 10, borderRadius: 12, minWidth: 220, backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground }}
|
|
249
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 10, padding: 10, borderRadius: 12, backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground }}
|
|
103
250
|
>
|
|
104
251
|
<View
|
|
105
252
|
accessible
|
|
@@ -121,12 +268,103 @@ export function ChatAttachmentCard({ attachment, message, kind, mine = false, on
|
|
|
121
268
|
</View>
|
|
122
269
|
</View>
|
|
123
270
|
{action ? (
|
|
124
|
-
<NativeButton
|
|
271
|
+
<NativeButton
|
|
272
|
+
testID={`${testID}-action`}
|
|
273
|
+
variant={mine ? "secondary" : "default"}
|
|
274
|
+
className="h-11"
|
|
275
|
+
loading={state.pending}
|
|
276
|
+
onPress={() => (run ? run(action.run) : action.run())}
|
|
277
|
+
>
|
|
125
278
|
{action.label}
|
|
126
279
|
</NativeButton>
|
|
127
280
|
) : null}
|
|
128
281
|
</View>
|
|
282
|
+
{state.error ? <CardError text={state.error} mine={mine} testID={`${testID}-error`} /> : null}
|
|
283
|
+
</View>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The line under a card whose action failed. */
|
|
288
|
+
export function CardError({ text, mine, testID }: { text: string; mine: boolean; testID?: string }) {
|
|
289
|
+
const colors = useThemeRoles();
|
|
290
|
+
return (
|
|
291
|
+
<NativeText testID={testID} preset="caption" accessibilityLiveRegion="polite" className={mine ? "text-primary-foreground" : undefined} style={mine ? { paddingHorizontal: 4 } : { paddingHorizontal: 4, color: colors.destructive }}>
|
|
292
|
+
{text}
|
|
293
|
+
</NativeText>
|
|
129
294
|
);
|
|
130
295
|
}
|
|
131
296
|
|
|
297
|
+
export interface AppAttachmentProps {
|
|
298
|
+
attachment: ChatAttachment;
|
|
299
|
+
message: ChatMessage;
|
|
300
|
+
kind: ChatAttachmentKind | undefined;
|
|
301
|
+
url: string | null;
|
|
302
|
+
mine: boolean;
|
|
303
|
+
/** The app's slot: a component draws it, `null` hides an unregistered kind, undefined draws the card. */
|
|
304
|
+
slot?: ChatAttachmentSlot | null;
|
|
305
|
+
/** Told whether it drew nothing (the slot returned null, or hid the kind), so the bubble can leave it out. */
|
|
306
|
+
onEmpty?: (id: string, empty: boolean) => void;
|
|
307
|
+
onOpenAttachment?: OpenAttachment;
|
|
308
|
+
run: (task: () => unknown) => void;
|
|
309
|
+
state: CardRunState;
|
|
310
|
+
testID: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One app attachment in a bubble: through the app's slot when there is one, else the card. */
|
|
314
|
+
export function AppAttachment({ attachment, message, kind, url, mine, slot, onOpenAttachment, onEmpty, run, state, testID }: AppAttachmentProps) {
|
|
315
|
+
const hidden = slot === null && !kind;
|
|
316
|
+
useLayoutEffect(() => {
|
|
317
|
+
if (slot === null || slot === undefined) onEmpty?.(attachment.id, hidden);
|
|
318
|
+
}, [slot, hidden, attachment.id, onEmpty]);
|
|
319
|
+
const fallback = <ChatAttachmentCard attachment={attachment} message={message} kind={kind} mine={mine} onOpenAttachment={onOpenAttachment} run={run} state={state} testID={testID} />;
|
|
320
|
+
if (slot === undefined) return fallback;
|
|
321
|
+
if (slot === null) return hidden ? null : fallback;
|
|
322
|
+
const found = cardAction(kind, attachment, message, onOpenAttachment, { mine });
|
|
323
|
+
const action = found ? { label: found.label, run: () => run(found.run) } : null;
|
|
324
|
+
const props: ChatAttachmentSlotProps = { attachment, message, url: slotUrl(url), mine, kind, action, run, pending: state.pending, error: state.error, fallback, testID };
|
|
325
|
+
// The slot draws sender-controlled data: if it throws, the card stands in until what it draws changes.
|
|
326
|
+
return (
|
|
327
|
+
<ChatErrorBoundary fallback={<Reported id={attachment.id} empty={false} onEmpty={onEmpty}>{fallback}</Reported>} resetKeys={[attachment, message, kind, slot]}>
|
|
328
|
+
<SlotProbe key={slotKey(slot)} slot={slot} props={props} onEmpty={onEmpty} />
|
|
329
|
+
</ChatErrorBoundary>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const slotKeys = new WeakMap<object, number>();
|
|
334
|
+
let nextSlotKey = 0;
|
|
335
|
+
/** A stable key per slot component, so a new slot starts a new probe (its hooks start over). */
|
|
336
|
+
function slotKey(slot: ChatAttachmentSlot): number {
|
|
337
|
+
let k = slotKeys.get(slot);
|
|
338
|
+
if (k === undefined) slotKeys.set(slot, (k = ++nextSlotKey));
|
|
339
|
+
return k;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A component React lets us call as a function (not a class, not a memo or forwardRef object). */
|
|
343
|
+
function isPlainFunction(slot: ChatAttachmentSlot): slot is (props: ChatAttachmentSlotProps) => ReactNode {
|
|
344
|
+
return typeof slot === "function" && !(slot.prototype as { isReactComponent?: unknown } | undefined)?.isReactComponent;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Draws the slot and tells the bubble when it drew nothing. A plain function slot is called here,
|
|
349
|
+
* so its hooks are this component's (always the same slot: the probe is keyed on it); a
|
|
350
|
+
* class or memo slot renders as an element and counts as drawn.
|
|
351
|
+
*/
|
|
352
|
+
function SlotProbe({ slot, props, onEmpty }: { slot: ChatAttachmentSlot; props: ChatAttachmentSlotProps; onEmpty?: (id: string, empty: boolean) => void }) {
|
|
353
|
+
const Slot = slot;
|
|
354
|
+
const out: ReactNode = isPlainFunction(slot) ? slot(props) : <Slot {...props} />;
|
|
355
|
+
const empty = out === null || out === undefined || out === false;
|
|
356
|
+
return (
|
|
357
|
+
<Reported id={props.attachment.id} empty={empty} onEmpty={onEmpty}>
|
|
358
|
+
{empty ? null : out}
|
|
359
|
+
</Reported>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function Reported({ id, empty, onEmpty, children }: { id: string; empty: boolean; onEmpty?: (id: string, empty: boolean) => void; children: ReactNode }) {
|
|
364
|
+
useLayoutEffect(() => {
|
|
365
|
+
onEmpty?.(id, empty);
|
|
366
|
+
}, [id, empty, onEmpty]);
|
|
367
|
+
return <>{children}</>;
|
|
368
|
+
}
|
|
369
|
+
|
|
132
370
|
declare const __DEV__: boolean | undefined;
|
package/src/native/copy.ts
CHANGED
|
@@ -104,6 +104,15 @@ export interface ChatNativeCopy extends ChatOrganiseCopy {
|
|
|
104
104
|
/** The bubble's VoiceOver action for its link preview. */
|
|
105
105
|
openLinkAction: string;
|
|
106
106
|
attachmentCard: string;
|
|
107
|
+
/** Under an attachment card whose action failed (ticking a task offline). */
|
|
108
|
+
cardActionFailed: string;
|
|
109
|
+
/** Under an attachment card whose action ran too long (`cardActionTimeoutMs`). */
|
|
110
|
+
cardActionTimedOut: string;
|
|
111
|
+
/** A voice note's transcript: still being made, its VoiceOver label, and the toggle for a long one. */
|
|
112
|
+
transcribing: string;
|
|
113
|
+
transcriptLabel: string;
|
|
114
|
+
showMore: string;
|
|
115
|
+
showLess: string;
|
|
107
116
|
openImage: string;
|
|
108
117
|
closeViewer: string;
|
|
109
118
|
photoOf: string;
|
|
@@ -239,6 +248,12 @@ export const chatNativeCopy: ChatNativeCopy = {
|
|
|
239
248
|
openLink: "Opens the link",
|
|
240
249
|
openLinkAction: "Open link",
|
|
241
250
|
attachmentCard: "{title}, {subtitle}",
|
|
251
|
+
cardActionFailed: "That didn't work. Try again.",
|
|
252
|
+
cardActionTimedOut: "That's taking too long. Try again.",
|
|
253
|
+
transcribing: "Transcribing…",
|
|
254
|
+
transcriptLabel: "Transcript: {text}",
|
|
255
|
+
showMore: "Show more",
|
|
256
|
+
showLess: "Show less",
|
|
242
257
|
openImage: "Open photo",
|
|
243
258
|
closeViewer: "Close",
|
|
244
259
|
photoOf: "Photo {n} of {count}",
|
package/src/native/index.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* Video messages: `ChatVideoRecorder` (expo-camera), `ChatVideoPlayer`
|
|
17
17
|
* (expo-video, HLS), uploads through `ChatProvider videoUpload` with
|
|
18
18
|
* `chunkedPut`; link previews (`ChatLinkPreviewCard`) and the app's own
|
|
19
|
-
* attachment cards (`ChatAttachmentCard`)
|
|
19
|
+
* attachment cards (`ChatAttachmentCard`), or their own drawing through the
|
|
20
|
+
* `attachment` slot (`ChatThreadScreen slots`).
|
|
20
21
|
*
|
|
21
22
|
* Optional peers, passed in rather than imported: expo-audio,
|
|
22
23
|
* expo-image-picker, expo-haptics, expo-clipboard, expo-camera, expo-video,
|
|
@@ -26,14 +27,14 @@
|
|
|
26
27
|
* @stability Beta
|
|
27
28
|
*/
|
|
28
29
|
export { ChatInboxScreen, inboxRow, type ChatInboxScreenProps } from "./inbox-screen";
|
|
29
|
-
export { ChatThreadScreen, threadListMode, nearBottom, AT_BOTTOM_POINTS, type ChatThreadScreenProps } from "./thread-screen";
|
|
30
|
+
export { ChatThreadScreen, threadListMode, nearBottom, AT_BOTTOM_POINTS, type ChatThreadScreenProps, type ChatThreadScreenSlots } from "./thread-screen";
|
|
30
31
|
export { chatThreadStackOptions, useChatThreadStackOptions, type ChatThreadStackOptionsProps } from "./stack-options";
|
|
31
32
|
export { ChatStartSheet, ChatStartButton, type ChatStartPerson, type ChatStartSheetProps } from "./start-sheet";
|
|
32
33
|
export { ChatComposer, type ChatComposerProps } from "./composer";
|
|
33
34
|
export { MessageBubble, displayBody, formatClock, type MessageBubbleProps } from "./message-bubble";
|
|
34
35
|
export { MessageActionsSheet, type MessageActionsSheetProps } from "./message-actions-sheet";
|
|
35
36
|
export { VoiceNotePlayer, claimPlayback, releasePlayback, pauseChatPlayback, activePlaybackId, toggleVoiceNote, cycleVoiceNoteRate, usePlayingVoiceNote, scrubSeconds, waveformBars, barsThatFit, type VoiceNotePlayerProps } from "./voice-player";
|
|
36
|
-
export { MessageAttachments, attachmentPaths, type BubbleAttachment } from "./media";
|
|
37
|
+
export { MessageAttachments, attachmentPaths, VoiceTranscript, transcriptIsLong, TRANSCRIPT_LINES, type BubbleAttachment, type VoiceTranscriptProps } from "./media";
|
|
37
38
|
export {
|
|
38
39
|
ChatMediaViewer,
|
|
39
40
|
ChatPhotoViewer,
|
|
@@ -72,7 +73,30 @@ export { ChatVideoPlayer, videoState, videoSource, muxStreamUrl, muxPosterUrl, t
|
|
|
72
73
|
export { chunkedPut, fileSource, nextOffset, createVideoUploads, videoUploads, type VideoUploadError, type MintedUpload, useVideoUploads, recordedVideoFile, VIDEO_CHUNK_BYTES, type ByteSource, type ChunkedPutOptions, type RecordedVideo, type VideoUploadTask, type VideoUploads, type StartVideoInput, type OutboxSend } from "./video-upload";
|
|
73
74
|
export { VideoUploadTray, type VideoUploadTrayProps } from "./video-upload-tray";
|
|
74
75
|
export { ChatLinkPreviewCard, previewToShow, linkPreviewIds, openChatLink, linkHost, type ChatLinkPreviewCardProps } from "./link-preview";
|
|
75
|
-
export {
|
|
76
|
+
export {
|
|
77
|
+
ChatAttachmentCard,
|
|
78
|
+
AppAttachment,
|
|
79
|
+
CardError,
|
|
80
|
+
useCardRuns,
|
|
81
|
+
slotUrl,
|
|
82
|
+
CARD_ACTION_TIMEOUT_MS,
|
|
83
|
+
type CardRunOptions,
|
|
84
|
+
splitCardAttachments,
|
|
85
|
+
attachmentKindOf,
|
|
86
|
+
BUILT_IN_KINDS,
|
|
87
|
+
isOpenableHref,
|
|
88
|
+
cardAction,
|
|
89
|
+
cardTitle,
|
|
90
|
+
attachmentPreview,
|
|
91
|
+
messagePreview,
|
|
92
|
+
type ChatAttachmentCardProps,
|
|
93
|
+
type ChatAttachmentSlot,
|
|
94
|
+
type ChatAttachmentSlotProps,
|
|
95
|
+
type AppAttachmentProps,
|
|
96
|
+
type CardRunState,
|
|
97
|
+
type ChatCardContext,
|
|
98
|
+
type OpenAttachment,
|
|
99
|
+
} from "./attachment-cards";
|
|
76
100
|
export { ChatNativeProvider, useChatNative, initials, type ChatNativeProviderProps, type ChatNativeContextValue, type ChatPerson } from "./context";
|
|
77
101
|
export { chatNativeCopy, resolveChatCopy, fill, type ChatNativeCopy, type ChatNativeCopyOverrides } from "./copy";
|
|
78
102
|
export {
|
package/src/native/media.tsx
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Pictures and files in a message: photos as a grid of NativeImage (expo-image
|
|
4
4
|
* when the app gave it) over signed links from `useFileUrls`, a local preview
|
|
5
5
|
* while one still uploads, a tap opening the full-screen viewer (photo-viewer.tsx); voice notes
|
|
6
|
-
* as VoiceNotePlayer
|
|
6
|
+
* as VoiceNotePlayer with their transcript under them (three lines, then "Show more"), as on the
|
|
7
|
+
* web; other files as a row with their name.
|
|
7
8
|
*/
|
|
9
|
+
import { useState } from "react";
|
|
8
10
|
import { Pressable, View, useWindowDimensions } from "react-native";
|
|
9
11
|
import { NativeIcon, NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
10
12
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
@@ -33,6 +35,9 @@ export interface BubbleAttachment {
|
|
|
33
35
|
posterUri?: string | null;
|
|
34
36
|
/** Still on its way from this phone. */
|
|
35
37
|
uploading?: boolean;
|
|
38
|
+
/** A voice note's (or video's) transcript, and whether one is still being made. */
|
|
39
|
+
transcript?: string | null;
|
|
40
|
+
transcriptStatus?: ChatAttachment["transcriptStatus"];
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export function fromServer(a: ChatAttachment, urls: Record<string, string>): BubbleAttachment {
|
|
@@ -50,6 +55,8 @@ export function fromServer(a: ChatAttachment, urls: Record<string, string>): Bub
|
|
|
50
55
|
playbackId: a.playbackId,
|
|
51
56
|
status: a.status,
|
|
52
57
|
posterUri: a.posterPath ? (urls[a.posterPath] ?? null) : null,
|
|
58
|
+
transcript: a.transcript,
|
|
59
|
+
transcriptStatus: a.transcriptStatus,
|
|
53
60
|
};
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -149,7 +156,10 @@ export function MessageAttachments({ attachments, mine, onOpenImage, testID }: A
|
|
|
149
156
|
) : null}
|
|
150
157
|
{others.map((a, i) =>
|
|
151
158
|
a.kind === "voice" ? (
|
|
152
|
-
<
|
|
159
|
+
<View key={a.key} style={{ gap: 2 }}>
|
|
160
|
+
<VoiceNotePlayer id={a.key} uri={a.uri} durationMs={a.durationMs} waveform={a.waveform} mine={mine} testID={`${testID}-voice-${i}`} />
|
|
161
|
+
<VoiceTranscript transcript={a.transcript} status={a.transcriptStatus} mine={mine} testID={`${testID}-voice-${i}-transcript`} />
|
|
162
|
+
</View>
|
|
153
163
|
) : a.kind === "video" && modules.video ? (
|
|
154
164
|
<ChatVideoPlayer
|
|
155
165
|
key={a.key}
|
|
@@ -185,3 +195,80 @@ export function MessageAttachments({ attachments, mine, onOpenImage, testID }: A
|
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
// ChatMediaViewer (one picture) is in photo-viewer.tsx, over the full-screen ChatPhotoViewer.
|
|
198
|
+
|
|
199
|
+
/** Lines a transcript shows before "Show more". */
|
|
200
|
+
export const TRANSCRIPT_LINES = 3;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether a transcript folds behind "Show more": by its laid-out line count once measured. Before
|
|
204
|
+
* any layout (unit tests, where nothing is laid out) a rough guess from its length stands in.
|
|
205
|
+
*/
|
|
206
|
+
export function transcriptIsLong(text: string, measuredLines?: number | null): boolean {
|
|
207
|
+
if (typeof measuredLines === "number") return measuredLines > TRANSCRIPT_LINES;
|
|
208
|
+
return text.length > 140 || text.split("\n").length > TRANSCRIPT_LINES;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface VoiceTranscriptProps {
|
|
212
|
+
transcript: string | null | undefined;
|
|
213
|
+
status: ChatAttachment["transcriptStatus"] | undefined;
|
|
214
|
+
mine: boolean;
|
|
215
|
+
testID: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A voice note's transcript under the player: "Transcribing…" while it is made, then the text,
|
|
220
|
+
* folded to three lines with "Show more" when it is longer. The bubble reads the whole transcript
|
|
221
|
+
* to VoiceOver, so the toggle is for sight only.
|
|
222
|
+
*/
|
|
223
|
+
export function VoiceTranscript({ transcript, status, mine, testID }: VoiceTranscriptProps) {
|
|
224
|
+
const { copy } = useChatNative();
|
|
225
|
+
const colors = useThemeRoles();
|
|
226
|
+
const [open, setOpen] = useState(false);
|
|
227
|
+
// Lines the whole transcript takes at this width, from an unclamped copy laid out out of sight
|
|
228
|
+
// (a clamped Text reports only its visible lines on Android).
|
|
229
|
+
const [lines, setLines] = useState<number | null>(null);
|
|
230
|
+
const fg = mine ? "text-primary-foreground" : undefined;
|
|
231
|
+
const text = transcript?.trim();
|
|
232
|
+
if (status === "pending" && !text) {
|
|
233
|
+
return (
|
|
234
|
+
<NativeText testID={testID} preset="caption" className={fg} style={mine ? { fontStyle: "italic", opacity: 0.85, paddingHorizontal: 8 } : { fontStyle: "italic", color: colors.secondaryLabel, paddingHorizontal: 8 }}>
|
|
235
|
+
{copy.transcribing}
|
|
236
|
+
</NativeText>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (!text) return null;
|
|
240
|
+
const long = transcriptIsLong(text, lines);
|
|
241
|
+
const textStyle = mine ? { fontSize: 15, lineHeight: 20 } : { fontSize: 15, lineHeight: 20, color: colors.label };
|
|
242
|
+
return (
|
|
243
|
+
<View testID={testID} style={{ paddingHorizontal: 8, alignItems: "flex-start" }}>
|
|
244
|
+
<NativeText testID={`${testID}-text`} className={fg} numberOfLines={open || !long ? undefined : TRANSCRIPT_LINES} style={textStyle}>
|
|
245
|
+
{text}
|
|
246
|
+
</NativeText>
|
|
247
|
+
<NativeText
|
|
248
|
+
testID={`${testID}-measure`}
|
|
249
|
+
aria-hidden
|
|
250
|
+
accessibilityElementsHidden
|
|
251
|
+
importantForAccessibility="no-hide-descendants"
|
|
252
|
+
onTextLayout={(e: { nativeEvent: { lines: unknown[] } }) => setLines(e.nativeEvent.lines.length)}
|
|
253
|
+
style={[textStyle, { position: "absolute", left: 8, right: 8, top: 0, opacity: 0 }]}
|
|
254
|
+
pointerEvents="none"
|
|
255
|
+
>
|
|
256
|
+
{text}
|
|
257
|
+
</NativeText>
|
|
258
|
+
{long ? (
|
|
259
|
+
<Pressable
|
|
260
|
+
testID={`${testID}-toggle`}
|
|
261
|
+
accessibilityRole="button"
|
|
262
|
+
accessibilityState={{ expanded: open }}
|
|
263
|
+
onPress={() => setOpen((o) => !o)}
|
|
264
|
+
hitSlop={{ top: 8, bottom: 8 }}
|
|
265
|
+
style={{ minHeight: 44, minWidth: 44, justifyContent: "center" }}
|
|
266
|
+
>
|
|
267
|
+
<NativeText preset="caption" className={fg} style={mine ? { fontWeight: "600" } : { fontWeight: "600", color: colors.tint }}>
|
|
268
|
+
{open ? copy.showLess : copy.showMore}
|
|
269
|
+
</NativeText>
|
|
270
|
+
</Pressable>
|
|
271
|
+
) : null}
|
|
272
|
+
</View>
|
|
273
|
+
);
|
|
274
|
+
}
|