@marktiderman/genesis-chat 0.1.1 → 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,576 @@
|
|
|
1
|
+
import { createOutbox, upsertMessages, currentActivity, mergeThread, throttle, firstUnreadId } from './chunk-5D5A7I4Q.js';
|
|
2
|
+
import { attachmentKindFor, randomId } from './chunk-7S6HYNWW.js';
|
|
3
|
+
import { useQueryClient, useQuery, useQueries } from '@tanstack/react-query';
|
|
4
|
+
import { createContext, useState, useRef, useMemo, useEffect, useContext, useCallback } from 'react';
|
|
5
|
+
import { jsx } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
var chatKeys = {
|
|
8
|
+
all: ["genesis-chat"],
|
|
9
|
+
inbox: (opts) => ["genesis-chat", "inbox", opts ?? {}],
|
|
10
|
+
unread: () => ["genesis-chat", "unread"],
|
|
11
|
+
conversation: (id) => ["genesis-chat", "conversation", id],
|
|
12
|
+
members: (id) => ["genesis-chat", "members", id],
|
|
13
|
+
role: (id) => ["genesis-chat", "role", id],
|
|
14
|
+
pins: (id) => ["genesis-chat", "pins", id],
|
|
15
|
+
files: (paths) => ["genesis-chat", "files", [...paths].sort()],
|
|
16
|
+
/** Under the conversation's key, so its `conversation` hint (set_topic broadcasts one) refreshes them. */
|
|
17
|
+
topics: (id) => ["genesis-chat", "conversation", id, "topics"],
|
|
18
|
+
scheduled: (conversationId) => ["genesis-chat", "scheduled", conversationId ?? null],
|
|
19
|
+
availability: (conversationId) => ["genesis-chat", "conversation", conversationId, "availability"],
|
|
20
|
+
myAvailability: () => ["genesis-chat", "my-availability"],
|
|
21
|
+
search: (query, conversationId) => ["genesis-chat", "search", conversationId ?? null, query],
|
|
22
|
+
previews: (messageIds) => ["genesis-chat", "previews", [...messageIds].sort()],
|
|
23
|
+
/** One message's link previews (useLinkPreviews keeps one query per message). */
|
|
24
|
+
preview: (messageId) => ["genesis-chat", "preview", messageId]
|
|
25
|
+
};
|
|
26
|
+
var CATCH_UP_PAGE = 200;
|
|
27
|
+
var CATCH_UP_MAX_PAGES = 10;
|
|
28
|
+
function isMessagesData(v) {
|
|
29
|
+
return typeof v === "object" && v !== null && Array.isArray(v.messages) && "hasEarlier" in v;
|
|
30
|
+
}
|
|
31
|
+
function upsertInto(prev, incoming) {
|
|
32
|
+
return isMessagesData(prev) ? { ...prev, messages: upsertMessages(prev.messages, incoming) } : void 0;
|
|
33
|
+
}
|
|
34
|
+
function belongsTo(key, m) {
|
|
35
|
+
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);
|
|
36
|
+
}
|
|
37
|
+
var ChatContext = createContext(null);
|
|
38
|
+
function ChatProvider({
|
|
39
|
+
client,
|
|
40
|
+
userId,
|
|
41
|
+
outboxStore,
|
|
42
|
+
readFile,
|
|
43
|
+
upload,
|
|
44
|
+
newId,
|
|
45
|
+
persistFile,
|
|
46
|
+
releasePreview,
|
|
47
|
+
loadPeople,
|
|
48
|
+
videoUpload,
|
|
49
|
+
attachmentKinds,
|
|
50
|
+
children
|
|
51
|
+
}) {
|
|
52
|
+
const queryClient = useQueryClient();
|
|
53
|
+
const [queue, setQueue] = useState([]);
|
|
54
|
+
const listeners = useRef(/* @__PURE__ */ new Set());
|
|
55
|
+
const outbox = useMemo(
|
|
56
|
+
() => createOutbox({
|
|
57
|
+
client,
|
|
58
|
+
userId,
|
|
59
|
+
store: outboxStore,
|
|
60
|
+
readFile,
|
|
61
|
+
upload,
|
|
62
|
+
newId,
|
|
63
|
+
persistFile,
|
|
64
|
+
releasePreview,
|
|
65
|
+
onChange: setQueue,
|
|
66
|
+
onSent: (message) => {
|
|
67
|
+
queryClient.setQueriesData(
|
|
68
|
+
{ predicate: (q) => belongsTo(q.queryKey, message) },
|
|
69
|
+
(prev) => upsertInto(prev, [message]) ?? prev
|
|
70
|
+
);
|
|
71
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] });
|
|
72
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
// A new person or client is a new outbox.
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
[client, userId]
|
|
78
|
+
);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
void outbox.load().then(() => outbox.retryAll()).catch(() => void 0);
|
|
81
|
+
const online = () => void outbox.retryAll().catch(() => void 0);
|
|
82
|
+
const target = typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null;
|
|
83
|
+
target?.addEventListener("online", online);
|
|
84
|
+
const sub = client.subscribeInbox(userId, (signal) => {
|
|
85
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
86
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
|
|
87
|
+
for (const l of listeners.current) l(signal);
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
sub.unsubscribe();
|
|
91
|
+
target?.removeEventListener("online", online);
|
|
92
|
+
};
|
|
93
|
+
}, [client, userId, outbox, queryClient]);
|
|
94
|
+
const value = useMemo(
|
|
95
|
+
() => ({
|
|
96
|
+
client,
|
|
97
|
+
userId,
|
|
98
|
+
outbox,
|
|
99
|
+
queue,
|
|
100
|
+
loadPeople,
|
|
101
|
+
videoUpload,
|
|
102
|
+
attachmentKinds,
|
|
103
|
+
retryAll: () => outbox.retryAll(),
|
|
104
|
+
clearOutbox: () => outbox.clear(),
|
|
105
|
+
onInbox: (l) => {
|
|
106
|
+
listeners.current.add(l);
|
|
107
|
+
return () => listeners.current.delete(l);
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
[client, userId, outbox, queue, loadPeople, videoUpload, attachmentKinds]
|
|
111
|
+
);
|
|
112
|
+
return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
|
|
113
|
+
}
|
|
114
|
+
function useChat() {
|
|
115
|
+
const ctx = useContext(ChatContext);
|
|
116
|
+
if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
function useInbox(opts = {}) {
|
|
120
|
+
const { client } = useChat();
|
|
121
|
+
const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15e3 });
|
|
122
|
+
return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
|
|
123
|
+
}
|
|
124
|
+
function useUnreadTotal(opts = {}) {
|
|
125
|
+
const { client } = useChat();
|
|
126
|
+
return useQuery({ queryKey: [...chatKeys.unread(), opts.excludeKinds ?? []], queryFn: () => client.unreadTotal(opts), staleTime: 15e3 }).data ?? 0;
|
|
127
|
+
}
|
|
128
|
+
function useConversation(conversationId, opts = {}) {
|
|
129
|
+
const { client, userId, outbox, queue } = useChat();
|
|
130
|
+
const queryClient = useQueryClient();
|
|
131
|
+
const id = conversationId ?? "";
|
|
132
|
+
const enabled = Boolean(conversationId);
|
|
133
|
+
const pageSize = opts.pageSize ?? 50;
|
|
134
|
+
const messagesKey = useMemo(
|
|
135
|
+
() => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null],
|
|
136
|
+
[id, opts.threadRootId, opts.topic]
|
|
137
|
+
);
|
|
138
|
+
const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
|
|
139
|
+
const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 6e4, refetchOnMount: "always" });
|
|
140
|
+
const kindRules = useQuery({
|
|
141
|
+
queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
|
|
142
|
+
queryFn: () => client.kind(conversation.data.kind),
|
|
143
|
+
enabled: Boolean(conversation.data?.kind),
|
|
144
|
+
staleTime: 10 * 6e4
|
|
145
|
+
});
|
|
146
|
+
const access = useQuery({
|
|
147
|
+
queryKey: chatKeys.role(id),
|
|
148
|
+
queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
|
|
149
|
+
enabled,
|
|
150
|
+
staleTime: 6e4
|
|
151
|
+
});
|
|
152
|
+
const [loadingEarlier, setLoadingEarlier] = useState(false);
|
|
153
|
+
const fetchPage = useCallback(
|
|
154
|
+
() => client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic }),
|
|
155
|
+
[client, id, pageSize, opts.threadRootId, opts.topic]
|
|
156
|
+
);
|
|
157
|
+
const messages = useQuery({ queryKey: messagesKey, queryFn: fetchPage, enabled, staleTime: Infinity });
|
|
158
|
+
const hasEarlier = messages.data?.hasEarlier ?? false;
|
|
159
|
+
const [readWhenOpened, setReadWhenOpened] = useState(null);
|
|
160
|
+
const merge = useCallback(
|
|
161
|
+
(incoming, hasEarlierNow) => queryClient.setQueryData(messagesKey, (prev) => {
|
|
162
|
+
const base = prev ?? { messages: [], hasEarlier: false };
|
|
163
|
+
return { messages: upsertMessages(base.messages, incoming), hasEarlier: hasEarlierNow ?? base.hasEarlier };
|
|
164
|
+
}),
|
|
165
|
+
[queryClient, messagesKey]
|
|
166
|
+
);
|
|
167
|
+
const catchUp = useCallback(async () => {
|
|
168
|
+
const current = queryClient.getQueryData(messagesKey) ?? await queryClient.ensureQueryData({ queryKey: messagesKey, queryFn: fetchPage });
|
|
169
|
+
const list2 = current.messages;
|
|
170
|
+
let after = list2.length ? list2[list2.length - 1].seq : 0;
|
|
171
|
+
for (let pages = 0; ; ) {
|
|
172
|
+
const page = await client.messages(id, { afterSeq: after, limit: CATCH_UP_PAGE, threadRootId: opts.threadRootId, topic: opts.topic });
|
|
173
|
+
if (page.messages.length) merge(page.messages);
|
|
174
|
+
if (page.messages.length < CATCH_UP_PAGE) return;
|
|
175
|
+
after = Math.max(after, ...page.messages.map((m) => m.seq));
|
|
176
|
+
if (++pages >= CATCH_UP_MAX_PAGES) break;
|
|
177
|
+
}
|
|
178
|
+
const newest = await fetchPage();
|
|
179
|
+
queryClient.setQueryData(messagesKey, { messages: newest.messages, hasEarlier: true });
|
|
180
|
+
}, [client, fetchPage, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
|
|
181
|
+
const [activity, setActivity] = useState([]);
|
|
182
|
+
const channel = useRef(null);
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!enabled) return;
|
|
185
|
+
const fetchOne = async (messageId) => {
|
|
186
|
+
const m = await client.message(messageId);
|
|
187
|
+
if (!m) return;
|
|
188
|
+
const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
|
|
189
|
+
if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
|
|
190
|
+
if (m.threadRootId && !opts.threadRootId) {
|
|
191
|
+
const root = await client.message(m.threadRootId);
|
|
192
|
+
if (root) merge([root]);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
channel.current = client.subscribeConversation(id, userId, {
|
|
196
|
+
onSignal: (s) => {
|
|
197
|
+
if (s.type === "message") void fetchOne(s.id);
|
|
198
|
+
else if (s.type === "reaction") void fetchOne(s.messageId);
|
|
199
|
+
else if (s.type === "members") {
|
|
200
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
|
|
201
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
|
|
202
|
+
} else if (s.type === "conversation") {
|
|
203
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id), exact: true });
|
|
204
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
|
|
205
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
|
|
209
|
+
onJoin: () => void catchUp().catch(() => void 0)
|
|
210
|
+
});
|
|
211
|
+
return () => {
|
|
212
|
+
channel.current?.unsubscribe();
|
|
213
|
+
channel.current = null;
|
|
214
|
+
};
|
|
215
|
+
}, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (activity.length === 0) return;
|
|
218
|
+
const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2e3);
|
|
219
|
+
return () => clearTimeout(t);
|
|
220
|
+
}, [activity]);
|
|
221
|
+
const list = useMemo(() => messages.data?.messages ?? [], [messages.data]);
|
|
222
|
+
const latestSeq = list.length ? list[list.length - 1].seq : 0;
|
|
223
|
+
const myLastRead = members.isFetchedAfterMount ? members.data?.find((m) => m.userId === userId)?.lastReadSeq : void 0;
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (readWhenOpened === null && myLastRead !== void 0) setReadWhenOpened(myLastRead);
|
|
226
|
+
}, [myLastRead, readWhenOpened]);
|
|
227
|
+
const markRead = useCallback(async () => {
|
|
228
|
+
if (!enabled || latestSeq === 0) return;
|
|
229
|
+
await client.markRead(id, latestSeq);
|
|
230
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
231
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
|
|
232
|
+
}, [client, enabled, id, latestSeq, queryClient]);
|
|
233
|
+
const shouldMark = opts.markRead !== false && !opts.threadRootId && !opts.topic;
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (shouldMark && latestSeq > 0) void markRead().catch(() => void 0);
|
|
236
|
+
}, [shouldMark, latestSeq, markRead]);
|
|
237
|
+
const loadEarlier = useCallback(async () => {
|
|
238
|
+
const first = list[0];
|
|
239
|
+
if (!first || loadingEarlier) return;
|
|
240
|
+
setLoadingEarlier(true);
|
|
241
|
+
try {
|
|
242
|
+
const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
|
|
243
|
+
merge(page.messages, page.hasEarlier);
|
|
244
|
+
} finally {
|
|
245
|
+
setLoadingEarlier(false);
|
|
246
|
+
}
|
|
247
|
+
}, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
|
|
248
|
+
const loadUntil = useCallback(
|
|
249
|
+
async (seq) => {
|
|
250
|
+
let current = queryClient.getQueryData(messagesKey);
|
|
251
|
+
for (let guard = 0; guard < 20 && current; guard++) {
|
|
252
|
+
const first = current.messages[0];
|
|
253
|
+
if (!first || first.seq <= seq || !current.hasEarlier) break;
|
|
254
|
+
const page = await client.messages(id, {
|
|
255
|
+
beforeSeq: first.seq,
|
|
256
|
+
limit: Math.min(200, first.seq - seq + 10),
|
|
257
|
+
threadRootId: opts.threadRootId,
|
|
258
|
+
topic: opts.topic
|
|
259
|
+
});
|
|
260
|
+
merge(page.messages, page.hasEarlier);
|
|
261
|
+
current = queryClient.getQueryData(messagesKey);
|
|
262
|
+
}
|
|
263
|
+
return Boolean(current?.messages.some((m) => m.seq === seq));
|
|
264
|
+
},
|
|
265
|
+
[client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]
|
|
266
|
+
);
|
|
267
|
+
const pending = useMemo(
|
|
268
|
+
() => queue.filter(
|
|
269
|
+
(q) => q.input.conversationId === id && (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) && (!opts.topic || q.input.topic === opts.topic)
|
|
270
|
+
).map((q) => ({ ...q, files: q.pendingFiles })),
|
|
271
|
+
[queue, id, opts.threadRootId, opts.topic]
|
|
272
|
+
);
|
|
273
|
+
const items = useMemo(() => mergeThread(list, pending), [list, pending]);
|
|
274
|
+
const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
|
|
275
|
+
const signalRecording = useMemo(
|
|
276
|
+
() => throttle(() => channel.current?.signal("recording-voice")),
|
|
277
|
+
[]
|
|
278
|
+
);
|
|
279
|
+
const signalRecordingVideo = useMemo(() => throttle(() => channel.current?.signal("recording-video")), []);
|
|
280
|
+
const send = useCallback(
|
|
281
|
+
(input, files = []) => outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
|
|
282
|
+
[outbox, id, opts.threadRootId, opts.topic]
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
conversation: conversation.data ?? null,
|
|
286
|
+
members: members.data ?? [],
|
|
287
|
+
role: access.data?.role ?? null,
|
|
288
|
+
canPost: access.data?.canPost ?? false,
|
|
289
|
+
/** Your role and posting rights are still loading: show the composer as waiting, not refused. */
|
|
290
|
+
accessLoading: enabled && access.isLoading,
|
|
291
|
+
/** The conversation itself is still loading (it settles to `null` when you can't see it). */
|
|
292
|
+
conversationLoading: conversation.isLoading,
|
|
293
|
+
/** Your role couldn't be loaded (offline): offer `refetchAccess`, don't say "can't post". */
|
|
294
|
+
accessError: access.error ?? null,
|
|
295
|
+
refetchAccess: access.refetch,
|
|
296
|
+
/** The kind's rules (max members, threads, who posts). */
|
|
297
|
+
kind: kindRules.data ?? null,
|
|
298
|
+
/** May you pin here: owners and admins, or anyone in a two-person conversation. */
|
|
299
|
+
canPin: access.data?.role === "owner" || access.data?.role === "admin" || access.data?.role === "member" && kindRules.data?.maxMembers === 2,
|
|
300
|
+
/** Gives a message whose files were lost on a relaunch its files again. */
|
|
301
|
+
reattach: outbox.reattach,
|
|
302
|
+
items,
|
|
303
|
+
messages: list,
|
|
304
|
+
isLoading: messages.isLoading || conversation.isLoading,
|
|
305
|
+
error: messages.error ?? conversation.error,
|
|
306
|
+
hasEarlier,
|
|
307
|
+
loadingEarlier,
|
|
308
|
+
loadEarlier,
|
|
309
|
+
loadUntil,
|
|
310
|
+
/** Where to draw the "New" line, from what you had read when it opened. */
|
|
311
|
+
firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
|
|
312
|
+
activity: currentActivity(activity),
|
|
313
|
+
signalTyping,
|
|
314
|
+
signalRecording,
|
|
315
|
+
/** Tells the others you are recording a video (throttled). */
|
|
316
|
+
signalRecordingVideo,
|
|
317
|
+
markRead,
|
|
318
|
+
send,
|
|
319
|
+
retry: outbox.retry,
|
|
320
|
+
discard: outbox.discard,
|
|
321
|
+
refetch: catchUp
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function useMessageActions() {
|
|
325
|
+
const { client } = useChat();
|
|
326
|
+
const queryClient = useQueryClient();
|
|
327
|
+
return useMemo(() => {
|
|
328
|
+
const refresh = async (conversationId, messageId) => {
|
|
329
|
+
const m = await client.message(messageId);
|
|
330
|
+
queryClient.setQueriesData(
|
|
331
|
+
{ queryKey: chatKeys.conversation(conversationId) },
|
|
332
|
+
(prev) => m ? upsertInto(prev, [m]) ?? prev : prev
|
|
333
|
+
);
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
edit: async (m, body, mentions = []) => {
|
|
337
|
+
await client.edit(m.id, body, mentions);
|
|
338
|
+
await refresh(m.conversationId, m.id);
|
|
339
|
+
},
|
|
340
|
+
remove: async (m) => {
|
|
341
|
+
await client.remove(m.id);
|
|
342
|
+
await refresh(m.conversationId, m.id);
|
|
343
|
+
},
|
|
344
|
+
react: async (m, emoji, on) => {
|
|
345
|
+
await client.react(m.id, emoji, on);
|
|
346
|
+
await refresh(m.conversationId, m.id);
|
|
347
|
+
},
|
|
348
|
+
pin: async (m, on) => {
|
|
349
|
+
await client.pin(m.id, on);
|
|
350
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
|
|
351
|
+
},
|
|
352
|
+
save: (m, on, note) => client.save(m.id, on, note),
|
|
353
|
+
report: (m, reason, note) => client.report(m.id, reason, note)
|
|
354
|
+
};
|
|
355
|
+
}, [client, queryClient]);
|
|
356
|
+
}
|
|
357
|
+
function usePeople(userIds) {
|
|
358
|
+
const { loadPeople } = useChat();
|
|
359
|
+
const ids = useMemo(() => [...new Set(userIds.filter((x) => Boolean(x)))].sort(), [userIds]);
|
|
360
|
+
return useQuery({
|
|
361
|
+
queryKey: ["genesis-chat", "people", ids],
|
|
362
|
+
queryFn: () => loadPeople(ids),
|
|
363
|
+
enabled: Boolean(loadPeople) && ids.length > 0,
|
|
364
|
+
staleTime: 5 * 6e4
|
|
365
|
+
}).data ?? {};
|
|
366
|
+
}
|
|
367
|
+
function usePerson(userId) {
|
|
368
|
+
const people = usePeople(useMemo(() => [userId], [userId]));
|
|
369
|
+
return userId ? people[userId] ?? null : null;
|
|
370
|
+
}
|
|
371
|
+
function usePins(conversationId) {
|
|
372
|
+
const { client } = useChat();
|
|
373
|
+
return useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId), enabled: Boolean(conversationId) }).data ?? [];
|
|
374
|
+
}
|
|
375
|
+
function useFileUrls(paths) {
|
|
376
|
+
const { client } = useChat();
|
|
377
|
+
const list = useMemo(() => [...new Set(paths.filter((p) => Boolean(p)))], [paths]);
|
|
378
|
+
return useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 6e4 }).data ?? {};
|
|
379
|
+
}
|
|
380
|
+
function useStartConversation() {
|
|
381
|
+
const { client } = useChat();
|
|
382
|
+
const queryClient = useQueryClient();
|
|
383
|
+
return useCallback(
|
|
384
|
+
async (kind, memberIds, opts) => {
|
|
385
|
+
const id = await client.startConversation(kind, memberIds, opts);
|
|
386
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
387
|
+
return id;
|
|
388
|
+
},
|
|
389
|
+
[client, queryClient]
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
function useTopics(conversationId) {
|
|
393
|
+
const { client } = useChat();
|
|
394
|
+
const queryClient = useQueryClient();
|
|
395
|
+
const id = conversationId ?? "";
|
|
396
|
+
const query = useQuery({ queryKey: chatKeys.topics(id), queryFn: () => client.topics(id), enabled: Boolean(conversationId), staleTime: 6e4 });
|
|
397
|
+
const setTopic = useCallback(
|
|
398
|
+
async (topic, input = {}) => {
|
|
399
|
+
await client.setTopic(id, topic, input);
|
|
400
|
+
await queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
|
|
401
|
+
},
|
|
402
|
+
[client, id, queryClient]
|
|
403
|
+
);
|
|
404
|
+
const topics = query.data ?? [];
|
|
405
|
+
return { topics, isLoading: query.isLoading, error: query.error, setTopic, refetch: query.refetch };
|
|
406
|
+
}
|
|
407
|
+
function useScheduled(conversationId, opts = {}) {
|
|
408
|
+
const { client, userId } = useChat();
|
|
409
|
+
const queryClient = useQueryClient();
|
|
410
|
+
const query = useQuery({
|
|
411
|
+
queryKey: chatKeys.scheduled(conversationId),
|
|
412
|
+
queryFn: () => client.scheduled(conversationId ?? void 0),
|
|
413
|
+
enabled: opts.enabled ?? true,
|
|
414
|
+
staleTime: 3e4
|
|
415
|
+
});
|
|
416
|
+
const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] }), [queryClient]);
|
|
417
|
+
const schedule = useCallback(
|
|
418
|
+
async (input, files = []) => {
|
|
419
|
+
const attachments = [...input.attachments ?? []];
|
|
420
|
+
for (const f of files) {
|
|
421
|
+
if (typeof f.data === "string") throw new Error("Scheduled files must be bytes (a Blob or ArrayBuffer)");
|
|
422
|
+
const path = await client.upload(input.conversationId, userId, f.data, { name: f.name, contentType: f.contentType });
|
|
423
|
+
attachments.push({ ...f.meta, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), path, name: f.meta?.name ?? f.name, mime: f.contentType });
|
|
424
|
+
}
|
|
425
|
+
const id = await client.schedule({ ...input, clientId: input.clientId ?? randomId(), attachments });
|
|
426
|
+
await refresh();
|
|
427
|
+
return id;
|
|
428
|
+
},
|
|
429
|
+
[client, refresh, userId]
|
|
430
|
+
);
|
|
431
|
+
const cancel = useCallback(
|
|
432
|
+
async (id) => {
|
|
433
|
+
queryClient.setQueriesData({ queryKey: ["genesis-chat", "scheduled"] }, (prev) => Array.isArray(prev) ? prev.filter((s) => s.id !== id) : prev);
|
|
434
|
+
try {
|
|
435
|
+
await client.cancelScheduled(id);
|
|
436
|
+
} finally {
|
|
437
|
+
await refresh();
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
[client, queryClient, refresh]
|
|
441
|
+
);
|
|
442
|
+
const items = query.data ?? [];
|
|
443
|
+
return { items, isLoading: query.isLoading, error: query.error, schedule, cancel, refetch: query.refetch };
|
|
444
|
+
}
|
|
445
|
+
function useAvailability(conversationId) {
|
|
446
|
+
const { client, userId } = useChat();
|
|
447
|
+
const queryClient = useQueryClient();
|
|
448
|
+
const mine = useQuery({ queryKey: chatKeys.myAvailability(), queryFn: () => client.myAvailability(), staleTime: 5 * 6e4 });
|
|
449
|
+
const members = useQuery({
|
|
450
|
+
queryKey: chatKeys.availability(conversationId ?? ""),
|
|
451
|
+
queryFn: () => client.availabilityFor(conversationId),
|
|
452
|
+
enabled: Boolean(conversationId),
|
|
453
|
+
staleTime: 5 * 6e4
|
|
454
|
+
});
|
|
455
|
+
const setMine = useCallback(
|
|
456
|
+
async (input) => {
|
|
457
|
+
await client.setAvailability(input);
|
|
458
|
+
await queryClient.invalidateQueries({ queryKey: chatKeys.myAvailability() });
|
|
459
|
+
await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === "genesis-chat" && q.queryKey[3] === "availability" });
|
|
460
|
+
},
|
|
461
|
+
[client, queryClient]
|
|
462
|
+
);
|
|
463
|
+
const list = members.data ?? [];
|
|
464
|
+
const forUser = useCallback((id) => list.find((a) => a.userId === id) ?? null, [list]);
|
|
465
|
+
return {
|
|
466
|
+
mine: mine.data ?? null,
|
|
467
|
+
mineLoading: mine.isLoading,
|
|
468
|
+
setMine,
|
|
469
|
+
members: list,
|
|
470
|
+
/** Everyone else's, without yours. */
|
|
471
|
+
others: list.filter((a) => a.userId !== userId),
|
|
472
|
+
forUser,
|
|
473
|
+
isLoading: mine.isLoading || Boolean(conversationId) && members.isLoading
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function useBroadcast() {
|
|
477
|
+
const { client } = useChat();
|
|
478
|
+
const queryClient = useQueryClient();
|
|
479
|
+
const [sending, setSending] = useState(false);
|
|
480
|
+
const send = useCallback(
|
|
481
|
+
async (conversationIds, body, opts = {}) => {
|
|
482
|
+
setSending(true);
|
|
483
|
+
try {
|
|
484
|
+
const ids = await client.broadcast(conversationIds, opts.clientId ?? randomId(), body, { silent: opts.silent });
|
|
485
|
+
void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
|
|
486
|
+
return ids;
|
|
487
|
+
} finally {
|
|
488
|
+
setSending(false);
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
[client, queryClient]
|
|
492
|
+
);
|
|
493
|
+
return { send, sending };
|
|
494
|
+
}
|
|
495
|
+
function useDebouncedValue(value, ms) {
|
|
496
|
+
const [v, setV] = useState(value);
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
if (ms <= 0) {
|
|
499
|
+
setV(value);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const t = setTimeout(() => setV(value), ms);
|
|
503
|
+
return () => clearTimeout(t);
|
|
504
|
+
}, [value, ms]);
|
|
505
|
+
return ms <= 0 ? value : v;
|
|
506
|
+
}
|
|
507
|
+
function useSearch(query, opts = {}) {
|
|
508
|
+
const { client } = useChat();
|
|
509
|
+
const q = useDebouncedValue(query.trim(), opts.debounceMs ?? 250);
|
|
510
|
+
const enabled = q.length >= 2;
|
|
511
|
+
const result = useQuery({
|
|
512
|
+
queryKey: chatKeys.search(q, opts.conversationId),
|
|
513
|
+
queryFn: () => client.search(q, opts.conversationId ?? null, { limit: opts.limit }),
|
|
514
|
+
enabled,
|
|
515
|
+
staleTime: 3e4
|
|
516
|
+
});
|
|
517
|
+
const hits = enabled ? result.data ?? [] : [];
|
|
518
|
+
return {
|
|
519
|
+
hits,
|
|
520
|
+
/** The query the hits are for. */
|
|
521
|
+
query: q,
|
|
522
|
+
/** Typing, or fetching: results are on their way. */
|
|
523
|
+
isSearching: query.trim() !== q || enabled && result.isFetching,
|
|
524
|
+
error: result.error
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
var LINK_PREVIEW_CONCURRENCY = 4;
|
|
528
|
+
var previewGates = /* @__PURE__ */ new WeakMap();
|
|
529
|
+
function gate(max) {
|
|
530
|
+
let active = 0;
|
|
531
|
+
const waiting = [];
|
|
532
|
+
return async (task) => {
|
|
533
|
+
if (active >= max) await new Promise((r) => waiting.push(r));
|
|
534
|
+
active += 1;
|
|
535
|
+
try {
|
|
536
|
+
return await task();
|
|
537
|
+
} finally {
|
|
538
|
+
active -= 1;
|
|
539
|
+
waiting.shift()?.();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function useLinkPreviews(messageIds) {
|
|
544
|
+
const { client } = useChat();
|
|
545
|
+
const list = useMemo(() => [...new Set(messageIds.filter((u) => Boolean(u)))].sort(), [messageIds]);
|
|
546
|
+
let run = previewGates.get(client);
|
|
547
|
+
if (!run) {
|
|
548
|
+
run = gate(LINK_PREVIEW_CONCURRENCY);
|
|
549
|
+
previewGates.set(client, run);
|
|
550
|
+
}
|
|
551
|
+
const limited = run;
|
|
552
|
+
const combine = useCallback(
|
|
553
|
+
(results) => {
|
|
554
|
+
let out = null;
|
|
555
|
+
results.forEach((r, i) => {
|
|
556
|
+
if (r.data) (out ??= {})[list[i]] = r.data;
|
|
557
|
+
});
|
|
558
|
+
return out ?? EMPTY_PREVIEWS;
|
|
559
|
+
},
|
|
560
|
+
[list]
|
|
561
|
+
);
|
|
562
|
+
return useQueries({
|
|
563
|
+
queries: list.map((id) => ({
|
|
564
|
+
queryKey: chatKeys.preview(id),
|
|
565
|
+
queryFn: () => limited(async () => (await client.linkPreviewsFor([id]))[id] ?? []),
|
|
566
|
+
staleTime: 6e4,
|
|
567
|
+
refetchInterval: (q) => !q.state.data?.length && q.state.dataUpdateCount < 4 ? 5e3 : false
|
|
568
|
+
})),
|
|
569
|
+
combine
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
var EMPTY_PREVIEWS = {};
|
|
573
|
+
|
|
574
|
+
export { CATCH_UP_MAX_PAGES, CATCH_UP_PAGE, ChatProvider, LINK_PREVIEW_CONCURRENCY, belongsTo, chatKeys, useAvailability, useBroadcast, useChat, useConversation, useDebouncedValue, useFileUrls, useInbox, useLinkPreviews, useMessageActions, usePeople, usePerson, usePins, useScheduled, useSearch, useStartConversation, useTopics, useUnreadTotal };
|
|
575
|
+
//# sourceMappingURL=chunk-PZTOY2LY.js.map
|
|
576
|
+
//# sourceMappingURL=chunk-PZTOY2LY.js.map
|