birdclaw 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +122 -16
- package/package.json +4 -3
- package/src/cli.ts +232 -7
- package/src/components/AppNav.tsx +3 -1
- package/src/components/SavedTimelineView.tsx +115 -0
- package/src/components/TimelineCard.tsx +26 -17
- package/src/lib/archive-import.ts +193 -16
- package/src/lib/backup.ts +1617 -0
- package/src/lib/bird.ts +163 -24
- package/src/lib/blocks-write.ts +2 -2
- package/src/lib/blocks.ts +2 -7
- package/src/lib/bookmark-sync-job.ts +446 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/db.ts +82 -4
- package/src/lib/mentions-live.ts +3 -6
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +20 -1
- package/src/lib/seed.ts +4 -2
- package/src/lib/theme-transition.ts +6 -10
- package/src/lib/theme.tsx +13 -5
- package/src/lib/timeline-collections-live.ts +406 -0
- package/src/lib/types.ts +4 -1
- package/src/lib/xurl.ts +92 -6
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +6 -1
- package/src/routes/api/action.tsx +14 -4
- package/src/routes/api/inbox.tsx +3 -1
- package/src/routes/api/query.tsx +5 -1
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/blocks.tsx +1 -1
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,18 @@ import { Command } from "commander";
|
|
|
8
8
|
import { registerModerationCommands } from "#/cli-moderation";
|
|
9
9
|
import { findArchives } from "#/lib/archive-finder";
|
|
10
10
|
import { importArchive } from "#/lib/archive-import";
|
|
11
|
+
import {
|
|
12
|
+
exportBackup,
|
|
13
|
+
importBackup,
|
|
14
|
+
maybeAutoSyncBackup,
|
|
15
|
+
maybeAutoUpdateBackup,
|
|
16
|
+
syncBackup,
|
|
17
|
+
validateBackup,
|
|
18
|
+
} from "#/lib/backup";
|
|
19
|
+
import {
|
|
20
|
+
installBookmarkSyncLaunchAgent,
|
|
21
|
+
runBookmarkSyncJob,
|
|
22
|
+
} from "#/lib/bookmark-sync-job";
|
|
11
23
|
import { importBlocklist } from "#/lib/blocklist";
|
|
12
24
|
import {
|
|
13
25
|
type ActionsTransport,
|
|
@@ -32,6 +44,10 @@ import {
|
|
|
32
44
|
listDmConversations,
|
|
33
45
|
listTimelineItems,
|
|
34
46
|
} from "#/lib/queries";
|
|
47
|
+
import {
|
|
48
|
+
syncTimelineCollection,
|
|
49
|
+
type TimelineCollectionMode,
|
|
50
|
+
} from "#/lib/timeline-collections-live";
|
|
35
51
|
|
|
36
52
|
const program = new Command();
|
|
37
53
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -53,9 +69,23 @@ function resolveActionOptions(options: { transport?: string }) {
|
|
|
53
69
|
};
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
async function autoUpdateBeforeRead() {
|
|
73
|
+
const result = await maybeAutoUpdateBackup();
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
console.error(`birdclaw backup auto-sync failed: ${result.error}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function autoSyncAfterWrite() {
|
|
80
|
+
const result = await maybeAutoSyncBackup();
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
console.error(`birdclaw backup sync failed: ${result.error}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
program
|
|
57
87
|
.name("birdclaw")
|
|
58
|
-
.description("Local-first
|
|
88
|
+
.description("Local-first Twitter workspace")
|
|
59
89
|
.version(packageVersion.version ?? "0.0.0")
|
|
60
90
|
.option("--json", "Emit JSON output");
|
|
61
91
|
|
|
@@ -88,7 +118,7 @@ program
|
|
|
88
118
|
|
|
89
119
|
program
|
|
90
120
|
.command("archive find")
|
|
91
|
-
.description("Find likely
|
|
121
|
+
.description("Find likely Twitter archives on disk")
|
|
92
122
|
.action(async () => {
|
|
93
123
|
const items = await findArchives();
|
|
94
124
|
print(items, program.opts().json ?? false);
|
|
@@ -100,7 +130,7 @@ const importCommand = program
|
|
|
100
130
|
|
|
101
131
|
importCommand
|
|
102
132
|
.command("archive [archivePath]")
|
|
103
|
-
.description("Import
|
|
133
|
+
.description("Import a Twitter archive into the local SQLite store")
|
|
104
134
|
.action(async (archivePath) => {
|
|
105
135
|
let resolvedArchivePath = archivePath;
|
|
106
136
|
if (!resolvedArchivePath) {
|
|
@@ -115,14 +145,16 @@ importCommand
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
const result = await importArchive(resolvedArchivePath);
|
|
148
|
+
await autoSyncAfterWrite();
|
|
118
149
|
print(result, program.opts().json ?? false);
|
|
119
150
|
});
|
|
120
151
|
|
|
121
152
|
importCommand
|
|
122
153
|
.command("hydrate-profiles")
|
|
123
|
-
.description("Backfill archive-imported profiles from live
|
|
154
|
+
.description("Backfill archive-imported profiles from live Twitter metadata")
|
|
124
155
|
.action(async () => {
|
|
125
156
|
const result = await hydrateProfilesFromX();
|
|
157
|
+
await autoSyncAfterWrite();
|
|
126
158
|
print(result, program.opts().json ?? false);
|
|
127
159
|
});
|
|
128
160
|
|
|
@@ -139,8 +171,11 @@ searchCommand
|
|
|
139
171
|
.option("--until <date>", "Include tweets created before this date")
|
|
140
172
|
.option("--originals-only", "Exclude authored replies that start with @")
|
|
141
173
|
.option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
|
|
174
|
+
.option("--liked", "Only liked tweets")
|
|
175
|
+
.option("--bookmarked", "Only bookmarked tweets")
|
|
142
176
|
.option("--limit <n>", "Limit results", "20")
|
|
143
|
-
.action((query, options) => {
|
|
177
|
+
.action(async (query, options) => {
|
|
178
|
+
await autoUpdateBeforeRead();
|
|
144
179
|
const replyFilter = options.replied
|
|
145
180
|
? "replied"
|
|
146
181
|
: options.unreplied
|
|
@@ -154,6 +189,8 @@ searchCommand
|
|
|
154
189
|
until: options.until,
|
|
155
190
|
includeReplies: !options.originalsOnly,
|
|
156
191
|
qualityFilter: options.hideLowQuality ? "summary" : "all",
|
|
192
|
+
likedOnly: Boolean(options.liked),
|
|
193
|
+
bookmarkedOnly: Boolean(options.bookmarked),
|
|
157
194
|
limit: Number(options.limit),
|
|
158
195
|
});
|
|
159
196
|
print(items, program.opts().json ?? false);
|
|
@@ -170,7 +207,8 @@ searchCommand
|
|
|
170
207
|
.option("--replied", "Only replied threads")
|
|
171
208
|
.option("--unreplied", "Only unreplied threads")
|
|
172
209
|
.option("--limit <n>", "Limit results", "20")
|
|
173
|
-
.action((query, options) => {
|
|
210
|
+
.action(async (query, options) => {
|
|
211
|
+
await autoUpdateBeforeRead();
|
|
174
212
|
const replyFilter = options.replied
|
|
175
213
|
? "replied"
|
|
176
214
|
: options.unreplied
|
|
@@ -218,6 +256,7 @@ mentionsCommand
|
|
|
218
256
|
)
|
|
219
257
|
.option("--limit <n>", "Limit results", "20")
|
|
220
258
|
.action(async (query, options) => {
|
|
259
|
+
await autoUpdateBeforeRead();
|
|
221
260
|
const replyFilter = options.replied
|
|
222
261
|
? "replied"
|
|
223
262
|
: options.unreplied
|
|
@@ -236,6 +275,7 @@ mentionsCommand
|
|
|
236
275
|
refresh: Boolean(options.refresh),
|
|
237
276
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
238
277
|
});
|
|
278
|
+
await autoSyncAfterWrite();
|
|
239
279
|
print(payload, true);
|
|
240
280
|
return;
|
|
241
281
|
}
|
|
@@ -250,6 +290,7 @@ mentionsCommand
|
|
|
250
290
|
refresh: Boolean(options.refresh),
|
|
251
291
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
252
292
|
});
|
|
293
|
+
await autoSyncAfterWrite();
|
|
253
294
|
print(payload, true);
|
|
254
295
|
return;
|
|
255
296
|
}
|
|
@@ -280,6 +321,108 @@ profilesCommand
|
|
|
280
321
|
|
|
281
322
|
const dmsCommand = program.command("dms").description("Direct messages");
|
|
282
323
|
|
|
324
|
+
const syncCommand = program
|
|
325
|
+
.command("sync")
|
|
326
|
+
.description("Refresh live Twitter collections into the local store");
|
|
327
|
+
|
|
328
|
+
for (const kind of ["likes", "bookmarks"] as const) {
|
|
329
|
+
syncCommand
|
|
330
|
+
.command(kind)
|
|
331
|
+
.description(`Refresh live ${kind} through xurl or bird`)
|
|
332
|
+
.option("--account <accountId>", "Account id")
|
|
333
|
+
.option("--mode <mode>", "auto, xurl, or bird", "auto")
|
|
334
|
+
.option("--limit <n>", "Per-page/result limit", "20")
|
|
335
|
+
.option("--all", "Fetch every retrievable page")
|
|
336
|
+
.option("--max-pages <n>", "Stop after N pages when using --all")
|
|
337
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
338
|
+
.option("--refresh", "Bypass live-cache freshness window")
|
|
339
|
+
.action(async (options) => {
|
|
340
|
+
const result = await syncTimelineCollection({
|
|
341
|
+
kind,
|
|
342
|
+
account: options.account,
|
|
343
|
+
mode: options.mode as TimelineCollectionMode,
|
|
344
|
+
limit: Number(options.limit),
|
|
345
|
+
all: Boolean(options.all) || options.maxPages !== undefined,
|
|
346
|
+
maxPages: options.maxPages ? Number(options.maxPages) : undefined,
|
|
347
|
+
refresh: Boolean(options.refresh),
|
|
348
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
349
|
+
});
|
|
350
|
+
await autoSyncAfterWrite();
|
|
351
|
+
print(result, true);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const jobsCommand = program
|
|
356
|
+
.command("jobs")
|
|
357
|
+
.description("Run and install background Birdclaw jobs");
|
|
358
|
+
|
|
359
|
+
jobsCommand
|
|
360
|
+
.command("sync-bookmarks")
|
|
361
|
+
.description("Refresh live bookmarks and append a JSONL audit entry")
|
|
362
|
+
.option("--account <accountId>", "Account id")
|
|
363
|
+
.option("--mode <mode>", "auto, xurl, or bird", "auto")
|
|
364
|
+
.option("--limit <n>", "Per-page/result limit", "100")
|
|
365
|
+
.option("--all", "Fetch every retrievable page")
|
|
366
|
+
.option("--max-pages <n>", "Stop after N pages", "5")
|
|
367
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
368
|
+
.option("--refresh", "Bypass live-cache freshness window")
|
|
369
|
+
.option("--log <path>", "Audit JSONL path")
|
|
370
|
+
.action(async (options) => {
|
|
371
|
+
const result = await runBookmarkSyncJob({
|
|
372
|
+
account: options.account,
|
|
373
|
+
mode: options.mode as TimelineCollectionMode,
|
|
374
|
+
limit: Number(options.limit),
|
|
375
|
+
all: Boolean(options.all) || options.maxPages !== undefined,
|
|
376
|
+
maxPages: options.all ? undefined : Number(options.maxPages),
|
|
377
|
+
refresh: Boolean(options.refresh),
|
|
378
|
+
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
379
|
+
logPath: options.log,
|
|
380
|
+
});
|
|
381
|
+
print(result, true);
|
|
382
|
+
if (!result.ok) {
|
|
383
|
+
process.exitCode = 1;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
jobsCommand
|
|
388
|
+
.command("install-bookmarks-launchd")
|
|
389
|
+
.description("Install a LaunchAgent that runs bookmark sync every 3 hours")
|
|
390
|
+
.option("--label <label>", "LaunchAgent label")
|
|
391
|
+
.option("--interval-seconds <seconds>", "Launch interval", "10800")
|
|
392
|
+
.option("--program <path>", "birdclaw executable or command", "birdclaw")
|
|
393
|
+
.option("--mode <mode>", "auto, xurl, or bird", "auto")
|
|
394
|
+
.option("--limit <n>", "Per-page/result limit", "100")
|
|
395
|
+
.option("--all", "Fetch every retrievable page")
|
|
396
|
+
.option("--max-pages <n>", "Stop after N pages", "5")
|
|
397
|
+
.option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
|
|
398
|
+
.option("--no-refresh", "Allow live-cache reuse")
|
|
399
|
+
.option("--log <path>", "Audit JSONL path")
|
|
400
|
+
.option("--env-file <path>", "Shell env file to source before running")
|
|
401
|
+
.option("--stdout <path>", "launchd stdout path")
|
|
402
|
+
.option("--stderr <path>", "launchd stderr path")
|
|
403
|
+
.option("--launch-agents-dir <path>", "LaunchAgents directory")
|
|
404
|
+
.option("--no-load", "Write plist without loading it")
|
|
405
|
+
.action(async (options) => {
|
|
406
|
+
const result = await installBookmarkSyncLaunchAgent({
|
|
407
|
+
label: options.label,
|
|
408
|
+
intervalSeconds: Number(options.intervalSeconds),
|
|
409
|
+
program: options.program,
|
|
410
|
+
mode: options.mode as TimelineCollectionMode,
|
|
411
|
+
limit: Number(options.limit),
|
|
412
|
+
all: Boolean(options.all) || options.maxPages !== undefined,
|
|
413
|
+
maxPages: options.all ? undefined : Number(options.maxPages),
|
|
414
|
+
refresh: options.refresh,
|
|
415
|
+
cacheTtlSeconds: Number(options.cacheTtl),
|
|
416
|
+
logPath: options.log,
|
|
417
|
+
envFile: options.envFile,
|
|
418
|
+
stdoutPath: options.stdout,
|
|
419
|
+
stderrPath: options.stderr,
|
|
420
|
+
launchAgentsDir: options.launchAgentsDir,
|
|
421
|
+
load: options.load,
|
|
422
|
+
});
|
|
423
|
+
print(result, true);
|
|
424
|
+
});
|
|
425
|
+
|
|
283
426
|
dmsCommand
|
|
284
427
|
.command("list")
|
|
285
428
|
.option("--account <accountId>", "Account id")
|
|
@@ -307,6 +450,9 @@ dmsCommand
|
|
|
307
450
|
refresh: true,
|
|
308
451
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
309
452
|
});
|
|
453
|
+
await autoSyncAfterWrite();
|
|
454
|
+
} else {
|
|
455
|
+
await autoUpdateBeforeRead();
|
|
310
456
|
}
|
|
311
457
|
const items = listDmConversations({
|
|
312
458
|
account: options.account,
|
|
@@ -344,6 +490,7 @@ dmsCommand
|
|
|
344
490
|
refresh: Boolean(options.refresh),
|
|
345
491
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
346
492
|
});
|
|
493
|
+
await autoSyncAfterWrite();
|
|
347
494
|
print(result, true);
|
|
348
495
|
});
|
|
349
496
|
|
|
@@ -364,6 +511,7 @@ composeCommand
|
|
|
364
511
|
.option("--account <accountId>", "Account id", "acct_primary")
|
|
365
512
|
.action(async (text, options) => {
|
|
366
513
|
const result = await createPost(options.account, text);
|
|
514
|
+
await autoSyncAfterWrite();
|
|
367
515
|
print(result, program.opts().json ?? false);
|
|
368
516
|
});
|
|
369
517
|
|
|
@@ -372,6 +520,7 @@ composeCommand
|
|
|
372
520
|
.option("--account <accountId>", "Account id", "acct_primary")
|
|
373
521
|
.action(async (tweetId, text, options) => {
|
|
374
522
|
const result = await createTweetReply(options.account, tweetId, text);
|
|
523
|
+
await autoSyncAfterWrite();
|
|
375
524
|
print(result, program.opts().json ?? false);
|
|
376
525
|
});
|
|
377
526
|
|
|
@@ -380,6 +529,7 @@ composeCommand
|
|
|
380
529
|
.description("Reply inside an existing DM conversation")
|
|
381
530
|
.action(async (conversationId, text) => {
|
|
382
531
|
const result = await createDmReply(conversationId, text);
|
|
532
|
+
await autoSyncAfterWrite();
|
|
383
533
|
print(result, program.opts().json ?? false);
|
|
384
534
|
});
|
|
385
535
|
|
|
@@ -391,6 +541,7 @@ program
|
|
|
391
541
|
.option("--score", "Score top items with OpenAI before listing")
|
|
392
542
|
.option("--limit <n>", "Limit results", "20")
|
|
393
543
|
.action(async (options) => {
|
|
544
|
+
await autoUpdateBeforeRead();
|
|
394
545
|
const kind =
|
|
395
546
|
options.kind === "mentions" || options.kind === "dms"
|
|
396
547
|
? options.kind
|
|
@@ -400,6 +551,7 @@ program
|
|
|
400
551
|
kind,
|
|
401
552
|
limit: Number(options.limit),
|
|
402
553
|
});
|
|
554
|
+
await autoSyncAfterWrite();
|
|
403
555
|
}
|
|
404
556
|
print(
|
|
405
557
|
listInboxItems({
|
|
@@ -416,6 +568,7 @@ program
|
|
|
416
568
|
.command("db stats")
|
|
417
569
|
.description("Show local storage and dataset stats")
|
|
418
570
|
.action(async () => {
|
|
571
|
+
await autoUpdateBeforeRead();
|
|
419
572
|
const meta = await getQueryEnvelope();
|
|
420
573
|
const paths = getBirdclawPaths();
|
|
421
574
|
print(
|
|
@@ -428,10 +581,82 @@ program
|
|
|
428
581
|
);
|
|
429
582
|
});
|
|
430
583
|
|
|
584
|
+
const backupCommand = program
|
|
585
|
+
.command("backup")
|
|
586
|
+
.description("Export, import, and validate Git-friendly text backups");
|
|
587
|
+
|
|
588
|
+
backupCommand
|
|
589
|
+
.command("export")
|
|
590
|
+
.description("Export canonical JSONL backup shards")
|
|
591
|
+
.requiredOption("--repo <path>", "Backup repository/path")
|
|
592
|
+
.option("--commit", "Create a git commit in the backup repo")
|
|
593
|
+
.option("--push", "Push the backup repo after committing")
|
|
594
|
+
.option(
|
|
595
|
+
"--message <message>",
|
|
596
|
+
"Git commit message",
|
|
597
|
+
"archive: update birdclaw backup",
|
|
598
|
+
)
|
|
599
|
+
.option("--no-validate", "Skip post-export validation")
|
|
600
|
+
.action(async (options) => {
|
|
601
|
+
const result = await exportBackup({
|
|
602
|
+
repoPath: options.repo,
|
|
603
|
+
commit: Boolean(options.commit) || Boolean(options.push),
|
|
604
|
+
push: Boolean(options.push),
|
|
605
|
+
message: options.message,
|
|
606
|
+
validate: options.validate,
|
|
607
|
+
});
|
|
608
|
+
print(result, true);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
backupCommand
|
|
612
|
+
.command("import <repo>")
|
|
613
|
+
.description("Merge a canonical JSONL backup into the local SQLite store")
|
|
614
|
+
.option("--no-validate", "Skip backup validation before import")
|
|
615
|
+
.option("--replace", "Replace local portable tables instead of merging")
|
|
616
|
+
.action(async (repo, options) => {
|
|
617
|
+
const result = await importBackup({
|
|
618
|
+
repoPath: repo,
|
|
619
|
+
validate: options.validate,
|
|
620
|
+
mode: options.replace ? "replace" : "merge",
|
|
621
|
+
});
|
|
622
|
+
print(result, true);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
backupCommand
|
|
626
|
+
.command("sync")
|
|
627
|
+
.description("Pull, merge-import, export, commit, and push a backup repo")
|
|
628
|
+
.requiredOption("--repo <path>", "Backup repository/path")
|
|
629
|
+
.option("--remote <url>", "Git remote to clone/configure")
|
|
630
|
+
.option(
|
|
631
|
+
"--message <message>",
|
|
632
|
+
"Git commit message",
|
|
633
|
+
"archive: sync birdclaw backup",
|
|
634
|
+
)
|
|
635
|
+
.action(async (options) => {
|
|
636
|
+
const result = await syncBackup({
|
|
637
|
+
repoPath: options.repo,
|
|
638
|
+
remote: options.remote,
|
|
639
|
+
message: options.message,
|
|
640
|
+
});
|
|
641
|
+
print(result, true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
backupCommand
|
|
645
|
+
.command("validate <repo>")
|
|
646
|
+
.description("Validate backup manifest, shard hashes, and JSONL rows")
|
|
647
|
+
.action(async (repo) => {
|
|
648
|
+
const result = await validateBackup(repo);
|
|
649
|
+
print(result, true);
|
|
650
|
+
if (!result.ok) {
|
|
651
|
+
process.exitCode = 1;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
431
655
|
program
|
|
432
656
|
.command("serve")
|
|
433
657
|
.description("Run the local web app")
|
|
434
|
-
.action(() => {
|
|
658
|
+
.action(async () => {
|
|
659
|
+
await autoUpdateBeforeRead();
|
|
435
660
|
const child = spawn(
|
|
436
661
|
process.execPath,
|
|
437
662
|
["node_modules/vite/bin/vite.js", "dev", "--port", "3000"],
|
|
@@ -14,6 +14,8 @@ const links = [
|
|
|
14
14
|
{ to: "/inbox", label: "Inbox" },
|
|
15
15
|
{ to: "/", label: "Home" },
|
|
16
16
|
{ to: "/mentions", label: "Mentions" },
|
|
17
|
+
{ to: "/likes", label: "Likes" },
|
|
18
|
+
{ to: "/bookmarks", label: "Bookmarks" },
|
|
17
19
|
{ to: "/dms", label: "DMs" },
|
|
18
20
|
{ to: "/blocks", label: "Blocks" },
|
|
19
21
|
] as const;
|
|
@@ -27,7 +29,7 @@ export function AppNav() {
|
|
|
27
29
|
<nav className={navClass}>
|
|
28
30
|
<div>
|
|
29
31
|
<p className={eyebrowClass}>birdclaw</p>
|
|
30
|
-
<h1 className={brandMarkClass}>Quiet signal for
|
|
32
|
+
<h1 className={brandMarkClass}>Quiet signal for Twitter.</h1>
|
|
31
33
|
</div>
|
|
32
34
|
<div className={navLinksClass}>
|
|
33
35
|
{links.map((link) => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { TimelineCard } from "#/components/TimelineCard";
|
|
3
|
+
import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
|
|
4
|
+
import {
|
|
5
|
+
cx,
|
|
6
|
+
eyebrowClass,
|
|
7
|
+
feedPageClass,
|
|
8
|
+
heroControlsClass,
|
|
9
|
+
heroCopyClass,
|
|
10
|
+
heroShellClass,
|
|
11
|
+
heroTitleClass,
|
|
12
|
+
pageWrapClass,
|
|
13
|
+
textFieldClass,
|
|
14
|
+
textFieldWideClass,
|
|
15
|
+
timelineLaneClass,
|
|
16
|
+
} from "#/lib/ui";
|
|
17
|
+
|
|
18
|
+
interface SavedTimelineViewProps {
|
|
19
|
+
filter: "liked" | "bookmarked";
|
|
20
|
+
eyebrow: string;
|
|
21
|
+
title: string;
|
|
22
|
+
loadingLabel: string;
|
|
23
|
+
searchPlaceholder: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SavedTimelineView({
|
|
27
|
+
filter,
|
|
28
|
+
eyebrow,
|
|
29
|
+
title,
|
|
30
|
+
loadingLabel,
|
|
31
|
+
searchPlaceholder,
|
|
32
|
+
}: SavedTimelineViewProps) {
|
|
33
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
34
|
+
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
35
|
+
const [search, setSearch] = useState("");
|
|
36
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetch("/api/status")
|
|
40
|
+
.then((response) => response.json())
|
|
41
|
+
.then((data: QueryEnvelope) => setMeta(data));
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const url = new URL("/api/query", window.location.origin);
|
|
46
|
+
url.searchParams.set("resource", "home");
|
|
47
|
+
url.searchParams.set(filter, "true");
|
|
48
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
49
|
+
if (search.trim()) {
|
|
50
|
+
url.searchParams.set("search", search.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fetch(url)
|
|
54
|
+
.then((response) => response.json())
|
|
55
|
+
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
56
|
+
}, [filter, refreshTick, search]);
|
|
57
|
+
|
|
58
|
+
const subtitle = useMemo(() => {
|
|
59
|
+
if (!meta) {
|
|
60
|
+
return items.length > 0 ? `${items.length} visible` : loadingLabel;
|
|
61
|
+
}
|
|
62
|
+
return `${items.length} visible · ${meta.transport.statusText}`;
|
|
63
|
+
}, [items.length, loadingLabel, meta]);
|
|
64
|
+
|
|
65
|
+
async function replyToTweet(tweetId: string) {
|
|
66
|
+
const text = window.prompt("Reply text");
|
|
67
|
+
if (!text?.trim()) return;
|
|
68
|
+
|
|
69
|
+
await fetch("/api/action", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "content-type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
kind: "replyTweet",
|
|
74
|
+
accountId: "acct_primary",
|
|
75
|
+
tweetId,
|
|
76
|
+
text,
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
setRefreshTick((value) => value + 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<main className={pageWrapClass}>
|
|
85
|
+
<div className={feedPageClass}>
|
|
86
|
+
<section className={heroShellClass}>
|
|
87
|
+
<div>
|
|
88
|
+
<p className={eyebrowClass}>{eyebrow}</p>
|
|
89
|
+
<h2 className={heroTitleClass}>{title}</h2>
|
|
90
|
+
<p className={heroCopyClass}>{subtitle}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className={heroControlsClass}>
|
|
93
|
+
<input
|
|
94
|
+
className={cx(textFieldClass, textFieldWideClass)}
|
|
95
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
96
|
+
placeholder={searchPlaceholder}
|
|
97
|
+
value={search}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section className={timelineLaneClass}>
|
|
103
|
+
{items.map((item) => (
|
|
104
|
+
<TimelineCard
|
|
105
|
+
key={item.id}
|
|
106
|
+
item={item}
|
|
107
|
+
onReply={replyToTweet}
|
|
108
|
+
showReplyControls={false}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</section>
|
|
112
|
+
</div>
|
|
113
|
+
</main>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -34,10 +34,15 @@ function getVisibleUrlCards(item: TimelineItem) {
|
|
|
34
34
|
export function TimelineCard({
|
|
35
35
|
item,
|
|
36
36
|
onReply,
|
|
37
|
+
showReplyControls = true,
|
|
37
38
|
}: {
|
|
38
39
|
item: TimelineItem;
|
|
39
40
|
onReply: (tweetId: string) => void;
|
|
41
|
+
showReplyControls?: boolean;
|
|
40
42
|
}) {
|
|
43
|
+
const canReply =
|
|
44
|
+
showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
|
|
45
|
+
|
|
41
46
|
return (
|
|
42
47
|
<article className={contentCardClass}>
|
|
43
48
|
<header className={cardHeaderClass}>
|
|
@@ -62,14 +67,16 @@ export function TimelineCard({
|
|
|
62
67
|
</div>
|
|
63
68
|
</div>
|
|
64
69
|
<div className={metaStackClass}>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
{canReply ? (
|
|
71
|
+
<span
|
|
72
|
+
className={cx(
|
|
73
|
+
pillClass,
|
|
74
|
+
item.isReplied ? pillSoftClass : pillAlertClass,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{item.isReplied ? "replied" : "needs reply"}
|
|
78
|
+
</span>
|
|
79
|
+
) : null}
|
|
73
80
|
<span className={timestampClass}>
|
|
74
81
|
{formatShortTimestamp(item.createdAt)}
|
|
75
82
|
</span>
|
|
@@ -83,9 +90,9 @@ export function TimelineCard({
|
|
|
83
90
|
{item.quotedTweet ? (
|
|
84
91
|
<EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
|
|
85
92
|
) : null}
|
|
86
|
-
{getVisibleUrlCards(item).map((entry) => (
|
|
93
|
+
{getVisibleUrlCards(item).map((entry, index) => (
|
|
87
94
|
<a
|
|
88
|
-
key={entry.expandedUrl}
|
|
95
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
89
96
|
className={linkPreviewCardClass}
|
|
90
97
|
href={entry.expandedUrl}
|
|
91
98
|
rel="noreferrer"
|
|
@@ -105,13 +112,15 @@ export function TimelineCard({
|
|
|
105
112
|
<span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
|
|
106
113
|
<span>{item.accountHandle}</span>
|
|
107
114
|
</div>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
{canReply ? (
|
|
116
|
+
<button
|
|
117
|
+
className={actionButtonClass}
|
|
118
|
+
onClick={() => onReply(item.id)}
|
|
119
|
+
type="button"
|
|
120
|
+
>
|
|
121
|
+
Reply
|
|
122
|
+
</button>
|
|
123
|
+
) : null}
|
|
115
124
|
</footer>
|
|
116
125
|
</article>
|
|
117
126
|
);
|