Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Persist quoted tweet payloads returned by Bird-backed live syncs so quote cards render without a separate hydrate.
- 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.

Expand Down
40 changes: 40 additions & 0 deletions src/lib/bird.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,46 @@ 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",
}),
],
},
}),
);
expect(
__test__.normalizeBirdTweets([
{
Expand Down
34 changes: 29 additions & 5 deletions src/lib/bird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface BirdTweetItem {
inReplyToStatusId?: string | null;
quotedStatusId?: string | null;
retweetedStatusId?: string | null;
quotedTweet?: { id?: string | null } | null;
quotedTweet?: Partial<BirdTweetItem> | null;
retweetedTweet?: { id?: string | null } | null;
author?: BirdTweetAuthor;
authorId?: string;
Expand Down Expand Up @@ -444,9 +444,19 @@ 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<string, XurlMentionUser>();
const data = items.map((item): XurlMentionData => {
const includedTweets = new Map<string, XurlMentionData>();
const normalizeItem = (item: BirdTweetItem): XurlMentionData => {
const authorId = String(
item.authorId ?? item.author?.username ?? "unknown",
);
Expand All @@ -458,6 +468,13 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
});
}

if (isHydratedBirdTweetItem(item.quotedTweet)) {
const quotedTweet = normalizeItem(item.quotedTweet);
if (quotedTweet.id !== item.id) {
includedTweets.set(quotedTweet.id, quotedTweet);
}
}

return {
id: item.id,
author_id: authorId,
Expand All @@ -473,12 +490,19 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
},
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,
Expand Down
26 changes: 18 additions & 8 deletions src/lib/profile-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,15 @@ function mergeXurlTweetsIntoLocalStore(
const existingTweet = db.prepare("select text from tweets where id = ?");
const seenAt = new Date().toISOString();
db.transaction(() => {
const primaryTweetIds = new Set(payload.data.map((tweet) => tweet.id));
const tweetsById = new Map<string, XurlTweetData>();
for (const tweet of payload.includes?.tweets ?? []) {
tweetsById.set(tweet.id, tweet);
}
for (const tweet of payload.data) {
tweetsById.set(tweet.id, tweet);
}
for (const tweet of tweetsById.values()) {
const authorId = tweet.author_id;
if (!authorId) continue;
const author = usersById.get(authorId);
Expand Down Expand Up @@ -396,14 +404,16 @@ function mergeXurlTweetsIntoLocalStore(
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
quotedTweetId,
);
upsertTweetAccountEdge(db, {
accountId,
tweetId: tweet.id,
kind: edgeKind,
source,
seenAt,
rawJson: JSON.stringify(tweet),
});
if (primaryTweetIds.has(tweet.id)) {
upsertTweetAccountEdge(db, {
accountId,
tweetId: tweet.id,
kind: edgeKind,
source,
seenAt,
rawJson: JSON.stringify(tweet),
});
}
refreshTweetFts(db, tweet.id, tweet.text, previousText);
}
})();
Expand Down
65 changes: 65 additions & 0 deletions src/lib/timeline-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,71 @@ 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 existing media_json when home payload omits media details", async () => {
makeTempHome();
const db = getNativeDb();
Expand Down
5 changes: 5 additions & 0 deletions src/lib/timeline-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function mergeTimelinePayloads(
) {
const data: XurlMentionsResponse["data"] = [];
const usersById = new Map<string, XurlMentionUser>();
const tweetsById = new Map<string, XurlMentionsResponse["data"][number]>();
const mediaByKey = new Map<string, XurlMediaItem>();
let meta: XurlMentionsResponse["meta"] | undefined;

Expand All @@ -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);
}
Expand All @@ -114,6 +118,7 @@ function mergeTimelinePayloads(
data,
includes: {
users: [...usersById.values()],
tweets: [...tweetsById.values()],
media: [...mediaByKey.values()],
},
meta,
Expand Down
39 changes: 28 additions & 11 deletions src/lib/tweet-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ function getReferencedTweetId(tweet: XurlMentionData, type: string) {
);
}

function toCanonicalTweets(payload: XurlMentionsResponse) {
const tweetsById = new Map<string, XurlMentionData>();
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(
Expand Down Expand Up @@ -80,7 +91,8 @@ 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 author = usersById.get(tweet.author_id);
const profile = author
? upsertProfileFromXUser(db, author)
Expand All @@ -100,7 +112,8 @@ export function ingestTweetPayload(
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
quotedTweetId,
);
if (edgeKind) {
const isPrimaryTweet = primaryTweetIds.has(tweet.id);
if (edgeKind && isPrimaryTweet) {
upsertTweetAccountEdge(db, {
accountId,
tweetId: tweet.id,
Expand All @@ -110,16 +123,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);
}
}
})();

Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ export interface XurlMentionsResponse {
data: XurlMentionData[];
includes?: {
users?: XurlMentionUser[];
tweets?: XurlTweetData[];
media?: XurlMediaItem[];
};
meta?: Record<string, unknown>;
Expand Down Expand Up @@ -623,6 +624,7 @@ export interface XurlTweetsResponse {
data: XurlTweetData[];
includes?: {
users?: XurlMentionUser[];
tweets?: XurlTweetData[];
media?: XurlMediaItem[];
};
meta?: Record<string, unknown>;
Expand Down