birdclaw 0.8.0 → 0.8.2

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -8
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/components/ConversationThread.tsx +9 -4
  5. package/src/components/DmWorkspace.tsx +26 -9
  6. package/src/components/EmbeddedTweetCard.tsx +9 -4
  7. package/src/components/FloatingPreview.tsx +382 -0
  8. package/src/components/InboxCard.tsx +6 -4
  9. package/src/components/MarkdownViewer.tsx +59 -100
  10. package/src/components/ProfilePreview.tsx +39 -81
  11. package/src/components/SmartTimestamp.tsx +72 -0
  12. package/src/components/TimelineCard.tsx +24 -6
  13. package/src/components/TweetArticleCard.tsx +66 -0
  14. package/src/components/TweetRichText.tsx +33 -2
  15. package/src/components/useDebouncedValue.ts +12 -0
  16. package/src/components/useTimelineRouteData.ts +149 -47
  17. package/src/lib/api-client.ts +117 -2
  18. package/src/lib/archive-finder.ts +38 -0
  19. package/src/lib/authored-live.ts +2 -62
  20. package/src/lib/bird.ts +32 -1
  21. package/src/lib/client-cache.ts +109 -0
  22. package/src/lib/db.ts +57 -4
  23. package/src/lib/mention-threads-live.ts +2 -1
  24. package/src/lib/mentions-live.ts +2 -46
  25. package/src/lib/ndjson-stream.ts +106 -0
  26. package/src/lib/period-digest.ts +184 -24
  27. package/src/lib/present.ts +99 -1
  28. package/src/lib/profile-analysis.ts +1 -1
  29. package/src/lib/queries.ts +68 -6
  30. package/src/lib/sqlite.ts +5 -2
  31. package/src/lib/timeline-collections-live.ts +2 -1
  32. package/src/lib/timeline-live.ts +2 -1
  33. package/src/lib/tweet-render.ts +78 -0
  34. package/src/lib/tweet-search-live.ts +2 -1
  35. package/src/lib/types.ts +8 -0
  36. package/src/lib/ui.ts +1 -1
  37. package/src/router.tsx +1 -1
  38. package/src/routes/__root.tsx +0 -13
  39. package/src/routes/api/period-digest.tsx +21 -69
  40. package/src/routes/api/profile-analysis.tsx +22 -77
  41. package/src/routes/api/search-discussion.tsx +19 -75
  42. package/src/routes/api/status.tsx +3 -1
  43. package/src/routes/dms.tsx +40 -22
  44. package/src/routes/links.tsx +75 -9
  45. package/src/routes/today.tsx +67 -11
  46. package/vite.config.ts +0 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.8.2 - 2026-06-15
4
+
5
+ ### Fixed
6
+
7
+ - Wait for concurrent SQLite writers before starting write transactions and avoid unnecessary legacy backfill writes during normal startup.
8
+ - Retain timeline and DM data across sidebar navigation, deduplicate status reads, debounce searches, skip unused Spotlight archive scans in web status, and remove TanStack debug controls.
9
+ - Keep tweet and profile hover previews outside wrapped links, flip them to the roomier vertical side, and constrain them to the viewport.
10
+ - Expand Twitter Articles into titled preview cards and citation popovers instead of showing bare `t.co` links.
11
+
12
+ ## 0.8.1 - 2026-06-13
13
+
14
+ ### Changed
15
+
16
+ - Refresh dependency backstop updates for `@steipete/sweet-cookie`, `@types/node`, the adopted TypeScript native-preview toolchain, and the pnpm 10 package-manager pin.
17
+ - Remove the separate public read-only web profile; deployments can expose the full private app behind an external authentication boundary.
18
+ - Show recent web timestamps as live relative time, then switch to calendar dates with exact local time on hover.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+ - 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.
24
+
3
25
  ## 0.8.0 - 2026-06-10
4
26
 
5
27
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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,12 +52,9 @@
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
- "@tanstack/devtools-vite": "0.7.0",
58
- "@tanstack/react-devtools": "0.10.5",
59
57
  "@tanstack/react-router": "1.170.15",
60
- "@tanstack/react-router-devtools": "1.167.0",
61
58
  "@tanstack/react-router-ssr-query": "1.167.1",
62
59
  "@tanstack/react-start": "1.168.25",
63
60
  "@tanstack/router-plugin": "^1.168.18",
@@ -83,11 +80,11 @@
83
80
  "@testing-library/jest-dom": "^6.9.1",
84
81
  "@testing-library/react": "^16.3.2",
85
82
  "@types/geojson": "^7946.0.16",
86
- "@types/node": "^25.9.2",
83
+ "@types/node": "^25.9.3",
87
84
  "@types/react": "^19.2.17",
88
85
  "@types/react-dom": "^19.2.3",
89
86
  "@types/supercluster": "^7.1.3",
90
- "@typescript/native-preview": "7.0.0-dev.20260609.1",
87
+ "@typescript/native-preview": "7.0.0-dev.20260611.2",
91
88
  "@vitest/coverage-v8": "^4.1.8",
92
89
  "jsdom": "^29.1.1",
93
90
  "oxfmt": "^0.54.0",
@@ -98,5 +95,5 @@
98
95
  "engines": {
99
96
  "node": ">=25.8.1 <27"
100
97
  },
101
- "packageManager": "pnpm@10.31.0"
98
+ "packageManager": "pnpm@10.34.3"
102
99
  }
@@ -18,6 +18,33 @@ const SCENARIOS = {
18
18
  path: "/mentions",
19
19
  ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
20
20
  },
21
+ "sidebar-roundtrip": {
22
+ path: "/",
23
+ ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
24
+ action: async (page) => {
25
+ await page.getByRole("link", { name: "Mentions" }).click();
26
+ await page.waitForURL("**/mentions");
27
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
28
+ const queryCallsBeforeReturn = await page.evaluate(
29
+ () =>
30
+ performance
31
+ .getEntriesByType("resource")
32
+ .filter((entry) => entry.name.includes("/api/query")).length,
33
+ );
34
+ await page.getByRole("link", { name: "Home" }).click();
35
+ await page.waitForURL((url) => url.pathname === "/");
36
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
37
+ const queryCallsAfterReturn = await page.evaluate(
38
+ () =>
39
+ performance
40
+ .getEntriesByType("resource")
41
+ .filter((entry) => entry.name.includes("/api/query")).length,
42
+ );
43
+ if (queryCallsAfterReturn !== queryCallsBeforeReturn) {
44
+ throw new Error("Returning Home issued a new query request");
45
+ }
46
+ },
47
+ },
21
48
  "mentions-search": {
22
49
  path: "/mentions",
23
50
  ready: async (page) => {
@@ -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,8 @@ 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
+ import { TweetArticleCard } from "./TweetArticleCard";
14
15
  import { TweetMediaGrid } from "./TweetMediaGrid";
15
16
  import { TweetRichText } from "./TweetRichText";
16
17
 
@@ -101,9 +102,10 @@ export function ConversationThread({
101
102
  </span>
102
103
  </ProfilePreview>
103
104
  <span className="text-[var(--ink-soft)]">·</span>
104
- <span className={feedRowTimestampClass}>
105
- {formatShortTimestamp(tweet.createdAt)}
106
- </span>
105
+ <SmartTimestamp
106
+ className={feedRowTimestampClass}
107
+ value={tweet.createdAt}
108
+ />
107
109
  {isAnchor ? (
108
110
  <span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
109
111
  selected
@@ -116,6 +118,9 @@ export function ConversationThread({
116
118
  text={tweet.text}
117
119
  />
118
120
  <TweetMediaGrid items={tweet.media} />
121
+ {tweet.entities.article ? (
122
+ <TweetArticleCard article={tweet.entities.article} />
123
+ ) : null}
119
124
  </div>
120
125
  </div>
121
126
  );
@@ -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,8 @@ import {
10
9
  feedRowTimestampClass,
11
10
  } from "#/lib/ui";
12
11
  import { ProfilePreview } from "./ProfilePreview";
12
+ import { SmartTimestamp } from "./SmartTimestamp";
13
+ import { TweetArticleCard } from "./TweetArticleCard";
13
14
  import { TweetMediaGrid } from "./TweetMediaGrid";
14
15
  import { TweetRichText } from "./TweetRichText";
15
16
 
@@ -35,9 +36,10 @@ export function EmbeddedTweetCard({
35
36
  </span>
36
37
  </ProfilePreview>
37
38
  <span className="text-[var(--ink-soft)]">·</span>
38
- <span className={feedRowTimestampClass}>
39
- {formatShortTimestamp(item.createdAt)}
40
- </span>
39
+ <SmartTimestamp
40
+ className={feedRowTimestampClass}
41
+ value={item.createdAt}
42
+ />
41
43
  </header>
42
44
  <TweetRichText
43
45
  className={embeddedCardCopyClass}
@@ -45,6 +47,9 @@ export function EmbeddedTweetCard({
45
47
  text={item.text}
46
48
  />
47
49
  <TweetMediaGrid items={item.media} />
50
+ {item.entities.article ? (
51
+ <TweetArticleCard article={item.entities.article} />
52
+ ) : null}
48
53
  </section>
49
54
  );
50
55
  }
@@ -0,0 +1,382 @@
1
+ import {
2
+ type CSSProperties,
3
+ type FocusEvent,
4
+ type KeyboardEvent,
5
+ type PointerEvent,
6
+ type RefObject,
7
+ useCallback,
8
+ useEffect,
9
+ useId,
10
+ useLayoutEffect,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+
15
+ type PreviewSide = "top" | "bottom";
16
+
17
+ type Rect = {
18
+ top: number;
19
+ right: number;
20
+ bottom: number;
21
+ left: number;
22
+ width: number;
23
+ height: number;
24
+ };
25
+
26
+ type PreviewPosition = {
27
+ top: number;
28
+ left: number;
29
+ maxWidth: number;
30
+ maxHeight: number;
31
+ side: PreviewSide;
32
+ measured: boolean;
33
+ ready: boolean;
34
+ };
35
+
36
+ type PlacementInput = {
37
+ referenceRects: Rect[];
38
+ floatingWidth: number;
39
+ floatingHeight: number;
40
+ viewport: Rect;
41
+ gap?: number;
42
+ gutter?: number;
43
+ };
44
+
45
+ const DEFAULT_GAP = 10;
46
+ const DEFAULT_GUTTER = 12;
47
+ const DEFAULT_CLOSE_DELAY_MS = 120;
48
+
49
+ const initialPosition: PreviewPosition = {
50
+ top: 0,
51
+ left: 0,
52
+ maxWidth: 0,
53
+ maxHeight: 0,
54
+ side: "bottom",
55
+ measured: false,
56
+ ready: false,
57
+ };
58
+
59
+ function edgeRect(rects: Rect[], side: PreviewSide) {
60
+ return rects.reduce((best, rect) => {
61
+ if (side === "bottom") {
62
+ if (rect.bottom > best.bottom) return rect;
63
+ if (rect.bottom === best.bottom && rect.width > best.width) return rect;
64
+ return best;
65
+ }
66
+ if (rect.top < best.top) return rect;
67
+ if (rect.top === best.top && rect.width > best.width) return rect;
68
+ return best;
69
+ });
70
+ }
71
+
72
+ function clamp(value: number, minimum: number, maximum: number) {
73
+ if (maximum < minimum) return minimum;
74
+ return Math.min(Math.max(value, minimum), maximum);
75
+ }
76
+
77
+ function calculatePreviewPosition({
78
+ referenceRects,
79
+ floatingWidth,
80
+ floatingHeight,
81
+ viewport,
82
+ gap = DEFAULT_GAP,
83
+ gutter = DEFAULT_GUTTER,
84
+ }: PlacementInput): PreviewPosition {
85
+ const referenceTop = Math.min(...referenceRects.map((rect) => rect.top));
86
+ const referenceBottom = Math.max(
87
+ ...referenceRects.map((rect) => rect.bottom),
88
+ );
89
+ const availableBelow = Math.max(
90
+ 0,
91
+ viewport.bottom - gutter - referenceBottom - gap,
92
+ );
93
+ const availableAbove = Math.max(
94
+ 0,
95
+ referenceTop - viewport.top - gutter - gap,
96
+ );
97
+ const side: PreviewSide =
98
+ availableBelow >= floatingHeight || availableBelow >= availableAbove
99
+ ? "bottom"
100
+ : "top";
101
+ const availableHeight = side === "bottom" ? availableBelow : availableAbove;
102
+ const renderedHeight = Math.min(floatingHeight, availableHeight);
103
+ const anchor = edgeRect(referenceRects, side);
104
+ const maxWidth = Math.max(0, viewport.width - gutter * 2);
105
+ const renderedWidth = Math.min(floatingWidth, maxWidth);
106
+ const minimumLeft = viewport.left + gutter;
107
+ const maximumLeft = viewport.right - gutter - renderedWidth;
108
+ const left = clamp(
109
+ anchor.left + anchor.width / 2 - renderedWidth / 2,
110
+ minimumLeft,
111
+ maximumLeft,
112
+ );
113
+ const top =
114
+ side === "bottom"
115
+ ? referenceBottom + gap
116
+ : referenceTop - gap - renderedHeight;
117
+
118
+ return {
119
+ top,
120
+ left,
121
+ maxWidth,
122
+ maxHeight: availableHeight,
123
+ side,
124
+ measured: true,
125
+ ready: true,
126
+ };
127
+ }
128
+
129
+ function viewportRect(): Rect {
130
+ const viewport = window.visualViewport;
131
+ const left = viewport?.offsetLeft ?? 0;
132
+ const top = viewport?.offsetTop ?? 0;
133
+ const width = viewport?.width ?? window.innerWidth;
134
+ const height = viewport?.height ?? window.innerHeight;
135
+ return {
136
+ top,
137
+ right: left + width,
138
+ bottom: top + height,
139
+ left,
140
+ width,
141
+ height,
142
+ };
143
+ }
144
+
145
+ function elementRects(element: HTMLElement): Rect[] {
146
+ const fragments = Array.from(element.getClientRects()).filter(
147
+ (rect) => rect.width > 0 || rect.height > 0,
148
+ );
149
+ return fragments.length > 0 ? fragments : [element.getBoundingClientRect()];
150
+ }
151
+
152
+ function referenceIntersectsViewport(rects: Rect[], viewport: Rect) {
153
+ return rects.some(
154
+ (rect) =>
155
+ rect.right >= viewport.left &&
156
+ rect.left <= viewport.right &&
157
+ rect.bottom >= viewport.top &&
158
+ rect.top <= viewport.bottom,
159
+ );
160
+ }
161
+
162
+ function samePosition(left: PreviewPosition, right: PreviewPosition) {
163
+ return (
164
+ left.top === right.top &&
165
+ left.left === right.left &&
166
+ left.maxWidth === right.maxWidth &&
167
+ left.maxHeight === right.maxHeight &&
168
+ left.side === right.side &&
169
+ left.measured === right.measured &&
170
+ left.ready === right.ready
171
+ );
172
+ }
173
+
174
+ function floatingStyle(position: PreviewPosition): CSSProperties {
175
+ return {
176
+ position: "fixed",
177
+ top: position.top,
178
+ left: position.left,
179
+ // Apply constraints during the hidden first pass so remeasurement can settle.
180
+ maxWidth: position.measured ? position.maxWidth : undefined,
181
+ maxHeight: position.measured ? position.maxHeight : undefined,
182
+ visibility: position.ready ? "visible" : "hidden",
183
+ };
184
+ }
185
+
186
+ function containsTarget(
187
+ target: EventTarget | null,
188
+ ...elements: Array<HTMLElement | null>
189
+ ) {
190
+ return (
191
+ target instanceof Node &&
192
+ elements.some((element) => element?.contains(target))
193
+ );
194
+ }
195
+
196
+ export function useFloatingPreview(options: { closeDelayMs?: number } = {}): {
197
+ open: boolean;
198
+ referenceRef: RefObject<HTMLSpanElement | null>;
199
+ floatingRef: RefObject<HTMLSpanElement | null>;
200
+ floatingStyle: CSSProperties;
201
+ referenceProps: {
202
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
203
+ onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
204
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
205
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
206
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
207
+ };
208
+ floatingProps: {
209
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => void;
210
+ onPointerLeave: (event: PointerEvent<HTMLElement>) => void;
211
+ onFocus: (event: FocusEvent<HTMLElement>) => void;
212
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
213
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
214
+ };
215
+ floatingId: string;
216
+ closePreview: () => void;
217
+ } {
218
+ const closeDelayMs = options.closeDelayMs ?? DEFAULT_CLOSE_DELAY_MS;
219
+ const [open, setOpen] = useState(false);
220
+ const [position, setPosition] = useState(initialPosition);
221
+ const floatingId = useId();
222
+ const referenceRef = useRef<HTMLSpanElement | null>(null);
223
+ const floatingRef = useRef<HTMLSpanElement | null>(null);
224
+ const closeTimerRef = useRef<number | null>(null);
225
+
226
+ const clearCloseTimer = useCallback(() => {
227
+ if (closeTimerRef.current === null) return;
228
+ window.clearTimeout(closeTimerRef.current);
229
+ closeTimerRef.current = null;
230
+ }, []);
231
+
232
+ const openPreview = useCallback(() => {
233
+ clearCloseTimer();
234
+ if (!open) setPosition(initialPosition);
235
+ setOpen(true);
236
+ }, [clearCloseTimer, open]);
237
+
238
+ const closePreview = useCallback(() => {
239
+ clearCloseTimer();
240
+ setOpen(false);
241
+ }, [clearCloseTimer]);
242
+
243
+ const scheduleClose = useCallback(() => {
244
+ clearCloseTimer();
245
+ closeTimerRef.current = window.setTimeout(() => {
246
+ closeTimerRef.current = null;
247
+ const reference = referenceRef.current;
248
+ const floating = floatingRef.current;
249
+ const focusInside = containsTarget(
250
+ document.activeElement,
251
+ reference,
252
+ floating,
253
+ );
254
+ const pointerInside =
255
+ Boolean(reference?.matches(":hover")) ||
256
+ Boolean(floating?.matches(":hover"));
257
+ if (!focusInside && !pointerInside) setOpen(false);
258
+ }, closeDelayMs);
259
+ }, [clearCloseTimer, closeDelayMs]);
260
+
261
+ const handleBlur = useCallback(
262
+ (event: FocusEvent<HTMLElement>) => {
263
+ if (
264
+ containsTarget(
265
+ event.relatedTarget,
266
+ referenceRef.current,
267
+ floatingRef.current,
268
+ )
269
+ ) {
270
+ return;
271
+ }
272
+ scheduleClose();
273
+ },
274
+ [scheduleClose],
275
+ );
276
+
277
+ const handleKeyDown = useCallback(
278
+ (event: KeyboardEvent<HTMLElement>) => {
279
+ if (event.key !== "Escape") return;
280
+ closePreview();
281
+ referenceRef.current?.querySelector<HTMLElement>("a,button")?.focus();
282
+ },
283
+ [closePreview],
284
+ );
285
+
286
+ const updatePlacement = useCallback(() => {
287
+ const reference = referenceRef.current;
288
+ const floating = floatingRef.current;
289
+ if (!reference || !floating) return;
290
+ const viewport = viewportRect();
291
+ const referenceRects = elementRects(reference);
292
+ if (!referenceIntersectsViewport(referenceRects, viewport)) {
293
+ setPosition((current) =>
294
+ current.ready ? { ...current, ready: false } : current,
295
+ );
296
+ return;
297
+ }
298
+ const floatingRect = floating.getBoundingClientRect();
299
+ const content = floating.querySelector<HTMLElement>(
300
+ "[data-floating-preview-content]",
301
+ );
302
+ const style = window.getComputedStyle(floating);
303
+ const verticalChrome = [
304
+ style.paddingTop,
305
+ style.paddingBottom,
306
+ style.borderTopWidth,
307
+ style.borderBottomWidth,
308
+ ].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0);
309
+ // Measure the unclipped child so a previously constrained card can grow again.
310
+ const naturalHeight = Math.max(
311
+ floatingRect.height,
312
+ (content?.getBoundingClientRect().height ?? 0) + verticalChrome,
313
+ );
314
+ const next = calculatePreviewPosition({
315
+ referenceRects,
316
+ floatingWidth: floatingRect.width,
317
+ floatingHeight: naturalHeight,
318
+ viewport,
319
+ });
320
+ if (floatingRect.width > next.maxWidth + 0.5) next.ready = false;
321
+ setPosition((current) => (samePosition(current, next) ? current : next));
322
+ }, []);
323
+
324
+ useLayoutEffect(() => {
325
+ if (!open) return;
326
+ updatePlacement();
327
+ let frame = 0;
328
+ const trackLayout = () => {
329
+ updatePlacement();
330
+ frame = window.requestAnimationFrame(trackLayout);
331
+ };
332
+ // Fixed positioning needs explicit tracking when surrounding content shifts.
333
+ frame = window.requestAnimationFrame(trackLayout);
334
+ const viewport = window.visualViewport;
335
+ window.addEventListener("resize", updatePlacement);
336
+ window.addEventListener("scroll", updatePlacement, true);
337
+ viewport?.addEventListener("resize", updatePlacement);
338
+ viewport?.addEventListener("scroll", updatePlacement);
339
+ const observer =
340
+ typeof ResizeObserver === "undefined"
341
+ ? null
342
+ : new ResizeObserver(updatePlacement);
343
+ if (referenceRef.current) observer?.observe(referenceRef.current);
344
+ if (floatingRef.current) observer?.observe(floatingRef.current);
345
+
346
+ return () => {
347
+ window.cancelAnimationFrame(frame);
348
+ window.removeEventListener("resize", updatePlacement);
349
+ window.removeEventListener("scroll", updatePlacement, true);
350
+ viewport?.removeEventListener("resize", updatePlacement);
351
+ viewport?.removeEventListener("scroll", updatePlacement);
352
+ observer?.disconnect();
353
+ };
354
+ }, [open, updatePlacement]);
355
+
356
+ useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
357
+
358
+ const interactionProps = {
359
+ onPointerEnter: (_event: PointerEvent<HTMLElement>) => openPreview(),
360
+ onPointerLeave: (_event: PointerEvent<HTMLElement>) => scheduleClose(),
361
+ onFocus: (_event: FocusEvent<HTMLElement>) => openPreview(),
362
+ onBlur: handleBlur,
363
+ onKeyDown: handleKeyDown,
364
+ };
365
+
366
+ return {
367
+ open,
368
+ referenceRef,
369
+ floatingRef,
370
+ floatingStyle: floatingStyle(position),
371
+ referenceProps: interactionProps,
372
+ floatingProps: interactionProps,
373
+ floatingId,
374
+ closePreview,
375
+ };
376
+ }
377
+
378
+ export const __test__ = {
379
+ calculatePreviewPosition,
380
+ floatingStyle,
381
+ referenceIntersectsViewport,
382
+ };
@@ -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}