birdclaw 0.8.1 → 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.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -4
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +4 -0
- package/src/components/EmbeddedTweetCard.tsx +4 -0
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/MarkdownViewer.tsx +57 -99
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/TimelineCard.tsx +18 -2
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +7 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +71 -6
- package/vite.config.ts +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.8.1 - 2026-06-13
|
|
4
13
|
|
|
5
14
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birdclaw",
|
|
3
|
-
"version": "0.8.
|
|
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",
|
|
@@ -54,10 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
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",
|
package/scripts/browser-perf.mjs
CHANGED
|
@@ -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) => {
|
|
@@ -11,6 +11,7 @@ import { AvatarChip } from "./AvatarChip";
|
|
|
11
11
|
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
12
12
|
import { ProfilePreview } from "./ProfilePreview";
|
|
13
13
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
14
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
14
15
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
15
16
|
import { TweetRichText } from "./TweetRichText";
|
|
16
17
|
|
|
@@ -117,6 +118,9 @@ export function ConversationThread({
|
|
|
117
118
|
text={tweet.text}
|
|
118
119
|
/>
|
|
119
120
|
<TweetMediaGrid items={tweet.media} />
|
|
121
|
+
{tweet.entities.article ? (
|
|
122
|
+
<TweetArticleCard article={tweet.entities.article} />
|
|
123
|
+
) : null}
|
|
120
124
|
</div>
|
|
121
125
|
</div>
|
|
122
126
|
);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "#/lib/ui";
|
|
11
11
|
import { ProfilePreview } from "./ProfilePreview";
|
|
12
12
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
13
|
+
import { TweetArticleCard } from "./TweetArticleCard";
|
|
13
14
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
14
15
|
import { TweetRichText } from "./TweetRichText";
|
|
15
16
|
|
|
@@ -46,6 +47,9 @@ export function EmbeddedTweetCard({
|
|
|
46
47
|
text={item.text}
|
|
47
48
|
/>
|
|
48
49
|
<TweetMediaGrid items={item.media} />
|
|
50
|
+
{item.entities.article ? (
|
|
51
|
+
<TweetArticleCard article={item.entities.article} />
|
|
52
|
+
) : null}
|
|
49
53
|
</section>
|
|
50
54
|
);
|
|
51
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
|
+
};
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Fragment,
|
|
3
|
-
type MouseEventHandler,
|
|
4
|
-
type ReactNode,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
} from "react";
|
|
1
|
+
import { Fragment, type MouseEventHandler, type ReactNode } from "react";
|
|
9
2
|
import { formatCompactNumber } from "#/lib/present";
|
|
10
3
|
import type { PeriodDigestContext } from "#/lib/period-digest";
|
|
11
4
|
import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
|
|
@@ -14,35 +7,17 @@ import type { ProfileRecord } from "#/lib/types";
|
|
|
14
7
|
import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
|
|
15
8
|
import { safeHttpUrl } from "#/lib/url-safety";
|
|
16
9
|
import { AvatarChip } from "./AvatarChip";
|
|
10
|
+
import { useFloatingPreview } from "./FloatingPreview";
|
|
17
11
|
import { ProfilePreview } from "./ProfilePreview";
|
|
18
12
|
import { SmartTimestamp } from "./SmartTimestamp";
|
|
19
13
|
|
|
20
14
|
type CitationTweet = PeriodDigestContext["tweets"][number];
|
|
21
15
|
type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
|
|
22
|
-
type VerticalBounds = { top: number; bottom: number };
|
|
23
|
-
|
|
24
16
|
type InlineLookup = {
|
|
25
17
|
tweetsById: Map<string, CitationTweet>;
|
|
26
18
|
profilesByHandle: Map<string, ProfileRecord>;
|
|
27
19
|
};
|
|
28
20
|
|
|
29
|
-
function nearestVerticalClipBounds(element: HTMLElement): VerticalBounds {
|
|
30
|
-
let top = 0;
|
|
31
|
-
let bottom = window.innerHeight;
|
|
32
|
-
for (
|
|
33
|
-
let current = element.parentElement;
|
|
34
|
-
current;
|
|
35
|
-
current = current.parentElement
|
|
36
|
-
) {
|
|
37
|
-
const style = window.getComputedStyle(current);
|
|
38
|
-
if (!/(auto|scroll|hidden|clip)/.test(style.overflowY)) continue;
|
|
39
|
-
const rect = current.getBoundingClientRect();
|
|
40
|
-
top = Math.max(top, rect.top);
|
|
41
|
-
bottom = Math.min(bottom, rect.bottom);
|
|
42
|
-
}
|
|
43
|
-
return { top, bottom };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
21
|
function normalizeTweetReference(value: string) {
|
|
47
22
|
return value
|
|
48
23
|
.trim()
|
|
@@ -135,13 +110,16 @@ function TweetSourceLink({
|
|
|
135
110
|
children,
|
|
136
111
|
href,
|
|
137
112
|
onClick,
|
|
113
|
+
describedBy,
|
|
138
114
|
}: {
|
|
139
115
|
children: ReactNode;
|
|
140
116
|
href: string;
|
|
141
117
|
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
118
|
+
describedBy?: string;
|
|
142
119
|
}) {
|
|
143
120
|
return (
|
|
144
121
|
<a
|
|
122
|
+
aria-describedby={describedBy}
|
|
145
123
|
className="rounded-sm px-0.5 text-[var(--accent)] hover:bg-[var(--accent-soft)] hover:no-underline"
|
|
146
124
|
href={href}
|
|
147
125
|
onClick={onClick}
|
|
@@ -160,93 +138,73 @@ function TweetPreviewToken({
|
|
|
160
138
|
tweet: CitationTweet;
|
|
161
139
|
children: ReactNode;
|
|
162
140
|
}) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const cardRect = card?.getBoundingClientRect();
|
|
176
|
-
const cardHeight = Math.max(
|
|
177
|
-
card?.offsetHeight ?? 0,
|
|
178
|
-
cardRect?.height ?? 0,
|
|
179
|
-
220,
|
|
180
|
-
);
|
|
181
|
-
const bounds = nearestVerticalClipBounds(shell);
|
|
182
|
-
const belowSpace = bounds.bottom - shellRect.bottom;
|
|
183
|
-
const aboveSpace = shellRect.top - bounds.top;
|
|
184
|
-
setPlaceAbove(belowSpace < cardHeight + 18 && aboveSpace >= belowSpace);
|
|
185
|
-
};
|
|
186
|
-
updatePlacement();
|
|
187
|
-
const frame = window.requestAnimationFrame(updatePlacement);
|
|
188
|
-
return () => window.cancelAnimationFrame(frame);
|
|
189
|
-
}, [open]);
|
|
190
|
-
|
|
191
|
-
function closePreview() {
|
|
192
|
-
setOpen(false);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const previewText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
|
|
141
|
+
const preview = useFloatingPreview();
|
|
142
|
+
|
|
143
|
+
const article = tweet.entities?.article;
|
|
144
|
+
const renderedText = renderTweetPlainText(tweet.text, tweet.entities ?? {});
|
|
145
|
+
const previewText = article
|
|
146
|
+
? [article.title, article.previewText]
|
|
147
|
+
.filter(
|
|
148
|
+
(value, index, values): value is string =>
|
|
149
|
+
Boolean(value) && values.indexOf(value) === index,
|
|
150
|
+
)
|
|
151
|
+
.join("\n\n")
|
|
152
|
+
: renderedText;
|
|
196
153
|
|
|
197
154
|
return (
|
|
198
155
|
<span
|
|
199
|
-
ref={
|
|
200
|
-
className="
|
|
201
|
-
|
|
202
|
-
onFocus={() => setOpen(true)}
|
|
203
|
-
onPointerEnter={() => setOpen(true)}
|
|
204
|
-
onPointerLeave={closePreview}
|
|
156
|
+
ref={preview.referenceRef}
|
|
157
|
+
className="inline align-baseline"
|
|
158
|
+
{...preview.referenceProps}
|
|
205
159
|
>
|
|
206
160
|
<TweetSourceLink
|
|
161
|
+
describedBy={preview.open ? preview.floatingId : undefined}
|
|
207
162
|
href={getTweetUrl(tweet)}
|
|
208
163
|
onClick={(event) => {
|
|
209
|
-
closePreview();
|
|
164
|
+
preview.closePreview();
|
|
210
165
|
event.currentTarget.blur();
|
|
211
166
|
}}
|
|
212
167
|
>
|
|
213
168
|
{children}
|
|
214
169
|
</TweetSourceLink>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
170
|
+
{preview.open ? (
|
|
171
|
+
<span
|
|
172
|
+
id={preview.floatingId}
|
|
173
|
+
ref={preview.floatingRef}
|
|
174
|
+
className="fixed z-40 w-[360px] overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 text-left text-[14px] leading-[1.4] text-[var(--ink)] shadow-[0_14px_40px_var(--shadow-strong)]"
|
|
175
|
+
role="tooltip"
|
|
176
|
+
style={preview.floatingStyle}
|
|
177
|
+
{...preview.floatingProps}
|
|
178
|
+
>
|
|
179
|
+
<span className="block" data-floating-preview-content>
|
|
180
|
+
<span className="mb-2 flex items-center gap-2">
|
|
181
|
+
<AvatarChip
|
|
182
|
+
avatarUrl={tweet.authorProfile.avatarUrl}
|
|
183
|
+
hue={tweet.authorProfile.avatarHue}
|
|
184
|
+
name={tweet.name}
|
|
185
|
+
profileId={tweet.authorProfile.id}
|
|
186
|
+
size="small"
|
|
187
|
+
/>
|
|
188
|
+
<span className="min-w-0">
|
|
189
|
+
<span className="block truncate font-bold">{tweet.name}</span>
|
|
190
|
+
<span className="block truncate text-[12px] text-[var(--ink-soft)]">
|
|
191
|
+
@{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
|
|
192
|
+
</span>
|
|
193
|
+
</span>
|
|
194
|
+
</span>
|
|
195
|
+
<span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
196
|
+
{previewText}
|
|
197
|
+
</span>
|
|
198
|
+
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
|
|
199
|
+
<span>{tweet.source}</span>
|
|
200
|
+
{tweet.likeCount > 0 ? (
|
|
201
|
+
<span>{formatCompactNumber(tweet.likeCount)} likes</span>
|
|
202
|
+
) : null}
|
|
203
|
+
{tweet.needsReply ? <span>reply open</span> : null}
|
|
236
204
|
</span>
|
|
237
205
|
</span>
|
|
238
206
|
</span>
|
|
239
|
-
|
|
240
|
-
{previewText}
|
|
241
|
-
</span>
|
|
242
|
-
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
|
|
243
|
-
<span>{tweet.source}</span>
|
|
244
|
-
{tweet.likeCount > 0 ? (
|
|
245
|
-
<span>{formatCompactNumber(tweet.likeCount)} likes</span>
|
|
246
|
-
) : null}
|
|
247
|
-
{tweet.needsReply ? <span>reply open</span> : null}
|
|
248
|
-
</span>
|
|
249
|
-
</span>
|
|
207
|
+
) : null}
|
|
250
208
|
</span>
|
|
251
209
|
);
|
|
252
210
|
}
|