birdclaw 0.4.0 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -1,23 +1,41 @@
1
1
  import { Link, useRouterState } from "@tanstack/react-router";
2
2
  import {
3
- brandMarkClass,
3
+ Bell,
4
+ Bird,
5
+ Bookmark,
6
+ Heart,
7
+ Home,
8
+ Inbox,
9
+ Link as LinkIcon,
10
+ Mail,
11
+ ShieldOff,
12
+ } from "lucide-react";
13
+ import {
4
14
  cx,
5
- eyebrowClass,
6
- navClass,
7
15
  navLinkActiveClass,
8
16
  navLinkClass,
9
- navLinksClass,
17
+ navLinkIconClass,
18
+ navLinkLabelClass,
19
+ sidebarBrandClass,
20
+ sidebarBrandCopyClass,
21
+ sidebarBrandMarkClass,
22
+ sidebarBrandTaglineClass,
23
+ sidebarBrandTitleClass,
24
+ sidebarFooterClass,
25
+ sidebarNavClass,
26
+ sidebarShellClass,
10
27
  } from "#/lib/ui";
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: "/dms", label: "DMs" },
20
- { to: "/blocks", label: "Blocks" },
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
- <nav className={navClass}>
30
- <div>
31
- <p className={eyebrowClass}>birdclaw</p>
32
- <h1 className={brandMarkClass}>Quiet signal for Twitter.</h1>
47
+ <aside className={sidebarShellClass}>
48
+ <div className="flex flex-col">
49
+ <Link to="/" className={sidebarBrandClass}>
50
+ <span className={sidebarBrandMarkClass}>
51
+ <Bird className="size-5" strokeWidth={2.4} />
52
+ </span>
53
+ <span className={sidebarBrandCopyClass}>
54
+ <span className={sidebarBrandTitleClass}>birdclaw</span>
55
+ <span className={sidebarBrandTaglineClass}>
56
+ Quiet signal for Twitter.
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={navLinksClass}>
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
- </nav>
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 { avatarChipClass, avatarChipLargeClass, cx } from "#/lib/ui";
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
- "avatar-chip",
30
- size === "large" && cx(avatarChipLargeClass, "avatar-chip-large"),
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,125 @@
1
+ import { MessageCircle } from "lucide-react";
2
+ import { formatShortTimestamp } from "#/lib/present";
3
+ import type { EmbeddedTweet } from "#/lib/types";
4
+ import {
5
+ cx,
6
+ emptyStateClass,
7
+ feedActionIconClass,
8
+ feedRowHandleClass,
9
+ feedRowNameClass,
10
+ feedRowTimestampClass,
11
+ } from "#/lib/ui";
12
+ import { AvatarChip } from "./AvatarChip";
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)] px-4 py-4 text-[14px] text-[var(--ink-soft)]">
31
+ Loading conversation...
32
+ </section>
33
+ );
34
+ }
35
+
36
+ if (error) {
37
+ return (
38
+ <section className="mt-3 rounded-2xl border border-[var(--alert)] bg-[var(--alert-soft)] px-4 py-3 text-[14px] text-[var(--alert)]">
39
+ {error}
40
+ </section>
41
+ );
42
+ }
43
+
44
+ if (items.length <= 1) {
45
+ 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.
53
+ </section>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <section
59
+ aria-label="Conversation"
60
+ className="mt-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]"
61
+ >
62
+ <div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-2.5 text-[13px] font-bold text-[var(--ink)]">
63
+ <MessageCircle className={feedActionIconClass} strokeWidth={1.8} />
64
+ <span>{items.length} tweets in conversation</span>
65
+ </div>
66
+ <div className="flex flex-col">
67
+ {items.map((tweet, index) => {
68
+ const isAnchor = tweet.id === anchorId;
69
+ return (
70
+ <div
71
+ className={cx(
72
+ "flex gap-3 px-4 py-3",
73
+ index > 0 && "border-t border-[var(--line)]",
74
+ isAnchor && "bg-[var(--accent-soft)]",
75
+ )}
76
+ key={tweet.id}
77
+ >
78
+ <div className="flex flex-col items-center">
79
+ <AvatarChip
80
+ avatarUrl={tweet.author.avatarUrl}
81
+ hue={tweet.author.avatarHue}
82
+ name={tweet.author.displayName}
83
+ profileId={tweet.author.id}
84
+ size="small"
85
+ />
86
+ {index < items.length - 1 ? (
87
+ <span className="mt-2 w-px flex-1 bg-[var(--line)]" />
88
+ ) : null}
89
+ </div>
90
+ <div className="min-w-0 flex-1">
91
+ <header className="flex min-w-0 items-center gap-1.5 text-[14px]">
92
+ <ProfilePreview profile={tweet.author}>
93
+ <span className="flex min-w-0 items-center gap-1.5">
94
+ <span className={feedRowNameClass}>
95
+ {tweet.author.displayName}
96
+ </span>
97
+ <span className={feedRowHandleClass}>
98
+ @{tweet.author.handle}
99
+ </span>
100
+ </span>
101
+ </ProfilePreview>
102
+ <span className="text-[var(--ink-soft)]">·</span>
103
+ <span className={feedRowTimestampClass}>
104
+ {formatShortTimestamp(tweet.createdAt)}
105
+ </span>
106
+ {isAnchor ? (
107
+ <span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
108
+ selected
109
+ </span>
110
+ ) : null}
111
+ </header>
112
+ <TweetRichText
113
+ className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]"
114
+ entities={tweet.entities}
115
+ text={tweet.text}
116
+ />
117
+ <TweetMediaGrid items={tweet.media} />
118
+ </div>
119
+ </div>
120
+ );
121
+ })}
122
+ </div>
123
+ </section>
124
+ );
125
+ }
@@ -1,67 +1,62 @@
1
1
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
2
  import type { DmConversationItem, DmMessageItem } from "#/lib/types";
3
3
  import {
4
- actionButtonClass,
5
4
  composerBarClass,
6
5
  composerInputClass,
7
- composerShellClass,
8
- contextBioClass,
9
- contextHandleClass,
10
6
  contextStatRowClass,
11
- contextStatsClass,
12
7
  contextStatTermClass,
13
8
  contextStatValueClass,
14
9
  cx,
15
- dmGridClass,
10
+ dmComposerShellClass,
11
+ dmListBodyClass,
16
12
  dmListClass,
17
- dmListCopyClass,
13
+ dmListHandleClass,
14
+ dmListHeaderClass,
18
15
  dmListItemActiveClass,
19
16
  dmListItemClass,
20
- dmPreviewTextClass,
17
+ dmListNameClass,
18
+ dmListPreviewClass,
19
+ dmListTimestampClass,
20
+ dmMessageBubbleClass,
21
+ dmMessageBubbleOutboundClass,
22
+ dmMessageMetaClass,
23
+ dmMessageRowClass,
24
+ dmMessageRowOutboundClass,
25
+ dmMessagesClass,
26
+ dmShellClass,
27
+ dmThreadClass,
28
+ dmThreadHeaderClass,
29
+ dmThreadNameClass,
30
+ dmThreadSubtitleClass,
31
+ dmThreadTitleClass,
21
32
  emptyStateClass,
22
- eyebrowClass,
23
- identityRowClass,
24
- messageBubbleClass,
25
- messageBubbleOutboundClass,
26
- messageMetaClass,
27
- messageRowClass,
28
- messageRowOutboundClass,
29
- messageStackClass,
30
- metaStackClass,
31
33
  pillAlertClass,
32
34
  pillClass,
33
35
  pillSoftClass,
34
- threadBioClass,
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";
44
40
 
45
41
  function MessageBubble({ message }: { message: DmMessageItem }) {
42
+ const outbound = message.direction === "outbound";
46
43
  return (
47
44
  <div
48
- className={cx(
49
- messageRowClass,
50
- message.direction === "outbound" && messageRowOutboundClass,
51
- )}
45
+ className={cx(dmMessageRowClass, outbound && dmMessageRowOutboundClass)}
52
46
  >
53
- <div className={messageMetaClass}>
54
- <span>{message.sender.displayName}</span>
55
- <span>{formatShortTimestamp(message.createdAt)}</span>
56
- </div>
57
47
  <div
58
48
  className={cx(
59
- messageBubbleClass,
60
- message.direction === "outbound" && messageBubbleOutboundClass,
49
+ dmMessageBubbleClass,
50
+ outbound && dmMessageBubbleOutboundClass,
61
51
  )}
62
52
  >
63
53
  {message.text}
64
54
  </div>
55
+ <div className={dmMessageMetaClass}>
56
+ <span>{message.sender.displayName}</span>
57
+ <span>·</span>
58
+ <span>{formatShortTimestamp(message.createdAt)}</span>
59
+ </div>
65
60
  </div>
66
61
  );
67
62
  }
@@ -89,8 +84,11 @@ export function DmWorkspace({
89
84
  : "No conversation selected";
90
85
 
91
86
  return (
92
- <section className={dmGridClass}>
87
+ <section className={dmShellClass}>
93
88
  <aside className={dmListClass}>
89
+ {conversations.length === 0 ? (
90
+ <div className={emptyStateClass}>No conversations.</div>
91
+ ) : null}
94
92
  {conversations.map((conversation) => {
95
93
  const active = conversation.id === selectedConversation?.id;
96
94
  return (
@@ -106,70 +104,77 @@ export function DmWorkspace({
106
104
  name={conversation.participant.displayName}
107
105
  profileId={conversation.participant.id}
108
106
  />
109
- <div className={dmListCopyClass}>
110
- <div className={identityRowClass}>
111
- <strong>{conversation.participant.displayName}</strong>
112
- <span>@{conversation.participant.handle}</span>
107
+ <div className={dmListBodyClass}>
108
+ <div className={dmListHeaderClass}>
109
+ <div className="flex min-w-0 items-center gap-1.5">
110
+ <span className={dmListNameClass}>
111
+ {conversation.participant.displayName}
112
+ </span>
113
+ <span className={dmListHandleClass}>
114
+ @{conversation.participant.handle}
115
+ </span>
116
+ </div>
117
+ <span className={dmListTimestampClass}>
118
+ {formatShortTimestamp(conversation.lastMessageAt)}
119
+ </span>
113
120
  </div>
114
- <p className={dmPreviewTextClass}>
121
+ <p className={dmListPreviewClass}>
115
122
  {conversation.lastMessagePreview}
116
123
  </p>
117
- </div>
118
- <div className={metaStackClass}>
119
- <span
120
- className={cx(
121
- pillClass,
122
- conversation.needsReply ? pillAlertClass : pillSoftClass,
123
- )}
124
- >
125
- {conversation.needsReply ? "needs reply" : "clear"}
126
- </span>
127
- <span className={cx(pillClass, pillSoftClass)}>
128
- {conversation.influenceScore} · {conversation.influenceLabel}
129
- </span>
130
- <span className={timestampClass}>
131
- {formatShortTimestamp(conversation.lastMessageAt)}
132
- </span>
124
+ <div className="mt-1 flex items-center gap-1.5">
125
+ <span
126
+ className={cx(
127
+ pillClass,
128
+ conversation.needsReply ? pillAlertClass : pillSoftClass,
129
+ )}
130
+ >
131
+ {conversation.needsReply ? "needs reply" : "clear"}
132
+ </span>
133
+ <span className={cx(pillClass, pillSoftClass)}>
134
+ {conversation.influenceScore} ·{" "}
135
+ {conversation.influenceLabel}
136
+ </span>
137
+ </div>
133
138
  </div>
134
139
  </button>
135
140
  );
136
141
  })}
137
142
  </aside>
138
143
 
139
- <div className={threadShellClass}>
144
+ <div className={dmThreadClass}>
140
145
  {selectedConversation ? (
141
146
  <>
142
- <header className={threadHeaderClass}>
143
- <div>
144
- <p className={eyebrowClass}>direct messages</p>
145
- <h2 className={threadTitleClass}>
146
- {selectedConversation.participant.displayName}
147
- </h2>
148
- <p className={threadSubtitleClass}>{subtitle}</p>
149
- <p className={threadBioClass}>
150
- {selectedConversation.lastMessagePreview}
151
- </p>
152
- </div>
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>
147
+ <header className={dmThreadHeaderClass}>
148
+ <div className={dmThreadTitleClass}>
149
+ <AvatarChip
150
+ avatarUrl={participant?.avatarUrl}
151
+ hue={participant?.avatarHue ?? 18}
152
+ name={participant?.displayName ?? "Unknown"}
153
+ profileId={participant?.id ?? undefined}
154
+ />
155
+ <div className="min-w-0">
156
+ <div className={dmThreadNameClass}>
157
+ {selectedConversation.participant.displayName}
167
158
  </div>
159
+ <div className={dmThreadSubtitleClass}>{subtitle}</div>
168
160
  </div>
169
- <p className={cx("context-bio", contextBioClass)}>
170
- {participant?.bio}
171
- </p>
172
- <dl className={contextStatsClass}>
161
+ </div>
162
+ <button
163
+ className={primaryButtonClass}
164
+ onClick={() => onReplySend(selectedConversation.id)}
165
+ type="button"
166
+ >
167
+ Reply
168
+ </button>
169
+ </header>
170
+ {participant?.bio || participant?.followersCount ? (
171
+ <div className="border-b border-[var(--line)] px-4 py-3">
172
+ {participant?.bio ? (
173
+ <p className="text-[14px] leading-[1.4] text-[var(--ink)]">
174
+ {participant.bio}
175
+ </p>
176
+ ) : null}
177
+ <dl className="mt-2 grid grid-cols-2 gap-1 text-[13px]">
173
178
  <div className={contextStatRowClass}>
174
179
  <dt className={contextStatTermClass}>Followers</dt>
175
180
  <dd className={contextStatValueClass}>
@@ -198,26 +203,19 @@ export function DmWorkspace({
198
203
  </dd>
199
204
  </div>
200
205
  </dl>
201
- <button
202
- className={actionButtonClass}
203
- onClick={() => onReplySend(selectedConversation.id)}
204
- type="button"
205
- >
206
- Reply
207
- </button>
208
206
  </div>
209
- </header>
210
- <div className={messageStackClass}>
207
+ ) : null}
208
+ <div className={dmMessagesClass}>
211
209
  {selectedMessages.map((message) => (
212
210
  <MessageBubble key={message.id} message={message} />
213
211
  ))}
214
212
  </div>
215
- <div className={composerShellClass}>
213
+ <div className={dmComposerShellClass}>
216
214
  <textarea
217
215
  className={composerInputClass}
218
216
  onChange={(event) => onReplyDraftChange(event.target.value)}
219
217
  placeholder={`Reply to @${selectedConversation.participant.handle}`}
220
- rows={4}
218
+ rows={3}
221
219
  value={replyDraft}
222
220
  />
223
221
  <div className={composerBarClass}>
@@ -227,7 +225,7 @@ export function DmWorkspace({
227
225
  : "Thread clear"}
228
226
  </span>
229
227
  <button
230
- className={actionButtonClass}
228
+ className={primaryButtonClass}
231
229
  disabled={!replyDraft.trim()}
232
230
  onClick={() => onReplySend(selectedConversation.id)}
233
231
  type="button"
@@ -1,12 +1,13 @@
1
1
  import { formatShortTimestamp } from "#/lib/present";
2
2
  import type { EmbeddedTweet } from "#/lib/types";
3
3
  import {
4
- embeddedTweetAuthorClass,
5
- embeddedTweetCardClass,
6
- embeddedTweetCopyClass,
7
- embeddedTweetHeaderClass,
8
- embeddedTweetLabelClass,
9
- timestampClass,
4
+ embeddedCardBodyClass,
5
+ embeddedCardCopyClass,
6
+ embeddedCardHandleClass,
7
+ embeddedCardHeaderClass,
8
+ embeddedCardLabelClass,
9
+ embeddedCardNameClass,
10
+ feedRowTimestampClass,
10
11
  } from "#/lib/ui";
11
12
  import { ProfilePreview } from "./ProfilePreview";
12
13
  import { TweetMediaGrid } from "./TweetMediaGrid";
@@ -20,21 +21,26 @@ export function EmbeddedTweetCard({
20
21
  label: string;
21
22
  }) {
22
23
  return (
23
- <section className={embeddedTweetCardClass}>
24
- <p className={embeddedTweetLabelClass}>{label}</p>
25
- <header className={embeddedTweetHeaderClass}>
24
+ <section className={embeddedCardBodyClass}>
25
+ <p className={embeddedCardLabelClass}>{label}</p>
26
+ <header className={embeddedCardHeaderClass}>
26
27
  <ProfilePreview profile={item.author}>
27
- <span className={embeddedTweetAuthorClass}>
28
- <strong>{item.author.displayName}</strong>
29
- <span>@{item.author.handle}</span>
28
+ <span className="flex min-w-0 items-center gap-1.5">
29
+ <span className={embeddedCardNameClass}>
30
+ {item.author.displayName}
31
+ </span>
32
+ <span className={embeddedCardHandleClass}>
33
+ @{item.author.handle}
34
+ </span>
30
35
  </span>
31
36
  </ProfilePreview>
32
- <span className={timestampClass}>
37
+ <span className="text-[var(--ink-soft)]">·</span>
38
+ <span className={feedRowTimestampClass}>
33
39
  {formatShortTimestamp(item.createdAt)}
34
40
  </span>
35
41
  </header>
36
42
  <TweetRichText
37
- className={embeddedTweetCopyClass}
43
+ className={embeddedCardCopyClass}
38
44
  entities={item.entities}
39
45
  text={item.text}
40
46
  />