birdclaw 0.5.1 → 0.7.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 +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -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 +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -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 +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -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/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- 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/geocoding.ts +296 -0
- 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/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- 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/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- 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 +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -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/data-sources.tsx +24 -0
- 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/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Check, ChevronDown } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { fetchQueryEnvelope } from "#/lib/api-client";
|
|
5
|
+
import type { AccountRecord } from "#/lib/types";
|
|
6
|
+
import { cx } from "#/lib/ui";
|
|
7
|
+
import { AvatarChip } from "./AvatarChip";
|
|
8
|
+
import { setStoredAccountId, useSelectedAccountId } from "./account-selection";
|
|
9
|
+
|
|
10
|
+
function hueForAccount(account: AccountRecord) {
|
|
11
|
+
let hash = 0;
|
|
12
|
+
const value = account.handle || account.name || account.id;
|
|
13
|
+
for (const character of value) {
|
|
14
|
+
hash = (hash * 31 + character.charCodeAt(0)) % 360;
|
|
15
|
+
}
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function accountLabel(account: AccountRecord) {
|
|
20
|
+
return account.handle || account.name || account.id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function avatarName(account: AccountRecord) {
|
|
24
|
+
return account.name || account.handle || account.id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function AccountSwitcher({ action }: { action?: ReactNode } = {}) {
|
|
28
|
+
const [accounts, setAccounts] = useState<AccountRecord[]>([]);
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const switcherRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const selectedAccountId = useSelectedAccountId(accounts);
|
|
32
|
+
const selectedAccount = useMemo(
|
|
33
|
+
() => accounts.find((account) => account.id === selectedAccountId),
|
|
34
|
+
[accounts, selectedAccountId],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let active = true;
|
|
39
|
+
fetchQueryEnvelope()
|
|
40
|
+
.then((meta) => {
|
|
41
|
+
if (active) setAccounts(meta.accounts);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
if (active) setAccounts([]);
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
active = false;
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!open) return;
|
|
53
|
+
|
|
54
|
+
const closeOnOutside = (event: PointerEvent) => {
|
|
55
|
+
if (!switcherRef.current?.contains(event.target as Node)) {
|
|
56
|
+
setOpen(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
60
|
+
if (event.key === "Escape") setOpen(false);
|
|
61
|
+
};
|
|
62
|
+
window.addEventListener("pointerdown", closeOnOutside);
|
|
63
|
+
window.addEventListener("keydown", closeOnEscape);
|
|
64
|
+
return () => {
|
|
65
|
+
window.removeEventListener("pointerdown", closeOnOutside);
|
|
66
|
+
window.removeEventListener("keydown", closeOnEscape);
|
|
67
|
+
};
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
if (accounts.length < 2 || !selectedAccount) {
|
|
71
|
+
return action ? (
|
|
72
|
+
<div className="flex justify-center px-1 min-[1100px]:justify-start min-[1100px]:px-2">
|
|
73
|
+
{action}
|
|
74
|
+
</div>
|
|
75
|
+
) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div ref={switcherRef} className="relative px-1 min-[1100px]:px-2">
|
|
80
|
+
<div
|
|
81
|
+
className={cx(
|
|
82
|
+
"group grid w-full justify-items-center gap-2 text-[var(--ink)] min-[1100px]:flex min-[1100px]:h-11 min-[1100px]:items-center min-[1100px]:justify-start min-[1100px]:gap-1 min-[1100px]:rounded-full min-[1100px]:border min-[1100px]:border-transparent min-[1100px]:bg-transparent min-[1100px]:p-0.5 min-[1100px]:transition-[background,border-color,box-shadow] min-[1100px]:duration-150 min-[1100px]:hover:bg-[var(--bg-hover)]",
|
|
83
|
+
open &&
|
|
84
|
+
"min-[1100px]:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] min-[1100px]:bg-[var(--bg-active)] min-[1100px]:shadow-[0_0_0_1px_color-mix(in_srgb,var(--accent)_20%,transparent)]",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-expanded={open}
|
|
90
|
+
aria-haspopup="listbox"
|
|
91
|
+
aria-label={`Active account: ${accountLabel(selectedAccount)}`}
|
|
92
|
+
className="flex size-11 min-w-0 flex-none items-center justify-center gap-2 rounded-full px-1 py-1 text-left transition-colors duration-150 hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_srgb,var(--accent)_28%,transparent)] min-[1100px]:h-auto min-[1100px]:w-auto min-[1100px]:flex-1 min-[1100px]:justify-start min-[1100px]:hover:bg-transparent"
|
|
93
|
+
onClick={() => setOpen((value) => !value)}
|
|
94
|
+
>
|
|
95
|
+
<AvatarChip
|
|
96
|
+
avatarUrl={selectedAccount.avatarUrl}
|
|
97
|
+
hue={selectedAccount.avatarHue ?? hueForAccount(selectedAccount)}
|
|
98
|
+
name={avatarName(selectedAccount)}
|
|
99
|
+
profileId={selectedAccount.profileId}
|
|
100
|
+
size="small"
|
|
101
|
+
/>
|
|
102
|
+
<span className="hidden min-w-0 flex-1 flex-col leading-tight min-[1100px]:flex">
|
|
103
|
+
<span className="truncate text-[14px] font-bold">
|
|
104
|
+
{accountLabel(selectedAccount)}
|
|
105
|
+
</span>
|
|
106
|
+
<span className="truncate text-[11px] font-medium text-[var(--ink-soft)]">
|
|
107
|
+
active account
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
</button>
|
|
111
|
+
{action ? (
|
|
112
|
+
<div className="order-first shrink-0 min-[1100px]:order-none">
|
|
113
|
+
{action}
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
aria-expanded={open}
|
|
119
|
+
aria-haspopup="listbox"
|
|
120
|
+
aria-label="Switch account"
|
|
121
|
+
className="hidden size-8 shrink-0 items-center justify-center rounded-full text-[var(--ink-soft)] transition-colors duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_srgb,var(--accent)_28%,transparent)] min-[1100px]:inline-flex"
|
|
122
|
+
onClick={() => setOpen((value) => !value)}
|
|
123
|
+
>
|
|
124
|
+
<ChevronDown
|
|
125
|
+
className={cx(
|
|
126
|
+
"size-4 transition-transform duration-150",
|
|
127
|
+
open && "rotate-180",
|
|
128
|
+
)}
|
|
129
|
+
strokeWidth={2.2}
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
{open ? (
|
|
135
|
+
<div
|
|
136
|
+
role="listbox"
|
|
137
|
+
aria-label="Active account"
|
|
138
|
+
className="absolute bottom-[calc(100%+8px)] left-1 z-50 w-[228px] overflow-hidden rounded-2xl border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--bg)_96%,black_4%)] py-1.5 shadow-[0_18px_48px_rgba(0,0,0,.28)] backdrop-blur min-[1100px]:left-2"
|
|
139
|
+
>
|
|
140
|
+
{accounts.map((account) => {
|
|
141
|
+
const selected = account.id === selectedAccount.id;
|
|
142
|
+
return (
|
|
143
|
+
<button
|
|
144
|
+
key={account.id}
|
|
145
|
+
type="button"
|
|
146
|
+
role="option"
|
|
147
|
+
aria-selected={selected}
|
|
148
|
+
className={cx(
|
|
149
|
+
"flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-active)] focus-visible:outline-none",
|
|
150
|
+
selected && "bg-[var(--accent-soft)]",
|
|
151
|
+
)}
|
|
152
|
+
onClick={() => {
|
|
153
|
+
setStoredAccountId(account.id);
|
|
154
|
+
setOpen(false);
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<AvatarChip
|
|
158
|
+
avatarUrl={account.avatarUrl}
|
|
159
|
+
hue={account.avatarHue ?? hueForAccount(account)}
|
|
160
|
+
name={avatarName(account)}
|
|
161
|
+
profileId={account.profileId}
|
|
162
|
+
size="small"
|
|
163
|
+
/>
|
|
164
|
+
<span className="min-w-0 flex-1">
|
|
165
|
+
<span className="block truncate text-[14px] font-bold text-[var(--ink)]">
|
|
166
|
+
{accountLabel(account)}
|
|
167
|
+
</span>
|
|
168
|
+
<span className="block truncate text-[12px] text-[var(--ink-soft)]">
|
|
169
|
+
{account.name || account.id}
|
|
170
|
+
</span>
|
|
171
|
+
</span>
|
|
172
|
+
{selected ? (
|
|
173
|
+
<Check
|
|
174
|
+
className="size-4 shrink-0 text-[var(--accent)]"
|
|
175
|
+
strokeWidth={2.4}
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
/>
|
|
178
|
+
) : null}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
) : null}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -2,58 +2,79 @@ import { Link, useRouterState } from "@tanstack/react-router";
|
|
|
2
2
|
import {
|
|
3
3
|
Bell,
|
|
4
4
|
Bookmark,
|
|
5
|
+
CalendarDays,
|
|
6
|
+
Database,
|
|
7
|
+
Gauge,
|
|
8
|
+
Globe2,
|
|
5
9
|
Heart,
|
|
6
10
|
Home,
|
|
7
11
|
Inbox,
|
|
8
12
|
Link as LinkIcon,
|
|
9
13
|
Mail,
|
|
14
|
+
MessagesSquare,
|
|
10
15
|
ShieldOff,
|
|
16
|
+
UserSearch,
|
|
11
17
|
} from "lucide-react";
|
|
12
18
|
import {
|
|
13
19
|
cx,
|
|
14
20
|
navLinkActiveClass,
|
|
15
21
|
navLinkClass,
|
|
22
|
+
navLinkCompactClass,
|
|
16
23
|
navLinkIconClass,
|
|
17
24
|
navLinkLabelClass,
|
|
25
|
+
navLinkLabelCompactClass,
|
|
18
26
|
sidebarBrandClass,
|
|
19
27
|
sidebarBrandCopyClass,
|
|
28
|
+
sidebarBrandCopyCompactClass,
|
|
20
29
|
sidebarBrandMarkClass,
|
|
21
30
|
sidebarBrandTaglineClass,
|
|
22
31
|
sidebarBrandTitleClass,
|
|
32
|
+
sidebarShellCompactClass,
|
|
23
33
|
sidebarFooterClass,
|
|
24
34
|
sidebarNavClass,
|
|
25
35
|
sidebarShellClass,
|
|
26
36
|
} from "#/lib/ui";
|
|
37
|
+
import { AccountSwitcher } from "./AccountSwitcher";
|
|
27
38
|
import { BirdclawMark } from "./BrandMark";
|
|
28
39
|
import { ThemeSlider } from "./ThemeSlider";
|
|
29
40
|
|
|
30
41
|
const links = [
|
|
31
42
|
{ to: "/inbox", label: "Inbox", icon: Inbox },
|
|
43
|
+
{ to: "/today", label: "Today", icon: CalendarDays },
|
|
44
|
+
{ to: "/discuss", label: "Discuss", icon: MessagesSquare },
|
|
45
|
+
{ to: "/profile-analyze", label: "Analyse", icon: UserSearch },
|
|
46
|
+
{ to: "/network-map", label: "Map", icon: Globe2 },
|
|
47
|
+
{ to: "/data-sources", label: "Sources", icon: Database },
|
|
32
48
|
{ to: "/", label: "Home", icon: Home },
|
|
33
49
|
{ to: "/mentions", label: "Mentions", icon: Bell },
|
|
34
50
|
{ to: "/likes", label: "Likes", icon: Heart },
|
|
35
51
|
{ to: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
|
36
52
|
{ to: "/links", label: "Links", icon: LinkIcon },
|
|
53
|
+
{ to: "/rate-limits", label: "Rate Limits", icon: Gauge },
|
|
37
54
|
{ to: "/dms", label: "DMs", icon: Mail },
|
|
38
55
|
{ to: "/blocks", label: "Blocks", icon: ShieldOff },
|
|
39
56
|
] as const;
|
|
40
57
|
|
|
41
|
-
export function AppNav() {
|
|
58
|
+
export function AppNav({ compact = false }: { compact?: boolean }) {
|
|
42
59
|
const pathname = useRouterState({
|
|
43
60
|
select: (state) => state.location.pathname,
|
|
44
61
|
});
|
|
45
62
|
|
|
46
63
|
return (
|
|
47
|
-
<aside className={sidebarShellClass}>
|
|
64
|
+
<aside className={compact ? sidebarShellCompactClass : sidebarShellClass}>
|
|
48
65
|
<div className="flex flex-col">
|
|
49
66
|
<Link to="/" className={sidebarBrandClass}>
|
|
50
67
|
<span className={sidebarBrandMarkClass}>
|
|
51
68
|
<BirdclawMark className="size-10" />
|
|
52
69
|
</span>
|
|
53
|
-
<span
|
|
70
|
+
<span
|
|
71
|
+
className={
|
|
72
|
+
compact ? sidebarBrandCopyCompactClass : sidebarBrandCopyClass
|
|
73
|
+
}
|
|
74
|
+
>
|
|
54
75
|
<span className={sidebarBrandTitleClass}>birdclaw</span>
|
|
55
76
|
<span className={sidebarBrandTaglineClass}>
|
|
56
|
-
Fast search for your
|
|
77
|
+
Fast search for your archive.
|
|
57
78
|
</span>
|
|
58
79
|
</span>
|
|
59
80
|
</Link>
|
|
@@ -66,7 +87,10 @@ export function AppNav() {
|
|
|
66
87
|
key={link.to}
|
|
67
88
|
to={link.to}
|
|
68
89
|
aria-label={link.label}
|
|
69
|
-
className={cx(
|
|
90
|
+
className={cx(
|
|
91
|
+
compact ? navLinkCompactClass : navLinkClass,
|
|
92
|
+
active && navLinkActiveClass,
|
|
93
|
+
)}
|
|
70
94
|
>
|
|
71
95
|
<Icon
|
|
72
96
|
className={navLinkIconClass}
|
|
@@ -74,14 +98,20 @@ export function AppNav() {
|
|
|
74
98
|
strokeWidth={active ? 2.4 : 1.8}
|
|
75
99
|
aria-hidden="true"
|
|
76
100
|
/>
|
|
77
|
-
<span
|
|
101
|
+
<span
|
|
102
|
+
className={
|
|
103
|
+
compact ? navLinkLabelCompactClass : navLinkLabelClass
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
{link.label}
|
|
107
|
+
</span>
|
|
78
108
|
</Link>
|
|
79
109
|
);
|
|
80
110
|
})}
|
|
81
111
|
</nav>
|
|
82
112
|
</div>
|
|
83
113
|
<div className={sidebarFooterClass}>
|
|
84
|
-
<ThemeSlider />
|
|
114
|
+
<AccountSwitcher action={<ThemeSlider compact />} />
|
|
85
115
|
</div>
|
|
86
116
|
</aside>
|
|
87
117
|
);
|
|
@@ -21,9 +21,7 @@ export function AvatarChip({
|
|
|
21
21
|
size?: "default" | "large" | "small";
|
|
22
22
|
}) {
|
|
23
23
|
const avatarSrc =
|
|
24
|
-
profileId && avatarUrl
|
|
25
|
-
? `/api/avatar?profileId=${encodeURIComponent(profileId)}`
|
|
26
|
-
: null;
|
|
24
|
+
profileId && avatarUrl ? avatarPath(profileId, avatarUrl) : null;
|
|
27
25
|
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
28
26
|
const showImage = avatarSrc && failedSrc !== avatarSrc;
|
|
29
27
|
|
|
@@ -50,3 +48,11 @@ export function AvatarChip({
|
|
|
50
48
|
</span>
|
|
51
49
|
);
|
|
52
50
|
}
|
|
51
|
+
|
|
52
|
+
function avatarPath(profileId: string, avatarUrl: string) {
|
|
53
|
+
const query = new URLSearchParams({
|
|
54
|
+
profileId,
|
|
55
|
+
v: avatarUrl,
|
|
56
|
+
});
|
|
57
|
+
return `/api/avatar?${query.toString()}`;
|
|
58
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
dmListPreviewClass,
|
|
20
20
|
dmListTimestampClass,
|
|
21
21
|
dmMessageBubbleClass,
|
|
22
|
+
dmMessageBubbleInboundClass,
|
|
22
23
|
dmMessageBubbleOutboundClass,
|
|
23
24
|
dmMessageMetaClass,
|
|
24
25
|
dmMessageRowClass,
|
|
@@ -48,7 +49,7 @@ function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
|
48
49
|
<div
|
|
49
50
|
className={cx(
|
|
50
51
|
dmMessageBubbleClass,
|
|
51
|
-
outbound
|
|
52
|
+
outbound ? dmMessageBubbleOutboundClass : dmMessageBubbleInboundClass,
|
|
52
53
|
)}
|
|
53
54
|
>
|
|
54
55
|
{message.text}
|
|
@@ -81,11 +82,11 @@ export function DmWorkspace({
|
|
|
81
82
|
}) {
|
|
82
83
|
const participant = selectedConversation?.participant ?? null;
|
|
83
84
|
const subtitle = selectedConversation
|
|
84
|
-
? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
|
|
85
|
+
? `${selectedConversation.isMessageRequest ? "Message request" : selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
|
|
85
86
|
: "No conversation selected";
|
|
86
87
|
|
|
87
88
|
return (
|
|
88
|
-
<section className={dmShellClass}>
|
|
89
|
+
<section aria-label="DM workspace" className={dmShellClass}>
|
|
89
90
|
<aside className={dmListClass}>
|
|
90
91
|
{conversations.length === 0 ? (
|
|
91
92
|
<BirdclawEmpty
|
|
@@ -142,9 +143,16 @@ export function DmWorkspace({
|
|
|
142
143
|
)}
|
|
143
144
|
{conversation.needsReply ? "open" : "replied"}
|
|
144
145
|
</span>
|
|
146
|
+
{conversation.isMessageRequest ? (
|
|
147
|
+
<span className={cx(pillClass, pillAlertClass)}>
|
|
148
|
+
request
|
|
149
|
+
</span>
|
|
150
|
+
) : null}
|
|
145
151
|
<span className={cx(pillClass, pillSoftClass)}>
|
|
146
|
-
{
|
|
147
|
-
|
|
152
|
+
{formatCompactNumber(
|
|
153
|
+
conversation.participant.followersCount,
|
|
154
|
+
)}{" "}
|
|
155
|
+
followers
|
|
148
156
|
</span>
|
|
149
157
|
</div>
|
|
150
158
|
</div>
|
|
@@ -203,9 +211,11 @@ export function DmWorkspace({
|
|
|
203
211
|
<div className={contextStatRowClass}>
|
|
204
212
|
<dt className={contextStatTermClass}>Reply state</dt>
|
|
205
213
|
<dd className={contextStatValueClass}>
|
|
206
|
-
{selectedConversation.
|
|
207
|
-
? "
|
|
208
|
-
:
|
|
214
|
+
{selectedConversation.isMessageRequest
|
|
215
|
+
? "Message request"
|
|
216
|
+
: selectedConversation.needsReply
|
|
217
|
+
? "Reply open"
|
|
218
|
+
: "We replied"}
|
|
209
219
|
</dd>
|
|
210
220
|
</div>
|
|
211
221
|
<div className={contextStatRowClass}>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
linkPreviewHostClass,
|
|
10
10
|
linkPreviewTitleClass,
|
|
11
11
|
} from "#/lib/ui";
|
|
12
|
+
import { safeHttpUrl } from "#/lib/url-safety";
|
|
12
13
|
|
|
13
14
|
type LinkPreviewState = Pick<
|
|
14
15
|
TweetUrlEntity,
|
|
@@ -21,7 +22,6 @@ type LinkPreviewState = Pick<
|
|
|
21
22
|
>;
|
|
22
23
|
|
|
23
24
|
const previewCache = new Map<string, Promise<LinkPreviewMetadata | null>>();
|
|
24
|
-
const IMAGE_EXTENSION_PATTERN = /\.(?:avif|gif|jpe?g|png|webp)(?:[?#].*)?$/i;
|
|
25
25
|
const MAX_CONCURRENT_PREVIEW_FETCHES = 2;
|
|
26
26
|
let activePreviewFetches = 0;
|
|
27
27
|
const queuedPreviewFetches: Array<() => void> = [];
|
|
@@ -72,16 +72,13 @@ function schedulePreviewFetch(task: () => Promise<LinkPreviewMetadata | null>) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function fetchPreview(entry: TweetUrlEntity) {
|
|
75
|
-
const targetUrl = entry.expandedUrl || entry.url;
|
|
75
|
+
const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
|
|
76
76
|
if (!targetUrl) return Promise.resolve(null);
|
|
77
77
|
const key = `${entry.url} ${targetUrl}`;
|
|
78
78
|
const cached = previewCache.get(key);
|
|
79
79
|
if (cached) return cached;
|
|
80
80
|
|
|
81
81
|
const params = new URLSearchParams({ url: targetUrl });
|
|
82
|
-
if (entry.url && entry.url !== targetUrl) {
|
|
83
|
-
params.set("shortUrl", entry.url);
|
|
84
|
-
}
|
|
85
82
|
const promise = schedulePreviewFetch(() =>
|
|
86
83
|
fetch(`/api/link-preview?${params.toString()}`)
|
|
87
84
|
.then(async (response) => {
|
|
@@ -109,14 +106,32 @@ function displayHost(url: string, fallback: string) {
|
|
|
109
106
|
function isDirectImageUrl(url: string) {
|
|
110
107
|
try {
|
|
111
108
|
const parsed = new URL(url);
|
|
112
|
-
if (IMAGE_EXTENSION_PATTERN.test(parsed.pathname)) return true;
|
|
113
109
|
return (
|
|
110
|
+
parsed.protocol === "https:" &&
|
|
114
111
|
parsed.hostname === "pbs.twimg.com" &&
|
|
115
112
|
(parsed.pathname.startsWith("/media/") ||
|
|
116
113
|
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
117
114
|
);
|
|
118
115
|
} catch {
|
|
119
|
-
return
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safePreviewImageUrl(url: string | null | undefined) {
|
|
121
|
+
if (!url) return null;
|
|
122
|
+
try {
|
|
123
|
+
const parsed = new URL(url);
|
|
124
|
+
if (
|
|
125
|
+
parsed.protocol === "https:" &&
|
|
126
|
+
parsed.hostname === "pbs.twimg.com" &&
|
|
127
|
+
(parsed.pathname.startsWith("/media/") ||
|
|
128
|
+
parsed.pathname.startsWith("/amplify_video_thumb/"))
|
|
129
|
+
) {
|
|
130
|
+
return parsed.toString();
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
|
|
@@ -127,22 +142,25 @@ export function LinkPreviewCard({
|
|
|
127
142
|
entry: TweetUrlEntity;
|
|
128
143
|
index: number;
|
|
129
144
|
}) {
|
|
130
|
-
const targetUrl = entry.expandedUrl || entry.url;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
145
|
+
const targetUrl = safeHttpUrl(entry.expandedUrl || entry.url);
|
|
146
|
+
const previewUrl = targetUrl ?? "";
|
|
147
|
+
const displayUrl =
|
|
148
|
+
entry.displayUrl || (targetUrl ? displayHost(targetUrl, targetUrl) : "");
|
|
149
|
+
const directImageUrl =
|
|
150
|
+
targetUrl && isDirectImageUrl(targetUrl) ? targetUrl : null;
|
|
133
151
|
const initialPreview = useMemo<LinkPreviewState>(
|
|
134
152
|
() => ({
|
|
135
|
-
expandedUrl:
|
|
153
|
+
expandedUrl: previewUrl,
|
|
136
154
|
displayUrl,
|
|
137
155
|
title:
|
|
138
156
|
entry.title ??
|
|
139
|
-
(directImageUrl ? displayHost(
|
|
157
|
+
(directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
|
|
140
158
|
description:
|
|
141
159
|
entry.description ?? (directImageUrl ? displayUrl : undefined),
|
|
142
160
|
imageUrl: entry.imageUrl ?? directImageUrl,
|
|
143
161
|
siteName:
|
|
144
162
|
entry.siteName ??
|
|
145
|
-
(directImageUrl ? displayHost(
|
|
163
|
+
(directImageUrl ? displayHost(previewUrl, displayUrl) : undefined),
|
|
146
164
|
}),
|
|
147
165
|
[
|
|
148
166
|
directImageUrl,
|
|
@@ -151,7 +169,7 @@ export function LinkPreviewCard({
|
|
|
151
169
|
entry.imageUrl,
|
|
152
170
|
entry.siteName,
|
|
153
171
|
entry.title,
|
|
154
|
-
|
|
172
|
+
previewUrl,
|
|
155
173
|
],
|
|
156
174
|
);
|
|
157
175
|
const [preview, setPreview] = useState(initialPreview);
|
|
@@ -199,7 +217,7 @@ export function LinkPreviewCard({
|
|
|
199
217
|
void fetchPreview(entry).then((metadata) => {
|
|
200
218
|
if (cancelled || !metadata) return;
|
|
201
219
|
setPreview((current) => ({
|
|
202
|
-
expandedUrl: metadata.url
|
|
220
|
+
expandedUrl: safeHttpUrl(metadata.url) ?? current.expandedUrl,
|
|
203
221
|
displayUrl: current.displayUrl,
|
|
204
222
|
title: metadata.title ?? current.title,
|
|
205
223
|
description: metadata.description ?? current.description,
|
|
@@ -214,6 +232,8 @@ export function LinkPreviewCard({
|
|
|
214
232
|
};
|
|
215
233
|
}, [cacheKey, canHydrate, entry, hydratedKey, preview, targetUrl]);
|
|
216
234
|
|
|
235
|
+
if (!targetUrl) return null;
|
|
236
|
+
|
|
217
237
|
const title = preview.title || entry.displayUrl;
|
|
218
238
|
const description =
|
|
219
239
|
preview.description && preview.description !== title
|
|
@@ -221,14 +241,16 @@ export function LinkPreviewCard({
|
|
|
221
241
|
: preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
222
242
|
const host =
|
|
223
243
|
preview.siteName || displayHost(preview.expandedUrl, entry.displayUrl);
|
|
224
|
-
const
|
|
244
|
+
const imageUrl = safePreviewImageUrl(preview.imageUrl);
|
|
245
|
+
const showImage = Boolean(imageUrl && !imageFailed);
|
|
246
|
+
const previewHref = safeHttpUrl(preview.expandedUrl) ?? targetUrl;
|
|
225
247
|
|
|
226
248
|
return (
|
|
227
249
|
<a
|
|
228
250
|
key={`${entry.expandedUrl}-${String(index)}`}
|
|
229
251
|
className={linkPreviewCardClass}
|
|
230
252
|
data-perf="link-preview-card"
|
|
231
|
-
href={
|
|
253
|
+
href={previewHref}
|
|
232
254
|
ref={cardRef}
|
|
233
255
|
rel="noreferrer"
|
|
234
256
|
target="_blank"
|
|
@@ -255,7 +277,7 @@ export function LinkPreviewCard({
|
|
|
255
277
|
className="size-full object-cover transition-transform duration-200 group-hover/link-preview:scale-[1.03]"
|
|
256
278
|
loading="lazy"
|
|
257
279
|
onError={() => setImageFailed(true)}
|
|
258
|
-
src={
|
|
280
|
+
src={imageUrl ?? ""}
|
|
259
281
|
/>
|
|
260
282
|
) : (
|
|
261
283
|
<ImageIcon
|