birdclaw 0.4.1 → 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.
- package/CHANGELOG.md +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/scripts/browser-perf.mjs +399 -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 +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
Bell,
|
|
4
|
+
Bookmark,
|
|
5
|
+
Heart,
|
|
6
|
+
Home,
|
|
7
|
+
Inbox,
|
|
8
|
+
Link as LinkIcon,
|
|
9
|
+
Mail,
|
|
10
|
+
ShieldOff,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import {
|
|
4
13
|
cx,
|
|
5
|
-
eyebrowClass,
|
|
6
|
-
navClass,
|
|
7
14
|
navLinkActiveClass,
|
|
8
15
|
navLinkClass,
|
|
9
|
-
|
|
16
|
+
navLinkIconClass,
|
|
17
|
+
navLinkLabelClass,
|
|
18
|
+
sidebarBrandClass,
|
|
19
|
+
sidebarBrandCopyClass,
|
|
20
|
+
sidebarBrandMarkClass,
|
|
21
|
+
sidebarBrandTaglineClass,
|
|
22
|
+
sidebarBrandTitleClass,
|
|
23
|
+
sidebarFooterClass,
|
|
24
|
+
sidebarNavClass,
|
|
25
|
+
sidebarShellClass,
|
|
10
26
|
} from "#/lib/ui";
|
|
27
|
+
import { BirdclawMark } from "./BrandMark";
|
|
11
28
|
import { ThemeSlider } from "./ThemeSlider";
|
|
12
29
|
|
|
13
30
|
const links = [
|
|
14
|
-
{ to: "/inbox", label: "Inbox" },
|
|
15
|
-
{ to: "/", label: "Home" },
|
|
16
|
-
{ to: "/mentions", label: "Mentions" },
|
|
17
|
-
{ to: "/likes", label: "Likes" },
|
|
18
|
-
{ to: "/bookmarks", label: "Bookmarks" },
|
|
19
|
-
{ to: "/
|
|
20
|
-
{ to: "/
|
|
31
|
+
{ to: "/inbox", label: "Inbox", icon: Inbox },
|
|
32
|
+
{ to: "/", label: "Home", icon: Home },
|
|
33
|
+
{ to: "/mentions", label: "Mentions", icon: Bell },
|
|
34
|
+
{ to: "/likes", label: "Likes", icon: Heart },
|
|
35
|
+
{ to: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
|
36
|
+
{ to: "/links", label: "Links", icon: LinkIcon },
|
|
37
|
+
{ to: "/dms", label: "DMs", icon: Mail },
|
|
38
|
+
{ to: "/blocks", label: "Blocks", icon: ShieldOff },
|
|
21
39
|
] as const;
|
|
22
40
|
|
|
23
41
|
export function AppNav() {
|
|
@@ -26,26 +44,45 @@ export function AppNav() {
|
|
|
26
44
|
});
|
|
27
45
|
|
|
28
46
|
return (
|
|
29
|
-
<
|
|
30
|
-
<div>
|
|
31
|
-
<
|
|
32
|
-
|
|
47
|
+
<aside className={sidebarShellClass}>
|
|
48
|
+
<div className="flex flex-col">
|
|
49
|
+
<Link to="/" className={sidebarBrandClass}>
|
|
50
|
+
<span className={sidebarBrandMarkClass}>
|
|
51
|
+
<BirdclawMark className="size-10" />
|
|
52
|
+
</span>
|
|
53
|
+
<span className={sidebarBrandCopyClass}>
|
|
54
|
+
<span className={sidebarBrandTitleClass}>birdclaw</span>
|
|
55
|
+
<span className={sidebarBrandTaglineClass}>
|
|
56
|
+
Fast search for your Twitter archive.
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
</Link>
|
|
60
|
+
<nav className={sidebarNavClass} aria-label="Primary">
|
|
61
|
+
{links.map((link) => {
|
|
62
|
+
const active = pathname === link.to;
|
|
63
|
+
const Icon = link.icon;
|
|
64
|
+
return (
|
|
65
|
+
<Link
|
|
66
|
+
key={link.to}
|
|
67
|
+
to={link.to}
|
|
68
|
+
aria-label={link.label}
|
|
69
|
+
className={cx(navLinkClass, active && navLinkActiveClass)}
|
|
70
|
+
>
|
|
71
|
+
<Icon
|
|
72
|
+
className={navLinkIconClass}
|
|
73
|
+
size={22}
|
|
74
|
+
strokeWidth={active ? 2.4 : 1.8}
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
/>
|
|
77
|
+
<span className={navLinkLabelClass}>{link.label}</span>
|
|
78
|
+
</Link>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</nav>
|
|
33
82
|
</div>
|
|
34
|
-
<div className={
|
|
35
|
-
{links.map((link) => {
|
|
36
|
-
const active = pathname === link.to;
|
|
37
|
-
return (
|
|
38
|
-
<Link
|
|
39
|
-
key={link.to}
|
|
40
|
-
to={link.to}
|
|
41
|
-
className={cx(navLinkClass, active && navLinkActiveClass)}
|
|
42
|
-
>
|
|
43
|
-
{link.label}
|
|
44
|
-
</Link>
|
|
45
|
-
);
|
|
46
|
-
})}
|
|
83
|
+
<div className={sidebarFooterClass}>
|
|
47
84
|
<ThemeSlider />
|
|
48
85
|
</div>
|
|
49
|
-
</
|
|
86
|
+
</aside>
|
|
50
87
|
);
|
|
51
88
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { getInitials } from "#/lib/present";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
avatarChipClass,
|
|
5
|
+
avatarChipLargeClass,
|
|
6
|
+
avatarChipSmallClass,
|
|
7
|
+
cx,
|
|
8
|
+
} from "#/lib/ui";
|
|
4
9
|
|
|
5
10
|
export function AvatarChip({
|
|
6
11
|
profileId,
|
|
@@ -13,7 +18,7 @@ export function AvatarChip({
|
|
|
13
18
|
avatarUrl?: string;
|
|
14
19
|
name: string;
|
|
15
20
|
hue: number;
|
|
16
|
-
size?: "default" | "large";
|
|
21
|
+
size?: "default" | "large" | "small";
|
|
17
22
|
}) {
|
|
18
23
|
const avatarSrc =
|
|
19
24
|
profileId && avatarUrl
|
|
@@ -26,10 +31,10 @@ export function AvatarChip({
|
|
|
26
31
|
<span
|
|
27
32
|
className={cx(
|
|
28
33
|
avatarChipClass,
|
|
29
|
-
"
|
|
30
|
-
size === "
|
|
34
|
+
size === "large" && avatarChipLargeClass,
|
|
35
|
+
size === "small" && avatarChipSmallClass,
|
|
31
36
|
)}
|
|
32
|
-
style={{ backgroundColor: `hsl(${hue} 72% 50%)` }}
|
|
37
|
+
style={{ backgroundColor: `hsl(${String(hue)} 72% 50%)` }}
|
|
33
38
|
>
|
|
34
39
|
{showImage ? (
|
|
35
40
|
<img
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { MessageCircle } from "lucide-react";
|
|
2
|
+
import { formatShortTimestamp } from "#/lib/present";
|
|
3
|
+
import type { EmbeddedTweet } from "#/lib/types";
|
|
4
|
+
import {
|
|
5
|
+
cx,
|
|
6
|
+
feedActionIconClass,
|
|
7
|
+
feedRowHandleClass,
|
|
8
|
+
feedRowNameClass,
|
|
9
|
+
feedRowTimestampClass,
|
|
10
|
+
} from "#/lib/ui";
|
|
11
|
+
import { AvatarChip } from "./AvatarChip";
|
|
12
|
+
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
13
|
+
import { ProfilePreview } from "./ProfilePreview";
|
|
14
|
+
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
15
|
+
import { TweetRichText } from "./TweetRichText";
|
|
16
|
+
|
|
17
|
+
export function ConversationThread({
|
|
18
|
+
anchorId,
|
|
19
|
+
error,
|
|
20
|
+
items,
|
|
21
|
+
loading,
|
|
22
|
+
}: {
|
|
23
|
+
anchorId: string;
|
|
24
|
+
error?: string | null;
|
|
25
|
+
items: EmbeddedTweet[];
|
|
26
|
+
loading: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
if (loading) {
|
|
29
|
+
return (
|
|
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
|
+
/>
|
|
35
|
+
</section>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (error) {
|
|
40
|
+
return (
|
|
41
|
+
<section className="mt-3 rounded-2xl border border-[var(--alert)] bg-[var(--alert-soft)] px-4 py-3 text-[14px] text-[var(--alert)]">
|
|
42
|
+
{error}
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (items.length <= 1) {
|
|
48
|
+
return (
|
|
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
|
+
/>
|
|
54
|
+
</section>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section
|
|
60
|
+
aria-label="Conversation"
|
|
61
|
+
className="mt-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]"
|
|
62
|
+
>
|
|
63
|
+
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-2.5 text-[13px] font-bold text-[var(--ink)]">
|
|
64
|
+
<MessageCircle className={feedActionIconClass} strokeWidth={1.8} />
|
|
65
|
+
<span>{items.length} tweets in conversation</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex flex-col">
|
|
68
|
+
{items.map((tweet, index) => {
|
|
69
|
+
const isAnchor = tweet.id === anchorId;
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={cx(
|
|
73
|
+
"flex gap-3 px-4 py-3",
|
|
74
|
+
index > 0 && "border-t border-[var(--line)]",
|
|
75
|
+
isAnchor && "bg-[var(--accent-soft)]",
|
|
76
|
+
)}
|
|
77
|
+
key={tweet.id}
|
|
78
|
+
>
|
|
79
|
+
<div className="flex flex-col items-center">
|
|
80
|
+
<AvatarChip
|
|
81
|
+
avatarUrl={tweet.author.avatarUrl}
|
|
82
|
+
hue={tweet.author.avatarHue}
|
|
83
|
+
name={tweet.author.displayName}
|
|
84
|
+
profileId={tweet.author.id}
|
|
85
|
+
size="small"
|
|
86
|
+
/>
|
|
87
|
+
{index < items.length - 1 ? (
|
|
88
|
+
<span className="mt-2 w-px flex-1 bg-[var(--line)]" />
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
<div className="min-w-0 flex-1">
|
|
92
|
+
<header className="flex min-w-0 items-center gap-1.5 text-[14px]">
|
|
93
|
+
<ProfilePreview profile={tweet.author}>
|
|
94
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
95
|
+
<span className={feedRowNameClass}>
|
|
96
|
+
{tweet.author.displayName}
|
|
97
|
+
</span>
|
|
98
|
+
<span className={feedRowHandleClass}>
|
|
99
|
+
@{tweet.author.handle}
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
</ProfilePreview>
|
|
103
|
+
<span className="text-[var(--ink-soft)]">·</span>
|
|
104
|
+
<span className={feedRowTimestampClass}>
|
|
105
|
+
{formatShortTimestamp(tweet.createdAt)}
|
|
106
|
+
</span>
|
|
107
|
+
{isAnchor ? (
|
|
108
|
+
<span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
|
|
109
|
+
selected
|
|
110
|
+
</span>
|
|
111
|
+
) : null}
|
|
112
|
+
</header>
|
|
113
|
+
<TweetRichText
|
|
114
|
+
className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]"
|
|
115
|
+
entities={tweet.entities}
|
|
116
|
+
text={tweet.text}
|
|
117
|
+
/>
|
|
118
|
+
<TweetMediaGrid items={tweet.media} />
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
</section>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -1,67 +1,63 @@
|
|
|
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 {
|
|
4
|
-
actionButtonClass,
|
|
5
5
|
composerBarClass,
|
|
6
6
|
composerInputClass,
|
|
7
|
-
composerShellClass,
|
|
8
|
-
contextBioClass,
|
|
9
|
-
contextHandleClass,
|
|
10
7
|
contextStatRowClass,
|
|
11
|
-
contextStatsClass,
|
|
12
8
|
contextStatTermClass,
|
|
13
9
|
contextStatValueClass,
|
|
14
10
|
cx,
|
|
15
|
-
|
|
11
|
+
dmComposerShellClass,
|
|
12
|
+
dmListBodyClass,
|
|
16
13
|
dmListClass,
|
|
17
|
-
|
|
14
|
+
dmListHandleClass,
|
|
15
|
+
dmListHeaderClass,
|
|
18
16
|
dmListItemActiveClass,
|
|
19
17
|
dmListItemClass,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
dmListNameClass,
|
|
19
|
+
dmListPreviewClass,
|
|
20
|
+
dmListTimestampClass,
|
|
21
|
+
dmMessageBubbleClass,
|
|
22
|
+
dmMessageBubbleOutboundClass,
|
|
23
|
+
dmMessageMetaClass,
|
|
24
|
+
dmMessageRowClass,
|
|
25
|
+
dmMessageRowOutboundClass,
|
|
26
|
+
dmMessagesClass,
|
|
27
|
+
dmShellClass,
|
|
28
|
+
dmThreadClass,
|
|
29
|
+
dmThreadHeaderClass,
|
|
30
|
+
dmThreadNameClass,
|
|
31
|
+
dmThreadSubtitleClass,
|
|
32
|
+
dmThreadTitleClass,
|
|
31
33
|
pillAlertClass,
|
|
32
34
|
pillClass,
|
|
33
35
|
pillSoftClass,
|
|
34
|
-
|
|
35
|
-
threadDetailColumnClass,
|
|
36
|
-
threadDetailHeaderClass,
|
|
37
|
-
threadHeaderClass,
|
|
38
|
-
threadShellClass,
|
|
39
|
-
threadSubtitleClass,
|
|
40
|
-
threadTitleClass,
|
|
36
|
+
primaryButtonClass,
|
|
41
37
|
timestampClass,
|
|
42
38
|
} from "#/lib/ui";
|
|
43
39
|
import { AvatarChip } from "./AvatarChip";
|
|
40
|
+
import { BirdclawEmpty } from "./BrandMark";
|
|
44
41
|
|
|
45
42
|
function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
43
|
+
const outbound = message.direction === "outbound";
|
|
46
44
|
return (
|
|
47
45
|
<div
|
|
48
|
-
className={cx(
|
|
49
|
-
messageRowClass,
|
|
50
|
-
message.direction === "outbound" && messageRowOutboundClass,
|
|
51
|
-
)}
|
|
46
|
+
className={cx(dmMessageRowClass, outbound && dmMessageRowOutboundClass)}
|
|
52
47
|
>
|
|
53
|
-
<div className={messageMetaClass}>
|
|
54
|
-
<span>{message.sender.displayName}</span>
|
|
55
|
-
<span>{formatShortTimestamp(message.createdAt)}</span>
|
|
56
|
-
</div>
|
|
57
48
|
<div
|
|
58
49
|
className={cx(
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
dmMessageBubbleClass,
|
|
51
|
+
outbound && dmMessageBubbleOutboundClass,
|
|
61
52
|
)}
|
|
62
53
|
>
|
|
63
54
|
{message.text}
|
|
64
55
|
</div>
|
|
56
|
+
<div className={dmMessageMetaClass}>
|
|
57
|
+
<span>{message.sender.displayName}</span>
|
|
58
|
+
<span>·</span>
|
|
59
|
+
<span>{formatShortTimestamp(message.createdAt)}</span>
|
|
60
|
+
</div>
|
|
65
61
|
</div>
|
|
66
62
|
);
|
|
67
63
|
}
|
|
@@ -85,12 +81,18 @@ export function DmWorkspace({
|
|
|
85
81
|
}) {
|
|
86
82
|
const participant = selectedConversation?.participant ?? null;
|
|
87
83
|
const subtitle = selectedConversation
|
|
88
|
-
? `${selectedConversation.needsReply ? "Reply
|
|
84
|
+
? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
|
|
89
85
|
: "No conversation selected";
|
|
90
86
|
|
|
91
87
|
return (
|
|
92
|
-
<section className={
|
|
88
|
+
<section className={dmShellClass}>
|
|
93
89
|
<aside className={dmListClass}>
|
|
90
|
+
{conversations.length === 0 ? (
|
|
91
|
+
<BirdclawEmpty
|
|
92
|
+
detail="Sync DMs to populate this lane."
|
|
93
|
+
label="No conversations"
|
|
94
|
+
/>
|
|
95
|
+
) : null}
|
|
94
96
|
{conversations.map((conversation) => {
|
|
95
97
|
const active = conversation.id === selectedConversation?.id;
|
|
96
98
|
return (
|
|
@@ -106,70 +108,85 @@ export function DmWorkspace({
|
|
|
106
108
|
name={conversation.participant.displayName}
|
|
107
109
|
profileId={conversation.participant.id}
|
|
108
110
|
/>
|
|
109
|
-
<div className={
|
|
110
|
-
<div className={
|
|
111
|
-
<
|
|
112
|
-
|
|
111
|
+
<div className={dmListBodyClass}>
|
|
112
|
+
<div className={dmListHeaderClass}>
|
|
113
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
114
|
+
<span className={dmListNameClass}>
|
|
115
|
+
{conversation.participant.displayName}
|
|
116
|
+
</span>
|
|
117
|
+
<span className={dmListHandleClass}>
|
|
118
|
+
@{conversation.participant.handle}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
<span className={dmListTimestampClass}>
|
|
122
|
+
{formatShortTimestamp(conversation.lastMessageAt)}
|
|
123
|
+
</span>
|
|
113
124
|
</div>
|
|
114
|
-
<p className={
|
|
125
|
+
<p className={dmListPreviewClass}>
|
|
115
126
|
{conversation.lastMessagePreview}
|
|
116
127
|
</p>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
<div className="mt-1 flex items-center gap-1.5">
|
|
129
|
+
<span
|
|
130
|
+
className={cx(
|
|
131
|
+
pillClass,
|
|
132
|
+
conversation.needsReply ? pillAlertClass : pillSoftClass,
|
|
133
|
+
)}
|
|
134
|
+
aria-label={
|
|
135
|
+
conversation.needsReply ? "Reply open" : "We replied"
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
{conversation.needsReply ? (
|
|
139
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
140
|
+
) : (
|
|
141
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
142
|
+
)}
|
|
143
|
+
{conversation.needsReply ? "open" : "replied"}
|
|
144
|
+
</span>
|
|
145
|
+
<span className={cx(pillClass, pillSoftClass)}>
|
|
146
|
+
{conversation.influenceScore} ·{" "}
|
|
147
|
+
{conversation.influenceLabel}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
133
150
|
</div>
|
|
134
151
|
</button>
|
|
135
152
|
);
|
|
136
153
|
})}
|
|
137
154
|
</aside>
|
|
138
155
|
|
|
139
|
-
<div className={
|
|
156
|
+
<div className={dmThreadClass}>
|
|
140
157
|
{selectedConversation ? (
|
|
141
158
|
<>
|
|
142
|
-
<header className={
|
|
143
|
-
<div>
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
{
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<div className={threadDetailColumnClass}>
|
|
154
|
-
<div className={threadDetailHeaderClass}>
|
|
155
|
-
<AvatarChip
|
|
156
|
-
avatarUrl={participant?.avatarUrl}
|
|
157
|
-
hue={participant?.avatarHue ?? 18}
|
|
158
|
-
name={participant?.displayName ?? "Unknown"}
|
|
159
|
-
profileId={participant?.id ?? undefined}
|
|
160
|
-
size="large"
|
|
161
|
-
/>
|
|
162
|
-
<div>
|
|
163
|
-
<strong>{participant?.displayName}</strong>
|
|
164
|
-
<p className={cx("context-handle", contextHandleClass)}>
|
|
165
|
-
@{participant?.handle}
|
|
166
|
-
</p>
|
|
159
|
+
<header className={dmThreadHeaderClass}>
|
|
160
|
+
<div className={dmThreadTitleClass}>
|
|
161
|
+
<AvatarChip
|
|
162
|
+
avatarUrl={participant?.avatarUrl}
|
|
163
|
+
hue={participant?.avatarHue ?? 18}
|
|
164
|
+
name={participant?.displayName ?? "Unknown"}
|
|
165
|
+
profileId={participant?.id ?? undefined}
|
|
166
|
+
/>
|
|
167
|
+
<div className="min-w-0">
|
|
168
|
+
<div className={dmThreadNameClass}>
|
|
169
|
+
{selectedConversation.participant.displayName}
|
|
167
170
|
</div>
|
|
171
|
+
<div className={dmThreadSubtitleClass}>{subtitle}</div>
|
|
168
172
|
</div>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
</div>
|
|
174
|
+
<button
|
|
175
|
+
className={primaryButtonClass}
|
|
176
|
+
onClick={() => onReplySend(selectedConversation.id)}
|
|
177
|
+
type="button"
|
|
178
|
+
>
|
|
179
|
+
Reply
|
|
180
|
+
</button>
|
|
181
|
+
</header>
|
|
182
|
+
{participant?.bio || participant?.followersCount ? (
|
|
183
|
+
<div className="border-b border-[var(--line)] px-4 py-3">
|
|
184
|
+
{participant?.bio ? (
|
|
185
|
+
<p className="text-[14px] leading-[1.4] text-[var(--ink)]">
|
|
186
|
+
{participant.bio}
|
|
187
|
+
</p>
|
|
188
|
+
) : null}
|
|
189
|
+
<dl className="mt-2 grid grid-cols-2 gap-1 text-[13px]">
|
|
173
190
|
<div className={contextStatRowClass}>
|
|
174
191
|
<dt className={contextStatTermClass}>Followers</dt>
|
|
175
192
|
<dd className={contextStatValueClass}>
|
|
@@ -187,8 +204,8 @@ export function DmWorkspace({
|
|
|
187
204
|
<dt className={contextStatTermClass}>Reply state</dt>
|
|
188
205
|
<dd className={contextStatValueClass}>
|
|
189
206
|
{selectedConversation.needsReply
|
|
190
|
-
? "
|
|
191
|
-
: "
|
|
207
|
+
? "Reply open"
|
|
208
|
+
: "We replied"}
|
|
192
209
|
</dd>
|
|
193
210
|
</div>
|
|
194
211
|
<div className={contextStatRowClass}>
|
|
@@ -198,36 +215,29 @@ export function DmWorkspace({
|
|
|
198
215
|
</dd>
|
|
199
216
|
</div>
|
|
200
217
|
</dl>
|
|
201
|
-
<button
|
|
202
|
-
className={actionButtonClass}
|
|
203
|
-
onClick={() => onReplySend(selectedConversation.id)}
|
|
204
|
-
type="button"
|
|
205
|
-
>
|
|
206
|
-
Reply
|
|
207
|
-
</button>
|
|
208
218
|
</div>
|
|
209
|
-
|
|
210
|
-
<div className={
|
|
219
|
+
) : null}
|
|
220
|
+
<div className={dmMessagesClass}>
|
|
211
221
|
{selectedMessages.map((message) => (
|
|
212
222
|
<MessageBubble key={message.id} message={message} />
|
|
213
223
|
))}
|
|
214
224
|
</div>
|
|
215
|
-
<div className={
|
|
225
|
+
<div className={dmComposerShellClass}>
|
|
216
226
|
<textarea
|
|
217
227
|
className={composerInputClass}
|
|
218
228
|
onChange={(event) => onReplyDraftChange(event.target.value)}
|
|
219
229
|
placeholder={`Reply to @${selectedConversation.participant.handle}`}
|
|
220
|
-
rows={
|
|
230
|
+
rows={3}
|
|
221
231
|
value={replyDraft}
|
|
222
232
|
/>
|
|
223
233
|
<div className={composerBarClass}>
|
|
224
234
|
<span className={timestampClass}>
|
|
225
235
|
{selectedConversation.needsReply
|
|
226
|
-
? "Reply
|
|
227
|
-
: "
|
|
236
|
+
? "Reply open"
|
|
237
|
+
: "We replied"}
|
|
228
238
|
</span>
|
|
229
239
|
<button
|
|
230
|
-
className={
|
|
240
|
+
className={primaryButtonClass}
|
|
231
241
|
disabled={!replyDraft.trim()}
|
|
232
242
|
onClick={() => onReplySend(selectedConversation.id)}
|
|
233
243
|
type="button"
|
|
@@ -238,7 +248,10 @@ export function DmWorkspace({
|
|
|
238
248
|
</div>
|
|
239
249
|
</>
|
|
240
250
|
) : (
|
|
241
|
-
<
|
|
251
|
+
<BirdclawEmpty
|
|
252
|
+
detail="Pick a conversation to read the thread."
|
|
253
|
+
label="No DM selected"
|
|
254
|
+
/>
|
|
242
255
|
)}
|
|
243
256
|
</div>
|
|
244
257
|
</section>
|