birdclaw 0.8.2 → 0.8.3

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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -0,0 +1,437 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import type { Database } from "./sqlite";
4
+ import { getReadDb } from "./db";
5
+ import { databaseWriteEffect } from "./database-writer";
6
+ import { getConversationThread } from "./dm-read-model";
7
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
8
+ import {
9
+ dmViaXurlEffect,
10
+ lookupAuthenticatedUserFresh,
11
+ postViaXurlEffect,
12
+ replyViaXurlEffect,
13
+ } from "./xurl";
14
+
15
+ function toError(error: unknown) {
16
+ return error instanceof Error ? error : new Error(String(error));
17
+ }
18
+
19
+ function trySync<T>(try_: () => T) {
20
+ return Effect.try({ try: try_, catch: toError });
21
+ }
22
+
23
+ function e2eFakeLiveWritesEnabled() {
24
+ return (
25
+ process.env.BIRDCLAW_E2E === "1" &&
26
+ process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
27
+ );
28
+ }
29
+
30
+ function liveWritesDisabled() {
31
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
32
+ }
33
+
34
+ function verifySelectedXurlAccountEffect(accountId: string) {
35
+ return Effect.gen(function* () {
36
+ if (liveWritesDisabled()) return;
37
+ if (e2eFakeLiveWritesEnabled()) return;
38
+ const db = yield* trySync(() => getReadDb());
39
+ const account = yield* trySync(
40
+ () =>
41
+ db
42
+ .prepare("select handle, external_user_id from accounts where id = ?")
43
+ .get(accountId) as
44
+ | { handle: string; external_user_id: string | null }
45
+ | undefined,
46
+ );
47
+ if (!account) {
48
+ return yield* Effect.fail(new Error(`Unknown account: ${accountId}`));
49
+ }
50
+ const authenticated = yield* tryPromise(() =>
51
+ lookupAuthenticatedUserFresh(),
52
+ );
53
+ const authenticatedId =
54
+ typeof authenticated?.id === "string" ? authenticated.id : "";
55
+ const authenticatedHandle =
56
+ typeof authenticated?.username === "string"
57
+ ? authenticated.username.replace(/^@/, "")
58
+ : "";
59
+ const expectedHandle = account.handle.replace(/^@/, "");
60
+ if (
61
+ (account.external_user_id &&
62
+ account.external_user_id !== authenticatedId) ||
63
+ (!account.external_user_id &&
64
+ (!authenticatedHandle ||
65
+ authenticatedHandle.toLowerCase() !== expectedHandle.toLowerCase()))
66
+ ) {
67
+ return yield* Effect.fail(
68
+ new Error(
69
+ `xurl is authenticated as @${authenticatedHandle || authenticatedId}, not @${expectedHandle}`,
70
+ ),
71
+ );
72
+ }
73
+ });
74
+ }
75
+
76
+ function refreshDmConversationState(
77
+ db: Database,
78
+ conversationId: string,
79
+ lastMessageAt: string,
80
+ observedLastMessageAt = lastMessageAt,
81
+ ) {
82
+ db.prepare(
83
+ `
84
+ update dm_conversations
85
+ set last_message_at = case
86
+ when last_message_at < ? then ?
87
+ else last_message_at
88
+ end,
89
+ unread_count = case
90
+ when last_message_at = ? then 0
91
+ when last_message_at <= ? then 0
92
+ else unread_count
93
+ end,
94
+ needs_reply = case
95
+ when last_message_at = ? then 0
96
+ when last_message_at <= ? then 0
97
+ else needs_reply
98
+ end
99
+ where id = ?
100
+ `,
101
+ ).run(
102
+ lastMessageAt,
103
+ lastMessageAt,
104
+ observedLastMessageAt,
105
+ lastMessageAt,
106
+ observedLastMessageAt,
107
+ lastMessageAt,
108
+ conversationId,
109
+ );
110
+ }
111
+
112
+ function getLocalAuthorProfileId(accountId: string) {
113
+ const db = getReadDb();
114
+ const row = db
115
+ .prepare(
116
+ `
117
+ select p.id
118
+ from accounts a
119
+ join profiles p on p.handle = replace(a.handle, '@', '')
120
+ where a.id = ?
121
+ `,
122
+ )
123
+ .get(accountId) as { id: string } | undefined;
124
+
125
+ return row?.id;
126
+ }
127
+
128
+ let savepointCounter = 0;
129
+
130
+ function preflightWrite<T>(db: Database, write: (db: Database) => T) {
131
+ const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
132
+ db.exec(`savepoint ${savepoint}`);
133
+ try {
134
+ const result = write(db);
135
+ db.exec(`rollback to ${savepoint}`);
136
+ db.exec(`release ${savepoint}`);
137
+ return result;
138
+ } catch (error) {
139
+ try {
140
+ db.exec(`rollback to ${savepoint}`);
141
+ db.exec(`release ${savepoint}`);
142
+ } catch {
143
+ // Preserve the original staging error; cleanup is best effort here.
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ type PostDraft = {
150
+ actionId: string;
151
+ authorProfileId: string;
152
+ createdAt: string;
153
+ tweetId: string;
154
+ };
155
+
156
+ function preparePostDraft(accountId: string): PostDraft {
157
+ const authorProfileId = getLocalAuthorProfileId(accountId);
158
+ if (!authorProfileId) {
159
+ throw new Error("No local author profile for account");
160
+ }
161
+
162
+ return {
163
+ actionId: randomUUID(),
164
+ authorProfileId,
165
+ createdAt: new Date().toISOString(),
166
+ tweetId: `tweet_${randomUUID()}`,
167
+ };
168
+ }
169
+
170
+ function writePostDraft(
171
+ db: Database,
172
+ accountId: string,
173
+ text: string,
174
+ draft: PostDraft,
175
+ ) {
176
+ db.prepare(
177
+ `
178
+ insert into tweets (
179
+ id, account_id, author_profile_id, kind, text, created_at,
180
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked
181
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
182
+ `,
183
+ ).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
184
+
185
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
186
+ draft.tweetId,
187
+ text,
188
+ );
189
+ db.prepare(
190
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
191
+ ).run(
192
+ draft.actionId,
193
+ accountId,
194
+ draft.tweetId,
195
+ "post",
196
+ text,
197
+ draft.createdAt,
198
+ );
199
+ }
200
+
201
+ export function createPostEffect(accountId: string, text: string) {
202
+ return Effect.gen(function* () {
203
+ const draft = yield* trySync(() => preparePostDraft(accountId));
204
+ yield* databaseWriteEffect((db) =>
205
+ preflightWrite(db, (writeDb) =>
206
+ writePostDraft(writeDb, accountId, text, draft),
207
+ ),
208
+ );
209
+
210
+ yield* verifySelectedXurlAccountEffect(accountId);
211
+ const transport = yield* postViaXurlEffect(text);
212
+ if (!transport.ok) {
213
+ return yield* Effect.fail(new Error(transport.output || "post failed"));
214
+ }
215
+ yield* databaseWriteEffect((db) =>
216
+ writePostDraft(db, accountId, text, draft),
217
+ );
218
+
219
+ return { ok: true, transport, tweetId: draft.tweetId };
220
+ });
221
+ }
222
+
223
+ export function createPost(accountId: string, text: string) {
224
+ return runEffectPromise(createPostEffect(accountId, text));
225
+ }
226
+
227
+ export function createTweetReplyEffect(
228
+ accountId: string,
229
+ tweetId: string,
230
+ text: string,
231
+ ) {
232
+ type ReplyDraft = PostDraft & { replyId: string };
233
+
234
+ function prepareReplyDraft(): ReplyDraft {
235
+ const postDraft = preparePostDraft(accountId);
236
+ return {
237
+ ...postDraft,
238
+ replyId: postDraft.tweetId,
239
+ };
240
+ }
241
+
242
+ function writeReplyDraft(db: Database, draft: ReplyDraft) {
243
+ db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
244
+
245
+ db.prepare(
246
+ `
247
+ insert into tweets (
248
+ id, account_id, author_profile_id, kind, text, created_at,
249
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked
250
+ ) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
251
+ `,
252
+ ).run(
253
+ draft.replyId,
254
+ accountId,
255
+ draft.authorProfileId,
256
+ text,
257
+ draft.createdAt,
258
+ tweetId,
259
+ );
260
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
261
+ draft.replyId,
262
+ text,
263
+ );
264
+
265
+ db.prepare(
266
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
267
+ ).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
268
+ }
269
+
270
+ return Effect.gen(function* () {
271
+ const draft = yield* trySync(() => prepareReplyDraft());
272
+ yield* databaseWriteEffect((db) =>
273
+ preflightWrite(db, (writeDb) => writeReplyDraft(writeDb, draft)),
274
+ );
275
+
276
+ yield* verifySelectedXurlAccountEffect(accountId);
277
+ const transport = yield* replyViaXurlEffect(tweetId, text);
278
+ if (!transport.ok) {
279
+ return yield* Effect.fail(new Error(transport.output || "reply failed"));
280
+ }
281
+ yield* databaseWriteEffect((db) => writeReplyDraft(db, draft));
282
+
283
+ return { ok: true, transport, replyId: draft.replyId };
284
+ });
285
+ }
286
+
287
+ export function createTweetReply(
288
+ accountId: string,
289
+ tweetId: string,
290
+ text: string,
291
+ ) {
292
+ return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
293
+ }
294
+
295
+ export function createDmReplyEffect(conversationId: string, text: string) {
296
+ return Effect.gen(function* () {
297
+ const draft = yield* trySync(() => {
298
+ const conversation = getConversationThread(conversationId);
299
+ if (!conversation) {
300
+ throw new Error("Conversation not found");
301
+ }
302
+ const authorProfileId = getLocalAuthorProfileId(
303
+ conversation.conversation.accountId,
304
+ );
305
+ if (!authorProfileId) {
306
+ throw new Error("No local author profile for account");
307
+ }
308
+
309
+ const dmDraft = {
310
+ accountId: conversation.conversation.accountId,
311
+ authorProfileId,
312
+ createdAt: new Date().toISOString(),
313
+ handle: conversation.conversation.participant.handle,
314
+ observedLastMessageAt: conversation.conversation.lastMessageAt,
315
+ outboundId: `msg_${randomUUID()}`,
316
+ };
317
+ return dmDraft;
318
+ });
319
+ yield* databaseWriteEffect((db) =>
320
+ preflightWrite(db, (writeDb) => {
321
+ writeDb
322
+ .prepare(
323
+ `
324
+ insert into dm_messages (
325
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
326
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
327
+ `,
328
+ )
329
+ .run(
330
+ draft.outboundId,
331
+ conversationId,
332
+ draft.authorProfileId,
333
+ text,
334
+ draft.createdAt,
335
+ );
336
+ writeDb
337
+ .prepare("insert into dm_fts (message_id, text) values (?, ?)")
338
+ .run(draft.outboundId, text);
339
+
340
+ refreshDmConversationState(
341
+ writeDb,
342
+ conversationId,
343
+ draft.createdAt,
344
+ draft.observedLastMessageAt,
345
+ );
346
+ }),
347
+ );
348
+
349
+ yield* verifySelectedXurlAccountEffect(draft.accountId);
350
+ const transport = yield* dmViaXurlEffect(draft.handle, text);
351
+ if (!transport.ok) {
352
+ return yield* Effect.fail(new Error(transport.output || "dm failed"));
353
+ }
354
+ yield* databaseWriteEffect((db) => {
355
+ db.prepare(
356
+ `
357
+ insert into dm_messages (
358
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
359
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
360
+ `,
361
+ ).run(
362
+ draft.outboundId,
363
+ conversationId,
364
+ draft.authorProfileId,
365
+ text,
366
+ draft.createdAt,
367
+ );
368
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
369
+ draft.outboundId,
370
+ text,
371
+ );
372
+
373
+ refreshDmConversationState(
374
+ db,
375
+ conversationId,
376
+ draft.createdAt,
377
+ draft.observedLastMessageAt,
378
+ );
379
+ });
380
+
381
+ return { ok: true, transport, messageId: draft.outboundId };
382
+ });
383
+ }
384
+
385
+ export function createDmReply(conversationId: string, text: string) {
386
+ return runEffectPromise(createDmReplyEffect(conversationId, text));
387
+ }
388
+
389
+ export type DmRequestMutationAction = "accept" | "reject" | "block";
390
+
391
+ export async function applyDmRequestMutationToLocalStore(
392
+ conversationId: string,
393
+ action: DmRequestMutationAction,
394
+ ) {
395
+ return runEffectPromise(
396
+ databaseWriteEffect((db) => {
397
+ db.prepare(
398
+ "delete from sync_cache where cache_key like 'dms:bird:%'",
399
+ ).run();
400
+ if (action === "accept") {
401
+ return db
402
+ .prepare(
403
+ `
404
+ update dm_conversations
405
+ set inbox_kind = 'accepted'
406
+ where id = ?
407
+ `,
408
+ )
409
+ .run(conversationId).changes;
410
+ }
411
+
412
+ db.prepare(
413
+ `
414
+ delete from link_occurrences
415
+ where source_kind = 'dm'
416
+ and source_id in (
417
+ select id from dm_messages where conversation_id = ?
418
+ )
419
+ `,
420
+ ).run(conversationId);
421
+ db.prepare(
422
+ `
423
+ delete from dm_fts
424
+ where message_id in (
425
+ select id from dm_messages where conversation_id = ?
426
+ )
427
+ `,
428
+ ).run(conversationId);
429
+ db.prepare("delete from dm_messages where conversation_id = ?").run(
430
+ conversationId,
431
+ );
432
+ return db
433
+ .prepare("delete from dm_conversations where id = ?")
434
+ .run(conversationId).changes;
435
+ }),
436
+ );
437
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ QueryClient,
3
+ QueryClientProvider,
4
+ type QueryKey,
5
+ } from "@tanstack/react-query";
6
+ import { useState, type ReactNode } from "react";
7
+
8
+ export const queryKeys = {
9
+ status: ["status"] as const,
10
+ timelines: ["timeline"] as const,
11
+ dms: ["dms"] as const,
12
+ linkInsights: ["link-insights"] as const,
13
+ profileHydration: ["profile-hydration"] as const,
14
+ networkMap: ["network-map"] as const,
15
+ blocks: ["blocks"] as const,
16
+ blockSync: ["block-sync"] as const,
17
+ inbox: ["inbox"] as const,
18
+ dataSources: ["data-sources"] as const,
19
+ rateLimits: ["rate-limits"] as const,
20
+ };
21
+
22
+ export function createBirdclawQueryClient() {
23
+ return new QueryClient({
24
+ defaultOptions: {
25
+ queries: {
26
+ gcTime: 30 * 60_000,
27
+ refetchOnWindowFocus: false,
28
+ retry: 1,
29
+ staleTime: 60_000,
30
+ },
31
+ },
32
+ });
33
+ }
34
+
35
+ export function invalidateQueryFamily(
36
+ queryClient: QueryClient,
37
+ queryKey: QueryKey,
38
+ ) {
39
+ return queryClient.invalidateQueries({ queryKey });
40
+ }
41
+
42
+ export function BirdclawQueryProvider({ children }: { children: ReactNode }) {
43
+ const [queryClient] = useState(createBirdclawQueryClient);
44
+ return (
45
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
46
+ );
47
+ }
@@ -0,0 +1,52 @@
1
+ import type { ProfileRecord } from "./types";
2
+
3
+ export function toProfile(row: Record<string, unknown>): ProfileRecord {
4
+ const followingCount = Number(row.following_count ?? 0);
5
+ const entities = parseJsonField<Record<string, unknown> | undefined>(
6
+ row.entities_json,
7
+ undefined,
8
+ );
9
+ return {
10
+ id: String(row.id),
11
+ handle: String(row.handle),
12
+ displayName: String(row.display_name),
13
+ bio: String(row.bio),
14
+ followersCount: Number(row.followers_count),
15
+ ...(Number.isFinite(followingCount) ? { followingCount } : {}),
16
+ avatarHue: Number(row.avatar_hue),
17
+ avatarUrl:
18
+ typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
19
+ ...(typeof row.location === "string" && row.location.length > 0
20
+ ? { location: row.location }
21
+ : {}),
22
+ ...(typeof row.url === "string" && row.url.length > 0
23
+ ? { url: row.url }
24
+ : {}),
25
+ ...(typeof row.verified_type === "string" && row.verified_type.length > 0
26
+ ? { verifiedType: row.verified_type }
27
+ : {}),
28
+ ...(entities ? { entities } : {}),
29
+ createdAt: String(row.created_at),
30
+ };
31
+ }
32
+
33
+ export function parseJsonField<T>(value: unknown, fallback: T): T {
34
+ if (typeof value !== "string" || value.length === 0) {
35
+ return fallback;
36
+ }
37
+
38
+ try {
39
+ return JSON.parse(value) as T;
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
44
+
45
+ export function toFtsSearchQuery(value: string) {
46
+ const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
47
+ return terms
48
+ .map((term) => term.trim())
49
+ .filter((term) => term.length > 0)
50
+ .map((term) => `"${term.replaceAll('"', '""')}"`)
51
+ .join(" ");
52
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./dm-read-model";
2
+ export * from "./query-actions";
3
+ export * from "./query-resource";
4
+ export * from "./query-status";
5
+ export * from "./timeline-read-model";
@@ -0,0 +1,41 @@
1
+ import { getConversationThread, listDmConversations } from "./dm-read-model";
2
+ import { listTimelineItems } from "./timeline-read-model";
3
+ import type { DmQuery, QueryResponse, TimelineQuery } from "./types";
4
+
5
+ export type { QueryResponse } from "./types";
6
+
7
+ export function queryResource(
8
+ resource: "home" | "mentions" | "authored" | "search" | "dms",
9
+ filters: (TimelineQuery | DmQuery) & { conversationId?: string },
10
+ ): QueryResponse {
11
+ if (resource === "dms") {
12
+ const dmFilters = filters as DmQuery & { conversationId?: string };
13
+ const items = listDmConversations(dmFilters);
14
+ const requestedConversationId = dmFilters.conversationId;
15
+ const selectedConversationId =
16
+ requestedConversationId &&
17
+ items.some((item) => item.id === requestedConversationId)
18
+ ? requestedConversationId
19
+ : items[0]?.id;
20
+ return {
21
+ resource,
22
+ items,
23
+ selectedConversation: selectedConversationId
24
+ ? getConversationThread(selectedConversationId, {
25
+ account: dmFilters.account,
26
+ })
27
+ : null,
28
+ };
29
+ }
30
+
31
+ const { resource: _filterResource, ...timelineFilters } =
32
+ filters as TimelineQuery;
33
+
34
+ return {
35
+ resource,
36
+ items: listTimelineItems({
37
+ resource,
38
+ ...timelineFilters,
39
+ }),
40
+ };
41
+ }