birdclaw 0.5.0 → 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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -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 +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- 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 +205 -0
- package/src/lib/db.ts +35 -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 +1024 -189
- 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 +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- 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 +63 -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 +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
2
|
import {
|
|
3
3
|
Bell,
|
|
4
|
-
Bird,
|
|
5
4
|
Bookmark,
|
|
5
|
+
CalendarDays,
|
|
6
6
|
Heart,
|
|
7
7
|
Home,
|
|
8
8
|
Inbox,
|
|
@@ -14,21 +14,28 @@ import {
|
|
|
14
14
|
cx,
|
|
15
15
|
navLinkActiveClass,
|
|
16
16
|
navLinkClass,
|
|
17
|
+
navLinkCompactClass,
|
|
17
18
|
navLinkIconClass,
|
|
18
19
|
navLinkLabelClass,
|
|
20
|
+
navLinkLabelCompactClass,
|
|
19
21
|
sidebarBrandClass,
|
|
20
22
|
sidebarBrandCopyClass,
|
|
23
|
+
sidebarBrandCopyCompactClass,
|
|
21
24
|
sidebarBrandMarkClass,
|
|
22
25
|
sidebarBrandTaglineClass,
|
|
23
26
|
sidebarBrandTitleClass,
|
|
27
|
+
sidebarShellCompactClass,
|
|
24
28
|
sidebarFooterClass,
|
|
25
29
|
sidebarNavClass,
|
|
26
30
|
sidebarShellClass,
|
|
27
31
|
} from "#/lib/ui";
|
|
32
|
+
import { AccountSwitcher } from "./AccountSwitcher";
|
|
33
|
+
import { BirdclawMark } from "./BrandMark";
|
|
28
34
|
import { ThemeSlider } from "./ThemeSlider";
|
|
29
35
|
|
|
30
36
|
const links = [
|
|
31
37
|
{ to: "/inbox", label: "Inbox", icon: Inbox },
|
|
38
|
+
{ to: "/today", label: "Today", icon: CalendarDays },
|
|
32
39
|
{ to: "/", label: "Home", icon: Home },
|
|
33
40
|
{ to: "/mentions", label: "Mentions", icon: Bell },
|
|
34
41
|
{ to: "/likes", label: "Likes", icon: Heart },
|
|
@@ -38,22 +45,26 @@ const links = [
|
|
|
38
45
|
{ to: "/blocks", label: "Blocks", icon: ShieldOff },
|
|
39
46
|
] as const;
|
|
40
47
|
|
|
41
|
-
export function AppNav() {
|
|
48
|
+
export function AppNav({ compact = false }: { compact?: boolean }) {
|
|
42
49
|
const pathname = useRouterState({
|
|
43
50
|
select: (state) => state.location.pathname,
|
|
44
51
|
});
|
|
45
52
|
|
|
46
53
|
return (
|
|
47
|
-
<aside className={sidebarShellClass}>
|
|
54
|
+
<aside className={compact ? sidebarShellCompactClass : sidebarShellClass}>
|
|
48
55
|
<div className="flex flex-col">
|
|
49
56
|
<Link to="/" className={sidebarBrandClass}>
|
|
50
57
|
<span className={sidebarBrandMarkClass}>
|
|
51
|
-
<
|
|
58
|
+
<BirdclawMark className="size-10" />
|
|
52
59
|
</span>
|
|
53
|
-
<span
|
|
60
|
+
<span
|
|
61
|
+
className={
|
|
62
|
+
compact ? sidebarBrandCopyCompactClass : sidebarBrandCopyClass
|
|
63
|
+
}
|
|
64
|
+
>
|
|
54
65
|
<span className={sidebarBrandTitleClass}>birdclaw</span>
|
|
55
66
|
<span className={sidebarBrandTaglineClass}>
|
|
56
|
-
|
|
67
|
+
Fast search for your archive.
|
|
57
68
|
</span>
|
|
58
69
|
</span>
|
|
59
70
|
</Link>
|
|
@@ -66,7 +77,10 @@ export function AppNav() {
|
|
|
66
77
|
key={link.to}
|
|
67
78
|
to={link.to}
|
|
68
79
|
aria-label={link.label}
|
|
69
|
-
className={cx(
|
|
80
|
+
className={cx(
|
|
81
|
+
compact ? navLinkCompactClass : navLinkClass,
|
|
82
|
+
active && navLinkActiveClass,
|
|
83
|
+
)}
|
|
70
84
|
>
|
|
71
85
|
<Icon
|
|
72
86
|
className={navLinkIconClass}
|
|
@@ -74,14 +88,20 @@ export function AppNav() {
|
|
|
74
88
|
strokeWidth={active ? 2.4 : 1.8}
|
|
75
89
|
aria-hidden="true"
|
|
76
90
|
/>
|
|
77
|
-
<span
|
|
91
|
+
<span
|
|
92
|
+
className={
|
|
93
|
+
compact ? navLinkLabelCompactClass : navLinkLabelClass
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{link.label}
|
|
97
|
+
</span>
|
|
78
98
|
</Link>
|
|
79
99
|
);
|
|
80
100
|
})}
|
|
81
101
|
</nav>
|
|
82
102
|
</div>
|
|
83
103
|
<div className={sidebarFooterClass}>
|
|
84
|
-
<ThemeSlider />
|
|
104
|
+
<AccountSwitcher action={<ThemeSlider compact />} />
|
|
85
105
|
</div>
|
|
86
106
|
</aside>
|
|
87
107
|
);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { cx, emptyStateClass } from "#/lib/ui";
|
|
2
|
+
|
|
3
|
+
export function BirdclawMark({
|
|
4
|
+
animated = false,
|
|
5
|
+
className,
|
|
6
|
+
}: {
|
|
7
|
+
animated?: boolean;
|
|
8
|
+
className?: string;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<span
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
className={cx(
|
|
14
|
+
"birdclaw-mark relative inline-grid shrink-0 place-items-center",
|
|
15
|
+
animated && "birdclaw-mark-animated",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
>
|
|
19
|
+
<img
|
|
20
|
+
alt=""
|
|
21
|
+
className="size-full object-contain drop-shadow-[0_10px_22px_var(--brand-shadow)]"
|
|
22
|
+
draggable={false}
|
|
23
|
+
src="/birdclaw-mark.png"
|
|
24
|
+
/>
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function BirdclawLoading({
|
|
30
|
+
label,
|
|
31
|
+
detail,
|
|
32
|
+
}: {
|
|
33
|
+
label: string;
|
|
34
|
+
detail?: string;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cx(emptyStateClass, "birdclaw-state")}>
|
|
38
|
+
<BirdclawMark animated className="size-16" />
|
|
39
|
+
<div className="mt-3 text-[14px] font-semibold text-[var(--ink)]">
|
|
40
|
+
{label}
|
|
41
|
+
</div>
|
|
42
|
+
{detail ? (
|
|
43
|
+
<div className="mt-1 text-[13px] text-[var(--ink-soft)]">{detail}</div>
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function BirdclawEmpty({
|
|
50
|
+
label,
|
|
51
|
+
detail,
|
|
52
|
+
}: {
|
|
53
|
+
label: string;
|
|
54
|
+
detail?: string;
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={cx(emptyStateClass, "birdclaw-state")}>
|
|
58
|
+
<BirdclawMark className="size-12 opacity-75" />
|
|
59
|
+
<div className="mt-3 text-[14px] font-semibold text-[var(--ink)]">
|
|
60
|
+
{label}
|
|
61
|
+
</div>
|
|
62
|
+
{detail ? (
|
|
63
|
+
<div className="mt-1 text-[13px] text-[var(--ink-soft)]">{detail}</div>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -3,13 +3,13 @@ import { formatShortTimestamp } from "#/lib/present";
|
|
|
3
3
|
import type { EmbeddedTweet } from "#/lib/types";
|
|
4
4
|
import {
|
|
5
5
|
cx,
|
|
6
|
-
emptyStateClass,
|
|
7
6
|
feedActionIconClass,
|
|
8
7
|
feedRowHandleClass,
|
|
9
8
|
feedRowNameClass,
|
|
10
9
|
feedRowTimestampClass,
|
|
11
10
|
} from "#/lib/ui";
|
|
12
11
|
import { AvatarChip } from "./AvatarChip";
|
|
12
|
+
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
13
13
|
import { ProfilePreview } from "./ProfilePreview";
|
|
14
14
|
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
15
15
|
import { TweetRichText } from "./TweetRichText";
|
|
@@ -27,8 +27,11 @@ export function ConversationThread({
|
|
|
27
27
|
}) {
|
|
28
28
|
if (loading) {
|
|
29
29
|
return (
|
|
30
|
-
<section className="mt-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]
|
|
31
|
-
|
|
30
|
+
<section className="mt-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]">
|
|
31
|
+
<BirdclawLoading
|
|
32
|
+
detail="Finding archived replies around this post"
|
|
33
|
+
label="Loading conversation"
|
|
34
|
+
/>
|
|
32
35
|
</section>
|
|
33
36
|
);
|
|
34
37
|
}
|
|
@@ -43,13 +46,11 @@ export function ConversationThread({
|
|
|
43
46
|
|
|
44
47
|
if (items.length <= 1) {
|
|
45
48
|
return (
|
|
46
|
-
<section
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
>
|
|
52
|
-
No other archived replies in this conversation.
|
|
49
|
+
<section className="mt-3 rounded-2xl border border-[var(--line)]">
|
|
50
|
+
<BirdclawEmpty
|
|
51
|
+
detail="This post has no other archived replies locally."
|
|
52
|
+
label="No thread context yet"
|
|
53
|
+
/>
|
|
53
54
|
</section>
|
|
54
55
|
);
|
|
55
56
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CheckCircle2, Circle } from "lucide-react";
|
|
1
2
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
2
3
|
import type { DmConversationItem, DmMessageItem } from "#/lib/types";
|
|
3
4
|
import {
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
dmListPreviewClass,
|
|
19
20
|
dmListTimestampClass,
|
|
20
21
|
dmMessageBubbleClass,
|
|
22
|
+
dmMessageBubbleInboundClass,
|
|
21
23
|
dmMessageBubbleOutboundClass,
|
|
22
24
|
dmMessageMetaClass,
|
|
23
25
|
dmMessageRowClass,
|
|
@@ -29,7 +31,6 @@ import {
|
|
|
29
31
|
dmThreadNameClass,
|
|
30
32
|
dmThreadSubtitleClass,
|
|
31
33
|
dmThreadTitleClass,
|
|
32
|
-
emptyStateClass,
|
|
33
34
|
pillAlertClass,
|
|
34
35
|
pillClass,
|
|
35
36
|
pillSoftClass,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
timestampClass,
|
|
38
39
|
} from "#/lib/ui";
|
|
39
40
|
import { AvatarChip } from "./AvatarChip";
|
|
41
|
+
import { BirdclawEmpty } from "./BrandMark";
|
|
40
42
|
|
|
41
43
|
function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
42
44
|
const outbound = message.direction === "outbound";
|
|
@@ -47,7 +49,7 @@ function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
|
47
49
|
<div
|
|
48
50
|
className={cx(
|
|
49
51
|
dmMessageBubbleClass,
|
|
50
|
-
outbound
|
|
52
|
+
outbound ? dmMessageBubbleOutboundClass : dmMessageBubbleInboundClass,
|
|
51
53
|
)}
|
|
52
54
|
>
|
|
53
55
|
{message.text}
|
|
@@ -80,14 +82,17 @@ export function DmWorkspace({
|
|
|
80
82
|
}) {
|
|
81
83
|
const participant = selectedConversation?.participant ?? null;
|
|
82
84
|
const subtitle = selectedConversation
|
|
83
|
-
? `${selectedConversation.needsReply ? "Reply
|
|
85
|
+
? `${selectedConversation.isMessageRequest ? "Message request" : selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
|
|
84
86
|
: "No conversation selected";
|
|
85
87
|
|
|
86
88
|
return (
|
|
87
|
-
<section className={dmShellClass}>
|
|
89
|
+
<section aria-label="DM workspace" className={dmShellClass}>
|
|
88
90
|
<aside className={dmListClass}>
|
|
89
91
|
{conversations.length === 0 ? (
|
|
90
|
-
<
|
|
92
|
+
<BirdclawEmpty
|
|
93
|
+
detail="Sync DMs to populate this lane."
|
|
94
|
+
label="No conversations"
|
|
95
|
+
/>
|
|
91
96
|
) : null}
|
|
92
97
|
{conversations.map((conversation) => {
|
|
93
98
|
const active = conversation.id === selectedConversation?.id;
|
|
@@ -127,12 +132,27 @@ export function DmWorkspace({
|
|
|
127
132
|
pillClass,
|
|
128
133
|
conversation.needsReply ? pillAlertClass : pillSoftClass,
|
|
129
134
|
)}
|
|
135
|
+
aria-label={
|
|
136
|
+
conversation.needsReply ? "Reply open" : "We replied"
|
|
137
|
+
}
|
|
130
138
|
>
|
|
131
|
-
{conversation.needsReply ?
|
|
139
|
+
{conversation.needsReply ? (
|
|
140
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
141
|
+
) : (
|
|
142
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
143
|
+
)}
|
|
144
|
+
{conversation.needsReply ? "open" : "replied"}
|
|
132
145
|
</span>
|
|
146
|
+
{conversation.isMessageRequest ? (
|
|
147
|
+
<span className={cx(pillClass, pillAlertClass)}>
|
|
148
|
+
request
|
|
149
|
+
</span>
|
|
150
|
+
) : null}
|
|
133
151
|
<span className={cx(pillClass, pillSoftClass)}>
|
|
134
|
-
{
|
|
135
|
-
|
|
152
|
+
{formatCompactNumber(
|
|
153
|
+
conversation.participant.followersCount,
|
|
154
|
+
)}{" "}
|
|
155
|
+
followers
|
|
136
156
|
</span>
|
|
137
157
|
</div>
|
|
138
158
|
</div>
|
|
@@ -191,9 +211,11 @@ export function DmWorkspace({
|
|
|
191
211
|
<div className={contextStatRowClass}>
|
|
192
212
|
<dt className={contextStatTermClass}>Reply state</dt>
|
|
193
213
|
<dd className={contextStatValueClass}>
|
|
194
|
-
{selectedConversation.
|
|
195
|
-
? "
|
|
196
|
-
:
|
|
214
|
+
{selectedConversation.isMessageRequest
|
|
215
|
+
? "Message request"
|
|
216
|
+
: selectedConversation.needsReply
|
|
217
|
+
? "Reply open"
|
|
218
|
+
: "We replied"}
|
|
197
219
|
</dd>
|
|
198
220
|
</div>
|
|
199
221
|
<div className={contextStatRowClass}>
|
|
@@ -221,8 +243,8 @@ export function DmWorkspace({
|
|
|
221
243
|
<div className={composerBarClass}>
|
|
222
244
|
<span className={timestampClass}>
|
|
223
245
|
{selectedConversation.needsReply
|
|
224
|
-
? "Reply
|
|
225
|
-
: "
|
|
246
|
+
? "Reply open"
|
|
247
|
+
: "We replied"}
|
|
226
248
|
</span>
|
|
227
249
|
<button
|
|
228
250
|
className={primaryButtonClass}
|
|
@@ -236,7 +258,10 @@ export function DmWorkspace({
|
|
|
236
258
|
</div>
|
|
237
259
|
</>
|
|
238
260
|
) : (
|
|
239
|
-
<
|
|
261
|
+
<BirdclawEmpty
|
|
262
|
+
detail="Pick a conversation to read the thread."
|
|
263
|
+
label="No DM selected"
|
|
264
|
+
/>
|
|
240
265
|
)}
|
|
241
266
|
</div>
|
|
242
267
|
</section>
|