@marktiderman/genesis-chat 0.1.0 → 0.2.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 +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* One message row: mine on the trailing side in the tint, theirs on surface;
|
|
4
|
+
* in a group, the sender's name over the first of a run and their avatar
|
|
5
|
+
* beside the last. A reply shows what it answers; reactions sit under the
|
|
6
|
+
* bubble; a message still sending is faded, and one that failed says so with
|
|
7
|
+
* Retry and Delete. Long press opens the actions; swipe right to reply. Both
|
|
8
|
+
* are also VoiceOver / TalkBack actions, so nothing needs a gesture. The
|
|
9
|
+
* bubble is one accessible element (its parts would otherwise be read one by
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
import { memo, useMemo, useRef } from "react";
|
|
14
|
+
import { Animated, PanResponder, Pressable, View } from "react-native";
|
|
15
|
+
import { NativeAvatar, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
16
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
17
|
+
import { previewText, summarizeReactions } from "../core/thread";
|
|
18
|
+
import type { ChatLinkPreview, ChatMessage, PendingFile } from "../core/types";
|
|
19
|
+
import type { ChatAttachmentKind } from "../react/index";
|
|
20
|
+
import { cardAction, ChatAttachmentCard, splitCardAttachments, type OpenAttachment } from "./attachment-cards";
|
|
21
|
+
import { ChatLinkPreviewCard, linkHost, openChatLink } from "./link-preview";
|
|
22
|
+
import { cycleVideoRate, fullScreenVideo, toggleVideo, toggleVideoMute, usePlayingVideo } from "./video-player";
|
|
23
|
+
import { fill } from "./copy";
|
|
24
|
+
import { initials, useChatNative } from "./context";
|
|
25
|
+
import { fromPending, fromPendingInput, fromServer, MessageAttachments, type BubbleAttachment } from "./media";
|
|
26
|
+
import { ModerationMarker, moderationNote, moderationText } from "./moderation";
|
|
27
|
+
import { cycleVoiceNoteRate, toggleVoiceNote, usePlayingVoiceNote } from "./voice-player";
|
|
28
|
+
import type { ThreadRow } from "./rows";
|
|
29
|
+
|
|
30
|
+
type MessageRow = Extract<ThreadRow, { type: "message" }>;
|
|
31
|
+
|
|
32
|
+
export interface MessageBubbleProps {
|
|
33
|
+
row: MessageRow;
|
|
34
|
+
me: string;
|
|
35
|
+
/** Signed links by storage path. */
|
|
36
|
+
urls: Record<string, string>;
|
|
37
|
+
/** The message this one replies to, when it is loaded. */
|
|
38
|
+
repliedTo?: ChatMessage | null;
|
|
39
|
+
/** Avatars sit in a column when the conversation is a group. */
|
|
40
|
+
group: boolean;
|
|
41
|
+
reducedMotion: boolean;
|
|
42
|
+
onLongPress?: (message: ChatMessage) => void;
|
|
43
|
+
onReply?: (message: ChatMessage) => void;
|
|
44
|
+
onRetry?: (clientId: string) => void;
|
|
45
|
+
onDiscard?: (clientId: string) => void;
|
|
46
|
+
/** A failed message whose files didn't survive a relaunch: pick them again (`useConversation().reattach`). */
|
|
47
|
+
onReattach?: (clientId: string, files: readonly PendingFile[]) => void;
|
|
48
|
+
onToggleReaction?: (message: ChatMessage, emoji: string, on: boolean) => void;
|
|
49
|
+
onOpenImage?: (uri: string, alt: string) => void;
|
|
50
|
+
/** The swipe crossed the reply line (for a haptic). */
|
|
51
|
+
onSwipeReply?: () => void;
|
|
52
|
+
/** You observe this conversation: moderation's markers show (hidden, flagged). */
|
|
53
|
+
observer?: boolean;
|
|
54
|
+
/** Drawn highlighted for a moment (the message a search result opened). */
|
|
55
|
+
highlighted?: boolean;
|
|
56
|
+
/** The preview of a link in its text (`useLinkPreviews`, `previewToShow`). */
|
|
57
|
+
linkPreview?: ChatLinkPreview | null;
|
|
58
|
+
/** Show link previews' images (off by default; the thread screen passes `ChatProvider linkPreviewImages`). */
|
|
59
|
+
linkPreviewImages?: boolean;
|
|
60
|
+
/** The app's own attachment kinds, drawn as cards (`ChatProvider attachmentKinds`). */
|
|
61
|
+
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
62
|
+
/** A card's button, when its kind gives no `onPress` of its own. */
|
|
63
|
+
onOpenAttachment?: OpenAttachment;
|
|
64
|
+
testID: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SWIPE_REPLY = 56;
|
|
68
|
+
const SWIPE_MAX = 80;
|
|
69
|
+
|
|
70
|
+
export function formatClock(iso: string): string {
|
|
71
|
+
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mentions written `@[Name](user:<id>)` read as `@Name`. */
|
|
75
|
+
export function displayBody(body: string): string {
|
|
76
|
+
return body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
80
|
+
const { row, me, urls, repliedTo, group, reducedMotion, testID } = props;
|
|
81
|
+
const { copy, person, modules } = useChatNative();
|
|
82
|
+
const colors = useThemeRoles();
|
|
83
|
+
const { item, mine } = row;
|
|
84
|
+
const message = item.type === "message" ? item.message : null;
|
|
85
|
+
const pending = item.type === "pending" ? item.pending : null;
|
|
86
|
+
|
|
87
|
+
const body = message ? (message.deletedAt ? "" : message.body) : (pending?.input.body ?? "");
|
|
88
|
+
const createdAt = message?.createdAt ?? pending!.createdAt;
|
|
89
|
+
const failed = pending?.status === "failed";
|
|
90
|
+
// Its files are gone (bytes not kept between launches): Retry can't send it, picking them again can.
|
|
91
|
+
const lost = failed && (pending?.files ?? []).some((f) => f.lost);
|
|
92
|
+
const sending = pending?.status === "sending";
|
|
93
|
+
const sender = person(row.senderId);
|
|
94
|
+
const senderName = mine ? copy.you : (sender?.name ?? "");
|
|
95
|
+
|
|
96
|
+
const kinds = props.attachmentKinds;
|
|
97
|
+
const { cards, rest: plain } = useMemo(
|
|
98
|
+
() => splitCardAttachments(message && !message.deletedAt ? message.attachments : [], kinds),
|
|
99
|
+
[message, kinds],
|
|
100
|
+
);
|
|
101
|
+
const attachments: BubbleAttachment[] = useMemo(() => {
|
|
102
|
+
if (message) return message.deletedAt ? [] : plain.map((a) => fromServer(a, urls));
|
|
103
|
+
// What it already carries (a hosted video) comes first, as the outbox sends it.
|
|
104
|
+
return [
|
|
105
|
+
...(pending?.input.attachments ?? []).map((a, i) => fromPendingInput(pending!.clientId, i, a)),
|
|
106
|
+
...(pending?.files ?? []).map((f, i) => fromPending(pending!.clientId, i, f, pending!.previews?.[i])),
|
|
107
|
+
];
|
|
108
|
+
}, [message, pending, urls, plain]);
|
|
109
|
+
const preview = message && !message.deletedAt && message.moderation !== "hidden" ? (props.linkPreview ?? null) : null;
|
|
110
|
+
|
|
111
|
+
// The controls the accessible bubble hides, as its own actions.
|
|
112
|
+
const voices = attachments.filter((a) => a.kind === "voice" && a.uri && modules.audio);
|
|
113
|
+
const photos = attachments.filter((a) => a.kind === "image" && a.uri && props.onOpenImage);
|
|
114
|
+
const videos = attachments.filter((a) => a.kind === "video" && modules.video && (a.uri || a.playbackId));
|
|
115
|
+
const playingVideo = usePlayingVideo(videos.map((a) => a.key));
|
|
116
|
+
const cardActions = message ? cards.map((a) => cardAction(kinds?.[a.kind], a, message, props.onOpenAttachment)) : [];
|
|
117
|
+
const playingVoice = usePlayingVoiceNote(voices.map((a) => a.key));
|
|
118
|
+
|
|
119
|
+
// ---- Swipe to reply ----
|
|
120
|
+
const x = useRef(new Animated.Value(0)).current;
|
|
121
|
+
const latest = useRef(props);
|
|
122
|
+
latest.current = props;
|
|
123
|
+
const crossed = useRef(false);
|
|
124
|
+
const responder = useMemo(
|
|
125
|
+
() =>
|
|
126
|
+
PanResponder.create({
|
|
127
|
+
onMoveShouldSetPanResponder: (_e, g) => g.dx > 12 && Math.abs(g.dx) > Math.abs(g.dy) * 2,
|
|
128
|
+
onPanResponderMove: (_e, g) => {
|
|
129
|
+
const dx = Math.max(0, Math.min(SWIPE_MAX, g.dx));
|
|
130
|
+
x.setValue(dx);
|
|
131
|
+
const over = dx >= SWIPE_REPLY;
|
|
132
|
+
if (over && !crossed.current) latest.current.onSwipeReply?.();
|
|
133
|
+
crossed.current = over;
|
|
134
|
+
},
|
|
135
|
+
onPanResponderRelease: (_e, g) => {
|
|
136
|
+
const m = latest.current.row.item.type === "message" ? latest.current.row.item.message : null;
|
|
137
|
+
if (g.dx >= SWIPE_REPLY && m) latest.current.onReply?.(m);
|
|
138
|
+
crossed.current = false;
|
|
139
|
+
if (latest.current.reducedMotion) x.setValue(0);
|
|
140
|
+
else Animated.spring(x, { toValue: 0, useNativeDriver: true, bounciness: 4 }).start();
|
|
141
|
+
},
|
|
142
|
+
onPanResponderTerminate: () => {
|
|
143
|
+
crossed.current = false;
|
|
144
|
+
x.setValue(0);
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
[x],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (message?.kind === "system") {
|
|
151
|
+
return (
|
|
152
|
+
<View testID={testID} style={{ alignItems: "center", paddingVertical: 6, paddingHorizontal: 24 }}>
|
|
153
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel, textAlign: "center" }}>
|
|
154
|
+
{displayBody(message.body)}
|
|
155
|
+
</NativeText>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const canSwipe = Boolean(message && !message.deletedAt && props.onReply);
|
|
161
|
+
const reactions = message ? summarizeReactions(message, me) : [];
|
|
162
|
+
const time = formatClock(createdAt);
|
|
163
|
+
const moderation = moderationNote(message, { mine, observer: Boolean(props.observer) });
|
|
164
|
+
const statusText = failed ? copy.notSent : sending ? copy.sending : null;
|
|
165
|
+
const label = [
|
|
166
|
+
senderName,
|
|
167
|
+
message?.deletedAt ? copy.deleted : displayBody(body),
|
|
168
|
+
attachments.map((a) => (a.kind === "image" ? copy.photo : a.kind === "voice" ? copy.voice : a.kind === "video" ? copy.video : copy.file)).join(", "),
|
|
169
|
+
cards.map((a) => cardTitle(kinds?.[a.kind], a)).join(", "),
|
|
170
|
+
preview ? fill(copy.linkPreview, { title: preview.title?.trim() || linkHost(preview.url), site: preview.siteName?.trim() || linkHost(preview.url) }) : null,
|
|
171
|
+
time,
|
|
172
|
+
message?.editedAt ? copy.edited : null,
|
|
173
|
+
statusText,
|
|
174
|
+
moderation ? moderationText(moderation, copy) : null,
|
|
175
|
+
reactions.length ? fill(copy.reactions, { count: reactions.reduce((n, r) => n + r.count, 0) }) : null,
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(", ");
|
|
179
|
+
|
|
180
|
+
const actions = [
|
|
181
|
+
...voices.flatMap((a, i) => [
|
|
182
|
+
{ name: `voice-${i}`, label: playingVoice === a.key ? copy.pauseVoice : copy.playVoice },
|
|
183
|
+
{ name: `voice-rate-${i}`, label: copy.changeSpeed },
|
|
184
|
+
]),
|
|
185
|
+
...photos.map((_, i) => ({ name: `photo-${i}`, label: photos.length > 1 ? fill(copy.openPhotoN, { n: i + 1 }) : copy.openImage })),
|
|
186
|
+
...videos.flatMap((a, i) => [
|
|
187
|
+
{ name: `video-${i}`, label: playingVideo === a.key ? copy.pauseVideo : copy.playVideo },
|
|
188
|
+
// Mute and speed reach the player that is playing.
|
|
189
|
+
...(playingVideo === a.key ? [{ name: `video-mute-${i}`, label: copy.mute }, { name: `video-rate-${i}`, label: copy.changeSpeed }] : []),
|
|
190
|
+
{ name: `video-full-${i}`, label: copy.fullScreen },
|
|
191
|
+
]),
|
|
192
|
+
...cardActions.flatMap((c, i) => (c ? [{ name: `card-${i}`, label: c.label }] : [])),
|
|
193
|
+
...(preview ? [{ name: "open-link", label: copy.openLinkAction }] : []),
|
|
194
|
+
...(message && props.onReply && !message.deletedAt ? [{ name: "reply", label: copy.reply }] : []),
|
|
195
|
+
...(message && props.onLongPress && !message.deletedAt ? [{ name: "actions", label: copy.messageActions }] : []),
|
|
196
|
+
...(failed && !lost && props.onRetry ? [{ name: "retry", label: copy.retry }] : []),
|
|
197
|
+
...(lost && props.onReattach ? [{ name: "reattach", label: copy.attachAgain }] : []),
|
|
198
|
+
...(failed && props.onDiscard ? [{ name: "discard", label: copy.discard }] : []),
|
|
199
|
+
];
|
|
200
|
+
const onAction = (name: string) => {
|
|
201
|
+
const media = /^(voice-rate|voice|photo|video-full|video-mute|video-rate|video|card)-(\d+)$/.exec(name);
|
|
202
|
+
if (media) {
|
|
203
|
+
const i = Number(media[2]);
|
|
204
|
+
if (media[1] === "card") cardActions[i]?.run();
|
|
205
|
+
else if (media[1] === "video-mute" && videos[i]) toggleVideoMute(videos[i].key);
|
|
206
|
+
else if (media[1] === "video-rate" && videos[i]) cycleVideoRate(videos[i].key);
|
|
207
|
+
else if (media[1] === "video" && videos[i]) toggleVideo(videos[i].key);
|
|
208
|
+
else if (media[1] === "video-full" && videos[i]) fullScreenVideo(videos[i].key);
|
|
209
|
+
else if (media[1] === "voice" && voices[i]) toggleVoiceNote(voices[i].key);
|
|
210
|
+
else if (media[1] === "voice-rate" && voices[i]) cycleVoiceNoteRate(voices[i].key);
|
|
211
|
+
else if (media[1] === "photo" && photos[i]?.uri) props.onOpenImage?.(photos[i].uri, photos[i].name ? `${copy.photo}, ${photos[i].name}` : copy.photo);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (name === "open-link" && preview) void openChatLink(preview.url, modules.webBrowser);
|
|
215
|
+
else if (name === "reply" && message) props.onReply?.(message);
|
|
216
|
+
else if (name === "actions" && message) props.onLongPress?.(message);
|
|
217
|
+
else if (name === "retry" && pending) props.onRetry?.(pending.clientId);
|
|
218
|
+
else if (name === "reattach" && pending) props.onReattach?.(pending.clientId, pending.files ?? []);
|
|
219
|
+
else if (name === "discard" && pending) props.onDiscard?.(pending.clientId);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const quote = message?.replyToId ?? pending?.input.replyToId ?? null;
|
|
223
|
+
const quoted = quote && repliedTo ? repliedTo : null;
|
|
224
|
+
const bubbleColor = mine ? colors.tint : colors.surface;
|
|
225
|
+
const fg = mine ? "text-primary-foreground" : undefined;
|
|
226
|
+
const textStyle = mine ? { fontSize: 17, lineHeight: 22 } : { fontSize: 17, lineHeight: 22, color: colors.label };
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<View testID={testID} style={{ paddingHorizontal: 8, paddingTop: row.groupStart ? 8 : 2, backgroundColor: props.highlighted ? colors.fill : undefined }}>
|
|
230
|
+
{row.showName && sender?.name ? (
|
|
231
|
+
<NativeText preset="caption" style={{ marginLeft: group ? 48 : 12, marginBottom: 2, color: colors.secondaryLabel }}>
|
|
232
|
+
{sender.name}
|
|
233
|
+
</NativeText>
|
|
234
|
+
) : null}
|
|
235
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", justifyContent: mine ? "flex-end" : "flex-start" }}>
|
|
236
|
+
{group && !mine ? (
|
|
237
|
+
<View style={{ width: 40, alignItems: "flex-start" }}>
|
|
238
|
+
{row.showAvatar ? <NativeAvatar size={32} src={sender?.avatarUrl ?? null} fallback={initials(sender?.name)} alt={sender?.name} recyclingKey={row.senderId ?? undefined} /> : null}
|
|
239
|
+
</View>
|
|
240
|
+
) : null}
|
|
241
|
+
<Animated.View
|
|
242
|
+
style={{ maxWidth: "80%", transform: [{ translateX: x }] }}
|
|
243
|
+
{...(canSwipe ? responder.panHandlers : {})}
|
|
244
|
+
>
|
|
245
|
+
<Pressable
|
|
246
|
+
testID={`${testID}-bubble`}
|
|
247
|
+
accessible
|
|
248
|
+
accessibilityLabel={label}
|
|
249
|
+
accessibilityHint={failed ? copy.retry : undefined}
|
|
250
|
+
accessibilityActions={actions}
|
|
251
|
+
onAccessibilityAction={(e) => onAction(e.nativeEvent.actionName)}
|
|
252
|
+
onLongPress={message && !message.deletedAt ? () => props.onLongPress?.(message) : undefined}
|
|
253
|
+
delayLongPress={350}
|
|
254
|
+
style={{
|
|
255
|
+
paddingHorizontal: attachments.length && !body ? 4 : 12,
|
|
256
|
+
paddingVertical: attachments.length && !body ? 4 : 8,
|
|
257
|
+
borderRadius: 18,
|
|
258
|
+
backgroundColor: bubbleColor,
|
|
259
|
+
opacity: sending ? 0.6 : moderation === "hidden-mine" ? 0.7 : 1,
|
|
260
|
+
gap: 4,
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
{quote ? (
|
|
264
|
+
<View
|
|
265
|
+
testID={`${testID}-quote`}
|
|
266
|
+
style={{ borderLeftWidth: 3, borderLeftColor: mine ? colors.surface : colors.tint, paddingLeft: 8, paddingVertical: 2, opacity: 0.85 }}
|
|
267
|
+
>
|
|
268
|
+
<NativeText preset="caption" className={fg} style={mine ? { fontWeight: "600" } : { fontWeight: "600", color: colors.secondaryLabel }}>
|
|
269
|
+
{quoted ? (quoted.senderId === me ? copy.you : (person(quoted.senderId)?.name ?? "")) : ""}
|
|
270
|
+
</NativeText>
|
|
271
|
+
<NativeText preset="caption" numberOfLines={2} className={fg} style={mine ? undefined : { color: colors.secondaryLabel }}>
|
|
272
|
+
{quoted ? previewText(quoted.body, quoted.attachments[0]?.kind, Boolean(quoted.deletedAt)) : "…"}
|
|
273
|
+
</NativeText>
|
|
274
|
+
</View>
|
|
275
|
+
) : null}
|
|
276
|
+
{attachments.length ? <MessageAttachments attachments={attachments} mine={mine} onOpenImage={props.onOpenImage} testID={testID} /> : null}
|
|
277
|
+
{message && cards.length
|
|
278
|
+
? cards.map((a, i) => (
|
|
279
|
+
<ChatAttachmentCard key={a.id} attachment={a} message={message} kind={kinds![a.kind]!} mine={mine} onOpenAttachment={props.onOpenAttachment} testID={`${testID}-card-${i}`} />
|
|
280
|
+
))
|
|
281
|
+
: null}
|
|
282
|
+
{message?.deletedAt ? (
|
|
283
|
+
<NativeText className={fg} style={[textStyle, { fontStyle: "italic", opacity: 0.7 }]}>
|
|
284
|
+
{copy.deleted}
|
|
285
|
+
</NativeText>
|
|
286
|
+
) : body ? (
|
|
287
|
+
<NativeText className={fg} style={[textStyle, attachments.length ? { paddingHorizontal: 8, paddingBottom: 4 } : null]}>
|
|
288
|
+
{displayBody(body)}
|
|
289
|
+
</NativeText>
|
|
290
|
+
) : null}
|
|
291
|
+
{moderation ? <ModerationMarker note={moderation} mine={mine} testID={`${testID}-moderation`} /> : null}
|
|
292
|
+
{preview ? <ChatLinkPreviewCard preview={preview} mine={mine} images={props.linkPreviewImages} testID={`${testID}-preview`} /> : null}
|
|
293
|
+
</Pressable>
|
|
294
|
+
</Animated.View>
|
|
295
|
+
</View>
|
|
296
|
+
{reactions.length ? (
|
|
297
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginTop: 2, justifyContent: mine ? "flex-end" : "flex-start", marginLeft: group && !mine ? 40 : 0 }}>
|
|
298
|
+
{reactions.map((r) => (
|
|
299
|
+
<Pressable
|
|
300
|
+
key={r.emoji}
|
|
301
|
+
testID={`${testID}-reaction-${r.emoji}`}
|
|
302
|
+
accessibilityRole="button"
|
|
303
|
+
accessibilityState={{ selected: r.mine }}
|
|
304
|
+
accessibilityLabel={`${r.emoji} ${r.count}`}
|
|
305
|
+
disabled={!message || !props.onToggleReaction}
|
|
306
|
+
onPress={() => message && props.onToggleReaction?.(message, r.emoji, !r.mine)}
|
|
307
|
+
hitSlop={6}
|
|
308
|
+
style={{
|
|
309
|
+
flexDirection: "row",
|
|
310
|
+
alignItems: "center",
|
|
311
|
+
gap: 4,
|
|
312
|
+
minHeight: 32,
|
|
313
|
+
paddingHorizontal: 10,
|
|
314
|
+
borderRadius: 16,
|
|
315
|
+
backgroundColor: r.mine ? colors.fill : colors.surface,
|
|
316
|
+
borderWidth: r.mine ? 1 : 0,
|
|
317
|
+
borderColor: colors.tint,
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
<NativeText style={{ fontSize: 15 }}>{r.emoji}</NativeText>
|
|
321
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
|
|
322
|
+
{String(r.count)}
|
|
323
|
+
</NativeText>
|
|
324
|
+
</Pressable>
|
|
325
|
+
))}
|
|
326
|
+
</View>
|
|
327
|
+
) : null}
|
|
328
|
+
{row.groupEnd || failed ? (
|
|
329
|
+
<View style={{ flexDirection: "row", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "center", gap: 4, marginTop: 2, marginLeft: group && !mine ? 52 : 12, marginRight: 12 }}>
|
|
330
|
+
{failed ? (
|
|
331
|
+
<>
|
|
332
|
+
<NativeText testID={`${testID}-failed`} preset="caption" style={{ color: colors.destructive }}>
|
|
333
|
+
{pending?.error ?? copy.notSent}
|
|
334
|
+
</NativeText>
|
|
335
|
+
{lost ? (
|
|
336
|
+
props.onReattach ? (
|
|
337
|
+
<SmallAction testID={`${testID}-reattach`} label={copy.attachAgain} onPress={() => props.onReattach?.(pending!.clientId, pending!.files ?? [])} color={colors.tint} />
|
|
338
|
+
) : null
|
|
339
|
+
) : (
|
|
340
|
+
<SmallAction testID={`${testID}-retry`} label={copy.retry} onPress={() => props.onRetry?.(pending!.clientId)} color={colors.tint} />
|
|
341
|
+
)}
|
|
342
|
+
<SmallAction testID={`${testID}-discard`} label={copy.discard} onPress={() => props.onDiscard?.(pending!.clientId)} color={colors.destructive} />
|
|
343
|
+
</>
|
|
344
|
+
) : (
|
|
345
|
+
<NativeText preset="caption" style={{ color: colors.tertiaryLabel }}>
|
|
346
|
+
{[time, message?.editedAt ? copy.edited : null, sending ? copy.sending : null].filter(Boolean).join(" · ")}
|
|
347
|
+
</NativeText>
|
|
348
|
+
)}
|
|
349
|
+
</View>
|
|
350
|
+
) : null}
|
|
351
|
+
</View>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function SmallAction({ label, onPress, color, testID }: { label: string; onPress: () => void; color: unknown; testID: string }) {
|
|
356
|
+
return (
|
|
357
|
+
<Pressable testID={testID} accessibilityRole="button" accessibilityLabel={label} onPress={onPress} style={{ minHeight: 44, minWidth: 44, paddingHorizontal: 8, alignItems: "center", justifyContent: "center" }}>
|
|
358
|
+
<NativeText preset="label" style={{ color: color as string }}>
|
|
359
|
+
{label}
|
|
360
|
+
</NativeText>
|
|
361
|
+
</Pressable>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A card's title for the bubble's label (an app kind that throws reads as its name). */
|
|
366
|
+
function cardTitle(kind: ChatAttachmentKind | undefined, a: ChatMessage["attachments"][number]): string {
|
|
367
|
+
try {
|
|
368
|
+
return kind?.title(a) || a.name || a.kind;
|
|
369
|
+
} catch {
|
|
370
|
+
return a.name || a.kind;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const MessageBubble = memo(MessageBubbleImpl);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* What moderation said about a message, in its bubble. A hidden message reaches only its sender
|
|
4
|
+
* and the conversation's observers: the sender sees it dimmed with "Hidden by moderation. Only
|
|
5
|
+
* you can see it."; an observer sees a "Hidden by moderation" marker. A flagged message is seen
|
|
6
|
+
* by everyone; observers see a quiet "Flagged" marker. The same rules as the web's bubble.
|
|
7
|
+
*/
|
|
8
|
+
import { View } from "react-native";
|
|
9
|
+
import { NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
10
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
11
|
+
import type { ChatMessage } from "../core/types";
|
|
12
|
+
import { useChatNative } from "./context";
|
|
13
|
+
|
|
14
|
+
export type ModerationNote = "hidden-mine" | "hidden" | "flagged";
|
|
15
|
+
|
|
16
|
+
/** The note a bubble shows, or null. Deleted messages carry none. */
|
|
17
|
+
export function moderationNote(message: Pick<ChatMessage, "moderation" | "deletedAt"> | null | undefined, opts: { mine: boolean; observer: boolean }): ModerationNote | null {
|
|
18
|
+
if (!message || message.deletedAt) return null;
|
|
19
|
+
if (message.moderation === "hidden") return opts.mine && !opts.observer ? "hidden-mine" : "hidden";
|
|
20
|
+
if (message.moderation === "flagged" && opts.observer) return "flagged";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The note's words (for the bubble's accessibility label too). */
|
|
25
|
+
export function moderationText(note: ModerationNote, copy: { moderationHidden: string; moderationHiddenMine: string; moderationFlagged: string }): string {
|
|
26
|
+
return note === "hidden-mine" ? copy.moderationHiddenMine : note === "hidden" ? copy.moderationHidden : copy.moderationFlagged;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ModerationMarker({ note, mine, testID }: { note: ModerationNote; mine: boolean; testID?: string }) {
|
|
30
|
+
const { copy } = useChatNative();
|
|
31
|
+
const colors = useThemeRoles();
|
|
32
|
+
const color = note === "flagged" ? colors.secondaryLabel : colors.destructive;
|
|
33
|
+
// On your own bubble (the tint), in the bubble's own text color, as its body is.
|
|
34
|
+
return (
|
|
35
|
+
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
36
|
+
{mine ? null : <NativeIcon name={note === "flagged" ? "warning" : "lock"} size={12} color={color} />}
|
|
37
|
+
<NativeText preset="caption" className={mine ? "text-primary-foreground" : undefined} style={mine ? { fontWeight: "600", flexShrink: 1 } : { color, fontWeight: "600", flexShrink: 1 }}>
|
|
38
|
+
{moderationText(note, copy)}
|
|
39
|
+
</NativeText>
|
|
40
|
+
</View>
|
|
41
|
+
);
|
|
42
|
+
}
|