@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,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* React hooks for chat, the same on web and native: the inbox, one
|
|
4
|
+
* conversation (its messages, members, your role, who is typing) kept live by
|
|
5
|
+
* Realtime hints, sending through the outbox, and message actions. Server
|
|
6
|
+
* state is TanStack Query's; the renderers in /web and /native only draw.
|
|
7
|
+
*/
|
|
8
|
+
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
10
|
+
import type { ChatClient, ConversationChannel, MessagePage } from "../core/client";
|
|
11
|
+
import { createOutbox, type LocalFile, type Outbox, type OutboxStore, type QueuedMessage } from "../core/outbox";
|
|
12
|
+
import { currentActivity, firstUnreadId, mergeThread, throttle, upsertMessages, type ThreadItem } from "../core/thread";
|
|
13
|
+
import { attachmentKindFor, randomId } from "../core/media";
|
|
14
|
+
import type {
|
|
15
|
+
AvailabilityInput,
|
|
16
|
+
ChatActivity,
|
|
17
|
+
ChatAttachment,
|
|
18
|
+
ChatAvailability,
|
|
19
|
+
ChatLinkPreview,
|
|
20
|
+
ChatMessage,
|
|
21
|
+
ChatSearchHit,
|
|
22
|
+
ChatSignal,
|
|
23
|
+
ChatTopic,
|
|
24
|
+
ScheduleInput,
|
|
25
|
+
ScheduledMessage,
|
|
26
|
+
SendInput,
|
|
27
|
+
TopicInput,
|
|
28
|
+
} from "../core/types";
|
|
29
|
+
|
|
30
|
+
export const chatKeys = {
|
|
31
|
+
all: ["genesis-chat"] as const,
|
|
32
|
+
inbox: (opts?: object) => ["genesis-chat", "inbox", opts ?? {}] as const,
|
|
33
|
+
unread: () => ["genesis-chat", "unread"] as const,
|
|
34
|
+
conversation: (id: string) => ["genesis-chat", "conversation", id] as const,
|
|
35
|
+
members: (id: string) => ["genesis-chat", "members", id] as const,
|
|
36
|
+
role: (id: string) => ["genesis-chat", "role", id] as const,
|
|
37
|
+
pins: (id: string) => ["genesis-chat", "pins", id] as const,
|
|
38
|
+
files: (paths: string[]) => ["genesis-chat", "files", [...paths].sort()] as const,
|
|
39
|
+
/** Under the conversation's key, so its `conversation` hint (set_topic broadcasts one) refreshes them. */
|
|
40
|
+
topics: (id: string) => ["genesis-chat", "conversation", id, "topics"] as const,
|
|
41
|
+
scheduled: (conversationId?: string | null) => ["genesis-chat", "scheduled", conversationId ?? null] as const,
|
|
42
|
+
availability: (conversationId: string) => ["genesis-chat", "conversation", conversationId, "availability"] as const,
|
|
43
|
+
myAvailability: () => ["genesis-chat", "my-availability"] as const,
|
|
44
|
+
search: (query: string, conversationId?: string | null) => ["genesis-chat", "search", conversationId ?? null, query] as const,
|
|
45
|
+
previews: (messageIds: string[]) => ["genesis-chat", "previews", [...messageIds].sort()] as const,
|
|
46
|
+
/** One message's link previews (useLinkPreviews keeps one query per message). */
|
|
47
|
+
preview: (messageId: string) => ["genesis-chat", "preview", messageId] as const,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** How many messages a catch-up asks for at a time, and how many pages before it gives up and jumps to the newest. */
|
|
51
|
+
export const CATCH_UP_PAGE = 200;
|
|
52
|
+
export const CATCH_UP_MAX_PAGES = 10;
|
|
53
|
+
|
|
54
|
+
/** What a messages list holds in the query cache: the page, and whether older messages exist. */
|
|
55
|
+
export type MessagesData = MessagePage;
|
|
56
|
+
|
|
57
|
+
function isMessagesData(v: unknown): v is MessagesData {
|
|
58
|
+
return typeof v === "object" && v !== null && Array.isArray((v as MessagesData).messages) && "hasEarlier" in v;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Adds or replaces messages in a cached list, keeping `hasEarlier`. */
|
|
62
|
+
function upsertInto(prev: unknown, incoming: ChatMessage[]): MessagesData | undefined {
|
|
63
|
+
return isMessagesData(prev) ? { ...prev, messages: upsertMessages(prev.messages, incoming) } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Does a messages list (`[genesis-chat, conversation, id, messages, threadRootId, topic]`) hold this message? */
|
|
67
|
+
export function belongsTo(key: readonly unknown[], m: ChatMessage): boolean {
|
|
68
|
+
return (
|
|
69
|
+
key[0] === "genesis-chat" &&
|
|
70
|
+
key[1] === "conversation" &&
|
|
71
|
+
key[2] === m.conversationId &&
|
|
72
|
+
key[3] === "messages" &&
|
|
73
|
+
(key[4] ?? null) === (m.threadRootId ?? null) &&
|
|
74
|
+
(key[5] == null || key[5] === m.topic)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Hosted video uploads (Mux direct uploads): your server makes an upload and returns its URL;
|
|
80
|
+
* the renderer PUTs the recording there and sends `{ kind: "video", provider: "mux", providerId,
|
|
81
|
+
* status: "pending" }`. Without it, videos go to storage as files.
|
|
82
|
+
*/
|
|
83
|
+
export interface ChatVideoUpload {
|
|
84
|
+
create(conversationId: string): Promise<{ url: string; providerId: string; provider: "mux" }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What an app attachment kind's card shows (a form, a check-in): plain data, drawn by each renderer. */
|
|
88
|
+
export interface ChatAttachmentKind {
|
|
89
|
+
title(attachment: ChatAttachment): string;
|
|
90
|
+
subtitle?(attachment: ChatAttachment): string | null | undefined;
|
|
91
|
+
/** The card's button: a link (`href`) or a handler (`onPress`). */
|
|
92
|
+
action?(attachment: ChatAttachment): { label: string; href?: string; onPress?: () => void } | null | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ChatContextValue {
|
|
96
|
+
client: ChatClient;
|
|
97
|
+
userId: string;
|
|
98
|
+
outbox: Outbox;
|
|
99
|
+
queue: QueuedMessage[];
|
|
100
|
+
loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;
|
|
101
|
+
videoUpload?: ChatVideoUpload;
|
|
102
|
+
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
103
|
+
/** Retries every failed message (call it when the connection comes back: AppState, NetInfo). */
|
|
104
|
+
retryAll(): Promise<void>;
|
|
105
|
+
/** Forgets every unsent message, here and in the outbox store (call it on sign-out). */
|
|
106
|
+
clearOutbox(): Promise<void>;
|
|
107
|
+
/** Subscribe to inbox hints (one channel for the app, fanned out here). */
|
|
108
|
+
onInbox(listener: (s: ChatSignal) => void): () => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
112
|
+
|
|
113
|
+
export interface ChatProviderProps {
|
|
114
|
+
client: ChatClient;
|
|
115
|
+
/** The signed-in user's id (auth.users). */
|
|
116
|
+
userId: string;
|
|
117
|
+
/** Where unsent messages wait between launches (localStorage, MMKV…). Memory by default. */
|
|
118
|
+
outboxStore?: OutboxStore;
|
|
119
|
+
/** Reads a native file URI for upload. */
|
|
120
|
+
readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
|
|
121
|
+
/** Replaces the plain upload (resumable uploads on native). `signal` aborts when the message is discarded or the outbox cleared. */
|
|
122
|
+
upload?: (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal }) => Promise<string>;
|
|
123
|
+
/** Makes client ids (default: a random UUID that works without crypto.randomUUID). */
|
|
124
|
+
newId?: () => string;
|
|
125
|
+
/** Keeps a byte file (a pasted image, a recording) across launches: write it, return a URI. */
|
|
126
|
+
persistFile?: (file: LocalFile) => Promise<string>;
|
|
127
|
+
/** Lets go of a sent file's preview (default: revokes `blob:` object URLs only). */
|
|
128
|
+
releasePreview?: (url: string) => void;
|
|
129
|
+
/** Names and avatars for user ids, fetched in batches (usePeople, usePerson). */
|
|
130
|
+
loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;
|
|
131
|
+
/** Hosted video uploads (Mux); videos go to storage without it. */
|
|
132
|
+
videoUpload?: ChatVideoUpload;
|
|
133
|
+
/** Cards for your own attachment kinds (`{ form: { title, subtitle, action } }`). */
|
|
134
|
+
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
135
|
+
children: ReactNode;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** How the app shows a person. */
|
|
139
|
+
export interface ChatPerson {
|
|
140
|
+
name: string;
|
|
141
|
+
avatarUrl?: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Puts chat in reach of every hook below it. Needs a TanStack QueryClientProvider above it. */
|
|
145
|
+
export function ChatProvider({
|
|
146
|
+
client,
|
|
147
|
+
userId,
|
|
148
|
+
outboxStore,
|
|
149
|
+
readFile,
|
|
150
|
+
upload,
|
|
151
|
+
newId,
|
|
152
|
+
persistFile,
|
|
153
|
+
releasePreview,
|
|
154
|
+
loadPeople,
|
|
155
|
+
videoUpload,
|
|
156
|
+
attachmentKinds,
|
|
157
|
+
children,
|
|
158
|
+
}: ChatProviderProps) {
|
|
159
|
+
const queryClient = useQueryClient();
|
|
160
|
+
const [queue, setQueue] = useState<QueuedMessage[]>([]);
|
|
161
|
+
const listeners = useRef(new Set<(s: ChatSignal) => void>());
|
|
162
|
+
|
|
163
|
+
const outbox = useMemo(
|
|
164
|
+
() =>
|
|
165
|
+
createOutbox({
|
|
166
|
+
client,
|
|
167
|
+
userId,
|
|
168
|
+
store: outboxStore,
|
|
169
|
+
readFile,
|
|
170
|
+
upload,
|
|
171
|
+
newId,
|
|
172
|
+
persistFile,
|
|
173
|
+
releasePreview,
|
|
174
|
+
onChange: setQueue,
|
|
175
|
+
onSent: (message) => {
|
|
176
|
+
// Into every loaded list it belongs to (the main timeline, its thread, its topic), so
|
|
177
|
+
// the bubble never disappears between the send landing and the live hint.
|
|
178
|
+
queryClient.setQueriesData<MessagesData>(
|
|
179
|
+
{ predicate: (q) => belongsTo(q.queryKey, message) },
|
|
180
|
+
(prev) => upsertInto(prev, [message]) ?? prev,
|
|
181
|
+
);
|
|
182
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] });
|
|
183
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
// A new person or client is a new outbox.
|
|
187
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
+
[client, userId],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
// What a relaunch left unsent is retried once (messages waiting for a lost file stay put),
|
|
193
|
+
// and again whenever the browser comes back online.
|
|
194
|
+
void outbox.load().then(() => outbox.retryAll()).catch(() => undefined);
|
|
195
|
+
const online = () => void outbox.retryAll().catch(() => undefined);
|
|
196
|
+
const target = typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null;
|
|
197
|
+
target?.addEventListener("online", online);
|
|
198
|
+
const sub = client.subscribeInbox(userId, (signal) => {
|
|
199
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
200
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
|
|
201
|
+
for (const l of listeners.current) l(signal);
|
|
202
|
+
});
|
|
203
|
+
return () => {
|
|
204
|
+
sub.unsubscribe();
|
|
205
|
+
target?.removeEventListener("online", online);
|
|
206
|
+
};
|
|
207
|
+
}, [client, userId, outbox, queryClient]);
|
|
208
|
+
|
|
209
|
+
const value = useMemo<ChatContextValue>(
|
|
210
|
+
() => ({
|
|
211
|
+
client,
|
|
212
|
+
userId,
|
|
213
|
+
outbox,
|
|
214
|
+
queue,
|
|
215
|
+
loadPeople,
|
|
216
|
+
videoUpload,
|
|
217
|
+
attachmentKinds,
|
|
218
|
+
retryAll: () => outbox.retryAll(),
|
|
219
|
+
clearOutbox: () => outbox.clear(),
|
|
220
|
+
onInbox: (l) => {
|
|
221
|
+
listeners.current.add(l);
|
|
222
|
+
return () => listeners.current.delete(l);
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
[client, userId, outbox, queue, loadPeople, videoUpload, attachmentKinds],
|
|
226
|
+
);
|
|
227
|
+
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function useChat(): ChatContextValue {
|
|
231
|
+
const ctx = useContext(ChatContext);
|
|
232
|
+
if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
|
|
233
|
+
return ctx;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Your conversations, live. `kind` narrows to one kind; `archived` shows the archived ones. */
|
|
237
|
+
export function useInbox(opts: { kind?: string; kinds?: string[]; excludeKinds?: string[]; archived?: boolean; limit?: number } = {}) {
|
|
238
|
+
const { client } = useChat();
|
|
239
|
+
const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15_000 });
|
|
240
|
+
return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** How many messages wait for you everywhere (for a badge), live. */
|
|
244
|
+
export function useUnreadTotal(opts: { excludeKinds?: string[] } = {}): number {
|
|
245
|
+
const { client } = useChat();
|
|
246
|
+
return (
|
|
247
|
+
useQuery({ queryKey: [...chatKeys.unread(), opts.excludeKinds ?? []], queryFn: () => client.unreadTotal(opts), staleTime: 15_000 }).data ??
|
|
248
|
+
0
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface UseConversationOptions {
|
|
253
|
+
/** Mark messages read as they arrive while this is true (the thread is on screen). Default true. */
|
|
254
|
+
markRead?: boolean;
|
|
255
|
+
/** One thread's replies instead of the main timeline. */
|
|
256
|
+
threadRootId?: string;
|
|
257
|
+
/** One topic's messages. */
|
|
258
|
+
topic?: string;
|
|
259
|
+
pageSize?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** One conversation: its messages (with what you are still sending), members, your role, and who is typing. */
|
|
263
|
+
export function useConversation(conversationId: string | null | undefined, opts: UseConversationOptions = {}) {
|
|
264
|
+
const { client, userId, outbox, queue } = useChat();
|
|
265
|
+
const queryClient = useQueryClient();
|
|
266
|
+
const id = conversationId ?? "";
|
|
267
|
+
const enabled = Boolean(conversationId);
|
|
268
|
+
const pageSize = opts.pageSize ?? 50;
|
|
269
|
+
const messagesKey = useMemo(
|
|
270
|
+
() => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null] as const,
|
|
271
|
+
[id, opts.threadRootId, opts.topic],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
|
|
275
|
+
const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 60_000, refetchOnMount: "always" });
|
|
276
|
+
const kindRules = useQuery({
|
|
277
|
+
queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
|
|
278
|
+
queryFn: () => client.kind(conversation.data!.kind),
|
|
279
|
+
enabled: Boolean(conversation.data?.kind),
|
|
280
|
+
staleTime: 10 * 60_000,
|
|
281
|
+
});
|
|
282
|
+
const access = useQuery({
|
|
283
|
+
queryKey: chatKeys.role(id),
|
|
284
|
+
queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
|
|
285
|
+
enabled,
|
|
286
|
+
staleTime: 60_000,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const [loadingEarlier, setLoadingEarlier] = useState(false);
|
|
290
|
+
const fetchPage = useCallback(
|
|
291
|
+
() => client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic }),
|
|
292
|
+
[client, id, pageSize, opts.threadRootId, opts.topic],
|
|
293
|
+
);
|
|
294
|
+
// Kept fresh by live hints and the catch-up on every join, so never refetched on its own.
|
|
295
|
+
const messages = useQuery<MessagesData>({ queryKey: messagesKey, queryFn: fetchPage, enabled, staleTime: Infinity });
|
|
296
|
+
const hasEarlier = messages.data?.hasEarlier ?? false;
|
|
297
|
+
// Where "New" goes: what you had read when the conversation opened.
|
|
298
|
+
const [readWhenOpened, setReadWhenOpened] = useState<number | null>(null);
|
|
299
|
+
|
|
300
|
+
const merge = useCallback(
|
|
301
|
+
(incoming: ChatMessage[], hasEarlierNow?: boolean) =>
|
|
302
|
+
queryClient.setQueryData<MessagesData>(messagesKey, (prev) => {
|
|
303
|
+
const base = prev ?? { messages: [], hasEarlier: false };
|
|
304
|
+
return { messages: upsertMessages(base.messages, incoming), hasEarlier: hasEarlierNow ?? base.hasEarlier };
|
|
305
|
+
}),
|
|
306
|
+
[queryClient, messagesKey],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fetches what came after the newest message held (everything, for an empty list). Runs on
|
|
311
|
+
* every join of the live channel, the first included, so messages that arrived while the
|
|
312
|
+
* thread was closed, or between the first fetch and the subscription, always show. Pages
|
|
313
|
+
* until a short one; after CATCH_UP_MAX_PAGES full pages (a long time away from a busy group)
|
|
314
|
+
* it shows the newest page instead, with the rest behind "load earlier".
|
|
315
|
+
*/
|
|
316
|
+
const catchUp = useCallback(async () => {
|
|
317
|
+
const current = queryClient.getQueryData<MessagesData>(messagesKey) ?? (await queryClient.ensureQueryData<MessagesData>({ queryKey: messagesKey, queryFn: fetchPage }));
|
|
318
|
+
const list = current.messages;
|
|
319
|
+
let after = list.length ? list[list.length - 1]!.seq : 0;
|
|
320
|
+
for (let pages = 0; ; ) {
|
|
321
|
+
const page = await client.messages(id, { afterSeq: after, limit: CATCH_UP_PAGE, threadRootId: opts.threadRootId, topic: opts.topic });
|
|
322
|
+
if (page.messages.length) merge(page.messages);
|
|
323
|
+
if (page.messages.length < CATCH_UP_PAGE) return;
|
|
324
|
+
after = Math.max(after, ...page.messages.map((m) => m.seq));
|
|
325
|
+
if (++pages >= CATCH_UP_MAX_PAGES) break;
|
|
326
|
+
}
|
|
327
|
+
const newest = await fetchPage();
|
|
328
|
+
queryClient.setQueryData<MessagesData>(messagesKey, { messages: newest.messages, hasEarlier: true });
|
|
329
|
+
}, [client, fetchPage, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
|
|
330
|
+
|
|
331
|
+
const [activity, setActivity] = useState<ChatActivity[]>([]);
|
|
332
|
+
const channel = useRef<ConversationChannel | null>(null);
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!enabled) return;
|
|
335
|
+
const fetchOne = async (messageId: string) => {
|
|
336
|
+
const m = await client.message(messageId);
|
|
337
|
+
if (!m) return;
|
|
338
|
+
const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
|
|
339
|
+
if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
|
|
340
|
+
if (m.threadRootId && !opts.threadRootId) {
|
|
341
|
+
const root = await client.message(m.threadRootId);
|
|
342
|
+
if (root) merge([root]);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
channel.current = client.subscribeConversation(id, userId, {
|
|
346
|
+
onSignal: (s) => {
|
|
347
|
+
if (s.type === "message") void fetchOne(s.id);
|
|
348
|
+
else if (s.type === "reaction") void fetchOne(s.messageId);
|
|
349
|
+
else if (s.type === "members") {
|
|
350
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
|
|
351
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
|
|
352
|
+
} else if (s.type === "conversation") {
|
|
353
|
+
// The conversation row, its topics and availability (not its messages, kept by hints).
|
|
354
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id), exact: true });
|
|
355
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
|
|
356
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
|
|
360
|
+
onJoin: () => void catchUp().catch(() => undefined),
|
|
361
|
+
});
|
|
362
|
+
return () => {
|
|
363
|
+
channel.current?.unsubscribe();
|
|
364
|
+
channel.current = null;
|
|
365
|
+
};
|
|
366
|
+
}, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
|
|
367
|
+
|
|
368
|
+
// Activity fades after a few seconds without news.
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (activity.length === 0) return;
|
|
371
|
+
const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2000);
|
|
372
|
+
return () => clearTimeout(t);
|
|
373
|
+
}, [activity]);
|
|
374
|
+
|
|
375
|
+
const list = useMemo(() => messages.data?.messages ?? [], [messages.data]);
|
|
376
|
+
const latestSeq = list.length ? list[list.length - 1]!.seq : 0;
|
|
377
|
+
// Where "New" goes comes from your read place as fetched for this opening, never a cached
|
|
378
|
+
// copy from an earlier visit (which would draw the line above messages already read).
|
|
379
|
+
const myLastRead = members.isFetchedAfterMount ? members.data?.find((m) => m.userId === userId)?.lastReadSeq : undefined;
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (readWhenOpened === null && myLastRead !== undefined) setReadWhenOpened(myLastRead);
|
|
382
|
+
}, [myLastRead, readWhenOpened]);
|
|
383
|
+
|
|
384
|
+
const markRead = useCallback(async () => {
|
|
385
|
+
if (!enabled || latestSeq === 0) return;
|
|
386
|
+
await client.markRead(id, latestSeq);
|
|
387
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
388
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
|
|
389
|
+
}, [client, enabled, id, latestSeq, queryClient]);
|
|
390
|
+
|
|
391
|
+
// Your read place is one number for the whole conversation, so only the whole timeline moves
|
|
392
|
+
// it: a topic (or a thread) shows a slice, and marking up to its newest message would also
|
|
393
|
+
// mark unseen messages in other topics read.
|
|
394
|
+
const shouldMark = opts.markRead !== false && !opts.threadRootId && !opts.topic;
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (shouldMark && latestSeq > 0) void markRead().catch(() => undefined);
|
|
397
|
+
}, [shouldMark, latestSeq, markRead]);
|
|
398
|
+
|
|
399
|
+
const loadEarlier = useCallback(async () => {
|
|
400
|
+
const first = list[0];
|
|
401
|
+
if (!first || loadingEarlier) return;
|
|
402
|
+
setLoadingEarlier(true);
|
|
403
|
+
try {
|
|
404
|
+
const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
|
|
405
|
+
merge(page.messages, page.hasEarlier);
|
|
406
|
+
} finally {
|
|
407
|
+
setLoadingEarlier(false);
|
|
408
|
+
}
|
|
409
|
+
}, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
|
|
410
|
+
|
|
411
|
+
/** Loads earlier messages until the one with this seq is held (a search result, a pinned message). True when it is. */
|
|
412
|
+
const loadUntil = useCallback(
|
|
413
|
+
async (seq: number) => {
|
|
414
|
+
let current = queryClient.getQueryData<MessagesData>(messagesKey);
|
|
415
|
+
for (let guard = 0; guard < 20 && current; guard++) {
|
|
416
|
+
const first = current.messages[0];
|
|
417
|
+
if (!first || first.seq <= seq || !current.hasEarlier) break;
|
|
418
|
+
const page = await client.messages(id, {
|
|
419
|
+
beforeSeq: first.seq,
|
|
420
|
+
limit: Math.min(200, first.seq - seq + 10),
|
|
421
|
+
threadRootId: opts.threadRootId,
|
|
422
|
+
topic: opts.topic,
|
|
423
|
+
});
|
|
424
|
+
merge(page.messages, page.hasEarlier);
|
|
425
|
+
current = queryClient.getQueryData<MessagesData>(messagesKey);
|
|
426
|
+
}
|
|
427
|
+
return Boolean(current?.messages.some((m) => m.seq === seq));
|
|
428
|
+
},
|
|
429
|
+
[client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient],
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const pending = useMemo(
|
|
433
|
+
() =>
|
|
434
|
+
queue.filter(
|
|
435
|
+
(q) =>
|
|
436
|
+
q.input.conversationId === id &&
|
|
437
|
+
(q.input.threadRootId ?? null) === (opts.threadRootId ?? null) &&
|
|
438
|
+
(!opts.topic || q.input.topic === opts.topic),
|
|
439
|
+
).map((q) => ({ ...q, files: q.pendingFiles })),
|
|
440
|
+
[queue, id, opts.threadRootId, opts.topic],
|
|
441
|
+
);
|
|
442
|
+
const items: ThreadItem[] = useMemo(() => mergeThread(list, pending), [list, pending]);
|
|
443
|
+
|
|
444
|
+
const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
|
|
445
|
+
const signalRecording = useMemo(
|
|
446
|
+
() => throttle(() => channel.current?.signal("recording-voice")),
|
|
447
|
+
[],
|
|
448
|
+
);
|
|
449
|
+
const signalRecordingVideo = useMemo(() => throttle(() => channel.current?.signal("recording-video")), []);
|
|
450
|
+
|
|
451
|
+
/** Sends text and files to this conversation (through the outbox: shown at once, retried safely). */
|
|
452
|
+
const send = useCallback(
|
|
453
|
+
(input: Omit<SendInput, "conversationId" | "clientId">, files: LocalFile[] = []) =>
|
|
454
|
+
outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
|
|
455
|
+
[outbox, id, opts.threadRootId, opts.topic],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
conversation: conversation.data ?? null,
|
|
460
|
+
members: members.data ?? [],
|
|
461
|
+
role: access.data?.role ?? null,
|
|
462
|
+
canPost: access.data?.canPost ?? false,
|
|
463
|
+
/** Your role and posting rights are still loading: show the composer as waiting, not refused. */
|
|
464
|
+
accessLoading: enabled && access.isLoading,
|
|
465
|
+
/** The conversation itself is still loading (it settles to `null` when you can't see it). */
|
|
466
|
+
conversationLoading: conversation.isLoading,
|
|
467
|
+
/** Your role couldn't be loaded (offline): offer `refetchAccess`, don't say "can't post". */
|
|
468
|
+
accessError: access.error ?? null,
|
|
469
|
+
refetchAccess: access.refetch,
|
|
470
|
+
/** The kind's rules (max members, threads, who posts). */
|
|
471
|
+
kind: kindRules.data ?? null,
|
|
472
|
+
/** May you pin here: owners and admins, or anyone in a two-person conversation. */
|
|
473
|
+
canPin:
|
|
474
|
+
access.data?.role === "owner" ||
|
|
475
|
+
access.data?.role === "admin" ||
|
|
476
|
+
(access.data?.role === "member" && kindRules.data?.maxMembers === 2),
|
|
477
|
+
/** Gives a message whose files were lost on a relaunch its files again. */
|
|
478
|
+
reattach: outbox.reattach,
|
|
479
|
+
items,
|
|
480
|
+
messages: list,
|
|
481
|
+
isLoading: messages.isLoading || conversation.isLoading,
|
|
482
|
+
error: messages.error ?? conversation.error,
|
|
483
|
+
hasEarlier,
|
|
484
|
+
loadingEarlier,
|
|
485
|
+
loadEarlier,
|
|
486
|
+
loadUntil,
|
|
487
|
+
/** Where to draw the "New" line, from what you had read when it opened. */
|
|
488
|
+
firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
|
|
489
|
+
activity: currentActivity(activity),
|
|
490
|
+
signalTyping,
|
|
491
|
+
signalRecording,
|
|
492
|
+
/** Tells the others you are recording a video (throttled). */
|
|
493
|
+
signalRecordingVideo,
|
|
494
|
+
markRead,
|
|
495
|
+
send,
|
|
496
|
+
retry: outbox.retry,
|
|
497
|
+
discard: outbox.discard,
|
|
498
|
+
refetch: catchUp,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export type ConversationState = ReturnType<typeof useConversation>;
|
|
503
|
+
|
|
504
|
+
/** Edit, delete, react, pin, save and report, refreshing what they change. */
|
|
505
|
+
export function useMessageActions() {
|
|
506
|
+
const { client } = useChat();
|
|
507
|
+
const queryClient = useQueryClient();
|
|
508
|
+
return useMemo(() => {
|
|
509
|
+
const refresh = async (conversationId: string, messageId: string) => {
|
|
510
|
+
const m = await client.message(messageId);
|
|
511
|
+
queryClient.setQueriesData<MessagesData>({ queryKey: chatKeys.conversation(conversationId) }, (prev) =>
|
|
512
|
+
m ? (upsertInto(prev, [m]) ?? prev) : prev,
|
|
513
|
+
);
|
|
514
|
+
};
|
|
515
|
+
return {
|
|
516
|
+
edit: async (m: ChatMessage, body: string, mentions: string[] = []) => {
|
|
517
|
+
await client.edit(m.id, body, mentions);
|
|
518
|
+
await refresh(m.conversationId, m.id);
|
|
519
|
+
},
|
|
520
|
+
remove: async (m: ChatMessage) => {
|
|
521
|
+
await client.remove(m.id);
|
|
522
|
+
await refresh(m.conversationId, m.id);
|
|
523
|
+
},
|
|
524
|
+
react: async (m: ChatMessage, emoji: string, on: boolean) => {
|
|
525
|
+
await client.react(m.id, emoji, on);
|
|
526
|
+
await refresh(m.conversationId, m.id);
|
|
527
|
+
},
|
|
528
|
+
pin: async (m: ChatMessage, on: boolean) => {
|
|
529
|
+
await client.pin(m.id, on);
|
|
530
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
|
|
531
|
+
},
|
|
532
|
+
save: (m: ChatMessage, on: boolean, note?: string) => client.save(m.id, on, note),
|
|
533
|
+
report: (m: ChatMessage, reason: "spam" | "harassment" | "safety" | "other", note?: string) => client.report(m.id, reason, note),
|
|
534
|
+
};
|
|
535
|
+
}, [client, queryClient]);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Names and avatars for these people, from the provider's `loadPeople`, fetched as one
|
|
540
|
+
* batch per set of ids and cached. Unknown ids are left out.
|
|
541
|
+
*/
|
|
542
|
+
export function usePeople(userIds: (string | null | undefined)[]): Record<string, ChatPerson> {
|
|
543
|
+
const { loadPeople } = useChat();
|
|
544
|
+
const ids = useMemo(() => [...new Set(userIds.filter((x): x is string => Boolean(x)))].sort(), [userIds]);
|
|
545
|
+
return (
|
|
546
|
+
useQuery({
|
|
547
|
+
queryKey: ["genesis-chat", "people", ids],
|
|
548
|
+
queryFn: () => loadPeople!(ids),
|
|
549
|
+
enabled: Boolean(loadPeople) && ids.length > 0,
|
|
550
|
+
staleTime: 5 * 60_000,
|
|
551
|
+
}).data ?? {}
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** One person's name and avatar (see usePeople). */
|
|
556
|
+
export function usePerson(userId: string | null | undefined): ChatPerson | null {
|
|
557
|
+
const people = usePeople(useMemo(() => [userId], [userId]));
|
|
558
|
+
return userId ? (people[userId] ?? null) : null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The pinned message ids of a conversation. */
|
|
562
|
+
export function usePins(conversationId: string | null | undefined): string[] {
|
|
563
|
+
const { client } = useChat();
|
|
564
|
+
return (
|
|
565
|
+
useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId!), enabled: Boolean(conversationId) }).data ?? []
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Signed links for files (cached by the client until shortly before they expire). */
|
|
570
|
+
export function useFileUrls(paths: (string | null | undefined)[]): Record<string, string> {
|
|
571
|
+
const { client } = useChat();
|
|
572
|
+
const list = useMemo(() => [...new Set(paths.filter((p): p is string => Boolean(p)))], [paths]);
|
|
573
|
+
return (
|
|
574
|
+
useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 60_000 }).data ?? {}
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Starts (or finds again) a conversation, then refreshes the inbox. */
|
|
579
|
+
export function useStartConversation() {
|
|
580
|
+
const { client } = useChat();
|
|
581
|
+
const queryClient = useQueryClient();
|
|
582
|
+
return useCallback(
|
|
583
|
+
async (kind: string, memberIds: string[], opts?: { title?: string; subjectType?: string; subjectId?: string }) => {
|
|
584
|
+
const id = await client.startConversation(kind, memberIds, opts);
|
|
585
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
586
|
+
return id;
|
|
587
|
+
},
|
|
588
|
+
[client, queryClient],
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Migration 0002: topics, scheduled send, availability, broadcast, search, previews ──
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A conversation's topics, live (set_topic's hint refreshes them), and `setTopic` for owners
|
|
596
|
+
* and admins: add one (`setTopic("week-1", { title: "Week 1" })`), close or reopen it.
|
|
597
|
+
*/
|
|
598
|
+
export function useTopics(conversationId: string | null | undefined) {
|
|
599
|
+
const { client } = useChat();
|
|
600
|
+
const queryClient = useQueryClient();
|
|
601
|
+
const id = conversationId ?? "";
|
|
602
|
+
const query = useQuery({ queryKey: chatKeys.topics(id), queryFn: () => client.topics(id), enabled: Boolean(conversationId), staleTime: 60_000 });
|
|
603
|
+
const setTopic = useCallback(
|
|
604
|
+
async (topic: string, input: TopicInput = {}) => {
|
|
605
|
+
await client.setTopic(id, topic, input);
|
|
606
|
+
await queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
|
|
607
|
+
},
|
|
608
|
+
[client, id, queryClient],
|
|
609
|
+
);
|
|
610
|
+
const topics: ChatTopic[] = query.data ?? [];
|
|
611
|
+
return { topics, isLoading: query.isLoading, error: query.error, setTopic, refetch: query.refetch };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Your scheduled messages (in one conversation, or all), soonest first, with `schedule` and
|
|
616
|
+
* `cancel`. `schedule` uploads byte files first (`LocalFile.data` a Blob or ArrayBuffer; a
|
|
617
|
+
* native URI goes through the provider's `readFile`), then schedules them with the message.
|
|
618
|
+
*/
|
|
619
|
+
export function useScheduled(conversationId?: string | null, opts: { enabled?: boolean } = {}) {
|
|
620
|
+
const { client, userId } = useChat();
|
|
621
|
+
const queryClient = useQueryClient();
|
|
622
|
+
const query = useQuery({
|
|
623
|
+
queryKey: chatKeys.scheduled(conversationId),
|
|
624
|
+
queryFn: () => client.scheduled(conversationId ?? undefined),
|
|
625
|
+
enabled: opts.enabled ?? true,
|
|
626
|
+
staleTime: 30_000,
|
|
627
|
+
});
|
|
628
|
+
const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] }), [queryClient]);
|
|
629
|
+
const schedule = useCallback(
|
|
630
|
+
async (input: Omit<ScheduleInput, "clientId"> & { clientId?: string }, files: LocalFile[] = []) => {
|
|
631
|
+
const attachments = [...(input.attachments ?? [])];
|
|
632
|
+
for (const f of files) {
|
|
633
|
+
if (typeof f.data === "string") throw new Error("Scheduled files must be bytes (a Blob or ArrayBuffer)");
|
|
634
|
+
const path = await client.upload(input.conversationId, userId, f.data, { name: f.name, contentType: f.contentType });
|
|
635
|
+
attachments.push({ ...f.meta, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), path, name: f.meta?.name ?? f.name, mime: f.contentType });
|
|
636
|
+
}
|
|
637
|
+
const id = await client.schedule({ ...input, clientId: input.clientId ?? randomId(), attachments });
|
|
638
|
+
await refresh();
|
|
639
|
+
return id;
|
|
640
|
+
},
|
|
641
|
+
[client, refresh, userId],
|
|
642
|
+
);
|
|
643
|
+
const cancel = useCallback(
|
|
644
|
+
async (id: string) => {
|
|
645
|
+
queryClient.setQueriesData<ScheduledMessage[]>({ queryKey: ["genesis-chat", "scheduled"] }, (prev) => (Array.isArray(prev) ? prev.filter((s) => s.id !== id) : prev));
|
|
646
|
+
try {
|
|
647
|
+
await client.cancelScheduled(id);
|
|
648
|
+
} finally {
|
|
649
|
+
await refresh();
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
[client, queryClient, refresh],
|
|
653
|
+
);
|
|
654
|
+
const items: ScheduledMessage[] = query.data ?? [];
|
|
655
|
+
return { items, isLoading: query.isLoading, error: query.error, schedule, cancel, refetch: query.refetch };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Availability: yours (`mine`, `setMine`) and, given a conversation, its members' (`members`,
|
|
660
|
+
* `forUser`). Pair with `awayLine` and `isWithinWorkingHours` from the core.
|
|
661
|
+
*/
|
|
662
|
+
export function useAvailability(conversationId?: string | null) {
|
|
663
|
+
const { client, userId } = useChat();
|
|
664
|
+
const queryClient = useQueryClient();
|
|
665
|
+
const mine = useQuery({ queryKey: chatKeys.myAvailability(), queryFn: () => client.myAvailability(), staleTime: 5 * 60_000 });
|
|
666
|
+
const members = useQuery({
|
|
667
|
+
queryKey: chatKeys.availability(conversationId ?? ""),
|
|
668
|
+
queryFn: () => client.availabilityFor(conversationId!),
|
|
669
|
+
enabled: Boolean(conversationId),
|
|
670
|
+
staleTime: 5 * 60_000,
|
|
671
|
+
});
|
|
672
|
+
const setMine = useCallback(
|
|
673
|
+
async (input: AvailabilityInput) => {
|
|
674
|
+
await client.setAvailability(input);
|
|
675
|
+
await queryClient.invalidateQueries({ queryKey: chatKeys.myAvailability() });
|
|
676
|
+
await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === "genesis-chat" && q.queryKey[3] === "availability" });
|
|
677
|
+
},
|
|
678
|
+
[client, queryClient],
|
|
679
|
+
);
|
|
680
|
+
const list: ChatAvailability[] = members.data ?? [];
|
|
681
|
+
const forUser = useCallback((id: string) => list.find((a) => a.userId === id) ?? null, [list]);
|
|
682
|
+
return {
|
|
683
|
+
mine: mine.data ?? null,
|
|
684
|
+
mineLoading: mine.isLoading,
|
|
685
|
+
setMine,
|
|
686
|
+
members: list,
|
|
687
|
+
/** Everyone else's, without yours. */
|
|
688
|
+
others: list.filter((a) => a.userId !== userId),
|
|
689
|
+
forUser,
|
|
690
|
+
isLoading: mine.isLoading || (Boolean(conversationId) && members.isLoading),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Sends one message into many conversations (replies come back privately in each). */
|
|
695
|
+
export function useBroadcast() {
|
|
696
|
+
const { client } = useChat();
|
|
697
|
+
const queryClient = useQueryClient();
|
|
698
|
+
const [sending, setSending] = useState(false);
|
|
699
|
+
const send = useCallback(
|
|
700
|
+
async (conversationIds: string[], body: string, opts: { silent?: boolean; clientId?: string } = {}) => {
|
|
701
|
+
setSending(true);
|
|
702
|
+
try {
|
|
703
|
+
const ids = await client.broadcast(conversationIds, opts.clientId ?? randomId(), body, { silent: opts.silent });
|
|
704
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
705
|
+
return ids;
|
|
706
|
+
} finally {
|
|
707
|
+
setSending(false);
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
[client, queryClient],
|
|
711
|
+
);
|
|
712
|
+
return { send, sending };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** The value, once it has stopped changing for `ms`. */
|
|
716
|
+
export function useDebouncedValue<T>(value: T, ms: number): T {
|
|
717
|
+
const [v, setV] = useState(value);
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
if (ms <= 0) {
|
|
720
|
+
setV(value);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const t = setTimeout(() => setV(value), ms);
|
|
724
|
+
return () => clearTimeout(t);
|
|
725
|
+
}, [value, ms]);
|
|
726
|
+
return ms <= 0 ? value : v;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Search, debounced (250 ms by default), in one conversation or all of yours. Queries under two characters return nothing. */
|
|
730
|
+
export function useSearch(query: string, opts: { conversationId?: string | null; debounceMs?: number; limit?: number } = {}) {
|
|
731
|
+
const { client } = useChat();
|
|
732
|
+
const q = useDebouncedValue(query.trim(), opts.debounceMs ?? 250);
|
|
733
|
+
const enabled = q.length >= 2;
|
|
734
|
+
const result = useQuery({
|
|
735
|
+
queryKey: chatKeys.search(q, opts.conversationId),
|
|
736
|
+
queryFn: () => client.search(q, opts.conversationId ?? null, { limit: opts.limit }),
|
|
737
|
+
enabled,
|
|
738
|
+
staleTime: 30_000,
|
|
739
|
+
});
|
|
740
|
+
const hits: ChatSearchHit[] = enabled ? (result.data ?? []) : [];
|
|
741
|
+
return {
|
|
742
|
+
hits,
|
|
743
|
+
/** The query the hits are for. */
|
|
744
|
+
query: q,
|
|
745
|
+
/** Typing, or fetching: results are on their way. */
|
|
746
|
+
isSearching: query.trim() !== q || (enabled && result.isFetching),
|
|
747
|
+
error: result.error,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** At most this many link-preview reads at once, per client. */
|
|
752
|
+
export const LINK_PREVIEW_CONCURRENCY = 4;
|
|
753
|
+
const previewGates = new WeakMap<object, <T>(task: () => Promise<T>) => Promise<T>>();
|
|
754
|
+
|
|
755
|
+
/** A gate that lets `max` tasks run at once and queues the rest. */
|
|
756
|
+
function gate(max: number) {
|
|
757
|
+
let active = 0;
|
|
758
|
+
const waiting: (() => void)[] = [];
|
|
759
|
+
return async <T,>(task: () => Promise<T>): Promise<T> => {
|
|
760
|
+
if (active >= max) await new Promise<void>((r) => waiting.push(r));
|
|
761
|
+
active += 1;
|
|
762
|
+
try {
|
|
763
|
+
return await task();
|
|
764
|
+
} finally {
|
|
765
|
+
active -= 1;
|
|
766
|
+
waiting.shift()?.();
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Link previews for these messages, by message id, in link order (only those the server has
|
|
773
|
+
* fetched). Pass the messages whose text has links (`extractLinks(body).length > 0`). One query
|
|
774
|
+
* per message, so a new message asks only for its own, and at most LINK_PREVIEW_CONCURRENCY
|
|
775
|
+
* run at once. One with none yet is asked again a few times, since the runner fills them
|
|
776
|
+
* moments after it lands.
|
|
777
|
+
*/
|
|
778
|
+
export function useLinkPreviews(messageIds: (string | null | undefined)[]): Record<string, ChatLinkPreview[]> {
|
|
779
|
+
const { client } = useChat();
|
|
780
|
+
const list = useMemo(() => [...new Set(messageIds.filter((u): u is string => Boolean(u)))].sort(), [messageIds]);
|
|
781
|
+
let run = previewGates.get(client);
|
|
782
|
+
if (!run) {
|
|
783
|
+
run = gate(LINK_PREVIEW_CONCURRENCY);
|
|
784
|
+
previewGates.set(client, run);
|
|
785
|
+
}
|
|
786
|
+
const limited = run;
|
|
787
|
+
const combine = useCallback(
|
|
788
|
+
(results: { data?: ChatLinkPreview[] }[]) => {
|
|
789
|
+
let out: Record<string, ChatLinkPreview[]> | null = null;
|
|
790
|
+
results.forEach((r, i) => {
|
|
791
|
+
if (r.data) (out ??= {})[list[i]!] = r.data;
|
|
792
|
+
});
|
|
793
|
+
return out ?? EMPTY_PREVIEWS;
|
|
794
|
+
},
|
|
795
|
+
[list],
|
|
796
|
+
);
|
|
797
|
+
return useQueries({
|
|
798
|
+
queries: list.map((id) => ({
|
|
799
|
+
queryKey: chatKeys.preview(id),
|
|
800
|
+
queryFn: () => limited(async () => (await client.linkPreviewsFor([id]))[id] ?? []),
|
|
801
|
+
staleTime: 60_000,
|
|
802
|
+
refetchInterval: (q: { state: { data?: ChatLinkPreview[]; dataUpdateCount: number } }) =>
|
|
803
|
+
!q.state.data?.length && q.state.dataUpdateCount < 4 ? 5_000 : false,
|
|
804
|
+
})),
|
|
805
|
+
combine,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const EMPTY_PREVIEWS: Record<string, ChatLinkPreview[]> = {};
|
|
809
|
+
|
|
810
|
+
export type { ThreadItem, LocalFile, QueuedMessage };
|