@marktiderman/genesis-chat 0.1.1 → 0.2.1

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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @feat:chat
3
+ * The full emoji picker on genesis-ui-native: a search bar, a row of
4
+ * category tabs (Recent first once there are any), and a grid of emoji.
5
+ * Every emoji is bundled (`emoji-data.ts`) and drawn by the system font:
6
+ * opening it fetches nothing. Recent emoji are kept per person with their
7
+ * drafts (`modules.drafts`), so signing out clears them.
8
+ *
9
+ * `ChatEmojiPicker` is the content, for a sheet of your own (the long-press
10
+ * sheet's "+" swaps it in); `ChatEmojiPickerSheet` is it in a NativeSheet
11
+ * (the composer's emoji button).
12
+ */
13
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
14
+ import { FlatList, Pressable, View, useWindowDimensions } from "react-native";
15
+ import { NativeEmptyState, NativeIcon, NativeSearchBar, NativeSheet, NativeText } from "@marktiderman/genesis-ui-native/components";
16
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
17
+ import type { ChatNativeCopy } from "./copy";
18
+ import { useChatNative } from "./context";
19
+ import { EMOJI_CATEGORIES, emojiEntry, emojiInCategory, loadRecentEmoji, pushRecentEmoji, saveRecentEmoji, searchEmoji, type EmojiCategoryId, type EmojiEntry } from "./emoji";
20
+
21
+ export interface ChatEmojiPickerProps {
22
+ onSelect: (emoji: string) => void;
23
+ /** The signed-in person, whose recent emoji are kept (with `modules.drafts`). Without it, none are kept. */
24
+ userId?: string | null;
25
+ /** Points tall for the grid. Default about 40% of the window. */
26
+ height?: number;
27
+ testID?: string;
28
+ }
29
+
30
+ type Tab = EmojiCategoryId | "recent";
31
+
32
+ const CATEGORY_COPY: Record<EmojiCategoryId, keyof ChatNativeCopy> = {
33
+ smileys: "emojiSmileys",
34
+ people: "emojiPeople",
35
+ nature: "emojiNature",
36
+ food: "emojiFood",
37
+ travel: "emojiTravel",
38
+ activities: "emojiActivities",
39
+ objects: "emojiObjects",
40
+ symbols: "emojiSymbols",
41
+ flags: "emojiFlags",
42
+ };
43
+
44
+ const CELL = 44;
45
+
46
+ // Recents read once per person per launch, then kept here as they change.
47
+ const recentCache = new Map<string, string[]>();
48
+
49
+ // Whose stored recents were read this launch (and merged into the cache).
50
+ const loaded = new Set<string>();
51
+
52
+ /** Forgets the recent emoji held in memory (one person's, or everyone's): call it on sign-out, with `clearDrafts`. */
53
+ export function forgetRecentEmoji(userId?: string): void {
54
+ if (userId) {
55
+ recentCache.delete(userId);
56
+ loaded.delete(userId);
57
+ } else {
58
+ recentCache.clear();
59
+ loaded.clear();
60
+ }
61
+ }
62
+
63
+ export function ChatEmojiPicker({ onSelect, userId = null, height, testID = "chat-emoji-picker" }: ChatEmojiPickerProps) {
64
+ const { modules, copy } = useChatNative();
65
+ const colors = useThemeRoles();
66
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
67
+ // Its own width once laid out (a sheet's padding), the window's until then.
68
+ const [width, setWidth] = useState(windowWidth);
69
+ const [query, setQuery] = useState("");
70
+ const [recents, setRecents] = useState<string[]>(() => (userId ? (recentCache.get(userId) ?? []) : []));
71
+ const [tab, setTab] = useState<Tab>(recents.length ? "recent" : "smileys");
72
+ const storage = modules.drafts;
73
+
74
+ useEffect(() => {
75
+ if (!userId || !storage || loaded.has(userId)) return;
76
+ loaded.add(userId);
77
+ let live = true;
78
+ void loadRecentEmoji(storage, userId).then((stored) => {
79
+ // Picked while it loaded: those come first, and the merged list is what's kept.
80
+ const picked = recentCache.get(userId) ?? [];
81
+ const list = picked.reduceRight((acc, e) => pushRecentEmoji(acc, e), stored);
82
+ recentCache.set(userId, list);
83
+ if (picked.length) void saveRecentEmoji(storage, userId, list);
84
+ if (!live || list.length === 0) return;
85
+ setRecents(list);
86
+ setTab((t) => (t === "smileys" ? "recent" : t));
87
+ });
88
+ return () => {
89
+ live = false;
90
+ };
91
+ }, [userId, storage]);
92
+
93
+ const columns = Math.max(6, Math.floor(width / CELL));
94
+ const gridHeight = height ?? Math.round(windowHeight * 0.4);
95
+ const searching = query.trim().length > 0;
96
+ const data: readonly EmojiEntry[] = useMemo(() => {
97
+ if (searching) return searchEmoji(query);
98
+ if (tab === "recent") return recents.map(emojiEntry);
99
+ return emojiInCategory(tab);
100
+ }, [searching, query, tab, recents]);
101
+
102
+ const listRef = useRef<{ scrollToOffset?: (o: { offset: number; animated: boolean }) => void } | null>(null);
103
+ const choose = useCallback(
104
+ (emoji: string) => {
105
+ onSelect(emoji);
106
+ if (!userId) return;
107
+ const next = pushRecentEmoji(recentCache.get(userId) ?? [], emoji);
108
+ // Kept for next time; the grid on screen doesn't reorder under the finger.
109
+ recentCache.set(userId, next);
110
+ if (storage) void saveRecentEmoji(storage, userId, next);
111
+ },
112
+ [onSelect, userId, storage],
113
+ );
114
+ // The columns share the width, so the grid fills it edge to edge.
115
+ const cellWidth = Math.floor(width / columns);
116
+ const renderItem = useCallback(
117
+ ({ item }: { item: EmojiEntry }) => <EmojiCell entry={item} width={cellWidth} onPress={choose} testID={`${testID}-emoji-${item.emoji}`} />,
118
+ [choose, testID, cellWidth],
119
+ );
120
+
121
+ const tabs: { id: Tab; label: string; icon: string | null }[] = [
122
+ ...(recents.length ? [{ id: "recent" as const, label: copy.recentEmoji, icon: null }] : []),
123
+ ...EMOJI_CATEGORIES.map((c) => ({ id: c.id, label: copy[CATEGORY_COPY[c.id]] as string, icon: c.icon })),
124
+ ];
125
+ const heading = searching ? null : (tabs.find((t) => t.id === tab)?.label ?? null);
126
+
127
+ return (
128
+ <View testID={testID} style={{ gap: 8 }} onLayout={(e) => e.nativeEvent.layout.width > 0 && setWidth(e.nativeEvent.layout.width)}>
129
+ <NativeSearchBar value={query} onChange={setQuery} placeholder={copy.searchEmoji} cancelLabel={copy.cancel} testID={`${testID}-search`} />
130
+ {searching ? null : (
131
+ <View accessibilityRole="tablist" testID={`${testID}-tabs`} style={{ flexDirection: "row", justifyContent: "space-between" }}>
132
+ {tabs.map((t) => {
133
+ const on = t.id === tab;
134
+ return (
135
+ <Pressable
136
+ key={t.id}
137
+ testID={`${testID}-tab-${t.id}`}
138
+ accessibilityRole="tab"
139
+ accessibilityLabel={t.label}
140
+ accessibilityState={{ selected: on }}
141
+ onPress={() => {
142
+ setTab(t.id);
143
+ listRef.current?.scrollToOffset?.({ offset: 0, animated: false });
144
+ }}
145
+ style={{ minWidth: 36, height: 44, flexGrow: 1, alignItems: "center", justifyContent: "center", borderRadius: 10, backgroundColor: on ? colors.fill : undefined }}
146
+ >
147
+ {t.icon ? (
148
+ <NativeText style={{ fontSize: 20, lineHeight: 26, opacity: on ? 1 : 0.55 }}>{t.icon}</NativeText>
149
+ ) : (
150
+ <NativeIcon name="clock" size={20} color={on ? colors.label : colors.secondaryLabel} />
151
+ )}
152
+ </Pressable>
153
+ );
154
+ })}
155
+ </View>
156
+ )}
157
+ {heading ? (
158
+ <NativeText preset="caption" accessibilityRole="header" style={{ color: colors.secondaryLabel, fontWeight: "600", paddingHorizontal: 4 }}>
159
+ {heading}
160
+ </NativeText>
161
+ ) : null}
162
+ <View style={{ height: gridHeight }}>
163
+ {data.length === 0 ? (
164
+ <NativeEmptyState variant="no-results" title={copy.noEmoji} testID={`${testID}-empty`} />
165
+ ) : (
166
+ <FlatList
167
+ ref={listRef as never}
168
+ key={columns}
169
+ testID={`${testID}-grid`}
170
+ data={data as EmojiEntry[]}
171
+ numColumns={columns}
172
+ keyExtractor={(e) => e.emoji}
173
+ renderItem={renderItem}
174
+ keyboardShouldPersistTaps="handled"
175
+ keyboardDismissMode="on-drag"
176
+ initialNumToRender={columns * 8}
177
+ // With numColumns, FlatList lays out rows: the index here is a row's.
178
+ getItemLayout={(_d, row) => ({ length: CELL, offset: CELL * row, index: row })}
179
+ />
180
+ )}
181
+ </View>
182
+ </View>
183
+ );
184
+ }
185
+
186
+ const EmojiCell = memo(function EmojiCell({ entry, width, onPress, testID }: { entry: EmojiEntry; width: number; onPress: (emoji: string) => void; testID: string }) {
187
+ return (
188
+ <Pressable
189
+ testID={testID}
190
+ accessibilityRole="button"
191
+ accessibilityLabel={entry.label}
192
+ onPress={() => onPress(entry.emoji)}
193
+ style={{ width: Math.max(CELL, width), height: CELL, alignItems: "center", justifyContent: "center" }}
194
+ >
195
+ {/* A line height of its own: the text preset's is a body line's and clips a 28pt glyph. */}
196
+ <NativeText style={{ fontSize: 28, lineHeight: 36 }} accessible={false}>
197
+ {entry.emoji}
198
+ </NativeText>
199
+ </Pressable>
200
+ );
201
+ });
202
+
203
+ export interface ChatEmojiPickerSheetProps extends Omit<ChatEmojiPickerProps, "onSelect"> {
204
+ open: boolean;
205
+ onClose: () => void;
206
+ /** An emoji was chosen. The sheet stays open, for another. */
207
+ onSelect: (emoji: string) => void;
208
+ }
209
+
210
+ /** The picker in a sheet, with a title and Done. */
211
+ export function ChatEmojiPickerSheet({ open, onClose, onSelect, testID = "chat-emoji-sheet", ...rest }: ChatEmojiPickerSheetProps) {
212
+ const { copy } = useChatNative();
213
+ const colors = useThemeRoles();
214
+ return (
215
+ <NativeSheet open={open} onOpenChange={(o) => (o ? undefined : onClose())} testID={testID}>
216
+ <View style={{ gap: 4, paddingHorizontal: 16 }}>
217
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", minHeight: 44 }}>
218
+ <NativeText preset="h4" accessibilityRole="header">
219
+ {copy.emojiPicker}
220
+ </NativeText>
221
+ <Pressable testID={`${testID}-done`} accessibilityRole="button" accessibilityLabel={copy.closeViewer} onPress={onClose} style={{ minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" }}>
222
+ <NativeIcon name="close" size={22} color={colors.secondaryLabel} />
223
+ </Pressable>
224
+ </View>
225
+ {open ? <ChatEmojiPicker onSelect={onSelect} testID={`${testID}-picker`} {...rest} /> : null}
226
+ </View>
227
+ </NativeSheet>
228
+ );
229
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @feat:chat
3
+ * The emoji picker's logic, without React: the bundled emoji by category
4
+ * (`emoji-data.ts`, generated, never fetched), search over their labels and
5
+ * tags, and the person's recent emoji, kept with their drafts (so signing out
6
+ * clears them with the drafts, through `clearDrafts`).
7
+ */
8
+ import { draftKey } from "./composer-state";
9
+ import { EMOJI_GROUP_DATA } from "./emoji-data";
10
+ import type { DraftStorageLike } from "./modules";
11
+
12
+ export type EmojiCategoryId = keyof typeof EMOJI_GROUP_DATA;
13
+
14
+ export interface EmojiEntry {
15
+ emoji: string;
16
+ label: string;
17
+ /** Search words beyond the label. */
18
+ tags: string;
19
+ }
20
+
21
+ /** The categories in the order the picker shows them, each with the emoji its tab shows. */
22
+ export const EMOJI_CATEGORIES: readonly { id: EmojiCategoryId; icon: string }[] = [
23
+ { id: "smileys", icon: "😀" },
24
+ { id: "people", icon: "👋" },
25
+ { id: "nature", icon: "🐻" },
26
+ { id: "food", icon: "🍔" },
27
+ { id: "travel", icon: "🚗" },
28
+ { id: "activities", icon: "⚽" },
29
+ { id: "objects", icon: "💡" },
30
+ { id: "symbols", icon: "❤️" },
31
+ { id: "flags", icon: "🏳️" },
32
+ ];
33
+
34
+ const parsed = new Map<EmojiCategoryId, EmojiEntry[]>();
35
+
36
+ /** One category's emoji, parsed the first time it's asked for. */
37
+ export function emojiInCategory(id: EmojiCategoryId): readonly EmojiEntry[] {
38
+ let list = parsed.get(id);
39
+ if (!list) {
40
+ list = EMOJI_GROUP_DATA[id].split("\n").map((line) => {
41
+ const [emoji = "", label = "", tags = ""] = line.split("\t");
42
+ return { emoji, label, tags };
43
+ });
44
+ parsed.set(id, list);
45
+ }
46
+ return list;
47
+ }
48
+
49
+ let index: Map<string, EmojiEntry> | null = null;
50
+ function byEmoji(): Map<string, EmojiEntry> {
51
+ if (!index) {
52
+ index = new Map();
53
+ for (const c of EMOJI_CATEGORIES) for (const e of emojiInCategory(c.id)) index.set(e.emoji, e);
54
+ }
55
+ return index;
56
+ }
57
+
58
+ /** An emoji's entry (its label for screen readers), or a bare one for an emoji the data doesn't have. */
59
+ export function emojiEntry(emoji: string): EmojiEntry {
60
+ return byEmoji().get(emoji) ?? { emoji, label: emoji, tags: "" };
61
+ }
62
+
63
+ /**
64
+ * Emoji whose label or tags have a word starting with every word of the query, those whose
65
+ * label starts with the query first. "thumbs up" finds 👍, "heart" every heart, "gri" 😀.
66
+ */
67
+ export function searchEmoji(query: string, limit = 120): EmojiEntry[] {
68
+ const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
69
+ if (words.length === 0) return [];
70
+ const whole = words.join(" ");
71
+ const first: EmojiEntry[] = [];
72
+ const rest: EmojiEntry[] = [];
73
+ for (const e of byEmoji().values()) {
74
+ const label = e.label.toLowerCase();
75
+ const hay = `${label} ${e.tags.toLowerCase()}`.split(/[^\p{L}\p{N}]+/u);
76
+ if (!words.every((w) => hay.some((h) => h.startsWith(w)))) continue;
77
+ (label.startsWith(whole) ? first : rest).push(e);
78
+ }
79
+ return [...first, ...rest].slice(0, limit);
80
+ }
81
+
82
+ /** How many recent emoji the picker keeps. */
83
+ export const RECENT_EMOJI_MAX = 24;
84
+
85
+ /** `emoji` first, once, the oldest dropped past `max`. */
86
+ export function pushRecentEmoji(recents: readonly string[], emoji: string, max = RECENT_EMOJI_MAX): string[] {
87
+ return [emoji, ...recents.filter((e) => e !== emoji)].slice(0, max);
88
+ }
89
+
90
+ /** Where a person's recent emoji are kept: under their drafts' prefix, so `clearDrafts` removes them too. */
91
+ export function recentEmojiKey(userId: string): string {
92
+ return draftKey(userId, "~recent-emoji");
93
+ }
94
+
95
+ export async function loadRecentEmoji(storage: DraftStorageLike, userId: string): Promise<string[]> {
96
+ try {
97
+ const raw = await storage.getItem(recentEmojiKey(userId));
98
+ const list: unknown = raw ? JSON.parse(raw) : [];
99
+ return Array.isArray(list) ? list.filter((e): e is string => typeof e === "string" && e.length > 0).slice(0, RECENT_EMOJI_MAX) : [];
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ export async function saveRecentEmoji(storage: DraftStorageLike, userId: string, recents: readonly string[]): Promise<void> {
106
+ try {
107
+ await storage.setItem(recentEmojiKey(userId), JSON.stringify(recents.slice(0, RECENT_EMOJI_MAX)));
108
+ } catch {
109
+ // Recents are a convenience: losing them is not worth an error.
110
+ }
111
+ }
112
+
113
+ /** Puts `insert` in place of the selected text (or at the cursor), and where the cursor goes after it. */
114
+ export function insertAtSelection(text: string, selection: { start: number; end: number } | null, insert: string): { text: string; cursor: number } {
115
+ const start = Math.max(0, Math.min(selection?.start ?? text.length, text.length));
116
+ const end = Math.max(start, Math.min(selection?.end ?? start, text.length));
117
+ return { text: text.slice(0, start) + insert + text.slice(end), cursor: start + insert.length };
118
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @feat:chat
3
+ * A 44-point icon button (a header's Search, the topic bar's options): genesis-ui-native's
4
+ * NativeIcon in a Pressable with its label, for VoiceOver and TalkBack.
5
+ */
6
+ import { Pressable } from "react-native";
7
+ import { NativeIcon, type NativeIconName } from "@marktiderman/genesis-ui-native/components";
8
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
9
+
10
+ export interface ChatIconButtonProps {
11
+ icon: NativeIconName;
12
+ /** Read by screen readers (the button has no text). */
13
+ label: string;
14
+ onPress: () => void;
15
+ onLongPress?: () => void;
16
+ disabled?: boolean;
17
+ /** The theme's tint by default. */
18
+ color?: unknown;
19
+ size?: number;
20
+ testID?: string;
21
+ }
22
+
23
+ export function ChatIconButton({ icon, label, onPress, onLongPress, disabled = false, color, size = 22, testID }: ChatIconButtonProps) {
24
+ const colors = useThemeRoles();
25
+ return (
26
+ <Pressable
27
+ testID={testID}
28
+ accessibilityRole="button"
29
+ accessibilityLabel={label}
30
+ accessibilityState={{ disabled }}
31
+ disabled={disabled}
32
+ onPress={onPress}
33
+ onLongPress={onLongPress}
34
+ hitSlop={4}
35
+ style={{ minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.4 : 1 }}
36
+ >
37
+ <NativeIcon name={icon} size={size} color={(color ?? colors.tint) as string} />
38
+ </Pressable>
39
+ );
40
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @feat:chat
3
+ * Your conversations on genesis-ui-native's InboxPage: each with its avatar,
4
+ * unread count, last message and time; pull to refresh; an empty state that
5
+ * offers to start a conversation; Mark read, Pin and Archive on swipe, long
6
+ * press and as accessibility actions. Under the navigator's large title
7
+ * (`getInboxPageStackOptions`).
8
+ */
9
+ import { useCallback, useMemo, useState } from "react";
10
+ import { NativeAvatar } from "@marktiderman/genesis-ui-native/components";
11
+ import { InboxPage, type InboxItem, type InboxPageProps } from "@marktiderman/genesis-ui-native/layouts";
12
+ import type { ChatInboxItem } from "../core/types";
13
+ import { chatKeys, useChat, useInbox, usePeople, type ChatAttachmentKind } from "../react/index";
14
+ import { attachmentPreview } from "./attachment-cards";
15
+ import { useQueryClient } from "@tanstack/react-query";
16
+ import type { ChatNativeCopy } from "./copy";
17
+ import { initials, useChatNative, type ChatPerson } from "./context";
18
+ import { displayBody } from "./message-bubble";
19
+
20
+ export interface ChatInboxScreenProps {
21
+ /** Opens a conversation (`router.push(chatPath(id))`). */
22
+ onOpen: (conversationId: string) => void;
23
+ /** Starts one (opens your people picker, or `ChatStartSheet`). Shows the empty state's button. */
24
+ onStart?: () => void;
25
+ /** One kind only. */
26
+ kind?: string;
27
+ /** Only these kinds. */
28
+ kinds?: string[];
29
+ /** Every kind but these (`["support"]` keeps support tickets out of a messages inbox). */
30
+ excludeKinds?: string[];
31
+ archived?: boolean;
32
+ /** Passed to InboxPage (a header, a section title, slots). */
33
+ pageProps?: Partial<Omit<InboxPageProps, "items">>;
34
+ testID?: string;
35
+ }
36
+
37
+ /** An inbox row from a conversation. Exported for apps that build their own list. */
38
+ export function inboxRow(
39
+ item: ChatInboxItem,
40
+ ctx: {
41
+ me: string;
42
+ copy: ChatNativeCopy;
43
+ person: (id: string | null) => ChatPerson | undefined;
44
+ /** The app's attachment kinds: a last message that is a card reads as its title ("Check-in"). */
45
+ kinds?: Record<string, ChatAttachmentKind>;
46
+ },
47
+ ): Omit<InboxItem, "onPress" | "swipeActions"> {
48
+ const other = item.otherUserId ? ctx.person(item.otherUserId) : undefined;
49
+ const title = item.title ?? other?.name ?? ctx.copy.untitled;
50
+ const last = item.lastMessage;
51
+ const text = last ? attachmentPreview(displayBody(last.body), last.attachment, ctx.kinds, last.deleted) : "";
52
+ const preview = last && text && last.senderId === ctx.me && last.kind !== "system" ? `${ctx.copy.youPrefix}${text}` : text;
53
+ return {
54
+ id: item.conversationId,
55
+ title,
56
+ preview: preview || undefined,
57
+ timestamp: item.lastMessageAt ?? undefined,
58
+ unread: item.unread > 0,
59
+ unreadCount: item.unread,
60
+ meta: item.mentioned ? ctx.copy.mentionedYou : undefined,
61
+ avatar: <NativeAvatar size={40} src={other?.avatarUrl ?? null} fallback={initials(title)} alt={title} recyclingKey={item.conversationId} />,
62
+ };
63
+ }
64
+
65
+ export function ChatInboxScreen({ onOpen, onStart, kind, kinds, excludeKinds, archived, pageProps, testID = "chat-inbox" }: ChatInboxScreenProps) {
66
+ const { client, userId, attachmentKinds } = useChat();
67
+ const queryClient = useQueryClient();
68
+ const { copy, person: known } = useChatNative();
69
+ const inbox = useInbox({ kind, kinds, excludeKinds, archived });
70
+ // The other person in each direct conversation, fetched in one batch (ChatProvider loadPeople).
71
+ const others = useMemo(() => inbox.items.map((i) => i.otherUserId), [inbox.items]);
72
+ const people = usePeople(others);
73
+ const person = useCallback((id: string | null | undefined) => known(id) ?? (id ? people[id] : undefined), [known, people]);
74
+ const [refreshing, setRefreshing] = useState(false);
75
+
76
+ const refresh = useCallback(async () => {
77
+ setRefreshing(true);
78
+ try {
79
+ await inbox.refetch();
80
+ } finally {
81
+ setRefreshing(false);
82
+ }
83
+ }, [inbox]);
84
+
85
+ const settle = useCallback(() => {
86
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
87
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
88
+ }, [queryClient]);
89
+
90
+ const items: InboxItem[] = inbox.items.map((item) => ({
91
+ ...inboxRow(item, { me: userId, copy, person, kinds: attachmentKinds }),
92
+ onPress: () => onOpen(item.conversationId),
93
+ swipeActions: [
94
+ ...(item.unread > 0 ? [{ id: "read", label: copy.markRead, onPress: () => void client.markRead(item.conversationId).then(settle, settle) }] : []),
95
+ {
96
+ id: "pin",
97
+ label: item.pinned ? copy.unpin : copy.pin,
98
+ onPress: () => void client.setMySettings(item.conversationId, { pinned: !item.pinned }).then(settle, settle),
99
+ },
100
+ ...(item.archived
101
+ ? []
102
+ : [{ id: "archive", label: copy.archive, onPress: () => void client.setMySettings(item.conversationId, { archived: true }).then(settle, settle) }]),
103
+ ],
104
+ }));
105
+
106
+ return (
107
+ <InboxPage
108
+ testID={testID}
109
+ items={items}
110
+ loading={inbox.isLoading}
111
+ error={Boolean(inbox.error)}
112
+ errorTitle={copy.inboxErrorTitle}
113
+ refreshing={refreshing}
114
+ onRefresh={refresh}
115
+ emptyTitle={copy.inboxEmptyTitle}
116
+ emptyBody={copy.inboxEmptyBody}
117
+ emptyActionLabel={onStart ? copy.startConversation : undefined}
118
+ onEmptyAction={onStart}
119
+ {...pageProps}
120
+ />
121
+ );
122
+ }