diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa2a32..c53ced4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.8.6 - Unreleased +### Fixed + +- Show inline tweet images in Today citation popovers instead of leaving media-only `t.co` links in the preview text. + ## 0.8.5 - 2026-06-21 ### Fixed diff --git a/src/components/MarkdownCitations.tsx b/src/components/MarkdownCitations.tsx index 7e2f6dc..4c5816f 100644 --- a/src/components/MarkdownCitations.tsx +++ b/src/components/MarkdownCitations.tsx @@ -2,8 +2,9 @@ import { Fragment, type MouseEventHandler, type ReactNode } from "react"; import { formatCompactNumber } from "#/lib/present"; import type { PeriodDigestContext } from "#/lib/period-digest"; import type { ProfileAnalysisContext } from "#/lib/profile-analysis"; +import type { SearchDiscussionContext } from "#/lib/search-discussion"; import { renderTweetPlainText } from "#/lib/tweet-render"; -import type { ProfileRecord } from "#/lib/types"; +import type { ProfileRecord, TweetEntities, TweetMediaItem } from "#/lib/types"; import { tweetLinkClass, tweetMentionClass } from "#/lib/ui"; import { safeHttpUrl } from "#/lib/url-safety"; import { AvatarChip } from "./AvatarChip"; @@ -12,8 +13,26 @@ import { useFloatingPreview } from "./FloatingPreview"; import { ProfilePreview } from "./ProfilePreview"; import { SmartTimestamp } from "./SmartTimestamp"; -type CitationTweet = PeriodDigestContext["tweets"][number]; -export type CitationContext = PeriodDigestContext | ProfileAnalysisContext; +type CitationTweet = { + id: string; + url: string; + source: string; + author: string; + name: string; + authorProfile: ProfileRecord; + createdAt: string; + text: string; + entities?: TweetEntities; + media?: TweetMediaItem[]; + likeCount: number; + liked: boolean; + bookmarked: boolean; + needsReply: boolean; +}; +export type CitationContext = + | PeriodDigestContext + | ProfileAnalysisContext + | SearchDiscussionContext; type InlineLookup = { tweetsById: Map; profilesByHandle: Map; @@ -107,6 +126,107 @@ function getFallbackTweetUrl(tweetId: string) { return `https://x.com/i/status/${normalizeTweetReference(tweetId)}`; } +function comparableUrl(value: string) { + try { + const parsed = new URL(value); + return `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`; + } catch { + return value.split("?")[0] ?? value; + } +} + +function isOwnStatusMediaUrl(value: string, tweetId: string) { + try { + const parsed = new URL(value); + const host = parsed.hostname.replace(/^www\./, ""); + if (host !== "x.com" && host !== "twitter.com") return false; + return new RegExp( + `/status/${tweetId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:photo|video)(?:/|$)`, + ).test(parsed.pathname); + } catch { + return false; + } +} + +function previewTextWithoutMediaLink( + text: string, + tweet: CitationTweet, + media: TweetMediaItem[], +) { + if (media.length === 0) return text; + const match = /(https?:\/\/[^\s]+)\s*$/.exec(text); + if (!match) return text; + const trailingUrl = match[1]; + if (!trailingUrl) return text; + const mediaUrls = new Set( + media.flatMap((item) => + item.thumbnailUrl ? [item.url, item.thumbnailUrl] : [item.url], + ), + ); + const comparableTrailing = comparableUrl(trailingUrl); + const directlyMatchesMedia = [...mediaUrls].some( + (url) => comparableUrl(url) === comparableTrailing, + ); + const mediaEntityLostItsRange = (tweet.entities?.urls ?? []).some((entry) => { + if (entry.end > entry.start) return false; + return [...mediaUrls].some( + (url) => comparableUrl(url) === comparableUrl(entry.expandedUrl), + ); + }); + const isUnresolvedShortUrl = (() => { + try { + return new URL(trailingUrl).hostname.replace(/^www\./, "") === "t.co"; + } catch { + return false; + } + })(); + if ( + !directlyMatchesMedia && + !isOwnStatusMediaUrl(trailingUrl, tweet.id) && + !(isUnresolvedShortUrl && mediaEntityLostItsRange) + ) { + return text; + } + return text.slice(0, match.index).trimEnd(); +} + +function TweetPreviewMedia({ items }: { items: TweetMediaItem[] }) { + const images = items + .flatMap((item) => { + const url = + item.type === "image" + ? safeHttpUrl(item.thumbnailUrl ?? item.url) + : safeHttpUrl(item.thumbnailUrl); + return url ? [{ item, url }] : []; + }) + .slice(0, 4); + if (images.length === 0) return null; + + return ( + + {images.map(({ item, url }, index) => ( + {item.altText + ))} + + ); +} + function TweetSourceLink({ children, href, @@ -147,6 +267,7 @@ function TweetPreviewToken({ ); const article = tweet.entities?.article; + const media = tweet.media ?? []; const renderedText = renderTweetPlainText(tweet.text, tweet.entities ?? {}); const previewText = article ? [article.title, article.previewText] @@ -155,7 +276,7 @@ function TweetPreviewToken({ Boolean(value) && values.indexOf(value) === index, ) .join("\n\n") - : renderedText; + : previewTextWithoutMediaLink(renderedText, tweet, media); return ( {previewText} + {tweet.source} {tweet.likeCount > 0 ? ( @@ -622,6 +744,7 @@ function profileAnalysisTweetToCitation( liked: false, bookmarked: false, needsReply: false, + media: [], }; } @@ -643,6 +766,7 @@ function conversationTweetToCitation( liked: false, bookmarked: false, needsReply: false, + media: [], }; } diff --git a/src/components/MarkdownViewer.test.tsx b/src/components/MarkdownViewer.test.tsx index 2ce7f0a..50ac295 100644 --- a/src/components/MarkdownViewer.test.tsx +++ b/src/components/MarkdownViewer.test.tsx @@ -55,6 +55,7 @@ const context = { liked: false, bookmarked: false, needsReply: true, + media: [], }, { id: "2057574939775938900", @@ -74,6 +75,7 @@ const context = { liked: false, bookmarked: false, needsReply: false, + media: [], }, { id: "2057578665408434460", @@ -93,6 +95,7 @@ const context = { liked: false, bookmarked: false, needsReply: false, + media: [], }, ], dms: [], @@ -677,4 +680,55 @@ describe("MarkdownViewer", () => { ); expect(screen.getByRole("tooltip")).not.toHaveTextContent("t.co"); }); + + it("shows tweet images in citation popovers instead of a media shortlink", () => { + const mediaContext = { + ...context, + tweets: [ + { + ...context.tweets[0], + text: "News from Europe https://t.co/media", + entities: { + urls: [ + { + url: "https://pbs.twimg.com/media/demo.jpg", + expandedUrl: "https://pbs.twimg.com/media/demo.jpg", + displayUrl: "https://pbs.twimg.com/media/demo.jpg", + start: 0, + end: 0, + }, + ], + }, + media: [ + { + url: "https://pbs.twimg.com/media/demo.jpg", + type: "image" as const, + altText: "Fugu benchmark chart", + }, + ], + }, + ], + } satisfies PeriodDigestContext; + render( + , + ); + + fireEvent.pointerEnter( + screen.getByRole("link", { name: "The benchmark result drew attention" }) + .parentElement as Element, + ); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("News from Europe"); + expect(tooltip).not.toHaveTextContent("t.co/media"); + expect(screen.getByAltText("Fugu benchmark chart")).toHaveAttribute( + "src", + "https://pbs.twimg.com/media/demo.jpg", + ); + }); }); diff --git a/src/lib/period-digest.test.ts b/src/lib/period-digest.test.ts index 37133f0..c69ad1f 100644 --- a/src/lib/period-digest.test.ts +++ b/src/lib/period-digest.test.ts @@ -92,6 +92,7 @@ describe("period digest", () => { expect(first.hash).toBe(second.hash); expect(first.tweets.length).toBeGreaterThan(0); + expect(first.tweets.some((tweet) => tweet.media.length > 0)).toBe(true); expect(first.counts.home).toBeGreaterThan(0); const profile = first.tweets[0]?.authorProfile; expect(profile).toBeDefined(); diff --git a/src/lib/period-digest.ts b/src/lib/period-digest.ts index d4b84a6..2b0aa06 100644 --- a/src/lib/period-digest.ts +++ b/src/lib/period-digest.ts @@ -22,7 +22,12 @@ import { } from "./openai-response-runtime"; import { readSyncCache, writeSyncCache } from "./sync-cache"; import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live"; -import type { EmbeddedTweet, ProfileRecord, TweetEntities } from "./types"; +import type { + EmbeddedTweet, + ProfileRecord, + TweetEntities, + TweetMediaItem, +} from "./types"; export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week"; export type PeriodDigestSourceKind = @@ -160,6 +165,7 @@ interface CompactTweet { createdAt: string; text: string; entities?: TweetEntities; + media: TweetMediaItem[]; likeCount: number; liked: boolean; bookmarked: boolean; @@ -348,6 +354,7 @@ function compactTweet( createdAt: item.createdAt, text: item.text, entities: item.entities, + media: item.media, likeCount: item.likeCount, liked: item.liked, bookmarked: item.bookmarked, @@ -368,6 +375,7 @@ function compactEmbeddedTweet(item: EmbeddedTweet): CompactTweet { createdAt: item.createdAt, text: item.text, entities: item.entities, + media: item.media, likeCount: item.likeCount ?? 0, liked: Boolean(item.liked), bookmarked: Boolean(item.bookmarked), diff --git a/src/lib/search-discussion.test.ts b/src/lib/search-discussion.test.ts index 388ca02..00754c6 100644 --- a/src/lib/search-discussion.test.ts +++ b/src/lib/search-discussion.test.ts @@ -71,6 +71,20 @@ describe("search discussion", () => { expect(context.hash).toHaveLength(40); }); + it("keeps tweet media available for discussion citation popovers", () => { + const context = collectSearchDiscussionContext({ + query: "developer-platform", + limit: 20, + }); + + expect(context.tweets[0]?.media).toEqual([ + expect.objectContaining({ + type: "image", + altText: "Pricing survey chart", + }), + ]); + }); + it("keeps live search scoped to the search bucket", () => { const context = collectSearchDiscussionContext({ query: "local-first", diff --git a/src/lib/search-discussion.ts b/src/lib/search-discussion.ts index 494295b..297115f 100644 --- a/src/lib/search-discussion.ts +++ b/src/lib/search-discussion.ts @@ -16,6 +16,7 @@ import { type OpenAIStreamState, processOpenAIResponseSseChunk, } from "./openai-response-runtime"; +import { parseJsonField } from "./query-read-model-shared"; import { listTimelineItems } from "./timeline-read-model"; import { readSyncCache, writeSyncCache } from "./sync-cache"; import { @@ -23,7 +24,7 @@ import { type SyncTweetSearchResult, type TweetSearchMode, } from "./tweet-search-live"; -import type { ProfileRecord } from "./types"; +import type { ProfileRecord, TweetEntities, TweetMediaItem } from "./types"; export type SearchDiscussionSource = | "all" @@ -69,6 +70,8 @@ interface CompactSearchTweet { authorProfile: ProfileRecord; createdAt: string; text: string; + entities?: TweetEntities; + media: TweetMediaItem[]; likeCount: number; liked: boolean; bookmarked: boolean; @@ -182,6 +185,8 @@ function compactTweet( authorProfile: item.author, createdAt: item.createdAt, text: item.text, + entities: item.entities, + media: item.media, likeCount: item.likeCount, liked: item.liked, bookmarked: item.bookmarked, @@ -231,7 +236,9 @@ function collectLiveSearchTweets( t.created_at, t.is_replied, t.like_count, - t.media_count, + t.media_count, + t.entities_json, + t.media_json, case when exists ( select 1 from tweet_collections collection @@ -295,6 +302,8 @@ function collectLiveSearchTweets( }, createdAt: String(row.created_at), text: String(row.text), + entities: parseJsonField(row.entities_json, {}), + media: parseJsonField(row.media_json, []), likeCount: Number(row.like_count), liked: Boolean(row.liked), bookmarked: Boolean(row.bookmarked), diff --git a/src/routes/discuss.tsx b/src/routes/discuss.tsx index 59e9b03..21ef8a7 100644 --- a/src/routes/discuss.tsx +++ b/src/routes/discuss.tsx @@ -408,7 +408,7 @@ export function DiscussRouteView({ {markdown ? ( ) : (