birdclaw 0.5.0 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -0
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +7 -6
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +399 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +29 -0
  12. package/src/cli.ts +46 -1
  13. package/src/components/AppNav.tsx +3 -3
  14. package/src/components/BrandMark.tsx +67 -0
  15. package/src/components/ConversationThread.tsx +11 -10
  16. package/src/components/DmWorkspace.tsx +24 -9
  17. package/src/components/FeedState.tsx +147 -0
  18. package/src/components/InboxCard.tsx +14 -2
  19. package/src/components/SavedTimelineView.tsx +64 -56
  20. package/src/components/SyncNowButton.tsx +105 -0
  21. package/src/components/ThemeSlider.tsx +10 -59
  22. package/src/components/TimelineCard.tsx +157 -61
  23. package/src/components/TimelineRouteFrame.tsx +156 -0
  24. package/src/components/TweetMediaGrid.tsx +120 -23
  25. package/src/components/TweetRichText.tsx +13 -2
  26. package/src/components/useTimelineRouteData.ts +137 -0
  27. package/src/lib/api-client.ts +229 -0
  28. package/src/lib/archive-finder.ts +24 -20
  29. package/src/lib/archive-import.ts +18 -3
  30. package/src/lib/conversation-surface.ts +174 -0
  31. package/src/lib/db.ts +2 -0
  32. package/src/lib/queries.ts +93 -28
  33. package/src/lib/ui.ts +11 -3
  34. package/src/lib/web-sync.ts +443 -0
  35. package/src/routeTree.gen.ts +21 -0
  36. package/src/routes/api/sync.tsx +59 -0
  37. package/src/routes/dms.tsx +100 -27
  38. package/src/routes/index.tsx +21 -127
  39. package/src/routes/links.tsx +50 -5
  40. package/src/routes/mentions.tsx +21 -127
  41. package/src/styles.css +74 -11
  42. package/vite.config.ts +8 -0
@@ -1,13 +1,20 @@
1
- import { useState } from "react";
2
1
  import {
3
2
  Bookmark,
4
3
  BookmarkCheck,
4
+ CheckCircle2,
5
+ Circle,
5
6
  Heart,
6
7
  MessageCircle,
7
8
  Repeat2,
8
9
  } from "lucide-react";
9
10
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
10
- import type { EmbeddedTweet, TimelineItem } from "#/lib/types";
11
+ import type {
12
+ TimelineItem,
13
+ TweetEntities,
14
+ TweetMediaItem,
15
+ TweetUrlEntity,
16
+ } from "#/lib/types";
17
+ import { useConversationSurface } from "#/lib/conversation-surface";
11
18
  import {
12
19
  cx,
13
20
  embeddedCardClass,
@@ -23,6 +30,9 @@ import {
23
30
  feedRowHandleClass,
24
31
  feedRowHeaderClass,
25
32
  feedRowNameClass,
33
+ feedRowStatePillActiveClass,
34
+ feedRowStatePillClass,
35
+ feedRowStatePillOpenClass,
26
36
  feedRowTextClass,
27
37
  feedRowTimestampClass,
28
38
  mutedDotClass,
@@ -35,9 +45,95 @@ import { ProfilePreview } from "./ProfilePreview";
35
45
  import { TweetMediaGrid } from "./TweetMediaGrid";
36
46
  import { TweetRichText } from "./TweetRichText";
37
47
 
38
- function getVisibleUrlCards(item: TimelineItem) {
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) {
39
135
  const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
40
- return (item.entities.urls ?? []).filter((entry) => {
136
+ return (entities.urls ?? []).filter((entry) => {
41
137
  if (!item.quotedTweet) return true;
42
138
  return !entry.expandedUrl.includes(quotedUrl ?? "");
43
139
  });
@@ -61,56 +157,28 @@ export function TimelineCard({
61
157
  }) {
62
158
  const canReply =
63
159
  showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
64
- const [conversationOpen, setConversationOpen] = useState(false);
65
- const [conversationLoading, setConversationLoading] = useState(false);
66
- const [conversationError, setConversationError] = useState<string | null>(
67
- null,
160
+ const conversation = useConversationSurface(item.id);
161
+ const visibleEntities = getVisibleEntities(
162
+ item.entities,
163
+ item.media,
164
+ item.id,
68
165
  );
69
- const [conversationItems, setConversationItems] = useState<EmbeddedTweet[]>(
70
- [],
166
+ const hiddenMediaUrlRanges = getHiddenMediaUrlRanges(
167
+ item.entities,
168
+ item.media,
169
+ item.id,
71
170
  );
72
-
73
- async function loadConversation() {
74
- if (conversationLoading || conversationItems.length > 0) return;
75
- setConversationLoading(true);
76
- setConversationError(null);
77
- try {
78
- const response = await fetch(
79
- `/api/conversation?tweetId=${encodeURIComponent(item.id)}`,
80
- );
81
- const data = (await response.json()) as {
82
- ok?: boolean;
83
- error?: string;
84
- items?: EmbeddedTweet[];
85
- };
86
- if (!response.ok || data.ok === false) {
87
- throw new Error(data.error ?? "Conversation unavailable");
88
- }
89
- setConversationItems((data.items ?? []).filter(Boolean));
90
- } catch (error) {
91
- setConversationError(
92
- error instanceof Error ? error.message : "Conversation unavailable",
93
- );
94
- } finally {
95
- setConversationLoading(false);
96
- }
97
- }
98
-
99
- function toggleConversation() {
100
- const nextOpen = !conversationOpen;
101
- setConversationOpen(nextOpen);
102
- if (nextOpen) {
103
- void loadConversation();
104
- }
105
- }
171
+ const hasConversation = Boolean(item.replyToTweet || item.replyToId);
106
172
 
107
173
  return (
108
174
  <article
109
175
  className={cx(feedRowClass, "cursor-pointer")}
110
176
  data-perf="timeline-card"
177
+ onFocus={conversation.prefetch}
178
+ onMouseEnter={conversation.prefetch}
111
179
  onClick={(event) => {
112
180
  if (isInteractiveTarget(event.target)) return;
113
- toggleConversation();
181
+ conversation.toggle();
114
182
  }}
115
183
  >
116
184
  <AvatarChip
@@ -133,19 +201,47 @@ export function TimelineCard({
133
201
  <span className={feedRowTimestampClass}>
134
202
  {formatShortTimestamp(item.createdAt)}
135
203
  </span>
136
- {canReply ? (
137
- <span className="ml-auto inline-flex items-center gap-1 text-[12px] text-[var(--ink-soft)]">
138
- {item.isReplied ? (
139
- <span className="text-[var(--accent)]">replied</span>
140
- ) : (
141
- <span className="text-[var(--alert)]">needs reply</span>
142
- )}
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"}
236
+ </span>
237
+ ) : null}
143
238
  </span>
144
239
  ) : null}
145
240
  </header>
146
241
  <TweetRichText
147
242
  className={feedRowTextClass}
148
243
  entities={item.entities}
244
+ hiddenUrlRanges={hiddenMediaUrlRanges}
149
245
  text={item.text}
150
246
  />
151
247
  <TweetMediaGrid items={item.media} />
@@ -159,7 +255,7 @@ export function TimelineCard({
159
255
  <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
160
256
  </div>
161
257
  ) : null}
162
- {getVisibleUrlCards(item).map((entry, index) => (
258
+ {getVisibleUrlCards(item, visibleEntities).map((entry, index) => (
163
259
  <LinkPreviewCard
164
260
  key={`${entry.expandedUrl}-${String(index)}`}
165
261
  entry={entry}
@@ -169,14 +265,14 @@ export function TimelineCard({
169
265
  <footer className={feedRowActionsClass}>
170
266
  <div className="flex items-center gap-3 text-[13px] text-[var(--ink-soft)]">
171
267
  <button
172
- aria-expanded={conversationOpen}
268
+ aria-expanded={conversation.isOpen}
173
269
  aria-label={
174
- conversationOpen ? "Hide conversation" : "Show conversation"
270
+ conversation.isOpen ? "Hide conversation" : "Show conversation"
175
271
  }
176
272
  className={feedActionButtonClass}
177
273
  onClick={(event) => {
178
274
  event.stopPropagation();
179
- toggleConversation();
275
+ conversation.toggle();
180
276
  }}
181
277
  type="button"
182
278
  >
@@ -187,7 +283,7 @@ export function TimelineCard({
187
283
  />
188
284
  </span>
189
285
  <span className="text-[13px]">
190
- {conversationOpen ? "Hide thread" : "Thread"}
286
+ {conversation.isOpen ? "Hide thread" : "Thread"}
191
287
  </span>
192
288
  </button>
193
289
  {canReply ? (
@@ -257,12 +353,12 @@ export function TimelineCard({
257
353
  <span>{item.accountHandle}</span>
258
354
  </div>
259
355
  </footer>
260
- {conversationOpen ? (
356
+ {conversation.isOpen ? (
261
357
  <ConversationThread
262
358
  anchorId={item.id}
263
- error={conversationError}
264
- items={conversationItems}
265
- loading={conversationLoading}
359
+ error={conversation.error}
360
+ items={conversation.items}
361
+ loading={conversation.loading}
266
362
  />
267
363
  ) : null}
268
364
  </div>
@@ -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
+ }
@@ -1,39 +1,136 @@
1
+ import { X } from "lucide-react";
2
+ import { useState } from "react";
1
3
  import type { TweetMediaItem } from "#/lib/types";
2
4
  import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
3
5
 
4
6
  export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
7
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
5
8
  if (items.length === 0) {
6
9
  return null;
7
10
  }
8
11
 
12
+ const visibleItems = items.slice(0, 4);
13
+ const selectedItem =
14
+ selectedIndex === null ? null : (visibleItems[selectedIndex] ?? null);
15
+ const selectedVideoUrl =
16
+ selectedItem?.type === "video" || selectedItem?.type === "gif"
17
+ ? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
18
+ : null;
19
+
9
20
  return (
10
- <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
11
- {items.slice(0, 4).map((item, index) => (
12
- <a
13
- key={item.url + String(index)}
14
- className={tweetMediaTileClass(index, Math.min(items.length, 4))}
15
- href={item.url}
16
- rel="noreferrer"
17
- target="_blank"
21
+ <>
22
+ <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
23
+ {visibleItems.map((item, index) => (
24
+ <button
25
+ key={item.url + String(index)}
26
+ aria-label={`Open tweet media ${String(index + 1)}`}
27
+ className={tweetMediaTileClass(index, Math.min(items.length, 4))}
28
+ onClick={(event) => {
29
+ event.stopPropagation();
30
+ setSelectedIndex(index);
31
+ }}
32
+ style={
33
+ visibleItems.length === 1 && item.width && item.height
34
+ ? {
35
+ aspectRatio: `${String(item.width)} / ${String(item.height)}`,
36
+ }
37
+ : undefined
38
+ }
39
+ type="button"
40
+ >
41
+ {item.type === "image" ? (
42
+ <img
43
+ alt={item.altText ?? `Tweet media ${String(index + 1)}`}
44
+ className="tweet-media-image block size-full object-contain"
45
+ loading="lazy"
46
+ src={item.thumbnailUrl ?? item.url}
47
+ />
48
+ ) : (
49
+ <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
50
+ {item.type === "video"
51
+ ? "Video"
52
+ : item.type === "gif"
53
+ ? "GIF"
54
+ : "Media"}
55
+ </span>
56
+ )}
57
+ </button>
58
+ ))}
59
+ </div>
60
+ {selectedItem ? (
61
+ <div
62
+ aria-modal="true"
63
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
64
+ onClick={(event) => {
65
+ event.stopPropagation();
66
+ setSelectedIndex(null);
67
+ }}
68
+ role="dialog"
18
69
  >
19
- {item.type === "image" ? (
70
+ <button
71
+ aria-label="Close media viewer"
72
+ className="absolute right-4 top-4 grid size-10 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
73
+ onClick={(event) => {
74
+ event.stopPropagation();
75
+ setSelectedIndex(null);
76
+ }}
77
+ type="button"
78
+ >
79
+ <X className="size-5" strokeWidth={1.8} />
80
+ </button>
81
+ {selectedItem.type === "image" ? (
20
82
  <img
21
- alt={item.altText ?? `Tweet media ${String(index + 1)}`}
22
- className="tweet-media-image block size-full object-cover"
23
- loading="lazy"
24
- src={item.thumbnailUrl ?? item.url}
83
+ alt={selectedItem.altText ?? "Tweet media"}
84
+ className="max-h-[92vh] max-w-[92vw] object-contain"
85
+ onClick={(event) => event.stopPropagation()}
86
+ src={selectedItem.url}
87
+ />
88
+ ) : selectedVideoUrl ? (
89
+ <video
90
+ autoPlay={selectedItem.type === "gif"}
91
+ className="max-h-[92vh] max-w-[92vw]"
92
+ controls
93
+ loop={selectedItem.type === "gif"}
94
+ muted={selectedItem.type === "gif"}
95
+ onClick={(event) => event.stopPropagation()}
96
+ playsInline
97
+ poster={selectedItem.thumbnailUrl}
98
+ src={selectedVideoUrl}
25
99
  />
26
100
  ) : (
27
- <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
28
- {item.type === "video"
29
- ? "Video"
30
- : item.type === "gif"
31
- ? "GIF"
32
- : "Media"}
33
- </span>
101
+ <div
102
+ className="grid min-h-64 min-w-80 place-items-center gap-3 rounded-2xl border border-white/20 bg-black p-6 text-white"
103
+ onClick={(event) => event.stopPropagation()}
104
+ >
105
+ <span>
106
+ {selectedItem.type === "video"
107
+ ? "Video"
108
+ : selectedItem.type === "gif"
109
+ ? "GIF"
110
+ : "Media"}
111
+ </span>
112
+ <a
113
+ className="rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/20"
114
+ href={selectedItem.url}
115
+ rel="noreferrer"
116
+ target="_blank"
117
+ >
118
+ Open media
119
+ </a>
120
+ </div>
34
121
  )}
35
- </a>
36
- ))}
37
- </div>
122
+ </div>
123
+ ) : null}
124
+ </>
38
125
  );
39
126
  }
127
+
128
+ function playableVideoUrl(url: string) {
129
+ try {
130
+ const parsed = new URL(url);
131
+ if (parsed.hostname === "video.twimg.com") return url;
132
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(parsed.pathname) ? url : undefined;
133
+ } catch {
134
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(url) ? url : undefined;
135
+ }
136
+ }
@@ -1,4 +1,5 @@
1
1
  import { Fragment } from "react";
2
+ import type { ReactNode } from "react";
2
3
  import {
3
4
  collectTweetSegments,
4
5
  enrichFallbackUrlEntities,
@@ -16,10 +17,12 @@ export function TweetRichText({
16
17
  text,
17
18
  entities,
18
19
  className = "body-copy",
20
+ hiddenUrlRanges = [],
19
21
  }: {
20
22
  text: string;
21
23
  entities: TweetEntities;
22
24
  className?: string;
25
+ hiddenUrlRanges?: Array<{ start: number; end: number }>;
23
26
  }) {
24
27
  const richEntities = enrichFallbackUrlEntities(text, entities);
25
28
  const segments = collectTweetSegments(richEntities);
@@ -39,12 +42,20 @@ export function TweetRichText({
39
42
  const prefix = text.slice(cursor, segment.start);
40
43
  cursor = segment.end;
41
44
 
42
- let node = (
45
+ let node: ReactNode = (
43
46
  <Fragment key={`segment-${String(index)}`}>
44
47
  {text.slice(segment.start, segment.end)}
45
48
  </Fragment>
46
49
  );
47
- if (segment.kind === "mention" && segment.profile) {
50
+ if (
51
+ segment.kind === "url" &&
52
+ hiddenUrlRanges.some(
53
+ (range) =>
54
+ range.start === segment.start && range.end === segment.end,
55
+ )
56
+ ) {
57
+ node = null;
58
+ } else if (segment.kind === "mention" && segment.profile) {
48
59
  node = (
49
60
  <ProfilePreview
50
61
  key={`segment-${String(index)}`}