birdclaw 0.1.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +23 -0
package/src/lib/seed.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
const now = new Date("2026-03-08T12:00:00.000Z");
|
|
4
|
+
|
|
5
|
+
function isoMinutesAgo(minutes: number) {
|
|
6
|
+
return new Date(now.getTime() - minutes * 60_000).toISOString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function svgImageDataUrl(label: string, hue: number) {
|
|
10
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800"><rect width="1200" height="800" fill="hsl(${String(
|
|
11
|
+
hue,
|
|
12
|
+
)} 48% 42%)"/><rect x="56" y="56" width="1088" height="688" rx="42" fill="hsl(${String(
|
|
13
|
+
hue + 18,
|
|
14
|
+
)} 34% 18%)" opacity="0.34"/><text x="70" y="420" fill="white" font-family="Instrument Sans, sans-serif" font-size="78" font-weight="700">${label}</text></svg>`;
|
|
15
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function svgAvatarDataUrl(label: string, hue: number) {
|
|
19
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><rect width="160" height="160" rx="42" fill="hsl(${String(
|
|
20
|
+
hue,
|
|
21
|
+
)} 54% 44%)"/><circle cx="80" cy="62" r="28" fill="rgba(255,255,255,0.22)"/><path d="M34 138c9-28 31-42 46-42s37 14 46 42" fill="rgba(255,255,255,0.22)"/><text x="80" y="98" text-anchor="middle" fill="white" font-family="Instrument Sans, sans-serif" font-size="44" font-weight="700">${label}</text></svg>`;
|
|
22
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function seedDemoData(db: Database.Database) {
|
|
26
|
+
const accountCount = db
|
|
27
|
+
.prepare("select count(*) as count from accounts")
|
|
28
|
+
.get() as {
|
|
29
|
+
count: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (accountCount.count > 0) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const insertAccount = db.prepare(`
|
|
37
|
+
insert into accounts (id, name, handle, transport, is_default, created_at)
|
|
38
|
+
values (@id, @name, @handle, @transport, @isDefault, @createdAt)
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
const insertProfile = db.prepare(`
|
|
42
|
+
insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
|
|
43
|
+
values (@id, @handle, @displayName, @bio, @followersCount, @avatarHue, @avatarUrl, @createdAt)
|
|
44
|
+
`);
|
|
45
|
+
|
|
46
|
+
const insertTweet = db.prepare(`
|
|
47
|
+
insert into tweets (
|
|
48
|
+
id, account_id, author_profile_id, kind, text, created_at, is_replied,
|
|
49
|
+
reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
|
|
50
|
+
) values (
|
|
51
|
+
@id, @accountId, @authorProfileId, @kind, @text, @createdAt, @isReplied,
|
|
52
|
+
@replyToId, @likeCount, @mediaCount, @bookmarked, @liked, @entitiesJson, @mediaJson, @quotedTweetId
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
|
|
56
|
+
const insertConversation = db.prepare(`
|
|
57
|
+
insert into dm_conversations (
|
|
58
|
+
id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
|
|
59
|
+
) values (
|
|
60
|
+
@id, @accountId, @participantProfileId, @title, @lastMessageAt, @unreadCount, @needsReply
|
|
61
|
+
)
|
|
62
|
+
`);
|
|
63
|
+
|
|
64
|
+
const insertMessage = db.prepare(`
|
|
65
|
+
insert into dm_messages (
|
|
66
|
+
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
67
|
+
) values (
|
|
68
|
+
@id, @conversationId, @senderProfileId, @text, @createdAt, @direction, @isReplied, @mediaCount
|
|
69
|
+
)
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const insertTweetsFts = db.prepare(
|
|
73
|
+
"insert into tweets_fts (tweet_id, text) values (?, ?)",
|
|
74
|
+
);
|
|
75
|
+
const insertDmFts = db.prepare(
|
|
76
|
+
"insert into dm_fts (message_id, text) values (?, ?)",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const accounts = [
|
|
80
|
+
{
|
|
81
|
+
id: "acct_primary",
|
|
82
|
+
name: "Peter",
|
|
83
|
+
handle: "@steipete",
|
|
84
|
+
transport: "xurl",
|
|
85
|
+
isDefault: 1,
|
|
86
|
+
createdAt: now.toISOString(),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "acct_studio",
|
|
90
|
+
name: "Studio",
|
|
91
|
+
handle: "@birdclaw_lab",
|
|
92
|
+
transport: "xurl",
|
|
93
|
+
isDefault: 0,
|
|
94
|
+
createdAt: now.toISOString(),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const profiles = [
|
|
99
|
+
{
|
|
100
|
+
id: "profile_me",
|
|
101
|
+
handle: "steipete",
|
|
102
|
+
displayName: "Peter Steinberger",
|
|
103
|
+
bio: "Builds native software, tooling, and sharp little systems.",
|
|
104
|
+
followersCount: 21450,
|
|
105
|
+
avatarHue: 18,
|
|
106
|
+
avatarUrl: svgAvatarDataUrl("PS", 18),
|
|
107
|
+
createdAt: now.toISOString(),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "profile_sam",
|
|
111
|
+
handle: "sam",
|
|
112
|
+
displayName: "Sam Altman",
|
|
113
|
+
bio: "Working on AGI, energy, chips, and shipping the hard parts.",
|
|
114
|
+
followersCount: 3180000,
|
|
115
|
+
avatarHue: 210,
|
|
116
|
+
avatarUrl: svgAvatarDataUrl("SA", 210),
|
|
117
|
+
createdAt: now.toISOString(),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "profile_des",
|
|
121
|
+
handle: "destraynor",
|
|
122
|
+
displayName: "Des Traynor",
|
|
123
|
+
bio: "Intercom co-founder. Product, writing, and oddly specific opinions.",
|
|
124
|
+
followersCount: 178000,
|
|
125
|
+
avatarHue: 144,
|
|
126
|
+
avatarUrl: svgAvatarDataUrl("DT", 144),
|
|
127
|
+
createdAt: now.toISOString(),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "profile_amelia",
|
|
131
|
+
handle: "amelia",
|
|
132
|
+
displayName: "Amelia N",
|
|
133
|
+
bio: "Design systems, prototypes, and good typography over noise.",
|
|
134
|
+
followersCount: 4200,
|
|
135
|
+
avatarHue: 320,
|
|
136
|
+
avatarUrl: svgAvatarDataUrl("AN", 320),
|
|
137
|
+
createdAt: now.toISOString(),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "profile_ava",
|
|
141
|
+
handle: "avawires",
|
|
142
|
+
displayName: "Ava Wires",
|
|
143
|
+
bio: "Reports on infrastructure, AI policy, and the business of software.",
|
|
144
|
+
followersCount: 632000,
|
|
145
|
+
avatarHue: 262,
|
|
146
|
+
avatarUrl: svgAvatarDataUrl("AW", 262),
|
|
147
|
+
createdAt: now.toISOString(),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "profile_noah",
|
|
151
|
+
handle: "noahbuilds",
|
|
152
|
+
displayName: "Noah Builds",
|
|
153
|
+
bio: "Bootstrapped indie apps. Pragmatic, fast, allergic to dashboards.",
|
|
154
|
+
followersCount: 12600,
|
|
155
|
+
avatarHue: 74,
|
|
156
|
+
avatarUrl: svgAvatarDataUrl("NB", 74),
|
|
157
|
+
createdAt: now.toISOString(),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const tweets = [
|
|
162
|
+
{
|
|
163
|
+
id: "tweet_001",
|
|
164
|
+
accountId: "acct_primary",
|
|
165
|
+
authorProfileId: "profile_sam",
|
|
166
|
+
kind: "home",
|
|
167
|
+
text: "We need more software that defaults to local-first, legible state, and repairable failure modes.",
|
|
168
|
+
createdAt: isoMinutesAgo(18),
|
|
169
|
+
isReplied: 0,
|
|
170
|
+
replyToId: null,
|
|
171
|
+
likeCount: 1240,
|
|
172
|
+
mediaCount: 0,
|
|
173
|
+
bookmarked: 1,
|
|
174
|
+
liked: 1,
|
|
175
|
+
entitiesJson: JSON.stringify({
|
|
176
|
+
urls: [
|
|
177
|
+
{
|
|
178
|
+
url: "https://t.co/local",
|
|
179
|
+
expandedUrl: "https://birdclaw.dev/local-first-systems",
|
|
180
|
+
displayUrl: "birdclaw.dev/local-first-systems",
|
|
181
|
+
start: 85,
|
|
182
|
+
end: 108,
|
|
183
|
+
title: "Local-first systems",
|
|
184
|
+
description: "Design notes on durable local software.",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
mediaJson: "[]",
|
|
189
|
+
quotedTweetId: null,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "tweet_002",
|
|
193
|
+
accountId: "acct_primary",
|
|
194
|
+
authorProfileId: "profile_des",
|
|
195
|
+
kind: "home",
|
|
196
|
+
text: "@sam The best product teams spend more time pruning scope than adding it.",
|
|
197
|
+
createdAt: isoMinutesAgo(42),
|
|
198
|
+
isReplied: 1,
|
|
199
|
+
replyToId: "tweet_001",
|
|
200
|
+
likeCount: 382,
|
|
201
|
+
mediaCount: 0,
|
|
202
|
+
bookmarked: 0,
|
|
203
|
+
liked: 1,
|
|
204
|
+
entitiesJson: JSON.stringify({
|
|
205
|
+
mentions: [
|
|
206
|
+
{
|
|
207
|
+
username: "sam",
|
|
208
|
+
id: "profile_sam",
|
|
209
|
+
start: 0,
|
|
210
|
+
end: 4,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
mediaJson: "[]",
|
|
215
|
+
quotedTweetId: null,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "tweet_003",
|
|
219
|
+
accountId: "acct_primary",
|
|
220
|
+
authorProfileId: "profile_ava",
|
|
221
|
+
kind: "home",
|
|
222
|
+
text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more.",
|
|
223
|
+
createdAt: isoMinutesAgo(91),
|
|
224
|
+
isReplied: 0,
|
|
225
|
+
replyToId: null,
|
|
226
|
+
likeCount: 128,
|
|
227
|
+
mediaCount: 1,
|
|
228
|
+
bookmarked: 0,
|
|
229
|
+
liked: 0,
|
|
230
|
+
entitiesJson: JSON.stringify({
|
|
231
|
+
urls: [
|
|
232
|
+
{
|
|
233
|
+
url: "https://t.co/survey",
|
|
234
|
+
expandedUrl: "https://example.com/developer-platform-pricing",
|
|
235
|
+
displayUrl: "example.com/developer-platform-pricing",
|
|
236
|
+
start: 78,
|
|
237
|
+
end: 101,
|
|
238
|
+
title: "Developer platform pricing survey",
|
|
239
|
+
description:
|
|
240
|
+
"A simple inline link preview card from tweet URL entities.",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
}),
|
|
244
|
+
mediaJson: JSON.stringify([
|
|
245
|
+
{
|
|
246
|
+
url: svgImageDataUrl("pricing map", 194),
|
|
247
|
+
type: "image",
|
|
248
|
+
altText: "Pricing survey chart",
|
|
249
|
+
width: 1200,
|
|
250
|
+
height: 800,
|
|
251
|
+
thumbnailUrl: svgImageDataUrl("pricing map", 194),
|
|
252
|
+
},
|
|
253
|
+
]),
|
|
254
|
+
quotedTweetId: null,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "tweet_004",
|
|
258
|
+
accountId: "acct_primary",
|
|
259
|
+
authorProfileId: "profile_amelia",
|
|
260
|
+
kind: "mention",
|
|
261
|
+
text: "@steipete curious how you decide when a local tool deserves a real sync engine versus manual import/export.",
|
|
262
|
+
createdAt: isoMinutesAgo(12),
|
|
263
|
+
isReplied: 0,
|
|
264
|
+
replyToId: null,
|
|
265
|
+
likeCount: 14,
|
|
266
|
+
mediaCount: 0,
|
|
267
|
+
bookmarked: 0,
|
|
268
|
+
liked: 0,
|
|
269
|
+
entitiesJson: JSON.stringify({
|
|
270
|
+
mentions: [
|
|
271
|
+
{
|
|
272
|
+
username: "steipete",
|
|
273
|
+
id: "profile_me",
|
|
274
|
+
start: 0,
|
|
275
|
+
end: 9,
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
}),
|
|
279
|
+
mediaJson: "[]",
|
|
280
|
+
quotedTweetId: null,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "tweet_005",
|
|
284
|
+
accountId: "acct_primary",
|
|
285
|
+
authorProfileId: "profile_noah",
|
|
286
|
+
kind: "mention",
|
|
287
|
+
text: "@steipete your archive-first note resonated. I still want a path for people with zero clean export data.",
|
|
288
|
+
createdAt: isoMinutesAgo(54),
|
|
289
|
+
isReplied: 1,
|
|
290
|
+
replyToId: null,
|
|
291
|
+
likeCount: 8,
|
|
292
|
+
mediaCount: 0,
|
|
293
|
+
bookmarked: 0,
|
|
294
|
+
liked: 0,
|
|
295
|
+
entitiesJson: JSON.stringify({
|
|
296
|
+
mentions: [
|
|
297
|
+
{
|
|
298
|
+
username: "steipete",
|
|
299
|
+
id: "profile_me",
|
|
300
|
+
start: 0,
|
|
301
|
+
end: 9,
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}),
|
|
305
|
+
mediaJson: "[]",
|
|
306
|
+
quotedTweetId: null,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "tweet_006",
|
|
310
|
+
accountId: "acct_studio",
|
|
311
|
+
authorProfileId: "profile_sam",
|
|
312
|
+
kind: "home",
|
|
313
|
+
text: "Agents need retrieval surfaces with small, stable contracts. Big blobs are not a strategy.",
|
|
314
|
+
createdAt: isoMinutesAgo(77),
|
|
315
|
+
isReplied: 0,
|
|
316
|
+
replyToId: null,
|
|
317
|
+
likeCount: 912,
|
|
318
|
+
mediaCount: 0,
|
|
319
|
+
bookmarked: 1,
|
|
320
|
+
liked: 1,
|
|
321
|
+
entitiesJson: JSON.stringify({
|
|
322
|
+
urls: [
|
|
323
|
+
{
|
|
324
|
+
url: "https://t.co/quoted",
|
|
325
|
+
expandedUrl: "https://x.com/sam/status/tweet_001",
|
|
326
|
+
displayUrl: "x.com/sam/status/tweet_001",
|
|
327
|
+
start: 58,
|
|
328
|
+
end: 81,
|
|
329
|
+
title: "Quoted tweet",
|
|
330
|
+
description: "Local quoted tweet expansion",
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
}),
|
|
334
|
+
mediaJson: "[]",
|
|
335
|
+
quotedTweetId: "tweet_001",
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const conversations = [
|
|
340
|
+
{
|
|
341
|
+
id: "dm_001",
|
|
342
|
+
accountId: "acct_primary",
|
|
343
|
+
participantProfileId: "profile_sam",
|
|
344
|
+
title: "Sam Altman",
|
|
345
|
+
lastMessageAt: isoMinutesAgo(8),
|
|
346
|
+
unreadCount: 1,
|
|
347
|
+
needsReply: 1,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: "dm_002",
|
|
351
|
+
accountId: "acct_primary",
|
|
352
|
+
participantProfileId: "profile_des",
|
|
353
|
+
title: "Des Traynor",
|
|
354
|
+
lastMessageAt: isoMinutesAgo(65),
|
|
355
|
+
unreadCount: 0,
|
|
356
|
+
needsReply: 0,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "dm_003",
|
|
360
|
+
accountId: "acct_primary",
|
|
361
|
+
participantProfileId: "profile_amelia",
|
|
362
|
+
title: "Amelia N",
|
|
363
|
+
lastMessageAt: isoMinutesAgo(25),
|
|
364
|
+
unreadCount: 2,
|
|
365
|
+
needsReply: 1,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "dm_004",
|
|
369
|
+
accountId: "acct_studio",
|
|
370
|
+
participantProfileId: "profile_ava",
|
|
371
|
+
title: "Ava Wires",
|
|
372
|
+
lastMessageAt: isoMinutesAgo(130),
|
|
373
|
+
unreadCount: 0,
|
|
374
|
+
needsReply: 0,
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const messages = [
|
|
379
|
+
{
|
|
380
|
+
id: "msg_001",
|
|
381
|
+
conversationId: "dm_001",
|
|
382
|
+
senderProfileId: "profile_sam",
|
|
383
|
+
text: "Can you send the local-first sync sketch? The inbox angle is strong.",
|
|
384
|
+
createdAt: isoMinutesAgo(8),
|
|
385
|
+
direction: "inbound",
|
|
386
|
+
isReplied: 0,
|
|
387
|
+
mediaCount: 0,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "msg_002",
|
|
391
|
+
conversationId: "dm_001",
|
|
392
|
+
senderProfileId: "profile_me",
|
|
393
|
+
text: "Yep. I am tightening the transport boundary first, then I will send the schema.",
|
|
394
|
+
createdAt: isoMinutesAgo(27),
|
|
395
|
+
direction: "outbound",
|
|
396
|
+
isReplied: 1,
|
|
397
|
+
mediaCount: 0,
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: "msg_003",
|
|
401
|
+
conversationId: "dm_002",
|
|
402
|
+
senderProfileId: "profile_des",
|
|
403
|
+
text: "The minimal UI direction feels right. People should read, not manage a cockpit.",
|
|
404
|
+
createdAt: isoMinutesAgo(65),
|
|
405
|
+
direction: "inbound",
|
|
406
|
+
isReplied: 1,
|
|
407
|
+
mediaCount: 0,
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "msg_004",
|
|
411
|
+
conversationId: "dm_002",
|
|
412
|
+
senderProfileId: "profile_me",
|
|
413
|
+
text: "Exactly. Dense signal, quiet chrome, clear action lanes.",
|
|
414
|
+
createdAt: isoMinutesAgo(58),
|
|
415
|
+
direction: "outbound",
|
|
416
|
+
isReplied: 1,
|
|
417
|
+
mediaCount: 0,
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: "msg_005",
|
|
421
|
+
conversationId: "dm_003",
|
|
422
|
+
senderProfileId: "profile_amelia",
|
|
423
|
+
text: "I mocked a cleaner split-pane DM layout. Want me to send it over?",
|
|
424
|
+
createdAt: isoMinutesAgo(25),
|
|
425
|
+
direction: "inbound",
|
|
426
|
+
isReplied: 0,
|
|
427
|
+
mediaCount: 1,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: "msg_006",
|
|
431
|
+
conversationId: "dm_003",
|
|
432
|
+
senderProfileId: "profile_amelia",
|
|
433
|
+
text: "Also added a tiny context rail for bios and follower counts.",
|
|
434
|
+
createdAt: isoMinutesAgo(22),
|
|
435
|
+
direction: "inbound",
|
|
436
|
+
isReplied: 0,
|
|
437
|
+
mediaCount: 0,
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "msg_007",
|
|
441
|
+
conversationId: "dm_004",
|
|
442
|
+
senderProfileId: "profile_ava",
|
|
443
|
+
text: "If you have a public draft later, I would love to quote the agent-query angle.",
|
|
444
|
+
createdAt: isoMinutesAgo(130),
|
|
445
|
+
direction: "inbound",
|
|
446
|
+
isReplied: 1,
|
|
447
|
+
mediaCount: 0,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
id: "msg_008",
|
|
451
|
+
conversationId: "dm_004",
|
|
452
|
+
senderProfileId: "profile_me",
|
|
453
|
+
text: "Will do. I want the filters and local storage story to be credible first.",
|
|
454
|
+
createdAt: isoMinutesAgo(124),
|
|
455
|
+
direction: "outbound",
|
|
456
|
+
isReplied: 1,
|
|
457
|
+
mediaCount: 0,
|
|
458
|
+
},
|
|
459
|
+
];
|
|
460
|
+
|
|
461
|
+
const transaction = db.transaction(() => {
|
|
462
|
+
for (const account of accounts) {
|
|
463
|
+
insertAccount.run(account);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const profile of profiles) {
|
|
467
|
+
insertProfile.run(profile);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const tweet of tweets) {
|
|
471
|
+
insertTweet.run(tweet);
|
|
472
|
+
insertTweetsFts.run(tweet.id, tweet.text);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const conversation of conversations) {
|
|
476
|
+
insertConversation.run(conversation);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const message of messages) {
|
|
480
|
+
insertMessage.run(message);
|
|
481
|
+
insertDmFts.run(message.id, message.text);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
transaction();
|
|
486
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
2
|
+
|
|
3
|
+
export interface SyncCacheEntry<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
updatedAt: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function readSyncCacheRow(cacheKey: string, db = getNativeDb()) {
|
|
9
|
+
return db
|
|
10
|
+
.prepare(
|
|
11
|
+
`
|
|
12
|
+
select value_json, updated_at
|
|
13
|
+
from sync_cache
|
|
14
|
+
where cache_key = ?
|
|
15
|
+
`,
|
|
16
|
+
)
|
|
17
|
+
.get(cacheKey) as
|
|
18
|
+
| {
|
|
19
|
+
value_json: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
}
|
|
22
|
+
| undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readSyncCache<T>(
|
|
26
|
+
cacheKey: string,
|
|
27
|
+
db = getNativeDb(),
|
|
28
|
+
): SyncCacheEntry<T> | null {
|
|
29
|
+
const row = readSyncCacheRow(cacheKey, db);
|
|
30
|
+
if (!row) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return {
|
|
36
|
+
value: JSON.parse(row.value_json) as T,
|
|
37
|
+
updatedAt: row.updated_at,
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeSyncCache(
|
|
45
|
+
cacheKey: string,
|
|
46
|
+
value: unknown,
|
|
47
|
+
db = getNativeDb(),
|
|
48
|
+
) {
|
|
49
|
+
const updatedAt = new Date().toISOString();
|
|
50
|
+
db.prepare(
|
|
51
|
+
`
|
|
52
|
+
insert into sync_cache (cache_key, value_json, updated_at)
|
|
53
|
+
values (?, ?, ?)
|
|
54
|
+
on conflict(cache_key) do update set
|
|
55
|
+
value_json = excluded.value_json,
|
|
56
|
+
updated_at = excluded.updated_at
|
|
57
|
+
`,
|
|
58
|
+
).run(cacheKey, JSON.stringify(value), updatedAt);
|
|
59
|
+
|
|
60
|
+
return updatedAt;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deleteSyncCache(cacheKey: string, db = getNativeDb()) {
|
|
64
|
+
db.prepare("delete from sync_cache where cache_key = ?").run(cacheKey);
|
|
65
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { flushSync } from "react-dom";
|
|
2
|
+
import type { ThemeValue } from "./theme";
|
|
3
|
+
|
|
4
|
+
export interface ThemeTransitionContext {
|
|
5
|
+
element?: HTMLElement | null;
|
|
6
|
+
pointerClientX?: number;
|
|
7
|
+
pointerClientY?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DocumentWithViewTransition extends Document {
|
|
11
|
+
startViewTransition?: (callback: () => void) => {
|
|
12
|
+
finished: Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const clamp01 = (value: number) => {
|
|
17
|
+
if (Number.isNaN(value)) return 0.5;
|
|
18
|
+
if (value <= 0) return 0;
|
|
19
|
+
if (value >= 1) return 1;
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const hasReducedMotionPreference = () => {
|
|
24
|
+
if (
|
|
25
|
+
typeof window === "undefined" ||
|
|
26
|
+
typeof window.matchMedia !== "function"
|
|
27
|
+
) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const cleanupThemeTransition = (root: HTMLElement) => {
|
|
35
|
+
root.classList.remove("theme-transition");
|
|
36
|
+
root.style.removeProperty("--theme-switch-x");
|
|
37
|
+
root.style.removeProperty("--theme-switch-y");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function startThemeTransition({
|
|
41
|
+
nextTheme,
|
|
42
|
+
currentTheme,
|
|
43
|
+
setTheme,
|
|
44
|
+
context,
|
|
45
|
+
}: {
|
|
46
|
+
nextTheme: ThemeValue;
|
|
47
|
+
currentTheme: ThemeValue | null;
|
|
48
|
+
setTheme: (theme: ThemeValue) => void;
|
|
49
|
+
context?: ThemeTransitionContext;
|
|
50
|
+
}) {
|
|
51
|
+
if (currentTheme === nextTheme) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof document === "undefined") {
|
|
56
|
+
setTheme(nextTheme);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const root = document.documentElement;
|
|
61
|
+
const documentWithTransition = document as DocumentWithViewTransition;
|
|
62
|
+
const canUseViewTransition =
|
|
63
|
+
Boolean(documentWithTransition.startViewTransition) &&
|
|
64
|
+
!hasReducedMotionPreference();
|
|
65
|
+
|
|
66
|
+
const applyTheme = () => {
|
|
67
|
+
flushSync(() => {
|
|
68
|
+
setTheme(nextTheme);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!canUseViewTransition) {
|
|
73
|
+
applyTheme();
|
|
74
|
+
cleanupThemeTransition(root);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let xPercent = 0.5;
|
|
79
|
+
let yPercent = 0.5;
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
context?.pointerClientX !== undefined &&
|
|
83
|
+
context.pointerClientY !== undefined &&
|
|
84
|
+
typeof window !== "undefined"
|
|
85
|
+
) {
|
|
86
|
+
xPercent = clamp01(context.pointerClientX / window.innerWidth);
|
|
87
|
+
yPercent = clamp01(context.pointerClientY / window.innerHeight);
|
|
88
|
+
} else if (context?.element && typeof window !== "undefined") {
|
|
89
|
+
const rect = context.element.getBoundingClientRect();
|
|
90
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
91
|
+
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
|
|
92
|
+
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
|
|
97
|
+
root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
|
|
98
|
+
root.classList.add("theme-transition");
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const transition = documentWithTransition.startViewTransition?.(() => {
|
|
102
|
+
applyTheme();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!transition?.finished) {
|
|
106
|
+
cleanupThemeTransition(root);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
void transition.finished
|
|
111
|
+
.catch(() => undefined)
|
|
112
|
+
.finally(() => cleanupThemeTransition(root));
|
|
113
|
+
} catch {
|
|
114
|
+
cleanupThemeTransition(root);
|
|
115
|
+
applyTheme();
|
|
116
|
+
}
|
|
117
|
+
}
|