birdclaw 0.4.1 → 0.5.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 +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -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/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -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/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- 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 +326 -35
- 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 +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -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/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -1,36 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bookmark,
|
|
3
|
+
BookmarkCheck,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
Circle,
|
|
6
|
+
Heart,
|
|
7
|
+
MessageCircle,
|
|
8
|
+
Repeat2,
|
|
9
|
+
} from "lucide-react";
|
|
1
10
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
2
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
TimelineItem,
|
|
13
|
+
TweetEntities,
|
|
14
|
+
TweetMediaItem,
|
|
15
|
+
TweetUrlEntity,
|
|
16
|
+
} from "#/lib/types";
|
|
17
|
+
import { useConversationSurface } from "#/lib/conversation-surface";
|
|
3
18
|
import {
|
|
4
|
-
actionButtonClass,
|
|
5
|
-
cardFooterClass,
|
|
6
|
-
cardHeaderClass,
|
|
7
|
-
contentCardClass,
|
|
8
19
|
cx,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
embeddedCardClass,
|
|
21
|
+
feedActionButtonClass,
|
|
22
|
+
feedActionIconClass,
|
|
23
|
+
feedActionIconWrapClass,
|
|
24
|
+
feedActionIconWrapLikeClass,
|
|
25
|
+
feedActionLikeClass,
|
|
26
|
+
feedRowActionsClass,
|
|
27
|
+
feedRowBodyClass,
|
|
28
|
+
feedRowClass,
|
|
29
|
+
feedRowDotClass,
|
|
30
|
+
feedRowHandleClass,
|
|
31
|
+
feedRowHeaderClass,
|
|
32
|
+
feedRowNameClass,
|
|
33
|
+
feedRowStatePillActiveClass,
|
|
34
|
+
feedRowStatePillClass,
|
|
35
|
+
feedRowStatePillOpenClass,
|
|
36
|
+
feedRowTextClass,
|
|
37
|
+
feedRowTimestampClass,
|
|
14
38
|
mutedDotClass,
|
|
15
|
-
pillAlertClass,
|
|
16
|
-
pillClass,
|
|
17
|
-
pillSoftClass,
|
|
18
|
-
timestampClass,
|
|
19
39
|
} from "#/lib/ui";
|
|
20
40
|
import { AvatarChip } from "./AvatarChip";
|
|
41
|
+
import { ConversationThread } from "./ConversationThread";
|
|
21
42
|
import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
|
|
43
|
+
import { LinkPreviewCard } from "./LinkPreviewCard";
|
|
22
44
|
import { ProfilePreview } from "./ProfilePreview";
|
|
23
45
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
24
46
|
import { TweetRichText } from "./TweetRichText";
|
|
25
47
|
|
|
26
|
-
function
|
|
48
|
+
function comparableUrl(value: string | null | undefined) {
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(value);
|
|
52
|
+
return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;
|
|
53
|
+
} catch {
|
|
54
|
+
return value.split("?")[0] ?? value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getMediaUrlSet(media: TweetMediaItem[]) {
|
|
59
|
+
const urls = new Set<string>();
|
|
60
|
+
for (const item of media) {
|
|
61
|
+
for (const url of [item.url, item.thumbnailUrl]) {
|
|
62
|
+
const comparable = comparableUrl(url);
|
|
63
|
+
if (comparable) urls.add(comparable);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return urls;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isMediaUrlEntity(
|
|
70
|
+
entry: TweetUrlEntity,
|
|
71
|
+
mediaUrls: Set<string>,
|
|
72
|
+
tweetId: string,
|
|
73
|
+
) {
|
|
74
|
+
if (mediaUrls.size > 0 && isOwnStatusMediaUrl(entry.expandedUrl, tweetId)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
for (const url of [entry.url, entry.expandedUrl, entry.displayUrl]) {
|
|
78
|
+
const comparable = comparableUrl(url);
|
|
79
|
+
if (comparable && mediaUrls.has(comparable)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isOwnStatusMediaUrl(
|
|
87
|
+
value: string | null | undefined,
|
|
88
|
+
tweetId: string,
|
|
89
|
+
) {
|
|
90
|
+
if (!value) return false;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(value);
|
|
93
|
+
const host = parsed.hostname.replace(/^www\./, "");
|
|
94
|
+
if (host !== "x.com" && host !== "twitter.com") return false;
|
|
95
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
96
|
+
const statusIndex = segments.indexOf("status");
|
|
97
|
+
if (statusIndex < 0 || segments[statusIndex + 1] !== tweetId) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const mediaSegment = segments[statusIndex + 2];
|
|
101
|
+
return mediaSegment === "photo" || mediaSegment === "video";
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getVisibleEntities(
|
|
108
|
+
entities: TweetEntities,
|
|
109
|
+
media: TweetMediaItem[],
|
|
110
|
+
tweetId: string,
|
|
111
|
+
) {
|
|
112
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
113
|
+
if (mediaUrls.size === 0) return entities;
|
|
114
|
+
return {
|
|
115
|
+
...entities,
|
|
116
|
+
urls: (entities.urls ?? []).filter(
|
|
117
|
+
(entry) => !isMediaUrlEntity(entry, mediaUrls, tweetId),
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getHiddenMediaUrlRanges(
|
|
123
|
+
entities: TweetEntities,
|
|
124
|
+
media: TweetMediaItem[],
|
|
125
|
+
tweetId: string,
|
|
126
|
+
) {
|
|
127
|
+
const mediaUrls = getMediaUrlSet(media);
|
|
128
|
+
if (mediaUrls.size === 0) return [];
|
|
129
|
+
return (entities.urls ?? [])
|
|
130
|
+
.filter((entry) => isMediaUrlEntity(entry, mediaUrls, tweetId))
|
|
131
|
+
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getVisibleUrlCards(item: TimelineItem, entities: TweetEntities) {
|
|
27
135
|
const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
|
|
28
|
-
return (
|
|
136
|
+
return (entities.urls ?? []).filter((entry) => {
|
|
29
137
|
if (!item.quotedTweet) return true;
|
|
30
138
|
return !entry.expandedUrl.includes(quotedUrl ?? "");
|
|
31
139
|
});
|
|
32
140
|
}
|
|
33
141
|
|
|
142
|
+
function isInteractiveTarget(target: EventTarget | null) {
|
|
143
|
+
return (
|
|
144
|
+
target instanceof Element &&
|
|
145
|
+
Boolean(target.closest("a,button,input,textarea,select,[role='button']"))
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
34
149
|
export function TimelineCard({
|
|
35
150
|
item,
|
|
36
151
|
onReply,
|
|
@@ -42,86 +157,211 @@ export function TimelineCard({
|
|
|
42
157
|
}) {
|
|
43
158
|
const canReply =
|
|
44
159
|
showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
|
|
160
|
+
const conversation = useConversationSurface(item.id);
|
|
161
|
+
const visibleEntities = getVisibleEntities(
|
|
162
|
+
item.entities,
|
|
163
|
+
item.media,
|
|
164
|
+
item.id,
|
|
165
|
+
);
|
|
166
|
+
const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
|
|
167
|
+
item.entities,
|
|
168
|
+
item.media,
|
|
169
|
+
item.id,
|
|
170
|
+
);
|
|
171
|
+
const hasConversation = Boolean(item.replyToTweet || item.replyToId);
|
|
45
172
|
|
|
46
173
|
return (
|
|
47
|
-
<article
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
174
|
+
<article
|
|
175
|
+
className={cx(feedRowClass, "cursor-pointer")}
|
|
176
|
+
data-perf="timeline-card"
|
|
177
|
+
onFocus={conversation.prefetch}
|
|
178
|
+
onMouseEnter={conversation.prefetch}
|
|
179
|
+
onClick={(event) => {
|
|
180
|
+
if (isInteractiveTarget(event.target)) return;
|
|
181
|
+
conversation.toggle();
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<AvatarChip
|
|
185
|
+
avatarUrl={item.author.avatarUrl}
|
|
186
|
+
hue={item.author.avatarHue}
|
|
187
|
+
name={item.author.displayName}
|
|
188
|
+
profileId={item.author.id}
|
|
189
|
+
/>
|
|
190
|
+
<div className={feedRowBodyClass}>
|
|
191
|
+
<header className={feedRowHeaderClass}>
|
|
192
|
+
<ProfilePreview profile={item.author}>
|
|
193
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
194
|
+
<span className={feedRowNameClass}>
|
|
195
|
+
{item.author.displayName}
|
|
196
|
+
</span>
|
|
197
|
+
<span className={feedRowHandleClass}>@{item.author.handle}</span>
|
|
198
|
+
</span>
|
|
199
|
+
</ProfilePreview>
|
|
200
|
+
<span className={feedRowDotClass}>·</span>
|
|
201
|
+
<span className={feedRowTimestampClass}>
|
|
202
|
+
{formatShortTimestamp(item.createdAt)}
|
|
203
|
+
</span>
|
|
204
|
+
{canReply || hasConversation ? (
|
|
205
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
206
|
+
{hasConversation ? (
|
|
207
|
+
<span
|
|
208
|
+
aria-label="Part of a conversation"
|
|
209
|
+
className={cx(
|
|
210
|
+
feedRowStatePillClass,
|
|
211
|
+
feedRowStatePillActiveClass,
|
|
212
|
+
)}
|
|
213
|
+
title="Part of a conversation"
|
|
214
|
+
>
|
|
215
|
+
<MessageCircle className="size-3.5" strokeWidth={2} />
|
|
216
|
+
thread
|
|
217
|
+
</span>
|
|
218
|
+
) : null}
|
|
219
|
+
{canReply ? (
|
|
220
|
+
<span
|
|
221
|
+
aria-label={item.isReplied ? "We replied" : "Reply open"}
|
|
222
|
+
className={cx(
|
|
223
|
+
feedRowStatePillClass,
|
|
224
|
+
item.isReplied
|
|
225
|
+
? feedRowStatePillActiveClass
|
|
226
|
+
: feedRowStatePillOpenClass,
|
|
227
|
+
)}
|
|
228
|
+
title={item.isReplied ? "We replied" : "Reply open"}
|
|
229
|
+
>
|
|
230
|
+
{item.isReplied ? (
|
|
231
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
232
|
+
) : (
|
|
233
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
234
|
+
)}
|
|
235
|
+
{item.isReplied ? "replied" : "open"}
|
|
64
236
|
</span>
|
|
65
|
-
|
|
66
|
-
</
|
|
237
|
+
) : null}
|
|
238
|
+
</span>
|
|
239
|
+
) : null}
|
|
240
|
+
</header>
|
|
241
|
+
<TweetRichText
|
|
242
|
+
className={feedRowTextClass}
|
|
243
|
+
entities={item.entities}
|
|
244
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
245
|
+
text={item.text}
|
|
246
|
+
/>
|
|
247
|
+
<TweetMediaGrid items={item.media} />
|
|
248
|
+
{item.replyToTweet ? (
|
|
249
|
+
<div className={embeddedCardClass}>
|
|
250
|
+
<EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
|
|
67
251
|
</div>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
{
|
|
252
|
+
) : null}
|
|
253
|
+
{item.quotedTweet ? (
|
|
254
|
+
<div className={embeddedCardClass}>
|
|
255
|
+
<EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
|
|
256
|
+
</div>
|
|
257
|
+
) : null}
|
|
258
|
+
{getVisibleUrlCards(item, visibleEntities).map((entry, index) => (
|
|
259
|
+
<LinkPreviewCard
|
|
260
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
261
|
+
entry={entry}
|
|
262
|
+
index={index}
|
|
263
|
+
/>
|
|
264
|
+
))}
|
|
265
|
+
<footer className={feedRowActionsClass}>
|
|
266
|
+
<div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
267
|
+
<button
|
|
268
|
+
aria-expanded={conversation.isOpen}
|
|
269
|
+
aria-label={
|
|
270
|
+
conversation.isOpen ? "Hide conversation" : "Show conversation"
|
|
271
|
+
}
|
|
272
|
+
className={feedActionButtonClass}
|
|
273
|
+
onClick={(event) => {
|
|
274
|
+
event.stopPropagation();
|
|
275
|
+
conversation.toggle();
|
|
276
|
+
}}
|
|
277
|
+
type="button"
|
|
278
|
+
>
|
|
279
|
+
<span className={feedActionIconWrapClass}>
|
|
280
|
+
<MessageCircle
|
|
281
|
+
className={feedActionIconClass}
|
|
282
|
+
strokeWidth={1.7}
|
|
283
|
+
/>
|
|
284
|
+
</span>
|
|
285
|
+
<span className="text-[13px]">
|
|
286
|
+
{conversation.isOpen ? "Hide thread" : "Thread"}
|
|
287
|
+
</span>
|
|
288
|
+
</button>
|
|
289
|
+
{canReply ? (
|
|
290
|
+
<button
|
|
291
|
+
className={feedActionButtonClass}
|
|
292
|
+
onClick={(event) => {
|
|
293
|
+
event.stopPropagation();
|
|
294
|
+
onReply(item.id);
|
|
295
|
+
}}
|
|
296
|
+
type="button"
|
|
297
|
+
aria-label="Reply"
|
|
298
|
+
>
|
|
299
|
+
<span className={feedActionIconWrapClass}>
|
|
300
|
+
<MessageCircle
|
|
301
|
+
className={feedActionIconClass}
|
|
302
|
+
strokeWidth={1.7}
|
|
303
|
+
/>
|
|
304
|
+
</span>
|
|
305
|
+
<span className="text-[13px]">Reply</span>
|
|
306
|
+
</button>
|
|
307
|
+
) : null}
|
|
308
|
+
<span className={cx(feedActionButtonClass, "cursor-default")}>
|
|
309
|
+
<span className={feedActionIconWrapClass}>
|
|
310
|
+
<Repeat2 className={feedActionIconClass} strokeWidth={1.7} />
|
|
311
|
+
</span>
|
|
312
|
+
</span>
|
|
71
313
|
<span
|
|
72
314
|
className={cx(
|
|
73
|
-
|
|
74
|
-
|
|
315
|
+
feedActionButtonClass,
|
|
316
|
+
feedActionLikeClass,
|
|
317
|
+
"cursor-default",
|
|
318
|
+
item.liked && "text-[var(--like)]",
|
|
75
319
|
)}
|
|
76
320
|
>
|
|
77
|
-
|
|
321
|
+
<span
|
|
322
|
+
className={cx(
|
|
323
|
+
feedActionIconWrapClass,
|
|
324
|
+
feedActionIconWrapLikeClass,
|
|
325
|
+
)}
|
|
326
|
+
>
|
|
327
|
+
<Heart
|
|
328
|
+
className={feedActionIconClass}
|
|
329
|
+
strokeWidth={1.7}
|
|
330
|
+
fill={item.liked ? "currentColor" : "none"}
|
|
331
|
+
/>
|
|
332
|
+
</span>
|
|
333
|
+
<span>{formatCompactNumber(item.likeCount)}</span>
|
|
78
334
|
</span>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
))}
|
|
108
|
-
<footer className={cardFooterClass}>
|
|
109
|
-
<div className={metricRowClass}>
|
|
110
|
-
<span>{formatCompactNumber(item.likeCount)} likes</span>
|
|
111
|
-
<span>{item.mediaCount} media</span>
|
|
112
|
-
<span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
|
|
113
|
-
<span>{item.accountHandle}</span>
|
|
114
|
-
</div>
|
|
115
|
-
{canReply ? (
|
|
116
|
-
<button
|
|
117
|
-
className={actionButtonClass}
|
|
118
|
-
onClick={() => onReply(item.id)}
|
|
119
|
-
type="button"
|
|
120
|
-
>
|
|
121
|
-
Reply
|
|
122
|
-
</button>
|
|
335
|
+
<span className={cx(feedActionButtonClass, "cursor-default")}>
|
|
336
|
+
<span className={feedActionIconWrapClass}>
|
|
337
|
+
{item.bookmarked ? (
|
|
338
|
+
<BookmarkCheck
|
|
339
|
+
className={feedActionIconClass}
|
|
340
|
+
strokeWidth={1.7}
|
|
341
|
+
/>
|
|
342
|
+
) : (
|
|
343
|
+
<Bookmark className={feedActionIconClass} strokeWidth={1.7} />
|
|
344
|
+
)}
|
|
345
|
+
</span>
|
|
346
|
+
</span>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
|
|
349
|
+
<span>{item.mediaCount} media</span>
|
|
350
|
+
<span className={mutedDotClass} />
|
|
351
|
+
<span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
|
|
352
|
+
<span className={mutedDotClass} />
|
|
353
|
+
<span>{item.accountHandle}</span>
|
|
354
|
+
</div>
|
|
355
|
+
</footer>
|
|
356
|
+
{conversation.isOpen ? (
|
|
357
|
+
<ConversationThread
|
|
358
|
+
anchorId={item.id}
|
|
359
|
+
error={conversation.error}
|
|
360
|
+
items={conversation.items}
|
|
361
|
+
loading={conversation.loading}
|
|
362
|
+
/>
|
|
123
363
|
) : null}
|
|
124
|
-
</
|
|
364
|
+
</div>
|
|
125
365
|
</article>
|
|
126
366
|
);
|
|
127
367
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FeedEmpty,
|
|
5
|
+
FeedError,
|
|
6
|
+
FeedLoading,
|
|
7
|
+
TweetSkeletonRows,
|
|
8
|
+
} from "#/components/FeedState";
|
|
9
|
+
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
10
|
+
import { TimelineCard } from "#/components/TimelineCard";
|
|
11
|
+
import { ConversationSurfaceScope } from "#/lib/conversation-surface";
|
|
12
|
+
import type { QueryEnvelope, ReplyFilter } from "#/lib/types";
|
|
13
|
+
import type { WebSyncKind } from "#/lib/web-sync";
|
|
14
|
+
import {
|
|
15
|
+
cx,
|
|
16
|
+
feedClass,
|
|
17
|
+
pageHeaderClass,
|
|
18
|
+
pageHeaderRowClass,
|
|
19
|
+
pageSubtitleClass,
|
|
20
|
+
pageTitleClass,
|
|
21
|
+
searchFieldIconClass,
|
|
22
|
+
searchFieldInputClass,
|
|
23
|
+
searchFieldShellClass,
|
|
24
|
+
tabButtonActiveClass,
|
|
25
|
+
tabButtonClass,
|
|
26
|
+
tabButtonIndicatorClass,
|
|
27
|
+
tabStripClass,
|
|
28
|
+
} from "#/lib/ui";
|
|
29
|
+
import { useTimelineRouteData } from "./useTimelineRouteData";
|
|
30
|
+
|
|
31
|
+
const TABS: Array<{ value: ReplyFilter; label: string }> = [
|
|
32
|
+
{ value: "all", label: "All" },
|
|
33
|
+
{ value: "unreplied", label: "Unreplied" },
|
|
34
|
+
{ value: "replied", label: "Replied" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
interface TimelineRouteFrameProps {
|
|
38
|
+
title: string;
|
|
39
|
+
resource: "home" | "mentions";
|
|
40
|
+
initialReplyFilter: ReplyFilter;
|
|
41
|
+
searchPlaceholder: string;
|
|
42
|
+
syncKind: WebSyncKind;
|
|
43
|
+
syncLabel: string;
|
|
44
|
+
loadingLabel: string;
|
|
45
|
+
loadingDetail: string;
|
|
46
|
+
errorTitle: string;
|
|
47
|
+
errorFallback: string;
|
|
48
|
+
emptyLabel: string;
|
|
49
|
+
emptyDetail: string;
|
|
50
|
+
subtitle: (meta: QueryEnvelope | null) => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TimelineRouteFrame({
|
|
54
|
+
title,
|
|
55
|
+
resource,
|
|
56
|
+
initialReplyFilter,
|
|
57
|
+
searchPlaceholder,
|
|
58
|
+
syncKind,
|
|
59
|
+
syncLabel,
|
|
60
|
+
loadingLabel,
|
|
61
|
+
loadingDetail,
|
|
62
|
+
errorTitle,
|
|
63
|
+
errorFallback,
|
|
64
|
+
emptyLabel,
|
|
65
|
+
emptyDetail,
|
|
66
|
+
subtitle,
|
|
67
|
+
}: TimelineRouteFrameProps) {
|
|
68
|
+
const [replyFilter, setReplyFilter] =
|
|
69
|
+
useState<ReplyFilter>(initialReplyFilter);
|
|
70
|
+
const [search, setSearch] = useState("");
|
|
71
|
+
const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
|
|
72
|
+
useTimelineRouteData({
|
|
73
|
+
resource,
|
|
74
|
+
replyFilter,
|
|
75
|
+
search,
|
|
76
|
+
errorFallback,
|
|
77
|
+
});
|
|
78
|
+
const subtitleText = useMemo(() => subtitle(meta), [meta, subtitle]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<header className={pageHeaderClass}>
|
|
83
|
+
<div className={pageHeaderRowClass}>
|
|
84
|
+
<div className="flex min-w-0 flex-col">
|
|
85
|
+
<h1 className={pageTitleClass}>{title}</h1>
|
|
86
|
+
<p className={pageSubtitleClass}>{subtitleText}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<SyncNowButton
|
|
89
|
+
accounts={syncKind === "mentions" ? meta?.accounts : undefined}
|
|
90
|
+
kind={syncKind}
|
|
91
|
+
label={syncLabel}
|
|
92
|
+
onSynced={refreshLocalView}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="px-4 pb-3">
|
|
96
|
+
<label className={searchFieldShellClass}>
|
|
97
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
98
|
+
<input
|
|
99
|
+
className={searchFieldInputClass}
|
|
100
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
101
|
+
placeholder={searchPlaceholder}
|
|
102
|
+
value={search}
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
</div>
|
|
106
|
+
<div className={tabStripClass}>
|
|
107
|
+
{TABS.map((tab) => {
|
|
108
|
+
const active = replyFilter === tab.value;
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
key={tab.value}
|
|
112
|
+
type="button"
|
|
113
|
+
aria-pressed={active}
|
|
114
|
+
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
115
|
+
onClick={() => setReplyFilter(tab.value)}
|
|
116
|
+
>
|
|
117
|
+
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
118
|
+
{tab.label}
|
|
119
|
+
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
120
|
+
</span>
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
</header>
|
|
126
|
+
<ConversationSurfaceScope>
|
|
127
|
+
<section className={feedClass}>
|
|
128
|
+
{loading ? (
|
|
129
|
+
<FeedLoading detail={loadingDetail} label={loadingLabel}>
|
|
130
|
+
<TweetSkeletonRows />
|
|
131
|
+
</FeedLoading>
|
|
132
|
+
) : error ? (
|
|
133
|
+
<FeedError
|
|
134
|
+
action={
|
|
135
|
+
<button
|
|
136
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
137
|
+
onClick={retry}
|
|
138
|
+
type="button"
|
|
139
|
+
>
|
|
140
|
+
Retry
|
|
141
|
+
</button>
|
|
142
|
+
}
|
|
143
|
+
message={error}
|
|
144
|
+
title={errorTitle}
|
|
145
|
+
/>
|
|
146
|
+
) : items.length === 0 ? (
|
|
147
|
+
<FeedEmpty detail={emptyDetail} label={emptyLabel} />
|
|
148
|
+
) : null}
|
|
149
|
+
{items.map((item) => (
|
|
150
|
+
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
151
|
+
))}
|
|
152
|
+
</section>
|
|
153
|
+
</ConversationSurfaceScope>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|