Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 128 additions & 4 deletions src/components/MarkdownCitations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, CitationTweet>;
profilesByHandle: Map<string, ProfileRecord>;
Expand Down Expand Up @@ -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),
);
Comment on lines +172 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip lost-range status media placeholders

When an imported media entity has no usable range, the normal archive shape stores the media entity's expandedUrl as the /status/.../photo/1 URL while extractTweetMedia stores the actual media_url_https as item.url (src/lib/archive/parsing.ts lines 64-66 and 189-193). In that case the raw trailing https://t.co/... remains in the rendered text because this lost-range check only compares entry.expandedUrl to the pbs media URLs, and isOwnStatusMediaUrl() is only applied to the raw trailing t.co URL, so the main malformed media-only placeholder scenario is still not suppressed.

Useful? React with 👍 / 👎.

});
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 (
<span
className={
images.length === 1
? "mt-2 block overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-active)]"
: "mt-2 grid grid-cols-2 gap-1.5 overflow-hidden rounded-xl"
}
>
{images.map(({ item, url }, index) => (
<img
key={`${url}-${String(index)}`}
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
className={
images.length === 1
? "block max-h-64 w-full object-contain"
: "block aspect-square size-full rounded-lg border border-[var(--line)] object-cover"
}
decoding="async"
src={url}
/>
))}
</span>
);
}

function TweetSourceLink({
children,
href,
Expand Down Expand Up @@ -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]
Expand All @@ -155,7 +276,7 @@ function TweetPreviewToken({
Boolean(value) && values.indexOf(value) === index,
)
.join("\n\n")
: renderedText;
: previewTextWithoutMediaLink(renderedText, tweet, media);

return (
<span
Expand Down Expand Up @@ -201,6 +322,7 @@ function TweetPreviewToken({
<span className="line-clamp-6 whitespace-pre-wrap [overflow-wrap:anywhere]">
{previewText}
</span>
<TweetPreviewMedia items={media} />
<span className="mt-2 flex gap-3 text-[12px] text-[var(--ink-soft)]">
<span>{tweet.source}</span>
{tweet.likeCount > 0 ? (
Expand Down Expand Up @@ -622,6 +744,7 @@ function profileAnalysisTweetToCitation(
liked: false,
bookmarked: false,
needsReply: false,
media: [],
};
}

Expand All @@ -643,6 +766,7 @@ function conversationTweetToCitation(
liked: false,
bookmarked: false,
needsReply: false,
media: [],
};
}

Expand Down
54 changes: 54 additions & 0 deletions src/components/MarkdownViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const context = {
liked: false,
bookmarked: false,
needsReply: true,
media: [],
},
{
id: "2057574939775938900",
Expand All @@ -74,6 +75,7 @@ const context = {
liked: false,
bookmarked: false,
needsReply: false,
media: [],
},
{
id: "2057578665408434460",
Expand All @@ -93,6 +95,7 @@ const context = {
liked: false,
bookmarked: false,
needsReply: false,
media: [],
},
],
dms: [],
Expand Down Expand Up @@ -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(
<MarkdownViewer
context={mediaContext}
markdown={
"The benchmark result drew attention (tweet_2056286865875935400)."
}
/>,
);

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",
);
});
});
1 change: 1 addition & 0 deletions src/lib/period-digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion src/lib/period-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -160,6 +165,7 @@ interface CompactTweet {
createdAt: string;
text: string;
entities?: TweetEntities;
media: TweetMediaItem[];
likeCount: number;
liked: boolean;
bookmarked: boolean;
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions src/lib/search-discussion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading