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 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 service tier.
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.7.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.3.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.169.2",
60
- "@tanstack/react-router-devtools": "1.166.13",
61
- "@tanstack/react-router-ssr-query": "1.166.12",
62
- "@tanstack/react-start": "1.167.65",
63
- "@tanstack/router-plugin": "^1.167.35",
64
- "@vitejs/plugin-react": "^6.0.1",
65
- "commander": "^14.0.3",
66
- "effect": "^3.21.2",
67
- "kysely": "^0.29.0",
68
- "lucide-react": "^1.16.0",
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.6",
71
- "react-dom": "^19.2.6",
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.0",
76
- "vite": "^8.0.13",
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.19",
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.8.0",
87
- "@types/react": "^19.2.14",
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.20260514.1",
91
- "@vitest/coverage-v8": "^4.1.6",
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.49.0",
94
- "oxlint": "^1.64.0",
93
+ "oxfmt": "^0.54.0",
94
+ "oxlint": "^1.69.0",
95
95
  "typescript": "^6.0.3",
96
- "vitest": "^4.1.6"
96
+ "vitest": "^4.1.8"
97
97
  },
98
98
  "engines": {
99
99
  "node": ">=25.8.1 <27"
100
100
  },
101
- "packageManager": "pnpm@10.31.0",
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
- ["Start", ["index.md", "install.md", "quickstart.md", "configuration.md"]],
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
- out = escapeHtml(out)
453
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
454
- .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
455
- .replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
456
- .replace(
457
- /\[([^\]]+)\]\(([^)]+)\)/g,
458
- (_, label, href) =>
459
- `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`,
460
- )
461
- .replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
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(/&lt;br&gt;/g, "<br>");
464
- return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
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
- <span className={feedRowTimestampClass}>
105
- {formatShortTimestamp(tweet.createdAt)}
106
- </span>
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, formatShortTimestamp } from "#/lib/present";
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
- <span>{formatShortTimestamp(message.createdAt)}</span>
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 subtitle = selectedConversation
85
- ? `${selectedConversation.isMessageRequest ? "Message request" : selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
86
- : "No conversation selected";
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
- <span className={dmListTimestampClass}>
123
- {formatShortTimestamp(conversation.lastMessageAt)}
124
- </span>
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}>{subtitle}</div>
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
- <span className={feedRowTimestampClass}>
39
- {formatShortTimestamp(item.createdAt)}
40
- </span>
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, formatShortTimestamp } from "#/lib/present";
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
- <span className={feedRowTimestampClass}>
69
- {formatShortTimestamp(item.createdAt)}
70
- </span>
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, formatShortTimestamp } from "#/lib/present";
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} · {formatShortTimestamp(tweet.createdAt)}
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, formatShortTimestamp } from "#/lib/present";
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
- <span className={feedRowTimestampClass}>
316
- {formatShortTimestamp(displayTweet.createdAt)}
317
- </span>
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 ? (
@@ -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;