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.
Files changed (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -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 +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. 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
- <Bird className="size-5" strokeWidth={2.4} />
58
+ <BirdclawMark className="size-10" />
52
59
  </span>
53
- <span className={sidebarBrandCopyClass}>
60
+ <span
61
+ className={
62
+ compact ? sidebarBrandCopyCompactClass : sidebarBrandCopyClass
63
+ }
64
+ >
54
65
  <span className={sidebarBrandTitleClass}>birdclaw</span>
55
66
  <span className={sidebarBrandTaglineClass}>
56
- Quiet signal for Twitter.
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(navLinkClass, active && navLinkActiveClass)}
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 className={navLinkLabelClass}>{link.label}</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)] px-4 py-4 text-[14px] text-[var(--ink-soft)]">
31
- Loading conversation...
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
- className={cx(
48
- emptyStateClass,
49
- "mt-3 rounded-2xl border border-[var(--line)] py-4",
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 && dmMessageBubbleOutboundClass,
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 owed" : "Thread clear"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
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
- <div className={emptyStateClass}>No conversations.</div>
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 ? "needs reply" : "clear"}
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
- {conversation.influenceScore} ·{" "}
135
- {conversation.influenceLabel}
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.needsReply
195
- ? "Needs reply"
196
- : "Replied"}
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 still owed"
225
- : "Thread clear"}
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
- <div className={emptyStateClass}>No DM selected.</div>
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>