birdclaw 0.4.0 → 0.5.0
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 +39 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/src/cli.ts +563 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1567 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +318 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/db.ts +282 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +629 -0
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +233 -7
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +219 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +33 -8
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { ExternalLink, MessageCircle } from "lucide-react";
|
|
2
3
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
3
4
|
import type { InboxItem } from "#/lib/types";
|
|
4
5
|
import {
|
|
5
|
-
actionButtonClass,
|
|
6
|
-
actionRowClass,
|
|
7
|
-
bodyCopyClass,
|
|
8
|
-
cardFooterClass,
|
|
9
|
-
cardHeaderClass,
|
|
10
6
|
composerBarClass,
|
|
11
7
|
composerInputClass,
|
|
12
8
|
composerShellClass,
|
|
13
|
-
contentCardClass,
|
|
14
9
|
cx,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
feedRowBodyClass,
|
|
11
|
+
feedRowClass,
|
|
12
|
+
feedRowDotClass,
|
|
13
|
+
feedRowHandleClass,
|
|
14
|
+
feedRowHeaderClass,
|
|
15
|
+
feedRowNameClass,
|
|
16
|
+
feedRowTextClass,
|
|
17
|
+
feedRowTimestampClass,
|
|
18
18
|
inboxAnalysisClass,
|
|
19
|
-
inboxTitleClass,
|
|
20
|
-
metaStackClass,
|
|
21
|
-
metricRowClass,
|
|
22
19
|
mutedDotClass,
|
|
23
|
-
navLinkClass,
|
|
24
20
|
pillAlertClass,
|
|
25
21
|
pillClass,
|
|
26
22
|
pillSoftClass,
|
|
23
|
+
primaryButtonClass,
|
|
24
|
+
secondaryButtonClass,
|
|
27
25
|
timestampClass,
|
|
28
26
|
} from "#/lib/ui";
|
|
29
27
|
import { AvatarChip } from "./AvatarChip";
|
|
@@ -44,93 +42,97 @@ export function InboxCard({
|
|
|
44
42
|
onReplySend: () => void;
|
|
45
43
|
}) {
|
|
46
44
|
return (
|
|
47
|
-
<article className={cx(
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
</span>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
<div className={metaStackClass}>
|
|
68
|
-
<span className={cx(pillClass, pillSoftClass)}>
|
|
69
|
-
{item.entityKind}
|
|
70
|
-
</span>
|
|
71
|
-
<span className={cx(pillClass, pillAlertClass)}>
|
|
72
|
-
score {item.score}
|
|
45
|
+
<article className={cx(feedRowClass, "items-start")}>
|
|
46
|
+
<AvatarChip
|
|
47
|
+
avatarUrl={item.participant.avatarUrl}
|
|
48
|
+
hue={item.participant.avatarHue}
|
|
49
|
+
name={item.participant.displayName}
|
|
50
|
+
profileId={item.participant.id}
|
|
51
|
+
/>
|
|
52
|
+
<div className={feedRowBodyClass}>
|
|
53
|
+
<header className={feedRowHeaderClass}>
|
|
54
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
55
|
+
<span className={feedRowNameClass}>
|
|
56
|
+
{item.participant.displayName}
|
|
57
|
+
</span>
|
|
58
|
+
<span className={feedRowHandleClass}>
|
|
59
|
+
@{item.participant.handle}
|
|
60
|
+
</span>
|
|
73
61
|
</span>
|
|
74
|
-
<span className={
|
|
62
|
+
<span className={feedRowDotClass}>·</span>
|
|
63
|
+
<span className={feedRowTimestampClass}>
|
|
75
64
|
{formatShortTimestamp(item.createdAt)}
|
|
76
65
|
</span>
|
|
66
|
+
<span className="ml-auto flex items-center gap-1.5">
|
|
67
|
+
<span className={cx(pillClass, pillSoftClass)}>
|
|
68
|
+
{item.entityKind}
|
|
69
|
+
</span>
|
|
70
|
+
<span className={cx(pillClass, pillAlertClass)}>
|
|
71
|
+
score {item.score}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
</header>
|
|
75
|
+
<h3 className="text-[15px] font-bold text-[var(--ink)]">
|
|
76
|
+
{item.title}
|
|
77
|
+
</h3>
|
|
78
|
+
<p className={feedRowTextClass}>{item.text}</p>
|
|
79
|
+
<div className={inboxAnalysisClass}>
|
|
80
|
+
<strong className="text-[var(--ink)]">{item.summary}</strong>
|
|
81
|
+
<p>{item.reasoning}</p>
|
|
77
82
|
</div>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<div className={metricRowClass}>
|
|
88
|
-
<span>{item.source}</span>
|
|
89
|
-
<span>influence {item.influenceScore}</span>
|
|
90
|
-
<span>{item.needsReply ? "needs reply" : "resolved"}</span>
|
|
91
|
-
</div>
|
|
92
|
-
<div className={actionRowClass}>
|
|
93
|
-
<button
|
|
94
|
-
className={navLinkClass}
|
|
95
|
-
onClick={onReplyToggle}
|
|
96
|
-
type="button"
|
|
97
|
-
>
|
|
98
|
-
{isReplying ? "Close reply" : "Reply"}
|
|
99
|
-
</button>
|
|
100
|
-
<Link
|
|
101
|
-
className={actionButtonClass}
|
|
102
|
-
to={item.entityKind === "dm" ? "/dms" : "/mentions"}
|
|
103
|
-
>
|
|
104
|
-
Open
|
|
105
|
-
</Link>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
{isReplying ? (
|
|
109
|
-
<div className={composerShellClass}>
|
|
110
|
-
<textarea
|
|
111
|
-
className={composerInputClass}
|
|
112
|
-
onChange={(event) => onReplyChange(event.target.value)}
|
|
113
|
-
placeholder={
|
|
114
|
-
item.entityKind === "dm"
|
|
115
|
-
? `Reply to @${item.participant.handle}`
|
|
116
|
-
: `Reply to mention from @${item.participant.handle}`
|
|
117
|
-
}
|
|
118
|
-
rows={4}
|
|
119
|
-
value={replyDraft}
|
|
120
|
-
/>
|
|
121
|
-
<div className={composerBarClass}>
|
|
122
|
-
<span className={timestampClass}>Send from inbox</span>
|
|
83
|
+
<div className="mt-2 flex items-center justify-between gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
84
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
85
|
+
<span>{item.source}</span>
|
|
86
|
+
<span className={mutedDotClass} />
|
|
87
|
+
<span>influence {formatCompactNumber(item.influenceScore)}</span>
|
|
88
|
+
<span className={mutedDotClass} />
|
|
89
|
+
<span>{item.needsReply ? "needs reply" : "resolved"}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
123
92
|
<button
|
|
124
|
-
className={
|
|
125
|
-
|
|
126
|
-
onClick={onReplySend}
|
|
93
|
+
className={secondaryButtonClass}
|
|
94
|
+
onClick={onReplyToggle}
|
|
127
95
|
type="button"
|
|
128
96
|
>
|
|
129
|
-
|
|
97
|
+
<MessageCircle className="size-4" strokeWidth={2} />
|
|
98
|
+
{isReplying ? "Close reply" : "Reply"}
|
|
130
99
|
</button>
|
|
100
|
+
<Link
|
|
101
|
+
className={cx(secondaryButtonClass, "gap-1.5")}
|
|
102
|
+
to={item.entityKind === "dm" ? "/dms" : "/mentions"}
|
|
103
|
+
>
|
|
104
|
+
<ExternalLink className="size-4" strokeWidth={2} />
|
|
105
|
+
Open
|
|
106
|
+
</Link>
|
|
131
107
|
</div>
|
|
132
108
|
</div>
|
|
133
|
-
|
|
109
|
+
{isReplying ? (
|
|
110
|
+
<div className={composerShellClass}>
|
|
111
|
+
<textarea
|
|
112
|
+
className={composerInputClass}
|
|
113
|
+
onChange={(event) => onReplyChange(event.target.value)}
|
|
114
|
+
placeholder={
|
|
115
|
+
item.entityKind === "dm"
|
|
116
|
+
? `Reply to @${item.participant.handle}`
|
|
117
|
+
: `Reply to mention from @${item.participant.handle}`
|
|
118
|
+
}
|
|
119
|
+
rows={4}
|
|
120
|
+
value={replyDraft}
|
|
121
|
+
/>
|
|
122
|
+
<div className={composerBarClass}>
|
|
123
|
+
<span className={timestampClass}>Send from inbox</span>
|
|
124
|
+
<button
|
|
125
|
+
className={primaryButtonClass}
|
|
126
|
+
disabled={!replyDraft.trim()}
|
|
127
|
+
onClick={onReplySend}
|
|
128
|
+
type="button"
|
|
129
|
+
>
|
|
130
|
+
Send
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
134
136
|
</article>
|
|
135
137
|
);
|
|
136
138
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { ExternalLink, Image as ImageIcon } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { LinkPreviewMetadata } from "#/lib/link-preview-metadata";
|
|
4
|
+
import type { TweetUrlEntity } from "#/lib/types";
|
|
5
|
+
import {
|
|
6
|
+
cx,
|
|
7
|
+
linkPreviewCardClass,
|
|
8
|
+
linkPreviewDescClass,
|
|
9
|
+
linkPreviewHostClass,
|
|
10
|
+
linkPreviewTitleClass,
|
|
11
|
+
} from "#/lib/ui";
|
|
12
|
+
|
|
13
|
+
type LinkPreviewState = Pick<
|
|
14
|
+
TweetUrlEntity,
|
|
15
|
+
| "expandedUrl"
|
|
16
|
+
| "displayUrl"
|
|
17
|
+
| "title"
|
|
18
|
+
| "description"
|
|
19
|
+
| "imageUrl"
|
|
20
|
+
| "siteName"
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
|
|
24
|
+
const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
|
|
25
|
+
const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
|
|
26
|
+
let activePreviewFetches = 0;
|
|
27
|
+
const queuedPreviewFetches: Array<() => void> = [];
|
|
28
|
+
|
|
29
|
+
function needsHydration(preview: LinkPreviewState) {
|
|
30
|
+
const targetUrl = preview.expandedUrl || "";
|
|
31
|
+
if (
|
|
32
|
+
preview.imageUrl &&
|
|
33
|
+
preview.siteName &&
|
|
34
|
+
preview.title &&
|
|
35
|
+
isDirectImageUrl(targetUrl)
|
|
36
|
+
) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
!preview.imageUrl ||
|
|
41
|
+
!preview.title ||
|
|
42
|
+
!preview.description ||
|
|
43
|
+
preview.title === preview.displayUrl ||
|
|
44
|
+
preview.description === preview.displayUrl
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runQueuedPreviewFetches() {
|
|
49
|
+
while (
|
|
50
|
+
activePreviewFetches < MAX_CONCURRENT_PREVIEW_FETCHES &&
|
|
51
|
+
queuedPreviewFetches.length > 0
|
|
52
|
+
) {
|
|
53
|
+
const next = queuedPreviewFetches.shift();
|
|
54
|
+
next?.();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
|
|
59
|
+
return new Promise<LinkPreviewMetadata | null>((resolve) => {
|
|
60
|
+
queuedPreviewFetches.push(() => {
|
|
61
|
+
activePreviewFetches += 1;
|
|
62
|
+
task()
|
|
63
|
+
.then(resolve)
|
|
64
|
+
.catch(() => resolve(null))
|
|
65
|
+
.finally(() => {
|
|
66
|
+
activePreviewFetches = Math.max(0, activePreviewFetches - 1);
|
|
67
|
+
runQueuedPreviewFetches();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
runQueuedPreviewFetches();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fetchPreview(entry: TweetUrlEntity) {
|
|
75
|
+
const targetUrl = entry.expandedUrl || entry.url;
|
|
76
|
+
if (!targetUrl) return Promise.resolve(null);
|
|
77
|
+
const key = `${entry.url} ${targetUrl}`;
|
|
78
|
+
const cached = previewCache.get(key);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
|
|
81
|
+
const params = new URLSearchParams({ url: targetUrl });
|
|
82
|
+
if (entry.url && entry.url !== targetUrl) {
|
|
83
|
+
params.set("shortUrl", entry.url);
|
|
84
|
+
}
|
|
85
|
+
const promise = schedulePreviewFetch(() =>
|
|
86
|
+
fetch(`/api/link-preview?${params.toString()}`)
|
|
87
|
+
.then(async (response) => {
|
|
88
|
+
if (!response.ok) return null;
|
|
89
|
+
const data = (await response.json()) as {
|
|
90
|
+
ok?: boolean;
|
|
91
|
+
preview?: LinkPreviewMetadata;
|
|
92
|
+
};
|
|
93
|
+
return data.ok ? (data.preview ?? null) : null;
|
|
94
|
+
})
|
|
95
|
+
.catch(() => null),
|
|
96
|
+
);
|
|
97
|
+
previewCache.set(key, promise);
|
|
98
|
+
return promise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function displayHost(url: string, fallback: string) {
|
|
102
|
+
try {
|
|
103
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
104
|
+
} catch {
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isDirectImageUrl(url: string) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
|
|
113
|
+
return (
|
|
114
|
+
parsed.hostname === "pbs.twimg.com" &&
|
|
115
|
+
(parsed.pathname.startsWith("/media/") ||
|
|
116
|
+
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
117
|
+
);
|
|
118
|
+
} catch {
|
|
119
|
+
return IMAGE_EXTENSION_PATTERN.test(url);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function LinkPreviewCard({
|
|
124
|
+
entry,
|
|
125
|
+
index,
|
|
126
|
+
}: {
|
|
127
|
+
entry: TweetUrlEntity;
|
|
128
|
+
index: number;
|
|
129
|
+
}) {
|
|
130
|
+
const targetUrl = entry.expandedUrl || entry.url;
|
|
131
|
+
const displayUrl = entry.displayUrl || displayHost(targetUrl, targetUrl);
|
|
132
|
+
const directImageUrl = isDirectImageUrl(targetUrl) ? targetUrl : null;
|
|
133
|
+
const initialPreview = useMemo<LinkPreviewState>(
|
|
134
|
+
() => ({
|
|
135
|
+
expandedUrl: targetUrl,
|
|
136
|
+
displayUrl,
|
|
137
|
+
title:
|
|
138
|
+
entry.title ??
|
|
139
|
+
(directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
|
|
140
|
+
description:
|
|
141
|
+
entry.description ?? (directImageUrl ? displayUrl : undefined),
|
|
142
|
+
imageUrl: entry.imageUrl ?? directImageUrl,
|
|
143
|
+
siteName:
|
|
144
|
+
entry.siteName ??
|
|
145
|
+
(directImageUrl ? displayHost(targetUrl, displayUrl) : undefined),
|
|
146
|
+
}),
|
|
147
|
+
[
|
|
148
|
+
directImageUrl,
|
|
149
|
+
displayUrl,
|
|
150
|
+
entry.description,
|
|
151
|
+
entry.imageUrl,
|
|
152
|
+
entry.siteName,
|
|
153
|
+
entry.title,
|
|
154
|
+
targetUrl,
|
|
155
|
+
],
|
|
156
|
+
);
|
|
157
|
+
const [preview, setPreview] = useState(initialPreview);
|
|
158
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
159
|
+
const [hydratedKey, setHydratedKey] = useState("");
|
|
160
|
+
const [canHydrate, setCanHydrate] = useState(false);
|
|
161
|
+
const cardRef = useRef<HTMLAnchorElement | null>(null);
|
|
162
|
+
const cacheKey = `${entry.url} ${targetUrl}`;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
setPreview(initialPreview);
|
|
166
|
+
setImageFailed(false);
|
|
167
|
+
setHydratedKey("");
|
|
168
|
+
setCanHydrate(false);
|
|
169
|
+
}, [initialPreview]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!targetUrl) return;
|
|
173
|
+
if (!needsHydration(preview)) return;
|
|
174
|
+
const node = cardRef.current;
|
|
175
|
+
if (!node || typeof IntersectionObserver === "undefined") {
|
|
176
|
+
setCanHydrate(true);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const observer = new IntersectionObserver(
|
|
180
|
+
(entries) => {
|
|
181
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
182
|
+
setCanHydrate(true);
|
|
183
|
+
observer.disconnect();
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{ rootMargin: "320px 0px" },
|
|
187
|
+
);
|
|
188
|
+
observer.observe(node);
|
|
189
|
+
return () => observer.disconnect();
|
|
190
|
+
}, [preview, targetUrl]);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!targetUrl || !canHydrate) return;
|
|
194
|
+
if (!needsHydration(preview)) return;
|
|
195
|
+
if (hydratedKey === cacheKey) return;
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
const timer = window.setTimeout(() => {
|
|
198
|
+
setHydratedKey(cacheKey);
|
|
199
|
+
void fetchPreview(entry).then((metadata) => {
|
|
200
|
+
if (cancelled || !metadata) return;
|
|
201
|
+
setPreview((current) => ({
|
|
202
|
+
expandedUrl: metadata.url || current.expandedUrl,
|
|
203
|
+
displayUrl: current.displayUrl,
|
|
204
|
+
title: metadata.title ?? current.title,
|
|
205
|
+
description: metadata.description ?? current.description,
|
|
206
|
+
imageUrl: metadata.imageUrl ?? current.imageUrl,
|
|
207
|
+
siteName: metadata.siteName ?? current.siteName,
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
}, 100);
|
|
211
|
+
return () => {
|
|
212
|
+
cancelled = true;
|
|
213
|
+
window.clearTimeout(timer);
|
|
214
|
+
};
|
|
215
|
+
}, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
|
|
216
|
+
|
|
217
|
+
const title = preview.title || entry.displayUrl;
|
|
218
|
+
const description =
|
|
219
|
+
preview.description && preview.description !== title
|
|
220
|
+
? preview.description
|
|
221
|
+
: preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
222
|
+
const host =
|
|
223
|
+
preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
224
|
+
const showImage = Boolean(preview.imageUrl && !imageFailed);
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<a
|
|
228
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
229
|
+
className={linkPreviewCardClass}
|
|
230
|
+
data-perf="link-preview-card"
|
|
231
|
+
href={preview.expandedUrl}
|
|
232
|
+
ref={cardRef}
|
|
233
|
+
rel="noreferrer"
|
|
234
|
+
target="_blank"
|
|
235
|
+
>
|
|
236
|
+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1 px-3.5 py-3">
|
|
237
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
238
|
+
<span className={linkPreviewHostClass}>{host}</span>
|
|
239
|
+
<ExternalLink
|
|
240
|
+
aria-hidden="true"
|
|
241
|
+
className="size-3.5 shrink-0 text-[var(--ink-soft)] opacity-0 transition-opacity group-hover/link-preview:opacity-100"
|
|
242
|
+
strokeWidth={1.8}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
<span className={linkPreviewTitleClass}>{title}</span>
|
|
246
|
+
<span className={linkPreviewDescClass}>{description}</span>
|
|
247
|
+
<span className={cx(linkPreviewHostClass, "text-[12px]")}>
|
|
248
|
+
{entry.displayUrl}
|
|
249
|
+
</span>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="flex aspect-[1.45] w-40 shrink-0 items-center justify-center overflow-hidden border-l border-[var(--line)] bg-[var(--bg-soft)] max-[720px]:w-28">
|
|
252
|
+
{showImage ? (
|
|
253
|
+
<img
|
|
254
|
+
alt={title}
|
|
255
|
+
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
256
|
+
loading="lazy"
|
|
257
|
+
onError={() => setImageFailed(true)}
|
|
258
|
+
src={preview.imageUrl ?? ""}
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<ImageIcon
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
className="size-8 text-[var(--ink-soft)]"
|
|
264
|
+
strokeWidth={1.7}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</a>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
profilePreviewHandleClass,
|
|
10
10
|
profilePreviewHeaderClass,
|
|
11
11
|
profilePreviewMetaClass,
|
|
12
|
+
profilePreviewNameClass,
|
|
12
13
|
profilePreviewTriggerClass,
|
|
13
14
|
} from "#/lib/ui";
|
|
14
15
|
import { AvatarChip } from "./AvatarChip";
|
|
@@ -40,12 +41,16 @@ export function ProfilePreview({
|
|
|
40
41
|
name={profile.displayName}
|
|
41
42
|
profileId={profile.id}
|
|
42
43
|
/>
|
|
43
|
-
<span>
|
|
44
|
-
<
|
|
44
|
+
<span className="flex min-w-0 flex-col">
|
|
45
|
+
<span className={profilePreviewNameClass}>
|
|
46
|
+
{profile.displayName}
|
|
47
|
+
</span>
|
|
45
48
|
<span className={profilePreviewHandleClass}>@{profile.handle}</span>
|
|
46
49
|
</span>
|
|
47
50
|
</span>
|
|
48
|
-
|
|
51
|
+
{profile.bio ? (
|
|
52
|
+
<span className={profilePreviewBioClass}>{profile.bio}</span>
|
|
53
|
+
) : null}
|
|
49
54
|
<span className={profilePreviewMetaClass}>
|
|
50
55
|
{formatCompactNumber(profile.followersCount)} followers
|
|
51
56
|
</span>
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
1
2
|
import { useEffect, useMemo, useState } from "react";
|
|
2
3
|
import { TimelineCard } from "#/components/TimelineCard";
|
|
3
4
|
import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
textFieldWideClass,
|
|
15
|
-
timelineLaneClass,
|
|
6
|
+
emptyStateClass,
|
|
7
|
+
feedClass,
|
|
8
|
+
pageHeaderClass,
|
|
9
|
+
pageHeaderRowClass,
|
|
10
|
+
pageSubtitleClass,
|
|
11
|
+
pageTitleClass,
|
|
12
|
+
searchFieldIconClass,
|
|
13
|
+
searchFieldInputClass,
|
|
14
|
+
searchFieldShellClass,
|
|
16
15
|
} from "#/lib/ui";
|
|
17
16
|
|
|
18
17
|
interface SavedTimelineViewProps {
|
|
@@ -23,9 +22,13 @@ interface SavedTimelineViewProps {
|
|
|
23
22
|
searchPlaceholder: string;
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
const TITLES: Record<SavedTimelineViewProps["filter"], string> = {
|
|
26
|
+
liked: "Likes",
|
|
27
|
+
bookmarked: "Bookmarks",
|
|
28
|
+
};
|
|
29
|
+
|
|
26
30
|
export function SavedTimelineView({
|
|
27
31
|
filter,
|
|
28
|
-
eyebrow,
|
|
29
32
|
title,
|
|
30
33
|
loadingLabel,
|
|
31
34
|
searchPlaceholder,
|
|
@@ -57,9 +60,11 @@ export function SavedTimelineView({
|
|
|
57
60
|
|
|
58
61
|
const subtitle = useMemo(() => {
|
|
59
62
|
if (!meta) {
|
|
60
|
-
return items.length > 0
|
|
63
|
+
return items.length > 0
|
|
64
|
+
? `${String(items.length)} visible`
|
|
65
|
+
: loadingLabel;
|
|
61
66
|
}
|
|
62
|
-
return `${items.length} visible · ${meta.transport.statusText}`;
|
|
67
|
+
return `${String(items.length)} visible · ${meta.transport.statusText}`;
|
|
63
68
|
}, [items.length, loadingLabel, meta]);
|
|
64
69
|
|
|
65
70
|
async function replyToTweet(tweetId: string) {
|
|
@@ -81,35 +86,40 @@ export function SavedTimelineView({
|
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
return (
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
<div>
|
|
88
|
-
<
|
|
89
|
-
<
|
|
90
|
-
<p className={
|
|
89
|
+
<>
|
|
90
|
+
<header className={pageHeaderClass}>
|
|
91
|
+
<div className={pageHeaderRowClass}>
|
|
92
|
+
<div className="flex min-w-0 flex-col">
|
|
93
|
+
<h1 className={pageTitleClass}>{TITLES[filter]}</h1>
|
|
94
|
+
<p className={pageSubtitleClass}>{title}</p>
|
|
95
|
+
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
91
96
|
</div>
|
|
92
|
-
|
|
97
|
+
</div>
|
|
98
|
+
<div className="px-4 pb-3">
|
|
99
|
+
<label className={searchFieldShellClass}>
|
|
100
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
93
101
|
<input
|
|
94
|
-
className={
|
|
102
|
+
className={searchFieldInputClass}
|
|
95
103
|
onChange={(event) => setSearch(event.target.value)}
|
|
96
104
|
placeholder={searchPlaceholder}
|
|
97
105
|
value={search}
|
|
98
106
|
/>
|
|
99
|
-
</
|
|
100
|
-
</
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
</label>
|
|
108
|
+
</div>
|
|
109
|
+
</header>
|
|
110
|
+
<section className={feedClass}>
|
|
111
|
+
{items.length === 0 ? (
|
|
112
|
+
<div className={emptyStateClass}>Nothing saved here yet.</div>
|
|
113
|
+
) : null}
|
|
114
|
+
{items.map((item) => (
|
|
115
|
+
<TimelineCard
|
|
116
|
+
key={item.id}
|
|
117
|
+
item={item}
|
|
118
|
+
onReply={replyToTweet}
|
|
119
|
+
showReplyControls={false}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</section>
|
|
123
|
+
</>
|
|
114
124
|
);
|
|
115
125
|
}
|