birdclaw 0.7.0 → 0.8.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 -0
- package/README.md +7 -2
- package/package.json +25 -31
- package/scripts/build-docs-site.mjs +39 -12
- package/src/cli.ts +19 -0
- package/src/components/ConversationThread.tsx +5 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +5 -4
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +3 -2
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +6 -4
- package/src/lib/link-insights.ts +2 -0
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +280 -45
- package/src/lib/present.ts +99 -1
- package/src/lib/queries.ts +61 -0
- package/src/routes/api/period-digest.tsx +38 -70
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/data-sources.tsx +3 -1
- package/src/routes/links.tsx +4 -3
- package/src/routes/today.tsx +67 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 0.8.1 - 2026-06-13
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Refresh dependency backstop updates for `@steipete/sweet-cookie`, `@types/node`, the adopted TypeScript native-preview toolchain, and the pnpm 10 package-manager pin.
|
|
8
|
+
- Remove the separate public read-only web profile; deployments can expose the full private app behind an external authentication boundary.
|
|
9
|
+
- Show recent web timestamps as live relative time, then switch to calendar dates with exact local time on hover.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Open long-running AI streams immediately, keep the Today digest within proxy limits by using the locally synchronized archive, and show actionable retry errors when a connection is interrupted.
|
|
14
|
+
- Reuse freshly generated Today reports across reloads while background sync updates the archive, and hydrate locally stored cited tweets so source hovercards remain available outside the selected time window.
|
|
15
|
+
|
|
16
|
+
## 0.8.0 - 2026-06-10
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add localized Today/digest reports through `--language <locale-id>`, `BIRDCLAW_DIGEST_LANGUAGE`, and the period-digest API, with canonical locale validation and separate caches. (#47 - thanks @yujiawei)
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Update runtime and development dependencies, align TanStack Start packages, and move pnpm's native build allowlist into workspace configuration.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Add accurate archive-first Sign in and archive-request onboarding, including account-binding requirements, current xurl/bird setup, scoped transport selection, and clean autolink rendering. (#46 - thanks @peetzweg)
|
|
29
|
+
- Respect `OPENAI_BASE_URL` when sending Today/digest requests to OpenAI-compatible API endpoints.
|
|
30
|
+
- Anchor Link Insights' Today range to UTC midnight so it matches stored `created_at` timestamps across local time zones.
|
|
31
|
+
|
|
3
32
|
## 0.7.0 - 2026-06-01
|
|
4
33
|
|
|
5
34
|
### Added
|
package/README.md
CHANGED
|
@@ -219,6 +219,8 @@ birdclaw auth status --json
|
|
|
219
219
|
birdclaw db stats --json
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
+
`auth status` reports Birdclaw's coarse xurl status. Verify xurl with `xurl whoami` and bird with `bird whoami`. For setup and transport selection, see [Sign in](docs/auth.md).
|
|
223
|
+
|
|
222
224
|
Find and import an archive:
|
|
223
225
|
|
|
224
226
|
```bash
|
|
@@ -227,6 +229,8 @@ birdclaw import archive --json
|
|
|
227
229
|
birdclaw import archive ~/Downloads/twitter-archive-2025.zip --json
|
|
228
230
|
```
|
|
229
231
|
|
|
232
|
+
Don't have an archive yet? Request it from <https://x.com/settings/download_your_data>; X emails a download link when it is ready, which may take a few days. A fresh Birdclaw database needs the archive import to establish account identity before live sync. See [Archive Import → Get an archive](docs/archive.md#get-an-archive).
|
|
233
|
+
|
|
230
234
|
Optional profile hydration can improve bios, follower counts, and avatars, but it performs live X profile reads and can spend API credits on large archives:
|
|
231
235
|
|
|
232
236
|
```bash
|
|
@@ -414,17 +418,18 @@ birdclaw profile-analyse openai --max-pages 20 --max-conversations 40 --conversa
|
|
|
414
418
|
|
|
415
419
|
### What happened today
|
|
416
420
|
|
|
417
|
-
`birdclaw today` streams a local "what happened" digest from the SQLite store. It uses the OpenAI Responses API with `gpt-5.5`, medium reasoning, and priority service tier by default. Set `OPENAI_API_KEY`; override with `BIRDCLAW_AI_MODEL`, `BIRDCLAW_OPENAI_REASONING_EFFORT`, or `BIRDCLAW_OPENAI_SERVICE_TIER` when needed.
|
|
421
|
+
`birdclaw today` streams a local "what happened" digest from the SQLite store. It uses the OpenAI Responses API with `gpt-5.5`, medium reasoning, and priority service tier by default. Set `OPENAI_API_KEY`; override with `BIRDCLAW_AI_MODEL`, `BIRDCLAW_OPENAI_REASONING_EFFORT`, or `BIRDCLAW_OPENAI_SERVICE_TIER` when needed. Use `--language <locale-id>` or `BIRDCLAW_DIGEST_LANGUAGE` for localized reports.
|
|
418
422
|
|
|
419
423
|
```bash
|
|
420
424
|
birdclaw today
|
|
425
|
+
birdclaw today --language zh-CN
|
|
421
426
|
birdclaw digest 24h --refresh
|
|
422
427
|
birdclaw digest week --json
|
|
423
428
|
birdclaw digest --since 2026-05-16T00:00:00Z --until 2026-05-17T00:00:00Z
|
|
424
429
|
birdclaw digest today --include-dms
|
|
425
430
|
```
|
|
426
431
|
|
|
427
|
-
The web UI exposes the same stream under `What happened`. DMs are excluded unless explicitly enabled. Final structured results are cached by the exact local context hash, model, reasoning effort, and
|
|
432
|
+
The web UI exposes the same stream under `What happened`. DMs are excluded unless explicitly enabled. Final structured results are cached by the exact local context hash, model, reasoning effort, service tier, and report language.
|
|
428
433
|
|
|
429
434
|
### Search and triage DMs
|
|
430
435
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birdclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
|
|
5
5
|
"homepage": "https://github.com/steipete/birdclaw#readme",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,57 +52,51 @@
|
|
|
52
52
|
"docs:site": "node scripts/build-docs-site.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@steipete/sweet-cookie": "^0.
|
|
55
|
+
"@steipete/sweet-cookie": "^0.4.0",
|
|
56
56
|
"@tailwindcss/vite": "^4.3.0",
|
|
57
57
|
"@tanstack/devtools-vite": "0.7.0",
|
|
58
58
|
"@tanstack/react-devtools": "0.10.5",
|
|
59
|
-
"@tanstack/react-router": "1.
|
|
60
|
-
"@tanstack/react-router-devtools": "1.
|
|
61
|
-
"@tanstack/react-router-ssr-query": "1.
|
|
62
|
-
"@tanstack/react-start": "1.
|
|
63
|
-
"@tanstack/router-plugin": "^1.
|
|
64
|
-
"@vitejs/plugin-react": "^6.0.
|
|
65
|
-
"commander": "^
|
|
66
|
-
"effect": "^3.21.
|
|
67
|
-
"kysely": "^0.29.
|
|
68
|
-
"lucide-react": "^1.
|
|
59
|
+
"@tanstack/react-router": "1.170.15",
|
|
60
|
+
"@tanstack/react-router-devtools": "1.167.0",
|
|
61
|
+
"@tanstack/react-router-ssr-query": "1.167.1",
|
|
62
|
+
"@tanstack/react-start": "1.168.25",
|
|
63
|
+
"@tanstack/router-plugin": "^1.168.18",
|
|
64
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
65
|
+
"commander": "^15.0.0",
|
|
66
|
+
"effect": "^3.21.3",
|
|
67
|
+
"kysely": "^0.29.2",
|
|
68
|
+
"lucide-react": "^1.17.0",
|
|
69
69
|
"mapbox-gl": "^3.24.0",
|
|
70
|
-
"react": "^19.2.
|
|
71
|
-
"react-dom": "^19.2.
|
|
70
|
+
"react": "^19.2.7",
|
|
71
|
+
"react-dom": "^19.2.7",
|
|
72
72
|
"react-map-gl": "^8.1.1",
|
|
73
73
|
"supercluster": "^8.0.1",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
|
-
"tsx": "^4.22.
|
|
76
|
-
"vite": "^8.0.
|
|
75
|
+
"tsx": "^4.22.4",
|
|
76
|
+
"vite": "^8.0.16",
|
|
77
77
|
"zod": "^4.4.3"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@playwright/test": "^1.60.0",
|
|
81
|
-
"@tailwindcss/typography": "^0.5.
|
|
81
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
82
82
|
"@testing-library/dom": "^10.4.1",
|
|
83
83
|
"@testing-library/jest-dom": "^6.9.1",
|
|
84
84
|
"@testing-library/react": "^16.3.2",
|
|
85
85
|
"@types/geojson": "^7946.0.16",
|
|
86
|
-
"@types/node": "^25.
|
|
87
|
-
"@types/react": "^19.2.
|
|
86
|
+
"@types/node": "^25.9.3",
|
|
87
|
+
"@types/react": "^19.2.17",
|
|
88
88
|
"@types/react-dom": "^19.2.3",
|
|
89
89
|
"@types/supercluster": "^7.1.3",
|
|
90
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
91
|
-
"@vitest/coverage-v8": "^4.1.
|
|
90
|
+
"@typescript/native-preview": "7.0.0-dev.20260611.2",
|
|
91
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
92
92
|
"jsdom": "^29.1.1",
|
|
93
|
-
"oxfmt": "^0.
|
|
94
|
-
"oxlint": "^1.
|
|
93
|
+
"oxfmt": "^0.54.0",
|
|
94
|
+
"oxlint": "^1.69.0",
|
|
95
95
|
"typescript": "^6.0.3",
|
|
96
|
-
"vitest": "^4.1.
|
|
96
|
+
"vitest": "^4.1.8"
|
|
97
97
|
},
|
|
98
98
|
"engines": {
|
|
99
99
|
"node": ">=25.8.1 <27"
|
|
100
100
|
},
|
|
101
|
-
"packageManager": "pnpm@10.
|
|
102
|
-
"pnpm": {
|
|
103
|
-
"onlyBuiltDependencies": [
|
|
104
|
-
"esbuild",
|
|
105
|
-
"lightningcss"
|
|
106
|
-
]
|
|
107
|
-
}
|
|
101
|
+
"packageManager": "pnpm@10.34.3"
|
|
108
102
|
}
|
|
@@ -24,7 +24,10 @@ const productDescription =
|
|
|
24
24
|
const brewInstall = "brew install steipete/tap/birdclaw";
|
|
25
25
|
|
|
26
26
|
const sections = [
|
|
27
|
-
[
|
|
27
|
+
[
|
|
28
|
+
"Start",
|
|
29
|
+
["index.md", "install.md", "auth.md", "quickstart.md", "configuration.md"],
|
|
30
|
+
],
|
|
28
31
|
[
|
|
29
32
|
"Archive & Sync",
|
|
30
33
|
["archive.md", "sync.md", "media.md", "backup.md", "jobs.md"],
|
|
@@ -449,19 +452,41 @@ function inline(text, currentRel) {
|
|
|
449
452
|
stash.push(`<code>${escapeHtml(code)}</code>`);
|
|
450
453
|
return `\u0000${stash.length - 1}\u0000`;
|
|
451
454
|
});
|
|
452
|
-
|
|
453
|
-
.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
455
|
+
const stashLink = (label, href) => {
|
|
456
|
+
stash.push(
|
|
457
|
+
`<a href="${escapeAttr(rewriteHref(href, currentRel))}">${renderInlineText(label, stash)}</a>`,
|
|
458
|
+
);
|
|
459
|
+
return `\u0000${stash.length - 1}\u0000`;
|
|
460
|
+
};
|
|
461
|
+
out = out.replace(/\[([^\]]+)\]\(<([^<>]+)>\)/g, (_, label, href) =>
|
|
462
|
+
stashLink(label, href),
|
|
463
|
+
);
|
|
464
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
|
|
465
|
+
stashLink(label, href),
|
|
466
|
+
);
|
|
467
|
+
out = out.replace(/<(https?:\/\/[^\s<>]+)>/g, (_, url) => {
|
|
468
|
+
stash.push(`<a href="${escapeAttr(url)}">${escapeHtml(url)}</a>`);
|
|
469
|
+
return `\u0000${stash.length - 1}\u0000`;
|
|
470
|
+
});
|
|
471
|
+
return renderInlineText(out, stash);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderInlineText(text, stash) {
|
|
475
|
+
let out = formatInlineText(text);
|
|
462
476
|
out = out.replace(/\\\|/g, "|");
|
|
463
477
|
out = out.replace(/<br>/g, "<br>");
|
|
464
|
-
return out
|
|
478
|
+
return restoreInlineStash(out, stash);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function formatInlineText(text) {
|
|
482
|
+
return escapeHtml(text)
|
|
483
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
484
|
+
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
|
|
485
|
+
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function restoreInlineStash(text, stash) {
|
|
489
|
+
return text.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
|
|
465
490
|
}
|
|
466
491
|
|
|
467
492
|
function rewriteHref(href, currentRel) {
|
|
@@ -886,6 +911,8 @@ function highlightYamlValue(rest) {
|
|
|
886
911
|
return escapeHtml(rest);
|
|
887
912
|
}
|
|
888
913
|
|
|
914
|
+
export const __test__ = { inline };
|
|
915
|
+
|
|
889
916
|
function validateLinks(outputDir) {
|
|
890
917
|
const failures = [];
|
|
891
918
|
const placeholderHrefs =
|
package/src/cli.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
syncMentions,
|
|
64
64
|
} from "#/lib/mentions-live";
|
|
65
65
|
import {
|
|
66
|
+
normalizeDigestLanguage,
|
|
66
67
|
streamPeriodDigest,
|
|
67
68
|
type PeriodDigestOptions,
|
|
68
69
|
type PeriodDigestPreset,
|
|
@@ -371,6 +372,7 @@ function buildDigestOptions(
|
|
|
371
372
|
account?: string;
|
|
372
373
|
includeDms?: boolean;
|
|
373
374
|
model?: string;
|
|
375
|
+
language?: string;
|
|
374
376
|
refresh?: boolean;
|
|
375
377
|
since?: string;
|
|
376
378
|
until?: string;
|
|
@@ -398,6 +400,14 @@ function buildDigestOptions(
|
|
|
398
400
|
if (liveSyncMode === undefined) {
|
|
399
401
|
return null;
|
|
400
402
|
}
|
|
403
|
+
let language: string | undefined;
|
|
404
|
+
try {
|
|
405
|
+
language = normalizeDigestLanguage(options.language);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
408
|
+
process.exitCode = 1;
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
401
411
|
return {
|
|
402
412
|
period: parseDigestPeriod(period),
|
|
403
413
|
since: options.since,
|
|
@@ -406,6 +416,7 @@ function buildDigestOptions(
|
|
|
406
416
|
includeDms: Boolean(options.includeDms),
|
|
407
417
|
refresh: Boolean(options.refresh),
|
|
408
418
|
model: options.model,
|
|
419
|
+
language,
|
|
409
420
|
maxTweets,
|
|
410
421
|
maxLinks,
|
|
411
422
|
liveSync: options.liveSync !== false,
|
|
@@ -1268,6 +1279,10 @@ program
|
|
|
1268
1279
|
.option("--account <accountId>", "Account id")
|
|
1269
1280
|
.option("--include-dms", "Include private DM context")
|
|
1270
1281
|
.option("--model <model>", "OpenAI model id")
|
|
1282
|
+
.option(
|
|
1283
|
+
"--language <tag>",
|
|
1284
|
+
"Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
|
|
1285
|
+
)
|
|
1271
1286
|
.option("--refresh", "Bypass the local digest cache")
|
|
1272
1287
|
.option("--max-tweets <n>", "Maximum tweet context", "5000")
|
|
1273
1288
|
.option("--max-links <n>", "Maximum linked articles", "12")
|
|
@@ -1292,6 +1307,10 @@ program
|
|
|
1292
1307
|
.option("--since <isoDate>", "Start of explicit window")
|
|
1293
1308
|
.option("--until <isoDate>", "End of explicit window")
|
|
1294
1309
|
.option("--model <model>", "OpenAI model id")
|
|
1310
|
+
.option(
|
|
1311
|
+
"--language <tag>",
|
|
1312
|
+
"Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
|
|
1313
|
+
)
|
|
1295
1314
|
.option("--refresh", "Bypass the local digest cache")
|
|
1296
1315
|
.option("--max-tweets <n>", "Maximum tweet context", "5000")
|
|
1297
1316
|
.option("--max-links <n>", "Maximum linked articles", "12")
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { MessageCircle } from "lucide-react";
|
|
2
|
-
import { formatShortTimestamp } from "#/lib/present";
|
|
3
2
|
import type { EmbeddedTweet } from "#/lib/types";
|
|
4
3
|
import {
|
|
5
4
|
cx,
|
|
@@ -11,6 +10,7 @@ import {
|
|
|
11
10
|
import { AvatarChip } from "./AvatarChip";
|
|
12
11
|
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
13
12
|
import { ProfilePreview } from "./ProfilePreview";
|
|
13
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
14
14
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
15
15
|
import { TweetRichText } from "./TweetRichText";
|
|
16
16
|
|
|
@@ -101,9 +101,10 @@ export function ConversationThread({
|
|
|
101
101
|
</span>
|
|
102
102
|
</ProfilePreview>
|
|
103
103
|
<span className="text-[var(--ink-soft)]">·</span>
|
|
104
|
-
<
|
|
105
|
-
{
|
|
106
|
-
|
|
104
|
+
<SmartTimestamp
|
|
105
|
+
className={feedRowTimestampClass}
|
|
106
|
+
value={tweet.createdAt}
|
|
107
|
+
/>
|
|
107
108
|
{isAnchor ? (
|
|
108
109
|
<span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
|
|
109
110
|
selected
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CheckCircle2, Circle } from "lucide-react";
|
|
2
|
-
import { formatCompactNumber
|
|
2
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
3
3
|
import type { DmConversationItem, DmMessageItem } from "#/lib/types";
|
|
4
4
|
import {
|
|
5
5
|
composerBarClass,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "#/lib/ui";
|
|
40
40
|
import { AvatarChip } from "./AvatarChip";
|
|
41
41
|
import { BirdclawEmpty } from "./BrandMark";
|
|
42
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
42
43
|
|
|
43
44
|
function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
44
45
|
const outbound = message.direction === "outbound";
|
|
@@ -57,7 +58,7 @@ function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
|
57
58
|
<div className={dmMessageMetaClass}>
|
|
58
59
|
<span>{message.sender.displayName}</span>
|
|
59
60
|
<span>·</span>
|
|
60
|
-
<
|
|
61
|
+
<SmartTimestamp value={message.createdAt} />
|
|
61
62
|
</div>
|
|
62
63
|
</div>
|
|
63
64
|
);
|
|
@@ -81,9 +82,13 @@ export function DmWorkspace({
|
|
|
81
82
|
onReplySend: (conversationId: string) => void;
|
|
82
83
|
}) {
|
|
83
84
|
const participant = selectedConversation?.participant ?? null;
|
|
84
|
-
const
|
|
85
|
-
?
|
|
86
|
-
|
|
85
|
+
const selectedStatus = selectedConversation
|
|
86
|
+
? selectedConversation.isMessageRequest
|
|
87
|
+
? "Message request"
|
|
88
|
+
: selectedConversation.needsReply
|
|
89
|
+
? "Reply open"
|
|
90
|
+
: "We replied"
|
|
91
|
+
: null;
|
|
87
92
|
|
|
88
93
|
return (
|
|
89
94
|
<section aria-label="DM workspace" className={dmShellClass}>
|
|
@@ -119,9 +124,10 @@ export function DmWorkspace({
|
|
|
119
124
|
@{conversation.participant.handle}
|
|
120
125
|
</span>
|
|
121
126
|
</div>
|
|
122
|
-
<
|
|
123
|
-
{
|
|
124
|
-
|
|
127
|
+
<SmartTimestamp
|
|
128
|
+
className={dmListTimestampClass}
|
|
129
|
+
value={conversation.lastMessageAt}
|
|
130
|
+
/>
|
|
125
131
|
</div>
|
|
126
132
|
<p className={dmListPreviewClass}>
|
|
127
133
|
{conversation.lastMessagePreview}
|
|
@@ -176,7 +182,18 @@ export function DmWorkspace({
|
|
|
176
182
|
<div className={dmThreadNameClass}>
|
|
177
183
|
{selectedConversation.participant.displayName}
|
|
178
184
|
</div>
|
|
179
|
-
<div className={dmThreadSubtitleClass}>
|
|
185
|
+
<div className={dmThreadSubtitleClass}>
|
|
186
|
+
{selectedStatus && selectedConversation ? (
|
|
187
|
+
<>
|
|
188
|
+
{selectedStatus} · last message{" "}
|
|
189
|
+
<SmartTimestamp
|
|
190
|
+
value={selectedConversation.lastMessageAt}
|
|
191
|
+
/>
|
|
192
|
+
</>
|
|
193
|
+
) : (
|
|
194
|
+
"No conversation selected"
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
180
197
|
</div>
|
|
181
198
|
</div>
|
|
182
199
|
<button
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { formatShortTimestamp } from "#/lib/present";
|
|
2
1
|
import type { EmbeddedTweet } from "#/lib/types";
|
|
3
2
|
import {
|
|
4
3
|
embeddedCardBodyClass,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
feedRowTimestampClass,
|
|
11
10
|
} from "#/lib/ui";
|
|
12
11
|
import { ProfilePreview } from "./ProfilePreview";
|
|
12
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
13
13
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
14
14
|
import { TweetRichText } from "./TweetRichText";
|
|
15
15
|
|
|
@@ -35,9 +35,10 @@ export function EmbeddedTweetCard({
|
|
|
35
35
|
</span>
|
|
36
36
|
</ProfilePreview>
|
|
37
37
|
<span className="text-[var(--ink-soft)]">·</span>
|
|
38
|
-
<
|
|
39
|
-
{
|
|
40
|
-
|
|
38
|
+
<SmartTimestamp
|
|
39
|
+
className={feedRowTimestampClass}
|
|
40
|
+
value={item.createdAt}
|
|
41
|
+
/>
|
|
41
42
|
</header>
|
|
42
43
|
<TweetRichText
|
|
43
44
|
className={embeddedCardCopyClass}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ExternalLink,
|
|
6
6
|
MessageCircle,
|
|
7
7
|
} from "lucide-react";
|
|
8
|
-
import { formatCompactNumber
|
|
8
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
9
9
|
import type { InboxItem } from "#/lib/types";
|
|
10
10
|
import {
|
|
11
11
|
composerBarClass,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
timestampClass,
|
|
31
31
|
} from "#/lib/ui";
|
|
32
32
|
import { AvatarChip } from "./AvatarChip";
|
|
33
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
33
34
|
|
|
34
35
|
export function InboxCard({
|
|
35
36
|
item,
|
|
@@ -65,9 +66,10 @@ export function InboxCard({
|
|
|
65
66
|
</span>
|
|
66
67
|
</span>
|
|
67
68
|
<span className={feedRowDotClass}>·</span>
|
|
68
|
-
<
|
|
69
|
-
{
|
|
70
|
-
|
|
69
|
+
<SmartTimestamp
|
|
70
|
+
className={feedRowTimestampClass}
|
|
71
|
+
value={item.createdAt}
|
|
72
|
+
/>
|
|
71
73
|
<span className="ml-auto flex items-center gap-1.5">
|
|
72
74
|
<span className={cx(pillClass, pillSoftClass)}>
|
|
73
75
|
{item.entityKind}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
|
-
import { formatCompactNumber
|
|
9
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
10
10
|
import type { PeriodDigestContext } from "#/lib/period-digest";
|
|
11
11
|
import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
|
|
12
12
|
import { renderTweetPlainText } from "#/lib/tweet-render";
|
|
@@ -15,6 +15,7 @@ import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
|
|
|
15
15
|
import { safeHttpUrl } from "#/lib/url-safety";
|
|
16
16
|
import { AvatarChip } from "./AvatarChip";
|
|
17
17
|
import { ProfilePreview } from "./ProfilePreview";
|
|
18
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
18
19
|
|
|
19
20
|
type CitationTweet = PeriodDigestContext["tweets"][number];
|
|
20
21
|
type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
|
|
@@ -231,7 +232,7 @@ function TweetPreviewToken({
|
|
|
231
232
|
<span className="min-w-0">
|
|
232
233
|
<span className="block truncate font-bold">{tweet.name}</span>
|
|
233
234
|
<span className="block truncate text-[12px] text-[var(--ink-soft)]">
|
|
234
|
-
@{tweet.author} · {
|
|
235
|
+
@{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
|
|
235
236
|
</span>
|
|
236
237
|
</span>
|
|
237
238
|
</span>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
formatExactTimestamp,
|
|
4
|
+
formatShortTimestamp,
|
|
5
|
+
formatSmartTimestamp,
|
|
6
|
+
} from "#/lib/present";
|
|
7
|
+
|
|
8
|
+
const CLOCK_INTERVAL_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
type ClockSnapshot = number | null;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<() => void>();
|
|
13
|
+
let currentNow = Date.now();
|
|
14
|
+
let clockInterval: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
|
|
16
|
+
function updateClock() {
|
|
17
|
+
currentNow = Date.now();
|
|
18
|
+
for (const listener of listeners) listener();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function subscribeToClock(listener: () => void) {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
|
|
24
|
+
if (clockInterval === null) {
|
|
25
|
+
currentNow = Date.now();
|
|
26
|
+
clockInterval = setInterval(updateClock, CLOCK_INTERVAL_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
if (listeners.size === 0 && clockInterval !== null) {
|
|
32
|
+
clearInterval(clockInterval);
|
|
33
|
+
clockInterval = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getClockSnapshot(): ClockSnapshot {
|
|
39
|
+
return currentNow;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getServerClockSnapshot(): ClockSnapshot {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SmartTimestamp({
|
|
47
|
+
className,
|
|
48
|
+
value,
|
|
49
|
+
}: {
|
|
50
|
+
className?: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}) {
|
|
53
|
+
const now = useSyncExternalStore(
|
|
54
|
+
subscribeToClock,
|
|
55
|
+
getClockSnapshot,
|
|
56
|
+
getServerClockSnapshot,
|
|
57
|
+
);
|
|
58
|
+
const exactTimestamp = formatExactTimestamp(value);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<time
|
|
62
|
+
aria-label={exactTimestamp}
|
|
63
|
+
className={className}
|
|
64
|
+
dateTime={value}
|
|
65
|
+
title={exactTimestamp}
|
|
66
|
+
>
|
|
67
|
+
{now === null
|
|
68
|
+
? formatShortTimestamp(value)
|
|
69
|
+
: formatSmartTimestamp(value, now)}
|
|
70
|
+
</time>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Repeat2,
|
|
9
9
|
UserSearch,
|
|
10
10
|
} from "lucide-react";
|
|
11
|
-
import { formatCompactNumber
|
|
11
|
+
import { formatCompactNumber } from "#/lib/present";
|
|
12
12
|
import { normalizeTweetUrlEntityRangeForText } from "#/lib/tweet-render";
|
|
13
13
|
import type {
|
|
14
14
|
TimelineItem,
|
|
@@ -41,6 +41,7 @@ import { ConversationThread } from "./ConversationThread";
|
|
|
41
41
|
import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
|
|
42
42
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
|
43
43
|
import { ProfilePreview } from "./ProfilePreview";
|
|
44
|
+
import { SmartTimestamp } from "./SmartTimestamp";
|
|
44
45
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
45
46
|
import { TweetRichText } from "./TweetRichText";
|
|
46
47
|
|
|
@@ -312,9 +313,10 @@ export function TimelineCard({
|
|
|
312
313
|
</span>
|
|
313
314
|
</ProfilePreview>
|
|
314
315
|
<span className={feedRowDotClass}>·</span>
|
|
315
|
-
<
|
|
316
|
-
{
|
|
317
|
-
|
|
316
|
+
<SmartTimestamp
|
|
317
|
+
className={feedRowTimestampClass}
|
|
318
|
+
value={displayTweet.createdAt}
|
|
319
|
+
/>
|
|
318
320
|
{canReply || hasConversation ? (
|
|
319
321
|
<span className="ml-auto inline-flex items-center gap-1">
|
|
320
322
|
{hasConversation ? (
|
package/src/lib/link-insights.ts
CHANGED
|
@@ -380,6 +380,8 @@ function resolveRange(
|
|
|
380
380
|
|
|
381
381
|
const start = new Date(now);
|
|
382
382
|
if (range === "today") {
|
|
383
|
+
// "Today" follows the user's local calendar day; toISOString below
|
|
384
|
+
// converts that local midnight into the correct UTC storage boundary.
|
|
383
385
|
start.setHours(0, 0, 0, 0);
|
|
384
386
|
} else {
|
|
385
387
|
const days = range === "week" ? 7 : range === "month" ? 30 : 365;
|