diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e49a325..02446c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Persist quoted tweet payloads returned by Bird-backed live syncs so quote cards render without a separate hydrate. (#76 - thanks @lukaskawerau) - Show full tweet text in Today citation popovers instead of truncating long posts after six lines. - Show inline tweet images in Today citation popovers instead of leaving media-only `t.co` links in the preview text. diff --git a/src/lib/bird.test.ts b/src/lib/bird.test.ts index dfb765a0..85c9a490 100644 --- a/src/lib/bird.test.ts +++ b/src/lib/bird.test.ts @@ -947,6 +947,47 @@ describe("bird transport wrapper", () => { quotedTweet: { id: "quoted" }, }), ).toEqual([{ type: "quoted", id: "quoted" }]); + expect( + __test__.normalizeBirdTweets([ + { + id: "1", + text: "quoting", + createdAt: "2026-05-02T00:00:00.000Z", + authorId: "42", + author: { username: "sam", name: "Sam" }, + quotedTweet: { + id: "quoted", + text: "quoted body", + createdAt: "2026-05-01T00:00:00.000Z", + authorId: "43", + author: { username: "alex", name: "Alex" }, + }, + }, + ]), + ).toEqual( + expect.objectContaining({ + data: [ + expect.objectContaining({ + id: "1", + referenced_tweets: [{ type: "quoted", id: "quoted" }], + }), + ], + includes: { + users: [ + { id: "42", username: "sam", name: "Sam" }, + { id: "43", username: "alex", name: "Alex" }, + ], + tweets: [ + expect.objectContaining({ + id: "quoted", + author_id: "43", + text: "quoted body", + public_metrics: {}, + }), + ], + }, + }), + ); expect( __test__.normalizeBirdTweets([ { diff --git a/src/lib/bird.ts b/src/lib/bird.ts index 87eb4de8..d34043ab 100644 --- a/src/lib/bird.ts +++ b/src/lib/bird.ts @@ -46,7 +46,7 @@ interface BirdTweetItem { inReplyToStatusId?: string | null; quotedStatusId?: string | null; retweetedStatusId?: string | null; - quotedTweet?: { id?: string | null } | null; + quotedTweet?: Partial | null; retweetedTweet?: { id?: string | null } | null; author?: BirdTweetAuthor; authorId?: string; @@ -444,9 +444,22 @@ function toReferencedTweets(item: BirdTweetItem) { return references.length > 0 ? references : undefined; } +function isHydratedBirdTweetItem(value: unknown): value is BirdTweetItem { + const item = value as BirdTweetItem | undefined; + return ( + typeof item?.id === "string" && + typeof item.text === "string" && + typeof item.createdAt === "string" + ); +} + function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse { const users = new Map(); - const data = items.map((item): XurlMentionData => { + const includedTweets = new Map(); + const normalizeItem = ( + item: BirdTweetItem, + preserveMissingMetrics = false, + ): XurlMentionData => { const authorId = String( item.authorId ?? item.author?.username ?? "unknown", ); @@ -458,6 +471,13 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse { }); } + if (isHydratedBirdTweetItem(item.quotedTweet)) { + const quotedTweet = normalizeItem(item.quotedTweet, true); + if (quotedTweet.id !== item.id) { + includedTweets.set(quotedTweet.id, quotedTweet); + } + } + return { id: item.id, author_id: authorId, @@ -466,19 +486,38 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse { conversation_id: item.conversationId ?? item.id, entities: toTweetEntities(item), referenced_tweets: toReferencedTweets(item), - public_metrics: { - reply_count: Number(item.replyCount ?? 0), - retweet_count: Number(item.retweetCount ?? 0), - like_count: Number(item.likeCount ?? 0), - }, + public_metrics: preserveMissingMetrics + ? { + ...(item.replyCount === undefined + ? {} + : { reply_count: Number(item.replyCount) }), + ...(item.retweetCount === undefined + ? {} + : { retweet_count: Number(item.retweetCount) }), + ...(item.likeCount === undefined + ? {} + : { like_count: Number(item.likeCount) }), + } + : { + reply_count: Number(item.replyCount ?? 0), + retweet_count: Number(item.retweetCount ?? 0), + like_count: Number(item.likeCount ?? 0), + }, edit_history_tweet_ids: [item.id], }; - }); + }; + const data = items.map((item): XurlMentionData => normalizeItem(item)); + const includes: XurlMentionsResponse["includes"] = {}; + if (users.size > 0) { + includes.users = Array.from(users.values()); + } + if (includedTweets.size > 0) { + includes.tweets = Array.from(includedTweets.values()); + } return { data, - includes: - users.size > 0 ? { users: Array.from(users.values()) } : undefined, + includes: users.size > 0 || includedTweets.size > 0 ? includes : undefined, meta: { result_count: data.length, page_count: 1, diff --git a/src/lib/timeline-live.test.ts b/src/lib/timeline-live.test.ts index 089c6ac8..845b2b91 100644 --- a/src/lib/timeline-live.test.ts +++ b/src/lib/timeline-live.test.ts @@ -153,6 +153,118 @@ describe("live home timeline sync", () => { ]); }); + it("persists included quoted tweets without home timeline edges", async () => { + makeTempHome(); + const db = getNativeDb(); + listHomeTimelineViaBirdMock.mockResolvedValueOnce({ + data: [ + { + id: "tweet_quote_ref", + author_id: "42", + text: "read this quote", + created_at: "2026-04-26T13:43:34.000Z", + referenced_tweets: [{ type: "quoted", id: "tweet_quoted" }], + public_metrics: { like_count: 12 }, + }, + ], + includes: { + users: [ + { id: "42", username: "sam", name: "Sam" }, + { id: "43", username: "alex", name: "Alex" }, + ], + tweets: [ + { + id: "tweet_quoted", + author_id: "43", + text: "the quoted body", + created_at: "2026-04-25T13:43:34.000Z", + public_metrics: { like_count: 5 }, + }, + ], + }, + meta: { result_count: 1 }, + }); + const { syncHomeTimeline } = await import("./timeline-live"); + + await syncHomeTimeline({ + account: "acct_primary", + limit: 5, + refresh: true, + }); + + expect( + db.prepare("select text from tweets where id = ?").get("tweet_quoted"), + ).toEqual({ text: "the quoted body" }); + expect( + db + .prepare("select tweet_id from tweet_account_edges where tweet_id = ?") + .get("tweet_quoted"), + ).toBeUndefined(); + expect( + listTimelineItems({ + resource: "home", + account: "acct_primary", + search: "quote", + limit: 5, + }), + ).toEqual([ + expect.objectContaining({ + id: "tweet_quote_ref", + quotedTweet: expect.objectContaining({ + id: "tweet_quoted", + text: "the quoted body", + }), + }), + ]); + }); + + it("preserves stored metrics when an included quote omits them", async () => { + makeTempHome(); + const db = getNativeDb(); + db.prepare( + `insert into tweets ( + id, author_profile_id, text, created_at, is_replied, + like_count, media_count, entities_json, media_json + ) values (?, 'profile_user_42', ?, ?, 0, 42, 0, '{}', '[]')`, + ).run("tweet_quoted_existing", "stored quote", "2026-04-25T13:43:34.000Z"); + listHomeTimelineViaBirdMock.mockResolvedValueOnce({ + data: [ + { + id: "tweet_quote_ref_existing", + author_id: "42", + text: "read this existing quote", + created_at: "2026-04-26T13:43:34.000Z", + referenced_tweets: [{ type: "quoted", id: "tweet_quoted_existing" }], + }, + ], + includes: { + users: [{ id: "42", username: "sam", name: "Sam" }], + tweets: [ + { + id: "tweet_quoted_existing", + author_id: "42", + text: "updated quote body", + created_at: "2026-04-25T13:43:34.000Z", + }, + ], + }, + meta: { result_count: 1 }, + }); + const { syncHomeTimeline } = await import("./timeline-live"); + + await syncHomeTimeline({ + account: "acct_primary", + limit: 5, + refresh: true, + }); + + expect( + db + .prepare("select text, like_count from tweets where id = ?") + .get("tweet_quoted_existing"), + ).toEqual({ text: "updated quote body", like_count: 42 }); + }); + it("preserves existing media_json when home payload omits media details", async () => { makeTempHome(); const db = getNativeDb(); diff --git a/src/lib/timeline-live.ts b/src/lib/timeline-live.ts index f4be4bdb..4778c89c 100644 --- a/src/lib/timeline-live.ts +++ b/src/lib/timeline-live.ts @@ -91,6 +91,7 @@ function mergeTimelinePayloads( ) { const data: XurlMentionsResponse["data"] = []; const usersById = new Map(); + const tweetsById = new Map(); const mediaByKey = new Map(); let meta: XurlMentionsResponse["meta"] | undefined; @@ -104,6 +105,9 @@ function mergeTimelinePayloads( for (const user of payload.includes?.users ?? []) { usersById.set(user.id, user); } + for (const tweet of payload.includes?.tweets ?? []) { + tweetsById.set(tweet.id, tweet); + } for (const media of payload.includes?.media ?? []) { mediaByKey.set(media.media_key, media); } @@ -114,6 +118,7 @@ function mergeTimelinePayloads( data, includes: { users: [...usersById.values()], + tweets: [...tweetsById.values()], media: [...mediaByKey.values()], }, meta, diff --git a/src/lib/tweet-repository.ts b/src/lib/tweet-repository.ts index 3e929ccf..d3a19575 100644 --- a/src/lib/tweet-repository.ts +++ b/src/lib/tweet-repository.ts @@ -23,6 +23,17 @@ function getReferencedTweetId(tweet: XurlMentionData, type: string) { ); } +function toCanonicalTweets(payload: XurlMentionsResponse) { + const tweetsById = new Map(); + for (const tweet of payload.includes?.tweets ?? []) { + tweetsById.set(tweet.id, tweet); + } + for (const tweet of payload.data) { + tweetsById.set(tweet.id, tweet); + } + return tweetsById.values(); +} + export function replaceTweetFts(db: Database, tweetId: string, text: string) { db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId); db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run( @@ -56,7 +67,7 @@ export function ingestTweetPayload( created_at = excluded.created_at, is_replied = max(tweets.is_replied, excluded.is_replied), reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id), - like_count = excluded.like_count, + like_count = case when ? then tweets.like_count else excluded.like_count end, media_count = max(tweets.media_count, excluded.media_count), entities_json = excluded.entities_json, media_json = case @@ -80,7 +91,9 @@ export function ingestTweetPayload( db.transaction(() => { const observedAt = new Date().toISOString(); - for (const tweet of payload.data) { + const primaryTweetIds = new Set(payload.data.map((tweet) => tweet.id)); + for (const tweet of toCanonicalTweets(payload)) { + const isPrimaryTweet = primaryTweetIds.has(tweet.id); const author = usersById.get(tweet.author_id); const profile = author ? upsertProfileFromXUser(db, author) @@ -99,8 +112,11 @@ export function ingestTweetPayload( JSON.stringify(tweetEntitiesFromXurl(tweet.entities)), buildMediaJsonFromIncludes(tweet, payload.includes?.media), quotedTweetId, + !isPrimaryTweet && tweet.public_metrics?.like_count === undefined + ? 1 + : 0, ); - if (edgeKind) { + if (edgeKind && isPrimaryTweet) { upsertTweetAccountEdge(db, { accountId, tweetId: tweet.id, @@ -110,16 +126,20 @@ export function ingestTweetPayload( rawJson: JSON.stringify(tweet), }); } - upsertCollection?.run( - accountId, - tweet.id, - collectionKind, - source, - JSON.stringify(tweet), - observedAt, - ); + if (isPrimaryTweet) { + upsertCollection?.run( + accountId, + tweet.id, + collectionKind, + source, + JSON.stringify(tweet), + observedAt, + ); + } replaceTweetFts(db, tweet.id, tweet.text); - tweetIds.push(tweet.id); + if (isPrimaryTweet) { + tweetIds.push(tweet.id); + } } })(); diff --git a/src/lib/types.ts b/src/lib/types.ts index 90ecbbe6..867c6a14 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -593,6 +593,7 @@ export interface XurlMentionsResponse { data: XurlMentionData[]; includes?: { users?: XurlMentionUser[]; + tweets?: XurlTweetData[]; media?: XurlMediaItem[]; }; meta?: Record; @@ -623,6 +624,7 @@ export interface XurlTweetsResponse { data: XurlTweetData[]; includes?: { users?: XurlMentionUser[]; + tweets?: XurlTweetData[]; media?: XurlMediaItem[]; }; meta?: Record;