birdclaw 0.5.1 → 0.6.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 +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/db.ts +33 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +969 -199
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/today.tsx +441 -0
|
@@ -17,59 +17,64 @@ const THEME_OPTIONS = [
|
|
|
17
17
|
label: string;
|
|
18
18
|
}>;
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<fieldset
|
|
25
|
-
className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
|
|
26
|
-
aria-label="Theme selector"
|
|
27
|
-
>
|
|
28
|
-
<div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
|
|
29
|
-
{THEME_OPTIONS.map((option, index) => {
|
|
30
|
-
const Icon = option.icon;
|
|
31
|
-
const isActive = option.key === theme || (index === 0 && !theme);
|
|
20
|
+
function themeIndex(theme: ThemeValue) {
|
|
21
|
+
return THEME_OPTIONS.findIndex((option) => option.key === theme);
|
|
22
|
+
}
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
export function ThemeSlider({ compact = false }: { compact?: boolean }) {
|
|
25
|
+
const { isReady, theme, setTheme } = useTheme();
|
|
26
|
+
const activeIndex = Math.max(themeIndex(theme), 0);
|
|
27
|
+
const activeOption = THEME_OPTIONS[activeIndex];
|
|
28
|
+
const nextOption = THEME_OPTIONS[(activeIndex + 1) % THEME_OPTIONS.length];
|
|
29
|
+
const Icon = activeOption.icon;
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
32
|
+
const context: ThemeTransitionContext = {
|
|
33
|
+
element: event.currentTarget,
|
|
34
|
+
pointerClientX: event.clientX,
|
|
35
|
+
pointerClientY: event.clientY,
|
|
36
|
+
};
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
startThemeTransition({
|
|
39
|
+
nextTheme: nextOption.key,
|
|
40
|
+
currentTheme: theme,
|
|
41
|
+
setTheme,
|
|
42
|
+
context,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cx(
|
|
49
|
+
"theme-toggle-shell flex justify-center",
|
|
50
|
+
compact
|
|
51
|
+
? "shrink-0"
|
|
52
|
+
: "px-1 py-1 min-[1100px]:justify-start min-[1100px]:px-2",
|
|
53
|
+
)}
|
|
54
|
+
title={`${activeOption.label}; click for ${nextOption.label}`}
|
|
55
|
+
>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className={cx(
|
|
59
|
+
"theme-toggle-button inline-flex items-center justify-center rounded-full border bg-transparent text-[var(--ink-soft)] transition-[background,border-color,color,transform,box-shadow] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_38%,var(--line))] hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
|
|
60
|
+
compact
|
|
61
|
+
? "size-8 border-transparent"
|
|
62
|
+
: "size-11 border-[var(--line)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_44%,transparent)]",
|
|
63
|
+
)}
|
|
64
|
+
onClick={handleClick}
|
|
65
|
+
aria-label={`Theme: ${activeOption.label}. Switch to ${nextOption.label}.`}
|
|
66
|
+
data-testid="theme-toggle"
|
|
67
|
+
disabled={!isReady}
|
|
68
|
+
>
|
|
69
|
+
<Icon
|
|
70
|
+
className={cx(
|
|
71
|
+
"theme-toggle-icon",
|
|
72
|
+
compact ? "size-[17px]" : "size-[19px]",
|
|
73
|
+
)}
|
|
74
|
+
strokeWidth={2}
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
/>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
74
79
|
);
|
|
75
80
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Bookmark,
|
|
3
2
|
BookmarkCheck,
|
|
4
3
|
CheckCircle2,
|
|
5
4
|
Circle,
|
|
6
5
|
Heart,
|
|
6
|
+
Image,
|
|
7
7
|
MessageCircle,
|
|
8
8
|
Repeat2,
|
|
9
9
|
} from "lucide-react";
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
feedActionButtonClass,
|
|
22
22
|
feedActionIconClass,
|
|
23
23
|
feedActionIconWrapClass,
|
|
24
|
-
feedActionIconWrapLikeClass,
|
|
25
|
-
feedActionLikeClass,
|
|
26
24
|
feedRowActionsClass,
|
|
27
25
|
feedRowBodyClass,
|
|
28
26
|
feedRowClass,
|
|
@@ -35,7 +33,6 @@ import {
|
|
|
35
33
|
feedRowStatePillOpenClass,
|
|
36
34
|
feedRowTextClass,
|
|
37
35
|
feedRowTimestampClass,
|
|
38
|
-
mutedDotClass,
|
|
39
36
|
} from "#/lib/ui";
|
|
40
37
|
import { AvatarChip } from "./AvatarChip";
|
|
41
38
|
import { ConversationThread } from "./ConversationThread";
|
|
@@ -83,6 +80,64 @@ function isMediaUrlEntity(
|
|
|
83
80
|
return false;
|
|
84
81
|
}
|
|
85
82
|
|
|
83
|
+
function isShortUrl(value: string | null | undefined) {
|
|
84
|
+
if (!value) return false;
|
|
85
|
+
try {
|
|
86
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
87
|
+
const parsed = new URL(candidate);
|
|
88
|
+
return parsed.hostname.replace(/^www\./, "") === "t.co";
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isUnresolvedShortUrlEntity(entry: TweetUrlEntity) {
|
|
95
|
+
if (isShortUrl(entry.expandedUrl)) return true;
|
|
96
|
+
if (entry.expandedUrl) return false;
|
|
97
|
+
if (isShortUrl(entry.displayUrl)) return true;
|
|
98
|
+
return !entry.displayUrl && isShortUrl(entry.url);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unresolvedShortUrlRanges(entities: TweetEntities) {
|
|
102
|
+
return (entities.urls ?? [])
|
|
103
|
+
.filter(isUnresolvedShortUrlEntity)
|
|
104
|
+
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function textOutsideRanges(
|
|
108
|
+
text: string,
|
|
109
|
+
ranges: Array<{ start: number; end: number }>,
|
|
110
|
+
) {
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
let output = "";
|
|
113
|
+
for (const range of [...ranges].sort(
|
|
114
|
+
(left, right) => left.start - right.start,
|
|
115
|
+
)) {
|
|
116
|
+
if (
|
|
117
|
+
range.start < cursor ||
|
|
118
|
+
range.end <= range.start ||
|
|
119
|
+
range.end > text.length
|
|
120
|
+
) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
output += text.slice(cursor, range.start);
|
|
124
|
+
cursor = range.end;
|
|
125
|
+
}
|
|
126
|
+
output += text.slice(cursor);
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shouldHideUnresolvedShortUrls(
|
|
131
|
+
text: string,
|
|
132
|
+
entities: TweetEntities,
|
|
133
|
+
mediaUrls: Set<string>,
|
|
134
|
+
) {
|
|
135
|
+
if (mediaUrls.size === 0) return false;
|
|
136
|
+
const ranges = unresolvedShortUrlRanges(entities);
|
|
137
|
+
if (ranges.length === 0) return false;
|
|
138
|
+
return textOutsideRanges(text, ranges).trim().length === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
function isOwnStatusMediaUrl(
|
|
87
142
|
value: string | null | undefined,
|
|
88
143
|
tweetId: string,
|
|
@@ -108,13 +163,21 @@ function getVisibleEntities(
|
|
|
108
163
|
entities: TweetEntities,
|
|
109
164
|
media: TweetMediaItem[],
|
|
110
165
|
tweetId: string,
|
|
166
|
+
text: string,
|
|
111
167
|
) {
|
|
112
168
|
const mediaUrls = getMediaUrlSet(media);
|
|
113
169
|
if (mediaUrls.size === 0) return entities;
|
|
170
|
+
const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
|
|
171
|
+
text,
|
|
172
|
+
entities,
|
|
173
|
+
mediaUrls,
|
|
174
|
+
);
|
|
114
175
|
return {
|
|
115
176
|
...entities,
|
|
116
177
|
urls: (entities.urls ?? []).filter(
|
|
117
|
-
(entry) =>
|
|
178
|
+
(entry) =>
|
|
179
|
+
!isMediaUrlEntity(entry, mediaUrls, tweetId) &&
|
|
180
|
+
!(hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
|
|
118
181
|
),
|
|
119
182
|
};
|
|
120
183
|
}
|
|
@@ -123,19 +186,32 @@ function getHiddenMediaUrlRanges(
|
|
|
123
186
|
entities: TweetEntities,
|
|
124
187
|
media: TweetMediaItem[],
|
|
125
188
|
tweetId: string,
|
|
189
|
+
text: string,
|
|
126
190
|
) {
|
|
127
191
|
const mediaUrls = getMediaUrlSet(media);
|
|
128
192
|
if (mediaUrls.size === 0) return [];
|
|
193
|
+
const hideUnresolvedShortUrls = shouldHideUnresolvedShortUrls(
|
|
194
|
+
text,
|
|
195
|
+
entities,
|
|
196
|
+
mediaUrls,
|
|
197
|
+
);
|
|
129
198
|
return (entities.urls ?? [])
|
|
130
|
-
.filter(
|
|
199
|
+
.filter(
|
|
200
|
+
(entry) =>
|
|
201
|
+
isMediaUrlEntity(entry, mediaUrls, tweetId) ||
|
|
202
|
+
(hideUnresolvedShortUrls && isUnresolvedShortUrlEntity(entry)),
|
|
203
|
+
)
|
|
131
204
|
.map((entry) => ({ start: entry.start, end: entry.end }));
|
|
132
205
|
}
|
|
133
206
|
|
|
134
|
-
function getVisibleUrlCards(
|
|
135
|
-
|
|
207
|
+
function getVisibleUrlCards(
|
|
208
|
+
entities: TweetEntities,
|
|
209
|
+
quotedTweetId: string | null,
|
|
210
|
+
) {
|
|
136
211
|
return (entities.urls ?? []).filter((entry) => {
|
|
137
|
-
if (
|
|
138
|
-
|
|
212
|
+
if (isUnresolvedShortUrlEntity(entry)) return false;
|
|
213
|
+
if (!quotedTweetId) return true;
|
|
214
|
+
return !entry.expandedUrl.includes(quotedTweetId);
|
|
139
215
|
});
|
|
140
216
|
}
|
|
141
217
|
|
|
@@ -157,18 +233,44 @@ export function TimelineCard({
|
|
|
157
233
|
}) {
|
|
158
234
|
const canReply =
|
|
159
235
|
showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
|
|
160
|
-
const
|
|
236
|
+
const displayTweet = item.retweetedTweet ?? item;
|
|
237
|
+
const displayTweetId = displayTweet.id;
|
|
238
|
+
const interactionTweetId =
|
|
239
|
+
item.retweetedTweet && displayTweetId === `${item.id}:retweeted`
|
|
240
|
+
? item.id
|
|
241
|
+
: displayTweetId;
|
|
242
|
+
const displayAuthor = displayTweet.author;
|
|
243
|
+
const conversation = useConversationSurface(item.id, interactionTweetId);
|
|
161
244
|
const visibleEntities = getVisibleEntities(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
245
|
+
displayTweet.entities,
|
|
246
|
+
displayTweet.media,
|
|
247
|
+
displayTweet.id,
|
|
248
|
+
displayTweet.text,
|
|
165
249
|
);
|
|
166
250
|
const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
251
|
+
displayTweet.entities,
|
|
252
|
+
displayTweet.media,
|
|
253
|
+
displayTweet.id,
|
|
254
|
+
displayTweet.text,
|
|
255
|
+
);
|
|
256
|
+
const visibleUrlCards = getVisibleUrlCards(
|
|
257
|
+
visibleEntities,
|
|
258
|
+
item.retweetedTweet ? null : (item.quotedTweet?.id ?? null),
|
|
259
|
+
);
|
|
260
|
+
const displayMediaCount = item.retweetedTweet
|
|
261
|
+
? (displayTweet.mediaCount ?? displayTweet.media.length)
|
|
262
|
+
: item.mediaCount;
|
|
263
|
+
const displayIsReplied = displayTweet.isReplied ?? item.isReplied;
|
|
264
|
+
const displayLikeCount = displayTweet.likeCount ?? item.likeCount;
|
|
265
|
+
const displayBookmarked = displayTweet.bookmarked ?? item.bookmarked;
|
|
266
|
+
const displayLiked = displayTweet.liked ?? item.liked;
|
|
267
|
+
const showLikeIndicator = displayLiked || displayLikeCount > 0;
|
|
268
|
+
const showMediaIndicator = displayMediaCount > 0;
|
|
269
|
+
const hasConversation = Boolean(
|
|
270
|
+
item.retweetedTweet
|
|
271
|
+
? displayTweet.replyToId
|
|
272
|
+
: item.replyToTweet || item.replyToId,
|
|
170
273
|
);
|
|
171
|
-
const hasConversation = Boolean(item.replyToTweet || item.replyToId);
|
|
172
274
|
|
|
173
275
|
return (
|
|
174
276
|
<article
|
|
@@ -182,24 +284,34 @@ export function TimelineCard({
|
|
|
182
284
|
}}
|
|
183
285
|
>
|
|
184
286
|
<AvatarChip
|
|
185
|
-
avatarUrl={
|
|
186
|
-
hue={
|
|
187
|
-
name={
|
|
188
|
-
profileId={
|
|
287
|
+
avatarUrl={displayAuthor.avatarUrl}
|
|
288
|
+
hue={displayAuthor.avatarHue}
|
|
289
|
+
name={displayAuthor.displayName}
|
|
290
|
+
profileId={displayAuthor.id}
|
|
189
291
|
/>
|
|
190
292
|
<div className={feedRowBodyClass}>
|
|
293
|
+
{item.retweetedTweet ? (
|
|
294
|
+
<div className="inline-flex items-center gap-2 text-[13px] font-medium text-[var(--ink-soft)]">
|
|
295
|
+
<Repeat2 className="size-4" strokeWidth={1.8} />
|
|
296
|
+
<ProfilePreview profile={item.author}>
|
|
297
|
+
<span>{item.author.displayName} reposted</span>
|
|
298
|
+
</ProfilePreview>
|
|
299
|
+
</div>
|
|
300
|
+
) : null}
|
|
191
301
|
<header className={feedRowHeaderClass}>
|
|
192
|
-
<ProfilePreview profile={
|
|
302
|
+
<ProfilePreview profile={displayAuthor}>
|
|
193
303
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
194
304
|
<span className={feedRowNameClass}>
|
|
195
|
-
{
|
|
305
|
+
{displayAuthor.displayName}
|
|
306
|
+
</span>
|
|
307
|
+
<span className={feedRowHandleClass}>
|
|
308
|
+
@{displayAuthor.handle}
|
|
196
309
|
</span>
|
|
197
|
-
<span className={feedRowHandleClass}>@{item.author.handle}</span>
|
|
198
310
|
</span>
|
|
199
311
|
</ProfilePreview>
|
|
200
312
|
<span className={feedRowDotClass}>·</span>
|
|
201
313
|
<span className={feedRowTimestampClass}>
|
|
202
|
-
{formatShortTimestamp(
|
|
314
|
+
{formatShortTimestamp(displayTweet.createdAt)}
|
|
203
315
|
</span>
|
|
204
316
|
{canReply || hasConversation ? (
|
|
205
317
|
<span className="ml-auto inline-flex items-center gap-1">
|
|
@@ -218,50 +330,77 @@ export function TimelineCard({
|
|
|
218
330
|
) : null}
|
|
219
331
|
{canReply ? (
|
|
220
332
|
<span
|
|
221
|
-
aria-label={
|
|
333
|
+
aria-label={displayIsReplied ? "We replied" : "Reply open"}
|
|
222
334
|
className={cx(
|
|
223
335
|
feedRowStatePillClass,
|
|
224
|
-
|
|
336
|
+
displayIsReplied
|
|
225
337
|
? feedRowStatePillActiveClass
|
|
226
338
|
: feedRowStatePillOpenClass,
|
|
227
339
|
)}
|
|
228
|
-
title={
|
|
340
|
+
title={displayIsReplied ? "We replied" : "Reply open"}
|
|
229
341
|
>
|
|
230
|
-
{
|
|
342
|
+
{displayIsReplied ? (
|
|
231
343
|
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
232
344
|
) : (
|
|
233
345
|
<Circle className="size-3" strokeWidth={2.2} />
|
|
234
346
|
)}
|
|
235
|
-
{
|
|
347
|
+
{displayIsReplied ? "replied" : "open"}
|
|
236
348
|
</span>
|
|
237
349
|
) : null}
|
|
238
350
|
</span>
|
|
239
351
|
) : null}
|
|
240
352
|
</header>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
353
|
+
{item.retweetedTweet ? (
|
|
354
|
+
<>
|
|
355
|
+
<TweetRichText
|
|
356
|
+
className={feedRowTextClass}
|
|
357
|
+
entities={displayTweet.entities}
|
|
358
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
359
|
+
text={displayTweet.text}
|
|
360
|
+
/>
|
|
361
|
+
<TweetMediaGrid items={displayTweet.media} />
|
|
362
|
+
{visibleUrlCards.map((entry, index) => (
|
|
363
|
+
<LinkPreviewCard
|
|
364
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
365
|
+
entry={entry}
|
|
366
|
+
index={index}
|
|
367
|
+
/>
|
|
368
|
+
))}
|
|
369
|
+
</>
|
|
370
|
+
) : (
|
|
371
|
+
<>
|
|
372
|
+
<TweetRichText
|
|
373
|
+
className={feedRowTextClass}
|
|
374
|
+
entities={item.entities}
|
|
375
|
+
hiddenUrlRanges={hiddenMediaUrlRanges}
|
|
376
|
+
text={item.text}
|
|
377
|
+
/>
|
|
378
|
+
<TweetMediaGrid items={item.media} />
|
|
379
|
+
{item.replyToTweet ? (
|
|
380
|
+
<div className={embeddedCardClass}>
|
|
381
|
+
<EmbeddedTweetCard
|
|
382
|
+
item={item.replyToTweet}
|
|
383
|
+
label="In reply to"
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
) : null}
|
|
387
|
+
{item.quotedTweet ? (
|
|
388
|
+
<div className={embeddedCardClass}>
|
|
389
|
+
<EmbeddedTweetCard
|
|
390
|
+
item={item.quotedTweet}
|
|
391
|
+
label="Quoted tweet"
|
|
392
|
+
/>
|
|
393
|
+
</div>
|
|
394
|
+
) : null}
|
|
395
|
+
{visibleUrlCards.map((entry, index) => (
|
|
396
|
+
<LinkPreviewCard
|
|
397
|
+
key={`${entry.expandedUrl}-${String(index)}`}
|
|
398
|
+
entry={entry}
|
|
399
|
+
index={index}
|
|
400
|
+
/>
|
|
401
|
+
))}
|
|
402
|
+
</>
|
|
403
|
+
)}
|
|
265
404
|
<footer className={feedRowActionsClass}>
|
|
266
405
|
<div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
|
|
267
406
|
<button
|
|
@@ -291,7 +430,7 @@ export function TimelineCard({
|
|
|
291
430
|
className={feedActionButtonClass}
|
|
292
431
|
onClick={(event) => {
|
|
293
432
|
event.stopPropagation();
|
|
294
|
-
onReply(
|
|
433
|
+
onReply(interactionTweetId);
|
|
295
434
|
}}
|
|
296
435
|
type="button"
|
|
297
436
|
aria-label="Reply"
|
|
@@ -305,57 +444,50 @@ export function TimelineCard({
|
|
|
305
444
|
<span className="text-[13px]">Reply</span>
|
|
306
445
|
</button>
|
|
307
446
|
) : null}
|
|
308
|
-
|
|
309
|
-
<span className={feedActionIconWrapClass}>
|
|
310
|
-
<Repeat2 className={feedActionIconClass} strokeWidth={1.7} />
|
|
311
|
-
</span>
|
|
312
|
-
</span>
|
|
313
|
-
<span
|
|
314
|
-
className={cx(
|
|
315
|
-
feedActionButtonClass,
|
|
316
|
-
feedActionLikeClass,
|
|
317
|
-
"cursor-default",
|
|
318
|
-
item.liked && "text-[var(--like)]",
|
|
319
|
-
)}
|
|
320
|
-
>
|
|
447
|
+
{showLikeIndicator ? (
|
|
321
448
|
<span
|
|
449
|
+
aria-label={`${formatCompactNumber(displayLikeCount)} likes`}
|
|
322
450
|
className={cx(
|
|
323
|
-
|
|
324
|
-
|
|
451
|
+
"inline-flex items-center gap-1 px-2 py-1 text-[13px]",
|
|
452
|
+
displayLiked && "text-[var(--like)]",
|
|
325
453
|
)}
|
|
454
|
+
title={`${formatCompactNumber(displayLikeCount)} likes`}
|
|
326
455
|
>
|
|
327
456
|
<Heart
|
|
328
457
|
className={feedActionIconClass}
|
|
329
458
|
strokeWidth={1.7}
|
|
330
|
-
fill={
|
|
459
|
+
fill={displayLiked ? "currentColor" : "none"}
|
|
331
460
|
/>
|
|
461
|
+
<span>{formatCompactNumber(displayLikeCount)}</span>
|
|
332
462
|
</span>
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)}
|
|
463
|
+
) : null}
|
|
464
|
+
{displayBookmarked ? (
|
|
465
|
+
<span
|
|
466
|
+
aria-label="Bookmarked"
|
|
467
|
+
className="inline-flex items-center px-2 py-1"
|
|
468
|
+
title="Bookmarked"
|
|
469
|
+
>
|
|
470
|
+
<BookmarkCheck
|
|
471
|
+
className={feedActionIconClass}
|
|
472
|
+
strokeWidth={1.7}
|
|
473
|
+
/>
|
|
345
474
|
</span>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
475
|
+
) : null}
|
|
476
|
+
{showMediaIndicator ? (
|
|
477
|
+
<span
|
|
478
|
+
aria-label={`${String(displayMediaCount)} media attachments`}
|
|
479
|
+
className="inline-flex items-center gap-1 px-2 py-1 text-[13px]"
|
|
480
|
+
title={`${String(displayMediaCount)} media attachments`}
|
|
481
|
+
>
|
|
482
|
+
<Image className={feedActionIconClass} strokeWidth={1.7} />
|
|
483
|
+
<span>{displayMediaCount}</span>
|
|
484
|
+
</span>
|
|
485
|
+
) : null}
|
|
354
486
|
</div>
|
|
355
487
|
</footer>
|
|
356
488
|
{conversation.isOpen ? (
|
|
357
489
|
<ConversationThread
|
|
358
|
-
anchorId={
|
|
490
|
+
anchorId={interactionTweetId}
|
|
359
491
|
error={conversation.error}
|
|
360
492
|
items={conversation.items}
|
|
361
493
|
loading={conversation.loading}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
tabButtonClass,
|
|
26
26
|
tabButtonIndicatorClass,
|
|
27
27
|
tabStripClass,
|
|
28
|
+
timestampClass,
|
|
28
29
|
} from "#/lib/ui";
|
|
29
30
|
import { useTimelineRouteData } from "./useTimelineRouteData";
|
|
30
31
|
|
|
@@ -68,13 +69,21 @@ export function TimelineRouteFrame({
|
|
|
68
69
|
const [replyFilter, setReplyFilter] =
|
|
69
70
|
useState<ReplyFilter>(initialReplyFilter);
|
|
70
71
|
const [search, setSearch] = useState("");
|
|
71
|
-
const {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
const {
|
|
73
|
+
meta,
|
|
74
|
+
items,
|
|
75
|
+
loading,
|
|
76
|
+
error,
|
|
77
|
+
replyError,
|
|
78
|
+
retry,
|
|
79
|
+
refreshLocalView,
|
|
80
|
+
replyToTweet,
|
|
81
|
+
} = useTimelineRouteData({
|
|
82
|
+
resource,
|
|
83
|
+
replyFilter,
|
|
84
|
+
search,
|
|
85
|
+
errorFallback,
|
|
86
|
+
});
|
|
78
87
|
const subtitleText = useMemo(() => subtitle(meta), [meta, subtitle]);
|
|
79
88
|
|
|
80
89
|
return (
|
|
@@ -86,7 +95,7 @@ export function TimelineRouteFrame({
|
|
|
86
95
|
<p className={pageSubtitleClass}>{subtitleText}</p>
|
|
87
96
|
</div>
|
|
88
97
|
<SyncNowButton
|
|
89
|
-
accounts={
|
|
98
|
+
accounts={meta?.accounts}
|
|
90
99
|
kind={syncKind}
|
|
91
100
|
label={syncLabel}
|
|
92
101
|
onSynced={refreshLocalView}
|
|
@@ -123,6 +132,11 @@ export function TimelineRouteFrame({
|
|
|
123
132
|
})}
|
|
124
133
|
</div>
|
|
125
134
|
</header>
|
|
135
|
+
{replyError ? (
|
|
136
|
+
<p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
|
|
137
|
+
{replyError}
|
|
138
|
+
</p>
|
|
139
|
+
) : null}
|
|
126
140
|
<ConversationSurfaceScope>
|
|
127
141
|
<section className={feedClass}>
|
|
128
142
|
{loading ? (
|