@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.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @feat:chat
3
+ * A voice note: play and pause, a waveform that fills as it plays and seeks
4
+ * where you click, the time, a 1× / 1.5× / 2× speed toggle, and the
5
+ * transcript when there is one. Only one voice note plays at a time.
6
+ */
7
+ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
8
+ import { Pause, Play } from "lucide-react";
9
+ import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, cn } from "@marktiderman/genesis-ui";
10
+ import { formatDuration, nextPlaybackRate } from "../core/media";
11
+
12
+ /** The one playing now, paused when another starts. */
13
+ let playing: HTMLAudioElement | null = null;
14
+
15
+ export interface VoiceNoteProps {
16
+ /** The audio's (signed) URL; while it loads the waveform still draws. */
17
+ src: string | null | undefined;
18
+ durationMs?: number | null;
19
+ /** Peaks 0–100. */
20
+ waveform?: number[] | null;
21
+ transcript?: string | null;
22
+ transcriptStatus?: "pending" | "ready" | "failed" | "off" | null;
23
+ /** On the primary (own) bubble. */
24
+ tone?: "default" | "primary";
25
+ className?: string;
26
+ }
27
+
28
+ const BARS = 48;
29
+
30
+ function resample(peaks: number[] | null | undefined, n = BARS): number[] {
31
+ if (!peaks || peaks.length === 0) return new Array(n).fill(12);
32
+ if (peaks.length === n) return peaks;
33
+ return Array.from({ length: n }, (_, i) => peaks[Math.min(peaks.length - 1, Math.floor((i / n) * peaks.length))] ?? 0);
34
+ }
35
+
36
+ export function VoiceNote({ src, durationMs, waveform, transcript, transcriptStatus, tone = "default", className }: VoiceNoteProps) {
37
+ const audio = useRef<HTMLAudioElement | null>(null);
38
+ const [isPlaying, setPlaying] = useState(false);
39
+ const [current, setCurrent] = useState(0);
40
+ const [duration, setDuration] = useState((durationMs ?? 0) / 1000);
41
+ const [rate, setRate] = useState(1);
42
+ const bars = resample(waveform);
43
+ const progress = duration > 0 ? Math.min(1, current / duration) : 0;
44
+
45
+ useEffect(() => {
46
+ const el = audio.current;
47
+ if (!el) return;
48
+ const onPlay = () => {
49
+ if (playing && playing !== el) playing.pause();
50
+ playing = el;
51
+ setPlaying(true);
52
+ };
53
+ const onPause = () => {
54
+ if (playing === el) playing = null;
55
+ setPlaying(false);
56
+ };
57
+ const onTime = () => setCurrent(el.currentTime);
58
+ const onMeta = () => {
59
+ if (Number.isFinite(el.duration) && el.duration > 0) setDuration(el.duration);
60
+ };
61
+ const onEnded = () => {
62
+ setPlaying(false);
63
+ setCurrent(0);
64
+ };
65
+ el.addEventListener("play", onPlay);
66
+ el.addEventListener("pause", onPause);
67
+ el.addEventListener("timeupdate", onTime);
68
+ el.addEventListener("loadedmetadata", onMeta);
69
+ el.addEventListener("durationchange", onMeta);
70
+ el.addEventListener("ended", onEnded);
71
+ return () => {
72
+ el.removeEventListener("play", onPlay);
73
+ el.removeEventListener("pause", onPause);
74
+ el.removeEventListener("timeupdate", onTime);
75
+ el.removeEventListener("loadedmetadata", onMeta);
76
+ el.removeEventListener("durationchange", onMeta);
77
+ el.removeEventListener("ended", onEnded);
78
+ if (playing === el) playing = null;
79
+ };
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ if (audio.current) audio.current.playbackRate = rate;
84
+ }, [rate]);
85
+
86
+ const toggle = () => {
87
+ const el = audio.current;
88
+ if (!el || !src) return;
89
+ if (el.paused) {
90
+ el.playbackRate = rate;
91
+ void Promise.resolve(el.play?.()).catch(() => setPlaying(false));
92
+ } else el.pause();
93
+ };
94
+
95
+ const seekTo = (fraction: number) => {
96
+ const el = audio.current;
97
+ const f = Math.max(0, Math.min(1, fraction));
98
+ if (el && duration > 0) {
99
+ el.currentTime = f * duration;
100
+ setCurrent(f * duration);
101
+ }
102
+ };
103
+
104
+ const onBarsClick = (e: MouseEvent<HTMLDivElement>) => {
105
+ const box = e.currentTarget.getBoundingClientRect();
106
+ if (box.width > 0) seekTo((e.clientX - box.left) / box.width);
107
+ };
108
+
109
+ // The waveform is a slider drawn as bars: Radix Slider draws a track and a
110
+ // thumb, not a waveform, so the role and keys are written here (arrows ±5 s,
111
+ // Home/End), on one focusable element.
112
+ const onBarsKey = (e: KeyboardEvent<HTMLDivElement>) => {
113
+ if (duration <= 0) return;
114
+ const step = 5 / duration;
115
+ const keys: Record<string, number> = { ArrowRight: progress + step, ArrowUp: progress + step, ArrowLeft: progress - step, ArrowDown: progress - step, Home: 0, End: 1 };
116
+ if (e.key in keys) {
117
+ e.preventDefault();
118
+ seekTo(keys[e.key]!);
119
+ }
120
+ };
121
+
122
+ const shown = isPlaying || current > 0 ? current * 1000 : duration * 1000;
123
+ const primary = tone === "primary";
124
+
125
+ return (
126
+ <div className={cn("flex min-w-[220px] max-w-full flex-col gap-1", className)} data-testid="genesis-chat-voice">
127
+ <div className="flex items-center gap-2">
128
+ <Button
129
+ type="button"
130
+ size="icon"
131
+ variant={primary ? "secondary" : "default"}
132
+ className="h-9 w-9 shrink-0 rounded-full"
133
+ onClick={toggle}
134
+ disabled={!src}
135
+ aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
136
+ data-testid="genesis-chat-voice-play"
137
+ >
138
+ {isPlaying ? <Pause /> : <Play />}
139
+ </Button>
140
+ <div
141
+ role="slider"
142
+ tabIndex={0}
143
+ aria-label="Position"
144
+ aria-valuemin={0}
145
+ aria-valuemax={Math.round(duration)}
146
+ aria-valuenow={Math.round(current)}
147
+ aria-valuetext={`${formatDuration(current * 1000)} of ${formatDuration(duration * 1000)}`}
148
+ className="flex h-8 flex-1 cursor-pointer items-center gap-[2px] rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
149
+ onClick={onBarsClick}
150
+ onKeyDown={onBarsKey}
151
+ >
152
+ {bars.map((peak, i) => (
153
+ <span
154
+ key={i}
155
+ aria-hidden
156
+ className={cn(
157
+ "w-[3px] flex-1 rounded-full transition-colors",
158
+ (i + 0.5) / bars.length <= progress
159
+ ? primary
160
+ ? "bg-primary-foreground"
161
+ : "bg-primary"
162
+ : primary
163
+ ? "bg-primary-foreground/40"
164
+ : "bg-muted-foreground/40",
165
+ )}
166
+ style={{ height: `${Math.max(12, peak)}%` }}
167
+ />
168
+ ))}
169
+ </div>
170
+ <Button
171
+ type="button"
172
+ size="sm"
173
+ variant="ghost"
174
+ className={cn("h-7 shrink-0 rounded-full px-2 text-xs tabular-nums", primary && "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground")}
175
+ onClick={() => setRate((r) => nextPlaybackRate(r))}
176
+ aria-label={`Playback speed ${rate}×`}
177
+ data-testid="genesis-chat-voice-rate"
178
+ >
179
+ {rate}×
180
+ </Button>
181
+ </div>
182
+ <div className={cn("pl-11 text-xs tabular-nums", primary ? "text-primary-foreground/80" : "text-muted-foreground")}>{formatDuration(shown)}</div>
183
+ {transcriptStatus === "pending" ? (
184
+ <div className={cn("pl-11 text-xs italic", primary ? "text-primary-foreground/80" : "text-muted-foreground")}>Transcribing…</div>
185
+ ) : transcript ? (
186
+ <Collapsible className="pl-11">
187
+ <CollapsibleTrigger className={cn("text-xs font-medium underline-offset-2 hover:underline", primary ? "text-primary-foreground" : "text-primary")}>
188
+ Transcript
189
+ </CollapsibleTrigger>
190
+ <CollapsibleContent className="pt-1 text-sm">{transcript}</CollapsibleContent>
191
+ </Collapsible>
192
+ ) : null}
193
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- the transcript above is the caption */}
194
+ <audio ref={audio} src={src ?? undefined} preload="metadata" className="hidden" />
195
+ </div>
196
+ );
197
+ }
@@ -1,322 +0,0 @@
1
- import { createOutbox, upsertMessages, currentActivity, mergeThread, throttle, firstUnreadId } from './chunk-SAY24E6V.js';
2
- import { useQueryClient, useQuery } from '@tanstack/react-query';
3
- import { createContext, useState, useRef, useMemo, useEffect, useContext, useCallback } from 'react';
4
- import { jsx } from 'react/jsx-runtime';
5
-
6
- var chatKeys = {
7
- all: ["genesis-chat"],
8
- inbox: (opts) => ["genesis-chat", "inbox", opts ?? {}],
9
- unread: () => ["genesis-chat", "unread"],
10
- conversation: (id) => ["genesis-chat", "conversation", id],
11
- members: (id) => ["genesis-chat", "members", id],
12
- role: (id) => ["genesis-chat", "role", id],
13
- pins: (id) => ["genesis-chat", "pins", id],
14
- files: (paths) => ["genesis-chat", "files", [...paths].sort()]
15
- };
16
- function belongsTo(key, m) {
17
- return key[0] === "genesis-chat" && key[1] === "conversation" && key[2] === m.conversationId && key[3] === "messages" && (key[4] ?? null) === (m.threadRootId ?? null) && (key[5] == null || key[5] === m.topic);
18
- }
19
- var ChatContext = createContext(null);
20
- function ChatProvider({ client, userId, outboxStore, readFile, upload, newId, persistFile, loadPeople, children }) {
21
- const queryClient = useQueryClient();
22
- const [queue, setQueue] = useState([]);
23
- const listeners = useRef(/* @__PURE__ */ new Set());
24
- const outbox = useMemo(
25
- () => createOutbox({
26
- client,
27
- userId,
28
- store: outboxStore,
29
- readFile,
30
- upload,
31
- newId,
32
- persistFile,
33
- onChange: setQueue,
34
- onSent: (message) => {
35
- queryClient.setQueriesData(
36
- { predicate: (q) => belongsTo(q.queryKey, message) },
37
- (prev) => Array.isArray(prev) ? upsertMessages(prev, [message]) : prev
38
- );
39
- void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
40
- }
41
- }),
42
- // A new person or client is a new outbox.
43
- // eslint-disable-next-line react-hooks/exhaustive-deps
44
- [client, userId]
45
- );
46
- useEffect(() => {
47
- void outbox.load();
48
- const sub = client.subscribeInbox(userId, (signal) => {
49
- void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
50
- void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
51
- for (const l of listeners.current) l(signal);
52
- });
53
- return () => sub.unsubscribe();
54
- }, [client, userId, outbox, queryClient]);
55
- const value = useMemo(
56
- () => ({
57
- client,
58
- userId,
59
- outbox,
60
- queue,
61
- loadPeople,
62
- onInbox: (l) => {
63
- listeners.current.add(l);
64
- return () => listeners.current.delete(l);
65
- }
66
- }),
67
- [client, userId, outbox, queue, loadPeople]
68
- );
69
- return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
70
- }
71
- function useChat() {
72
- const ctx = useContext(ChatContext);
73
- if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
74
- return ctx;
75
- }
76
- function useInbox(opts = {}) {
77
- const { client } = useChat();
78
- const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15e3 });
79
- return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
80
- }
81
- function useUnreadTotal() {
82
- const { client } = useChat();
83
- return useQuery({ queryKey: chatKeys.unread(), queryFn: () => client.unreadTotal(), staleTime: 15e3 }).data ?? 0;
84
- }
85
- function useConversation(conversationId, opts = {}) {
86
- const { client, userId, outbox, queue } = useChat();
87
- const queryClient = useQueryClient();
88
- const id = conversationId ?? "";
89
- const enabled = Boolean(conversationId);
90
- const pageSize = opts.pageSize ?? 50;
91
- const messagesKey = useMemo(
92
- () => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null],
93
- [id, opts.threadRootId, opts.topic]
94
- );
95
- const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
96
- const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 6e4 });
97
- const kindRules = useQuery({
98
- queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
99
- queryFn: () => client.kind(conversation.data.kind),
100
- enabled: Boolean(conversation.data?.kind),
101
- staleTime: 10 * 6e4
102
- });
103
- const access = useQuery({
104
- queryKey: chatKeys.role(id),
105
- queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
106
- enabled,
107
- staleTime: 6e4
108
- });
109
- const [hasEarlier, setHasEarlier] = useState(false);
110
- const [loadingEarlier, setLoadingEarlier] = useState(false);
111
- const messages = useQuery({
112
- queryKey: messagesKey,
113
- queryFn: async () => {
114
- const page = await client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
115
- setHasEarlier(page.hasEarlier);
116
- return page.messages;
117
- },
118
- enabled,
119
- staleTime: Infinity
120
- });
121
- const [readWhenOpened, setReadWhenOpened] = useState(null);
122
- const merge = useCallback(
123
- (incoming) => queryClient.setQueryData(messagesKey, (prev) => upsertMessages(prev ?? [], incoming)),
124
- [queryClient, messagesKey]
125
- );
126
- const catchUp = useCallback(async () => {
127
- const current = queryClient.getQueryData(messagesKey) ?? [];
128
- const after = current.length ? current[current.length - 1].seq : void 0;
129
- if (after == null) {
130
- await queryClient.invalidateQueries({ queryKey: messagesKey });
131
- return;
132
- }
133
- const page = await client.messages(id, { afterSeq: after, limit: 200, threadRootId: opts.threadRootId, topic: opts.topic });
134
- merge(page.messages);
135
- }, [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
136
- const [activity, setActivity] = useState([]);
137
- const channel = useRef(null);
138
- useEffect(() => {
139
- if (!enabled) return;
140
- const fetchOne = async (messageId) => {
141
- const m = await client.message(messageId);
142
- if (!m) return;
143
- const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
144
- if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
145
- if (m.threadRootId && !opts.threadRootId) {
146
- const root = await client.message(m.threadRootId);
147
- if (root) merge([root]);
148
- }
149
- };
150
- channel.current = client.subscribeConversation(id, userId, {
151
- onSignal: (s) => {
152
- if (s.type === "message") void fetchOne(s.id);
153
- else if (s.type === "reaction") void fetchOne(s.messageId);
154
- else if (s.type === "members") {
155
- void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
156
- void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
157
- } else if (s.type === "conversation") {
158
- void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id) });
159
- void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
160
- }
161
- },
162
- onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
163
- onReconnect: () => void catchUp()
164
- });
165
- return () => {
166
- channel.current?.unsubscribe();
167
- channel.current = null;
168
- };
169
- }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
170
- useEffect(() => {
171
- if (activity.length === 0) return;
172
- const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2e3);
173
- return () => clearTimeout(t);
174
- }, [activity]);
175
- const list = messages.data ?? [];
176
- const latestSeq = list.length ? list[list.length - 1].seq : 0;
177
- const myLastRead = members.data?.find((m) => m.userId === userId)?.lastReadSeq;
178
- useEffect(() => {
179
- if (readWhenOpened === null && myLastRead !== void 0) setReadWhenOpened(myLastRead);
180
- }, [myLastRead, readWhenOpened]);
181
- const markRead = useCallback(async () => {
182
- if (!enabled || latestSeq === 0) return;
183
- await client.markRead(id, latestSeq);
184
- void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
185
- void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
186
- }, [client, enabled, id, latestSeq, queryClient]);
187
- const shouldMark = opts.markRead !== false;
188
- useEffect(() => {
189
- if (shouldMark && latestSeq > 0 && !opts.threadRootId) void markRead().catch(() => void 0);
190
- }, [shouldMark, latestSeq, markRead, opts.threadRootId]);
191
- const loadEarlier = useCallback(async () => {
192
- const first = list[0];
193
- if (!first || loadingEarlier) return;
194
- setLoadingEarlier(true);
195
- try {
196
- const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
197
- merge(page.messages);
198
- setHasEarlier(page.hasEarlier);
199
- } finally {
200
- setLoadingEarlier(false);
201
- }
202
- }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
203
- const pending = useMemo(
204
- () => queue.filter(
205
- (q) => q.input.conversationId === id && (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) && (!opts.topic || q.input.topic === opts.topic)
206
- ).map((q) => ({ ...q, files: q.pendingFiles })),
207
- [queue, id, opts.threadRootId, opts.topic]
208
- );
209
- const items = useMemo(() => mergeThread(list, pending), [list, pending]);
210
- const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
211
- const signalRecording = useMemo(
212
- () => throttle(() => channel.current?.signal("recording-voice")),
213
- []
214
- );
215
- const send = useCallback(
216
- (input, files = []) => outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
217
- [outbox, id, opts.threadRootId, opts.topic]
218
- );
219
- return {
220
- conversation: conversation.data ?? null,
221
- members: members.data ?? [],
222
- role: access.data?.role ?? null,
223
- canPost: access.data?.canPost ?? false,
224
- /** Your role and posting rights are still loading: show the composer as waiting, not refused. */
225
- accessLoading: enabled && access.isLoading,
226
- /** The kind's rules (max members, threads, who posts). */
227
- kind: kindRules.data ?? null,
228
- /** May you pin here: owners and admins, or anyone in a two-person conversation. */
229
- canPin: access.data?.role === "owner" || access.data?.role === "admin" || access.data?.role === "member" && kindRules.data?.maxMembers === 2,
230
- /** Gives a message whose files were lost on a relaunch its files again. */
231
- reattach: outbox.reattach,
232
- items,
233
- messages: list,
234
- isLoading: messages.isLoading || conversation.isLoading,
235
- error: messages.error ?? conversation.error,
236
- hasEarlier,
237
- loadingEarlier,
238
- loadEarlier,
239
- /** Where to draw the "New" line, from what you had read when it opened. */
240
- firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
241
- activity: currentActivity(activity),
242
- signalTyping,
243
- signalRecording,
244
- markRead,
245
- send,
246
- retry: outbox.retry,
247
- discard: outbox.discard,
248
- refetch: catchUp
249
- };
250
- }
251
- function useMessageActions() {
252
- const { client } = useChat();
253
- const queryClient = useQueryClient();
254
- return useMemo(() => {
255
- const refresh = async (conversationId, messageId) => {
256
- const m = await client.message(messageId);
257
- queryClient.setQueriesData(
258
- { queryKey: chatKeys.conversation(conversationId) },
259
- (prev) => Array.isArray(prev) && m ? upsertMessages(prev, [m]) : prev
260
- );
261
- };
262
- return {
263
- edit: async (m, body, mentions = []) => {
264
- await client.edit(m.id, body, mentions);
265
- await refresh(m.conversationId, m.id);
266
- },
267
- remove: async (m) => {
268
- await client.remove(m.id);
269
- await refresh(m.conversationId, m.id);
270
- },
271
- react: async (m, emoji, on) => {
272
- await client.react(m.id, emoji, on);
273
- await refresh(m.conversationId, m.id);
274
- },
275
- pin: async (m, on) => {
276
- await client.pin(m.id, on);
277
- void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
278
- },
279
- save: (m, on, note) => client.save(m.id, on, note),
280
- report: (m, reason, note) => client.report(m.id, reason, note)
281
- };
282
- }, [client, queryClient]);
283
- }
284
- function usePeople(userIds) {
285
- const { loadPeople } = useChat();
286
- const ids = useMemo(() => [...new Set(userIds.filter((x) => Boolean(x)))].sort(), [userIds]);
287
- return useQuery({
288
- queryKey: ["genesis-chat", "people", ids],
289
- queryFn: () => loadPeople(ids),
290
- enabled: Boolean(loadPeople) && ids.length > 0,
291
- staleTime: 5 * 6e4
292
- }).data ?? {};
293
- }
294
- function usePerson(userId) {
295
- const people = usePeople(useMemo(() => [userId], [userId]));
296
- return userId ? people[userId] ?? null : null;
297
- }
298
- function usePins(conversationId) {
299
- const { client } = useChat();
300
- return useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId), enabled: Boolean(conversationId) }).data ?? [];
301
- }
302
- function useFileUrls(paths) {
303
- const { client } = useChat();
304
- const list = useMemo(() => [...new Set(paths.filter((p) => Boolean(p)))], [paths]);
305
- return useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 6e4 }).data ?? {};
306
- }
307
- function useStartConversation() {
308
- const { client } = useChat();
309
- const queryClient = useQueryClient();
310
- return useCallback(
311
- async (kind, memberIds, opts) => {
312
- const id = await client.startConversation(kind, memberIds, opts);
313
- void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
314
- return id;
315
- },
316
- [client, queryClient]
317
- );
318
- }
319
-
320
- export { ChatProvider, belongsTo, chatKeys, useChat, useConversation, useFileUrls, useInbox, useMessageActions, usePeople, usePerson, usePins, useStartConversation, useUnreadTotal };
321
- //# sourceMappingURL=chunk-27UTR5FW.js.map
322
- //# sourceMappingURL=chunk-27UTR5FW.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/react/index.tsx"],"names":[],"mappings":"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","file":"chunk-27UTR5FW.js","sourcesContent":["/**\n * @feat:chat\n * React hooks for chat, the same on web and native: the inbox, one\n * conversation (its messages, members, your role, who is typing) kept live by\n * Realtime hints, sending through the outbox, and message actions. Server\n * state is TanStack Query's; the renderers in /web and /native only draw.\n */\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { ChatClient, ConversationChannel } from \"../core/client\";\nimport { createOutbox, type LocalFile, type Outbox, type OutboxStore, type QueuedMessage } from \"../core/outbox\";\nimport { currentActivity, firstUnreadId, mergeThread, throttle, upsertMessages, type ThreadItem } from \"../core/thread\";\nimport type { ChatActivity, ChatMessage, ChatSignal, SendInput } from \"../core/types\";\n\nexport const chatKeys = {\n all: [\"genesis-chat\"] as const,\n inbox: (opts?: object) => [\"genesis-chat\", \"inbox\", opts ?? {}] as const,\n unread: () => [\"genesis-chat\", \"unread\"] as const,\n conversation: (id: string) => [\"genesis-chat\", \"conversation\", id] as const,\n members: (id: string) => [\"genesis-chat\", \"members\", id] as const,\n role: (id: string) => [\"genesis-chat\", \"role\", id] as const,\n pins: (id: string) => [\"genesis-chat\", \"pins\", id] as const,\n files: (paths: string[]) => [\"genesis-chat\", \"files\", [...paths].sort()] as const,\n};\n\n/** Does a messages list (`[genesis-chat, conversation, id, messages, threadRootId, topic]`) hold this message? */\nexport function belongsTo(key: readonly unknown[], m: ChatMessage): boolean {\n return (\n key[0] === \"genesis-chat\" &&\n key[1] === \"conversation\" &&\n key[2] === m.conversationId &&\n key[3] === \"messages\" &&\n (key[4] ?? null) === (m.threadRootId ?? null) &&\n (key[5] == null || key[5] === m.topic)\n );\n}\n\ninterface ChatContextValue {\n client: ChatClient;\n userId: string;\n outbox: Outbox;\n queue: QueuedMessage[];\n loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;\n /** Subscribe to inbox hints (one channel for the app, fanned out here). */\n onInbox(listener: (s: ChatSignal) => void): () => void;\n}\n\nconst ChatContext = createContext<ChatContextValue | null>(null);\n\nexport interface ChatProviderProps {\n client: ChatClient;\n /** The signed-in user's id (auth.users). */\n userId: string;\n /** Where unsent messages wait between launches (localStorage, MMKV…). Memory by default. */\n outboxStore?: OutboxStore;\n /** Reads a native file URI for upload. */\n readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;\n /** Replaces the plain upload (resumable uploads on native). */\n upload?: (conversationId: string, file: LocalFile) => Promise<string>;\n /** Makes client ids (default: a random UUID that works without crypto.randomUUID). */\n newId?: () => string;\n /** Keeps a byte file (a pasted image, a recording) across launches: write it, return a URI. */\n persistFile?: (file: LocalFile) => Promise<string>;\n /** Names and avatars for user ids, fetched in batches (usePeople, usePerson). */\n loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;\n children: ReactNode;\n}\n\n/** How the app shows a person. */\nexport interface ChatPerson {\n name: string;\n avatarUrl?: string | null;\n}\n\n/** Puts chat in reach of every hook below it. Needs a TanStack QueryClientProvider above it. */\nexport function ChatProvider({ client, userId, outboxStore, readFile, upload, newId, persistFile, loadPeople, children }: ChatProviderProps) {\n const queryClient = useQueryClient();\n const [queue, setQueue] = useState<QueuedMessage[]>([]);\n const listeners = useRef(new Set<(s: ChatSignal) => void>());\n\n const outbox = useMemo(\n () =>\n createOutbox({\n client,\n userId,\n store: outboxStore,\n readFile,\n upload,\n newId,\n persistFile,\n onChange: setQueue,\n onSent: (message) => {\n // Into every loaded list it belongs to (the main timeline, its thread, its topic), so\n // the bubble never disappears between the send landing and the live hint.\n queryClient.setQueriesData<ChatMessage[]>(\n { predicate: (q) => belongsTo(q.queryKey, message) },\n (prev) => (Array.isArray(prev) ? upsertMessages(prev, [message]) : prev),\n );\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n },\n }),\n // A new person or client is a new outbox.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [client, userId],\n );\n\n useEffect(() => {\n void outbox.load();\n const sub = client.subscribeInbox(userId, (signal) => {\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });\n for (const l of listeners.current) l(signal);\n });\n return () => sub.unsubscribe();\n }, [client, userId, outbox, queryClient]);\n\n const value = useMemo<ChatContextValue>(\n () => ({\n client,\n userId,\n outbox,\n queue,\n loadPeople,\n onInbox: (l) => {\n listeners.current.add(l);\n return () => listeners.current.delete(l);\n },\n }),\n [client, userId, outbox, queue, loadPeople],\n );\n return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;\n}\n\nexport function useChat(): ChatContextValue {\n const ctx = useContext(ChatContext);\n if (!ctx) throw new Error(\"Chat hooks need a <ChatProvider> above them.\");\n return ctx;\n}\n\n/** Your conversations, live. `kind` narrows to one kind; `archived` shows the archived ones. */\nexport function useInbox(opts: { kind?: string; archived?: boolean; limit?: number } = {}) {\n const { client } = useChat();\n const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15_000 });\n return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };\n}\n\n/** How many messages wait for you everywhere (for a badge), live. */\nexport function useUnreadTotal(): number {\n const { client } = useChat();\n return useQuery({ queryKey: chatKeys.unread(), queryFn: () => client.unreadTotal(), staleTime: 15_000 }).data ?? 0;\n}\n\nexport interface UseConversationOptions {\n /** Mark messages read as they arrive while this is true (the thread is on screen). Default true. */\n markRead?: boolean;\n /** One thread's replies instead of the main timeline. */\n threadRootId?: string;\n /** One topic's messages. */\n topic?: string;\n pageSize?: number;\n}\n\n/** One conversation: its messages (with what you are still sending), members, your role, and who is typing. */\nexport function useConversation(conversationId: string | null | undefined, opts: UseConversationOptions = {}) {\n const { client, userId, outbox, queue } = useChat();\n const queryClient = useQueryClient();\n const id = conversationId ?? \"\";\n const enabled = Boolean(conversationId);\n const pageSize = opts.pageSize ?? 50;\n const messagesKey = useMemo(\n () => [...chatKeys.conversation(id), \"messages\", opts.threadRootId ?? null, opts.topic ?? null] as const,\n [id, opts.threadRootId, opts.topic],\n );\n\n const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });\n const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 60_000 });\n const kindRules = useQuery({\n queryKey: [\"genesis-chat\", \"kind\", conversation.data?.kind ?? \"\"],\n queryFn: () => client.kind(conversation.data!.kind),\n enabled: Boolean(conversation.data?.kind),\n staleTime: 10 * 60_000,\n });\n const access = useQuery({\n queryKey: chatKeys.role(id),\n queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),\n enabled,\n staleTime: 60_000,\n });\n\n const [hasEarlier, setHasEarlier] = useState(false);\n const [loadingEarlier, setLoadingEarlier] = useState(false);\n const messages = useQuery({\n queryKey: messagesKey,\n queryFn: async () => {\n const page = await client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });\n setHasEarlier(page.hasEarlier);\n return page.messages;\n },\n enabled,\n staleTime: Infinity,\n });\n // Where \"New\" goes: what you had read when the conversation opened.\n const [readWhenOpened, setReadWhenOpened] = useState<number | null>(null);\n\n const merge = useCallback(\n (incoming: ChatMessage[]) =>\n queryClient.setQueryData<ChatMessage[]>(messagesKey, (prev) => upsertMessages(prev ?? [], incoming)),\n [queryClient, messagesKey],\n );\n\n const catchUp = useCallback(async () => {\n const current = queryClient.getQueryData<ChatMessage[]>(messagesKey) ?? [];\n const after = current.length ? current[current.length - 1]!.seq : undefined;\n if (after == null) {\n await queryClient.invalidateQueries({ queryKey: messagesKey });\n return;\n }\n const page = await client.messages(id, { afterSeq: after, limit: 200, threadRootId: opts.threadRootId, topic: opts.topic });\n merge(page.messages);\n }, [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);\n\n const [activity, setActivity] = useState<ChatActivity[]>([]);\n const channel = useRef<ConversationChannel | null>(null);\n useEffect(() => {\n if (!enabled) return;\n const fetchOne = async (messageId: string) => {\n const m = await client.message(messageId);\n if (!m) return;\n const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;\n if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);\n if (m.threadRootId && !opts.threadRootId) {\n const root = await client.message(m.threadRootId);\n if (root) merge([root]);\n }\n };\n channel.current = client.subscribeConversation(id, userId, {\n onSignal: (s) => {\n if (s.type === \"message\") void fetchOne(s.id);\n else if (s.type === \"reaction\") void fetchOne(s.messageId);\n else if (s.type === \"members\") {\n void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });\n void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });\n } else if (s.type === \"conversation\") {\n void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id) });\n void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });\n }\n },\n onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),\n onReconnect: () => void catchUp(),\n });\n return () => {\n channel.current?.unsubscribe();\n channel.current = null;\n };\n }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);\n\n // Activity fades after a few seconds without news.\n useEffect(() => {\n if (activity.length === 0) return;\n const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2000);\n return () => clearTimeout(t);\n }, [activity]);\n\n const list = messages.data ?? [];\n const latestSeq = list.length ? list[list.length - 1]!.seq : 0;\n const myLastRead = members.data?.find((m) => m.userId === userId)?.lastReadSeq;\n useEffect(() => {\n if (readWhenOpened === null && myLastRead !== undefined) setReadWhenOpened(myLastRead);\n }, [myLastRead, readWhenOpened]);\n\n const markRead = useCallback(async () => {\n if (!enabled || latestSeq === 0) return;\n await client.markRead(id, latestSeq);\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });\n }, [client, enabled, id, latestSeq, queryClient]);\n\n const shouldMark = opts.markRead !== false;\n useEffect(() => {\n if (shouldMark && latestSeq > 0 && !opts.threadRootId) void markRead().catch(() => undefined);\n }, [shouldMark, latestSeq, markRead, opts.threadRootId]);\n\n const loadEarlier = useCallback(async () => {\n const first = list[0];\n if (!first || loadingEarlier) return;\n setLoadingEarlier(true);\n try {\n const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });\n merge(page.messages);\n setHasEarlier(page.hasEarlier);\n } finally {\n setLoadingEarlier(false);\n }\n }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);\n\n const pending = useMemo(\n () =>\n queue.filter(\n (q) =>\n q.input.conversationId === id &&\n (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) &&\n (!opts.topic || q.input.topic === opts.topic),\n ).map((q) => ({ ...q, files: q.pendingFiles })),\n [queue, id, opts.threadRootId, opts.topic],\n );\n const items: ThreadItem[] = useMemo(() => mergeThread(list, pending), [list, pending]);\n\n const signalTyping = useMemo(() => throttle(() => channel.current?.signal(\"typing\")), []);\n const signalRecording = useMemo(\n () => throttle(() => channel.current?.signal(\"recording-voice\")),\n [],\n );\n\n /** Sends text and files to this conversation (through the outbox: shown at once, retried safely). */\n const send = useCallback(\n (input: Omit<SendInput, \"conversationId\" | \"clientId\">, files: LocalFile[] = []) =>\n outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),\n [outbox, id, opts.threadRootId, opts.topic],\n );\n\n return {\n conversation: conversation.data ?? null,\n members: members.data ?? [],\n role: access.data?.role ?? null,\n canPost: access.data?.canPost ?? false,\n /** Your role and posting rights are still loading: show the composer as waiting, not refused. */\n accessLoading: enabled && access.isLoading,\n /** The kind's rules (max members, threads, who posts). */\n kind: kindRules.data ?? null,\n /** May you pin here: owners and admins, or anyone in a two-person conversation. */\n canPin:\n access.data?.role === \"owner\" ||\n access.data?.role === \"admin\" ||\n (access.data?.role === \"member\" && kindRules.data?.maxMembers === 2),\n /** Gives a message whose files were lost on a relaunch its files again. */\n reattach: outbox.reattach,\n items,\n messages: list,\n isLoading: messages.isLoading || conversation.isLoading,\n error: messages.error ?? conversation.error,\n hasEarlier,\n loadingEarlier,\n loadEarlier,\n /** Where to draw the \"New\" line, from what you had read when it opened. */\n firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),\n activity: currentActivity(activity),\n signalTyping,\n signalRecording,\n markRead,\n send,\n retry: outbox.retry,\n discard: outbox.discard,\n refetch: catchUp,\n };\n}\n\nexport type ConversationState = ReturnType<typeof useConversation>;\n\n/** Edit, delete, react, pin, save and report, refreshing what they change. */\nexport function useMessageActions() {\n const { client } = useChat();\n const queryClient = useQueryClient();\n return useMemo(() => {\n const refresh = async (conversationId: string, messageId: string) => {\n const m = await client.message(messageId);\n queryClient.setQueriesData<ChatMessage[]>({ queryKey: chatKeys.conversation(conversationId) }, (prev) =>\n Array.isArray(prev) && m ? upsertMessages(prev, [m]) : prev,\n );\n };\n return {\n edit: async (m: ChatMessage, body: string, mentions: string[] = []) => {\n await client.edit(m.id, body, mentions);\n await refresh(m.conversationId, m.id);\n },\n remove: async (m: ChatMessage) => {\n await client.remove(m.id);\n await refresh(m.conversationId, m.id);\n },\n react: async (m: ChatMessage, emoji: string, on: boolean) => {\n await client.react(m.id, emoji, on);\n await refresh(m.conversationId, m.id);\n },\n pin: async (m: ChatMessage, on: boolean) => {\n await client.pin(m.id, on);\n void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });\n },\n save: (m: ChatMessage, on: boolean, note?: string) => client.save(m.id, on, note),\n report: (m: ChatMessage, reason: \"spam\" | \"harassment\" | \"safety\" | \"other\", note?: string) => client.report(m.id, reason, note),\n };\n }, [client, queryClient]);\n}\n\n/**\n * Names and avatars for these people, from the provider's `loadPeople`, fetched as one\n * batch per set of ids and cached. Unknown ids are left out.\n */\nexport function usePeople(userIds: (string | null | undefined)[]): Record<string, ChatPerson> {\n const { loadPeople } = useChat();\n const ids = useMemo(() => [...new Set(userIds.filter((x): x is string => Boolean(x)))].sort(), [userIds]);\n return (\n useQuery({\n queryKey: [\"genesis-chat\", \"people\", ids],\n queryFn: () => loadPeople!(ids),\n enabled: Boolean(loadPeople) && ids.length > 0,\n staleTime: 5 * 60_000,\n }).data ?? {}\n );\n}\n\n/** One person's name and avatar (see usePeople). */\nexport function usePerson(userId: string | null | undefined): ChatPerson | null {\n const people = usePeople(useMemo(() => [userId], [userId]));\n return userId ? (people[userId] ?? null) : null;\n}\n\n/** The pinned message ids of a conversation. */\nexport function usePins(conversationId: string | null | undefined): string[] {\n const { client } = useChat();\n return (\n useQuery({ queryKey: chatKeys.pins(conversationId ?? \"\"), queryFn: () => client.pins(conversationId!), enabled: Boolean(conversationId) }).data ?? []\n );\n}\n\n/** Signed links for files (cached by the client until shortly before they expire). */\nexport function useFileUrls(paths: (string | null | undefined)[]): Record<string, string> {\n const { client } = useChat();\n const list = useMemo(() => [...new Set(paths.filter((p): p is string => Boolean(p)))], [paths]);\n return (\n useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 60_000 }).data ?? {}\n );\n}\n\n/** Starts (or finds again) a conversation, then refreshes the inbox. */\nexport function useStartConversation() {\n const { client } = useChat();\n const queryClient = useQueryClient();\n return useCallback(\n async (kind: string, memberIds: string[], opts?: { title?: string; subjectType?: string; subjectId?: string }) => {\n const id = await client.startConversation(kind, memberIds, opts);\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n return id;\n },\n [client, queryClient],\n );\n}\n\nexport type { ThreadItem, LocalFile, QueuedMessage };\n"]}