Fix reader media and thread behavior #4

Merged
yuta merged 1 commits from agent/fix-reader-media-and-threads into main 2026-07-26 15:32:29 +09:00
14 changed files with 253 additions and 53 deletions
+16
View File
@@ -24,6 +24,10 @@ test('requires intent and infinitely loads a user timeline', async ({
}) => { }) => {
await homePage.goTo() await homePage.goTo()
await expect(page).toHaveURL(/\/$/) await expect(page).toHaveURL(/\/$/)
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
'content',
'no-referrer',
)
await expect(homePage.articlesLocator).toHaveCount(0) await expect(homePage.articlesLocator).toHaveCount(0)
await expect(homePage.ambientLinksLocator).toHaveCount(0) await expect(homePage.ambientLinksLocator).toHaveCount(0)
@@ -96,6 +100,18 @@ test('resolves a reply root and retries only the failed continuation', async ({
await expect(threadPage.mutationButtonsLocator).toHaveCount(0) await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
}) })
test('ends a thread without loading a continuation when there are no replies', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '3001')
await expect(page.getByText('no replies')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
})
test('searches Top posts from followed accounts', async ({ test('searches Top posts from followed accounts', async ({
page, page,
searchPage, searchPage,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+28 -3
View File
@@ -11,10 +11,12 @@ const failedRetryQueries = new Set()
const rootByTarget = new Map([ const rootByTarget = new Map([
['1001', '1000'], ['1001', '1000'],
['2001', '2000'], ['2001', '2000'],
['3001', '3000'],
]) ])
const targetByRoot = new Map([ const targetByRoot = new Map([
['1000', '1001'], ['1000', '1001'],
['2000', '2001'], ['2000', '2001'],
['3000', '3001'],
]) ])
const failedThreadRoots = new Set() const failedThreadRoots = new Set()
@@ -25,13 +27,13 @@ const tweetResult = (
id, id,
text, text,
username = 'yuta', username = 'yuta',
{ conversationId = id, inReplyTo } = {}, { conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({ ) => ({
rest_id: id, rest_id: id,
legacy: { legacy: {
full_text: text, full_text: text,
created_at: 'Mon Jul 13 00:00:00 +0000 2026', created_at: 'Mon Jul 13 00:00:00 +0000 2026',
reply_count: 1, reply_count: replyCount,
retweet_count: 2, retweet_count: 2,
favorite_count: 3, favorite_count: 3,
conversation_id_str: conversationId, conversation_id_str: conversationId,
@@ -288,10 +290,16 @@ const server = createServer(async (request, response) => {
const targetRoot = rootByTarget.get(focalTweetId) const targetRoot = rootByTarget.get(focalTweetId)
if (targetRoot && variables.cursor === undefined) { if (targetRoot && variables.cursor === undefined) {
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal' const noReplies = focalTweetId === '3001'
const text = noReplies
? 'no replies'
: focalTweetId === '1001'
? 'user page 1'
: 'retry focal'
const result = tweetResult(focalTweetId, text, 'focus', { const result = tweetResult(focalTweetId, text, 'focus', {
conversationId: targetRoot, conversationId: targetRoot,
inReplyTo: targetRoot, inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
}) })
send(response, { send(response, {
data: { data: {
@@ -303,6 +311,7 @@ const server = createServer(async (request, response) => {
tweet(focalTweetId, text, 'focus', { tweet(focalTweetId, text, 'focus', {
conversationId: targetRoot, conversationId: targetRoot,
inReplyTo: targetRoot, inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
}), }),
], ],
}, },
@@ -323,6 +332,10 @@ const server = createServer(async (request, response) => {
fail(response, 400, 'invalid thread cursor') fail(response, 400, 'invalid thread cursor')
return return
} }
if (variables.cursor && focalTweetId === '3000') {
fail(response, 500, 'zero-reply thread must not request a continuation')
return
}
if ( if (
variables.cursor && variables.cursor &&
focalTweetId === '2000' && focalTweetId === '2000' &&
@@ -340,6 +353,18 @@ const server = createServer(async (request, response) => {
inReplyTo: targetId, inReplyTo: targetId,
}), }),
] ]
: focalTweetId === '3000'
? [
tweet(focalTweetId, 'thread root', 'root', {
conversationId: focalTweetId,
}),
tweet(targetId, 'no replies', 'focus', {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
replyCount: 0,
}),
cursor(expectedCursor),
]
: [ : [
tweet(focalTweetId, 'thread root', 'root', { tweet(focalTweetId, 'thread root', 'root', {
conversationId: focalTweetId, conversationId: focalTweetId,
+1 -1
View File
@@ -22,7 +22,7 @@
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11; pnpm = pkgs.pnpm_11;
fetcherVersion = 4; fetcherVersion = 4;
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE="; hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
}; };
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
@@ -55,6 +55,10 @@ describe('PostCard', () => {
expect(container.querySelectorAll('video')).toHaveLength(2) expect(container.querySelectorAll('video')).toHaveLength(2)
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls') expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop') expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
'src',
'https://media.invalid/video.mp4',
)
expect(screen.getByText('Article title')).toBeInTheDocument() expect(screen.getByText('Article title')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument() expect(screen.getByText('Preview')).toBeInTheDocument()
expect(screen.getByText('quoted')).toBeInTheDocument() expect(screen.getByText('quoted')).toBeInTheDocument()
@@ -80,12 +84,39 @@ describe('PostCard', () => {
expect(screen.getByText('@yuta').closest('a')).toBeNull() expect(screen.getByText('@yuta').closest('a')).toBeNull()
}) })
it('decodes HTML entities in the post body before linking URLs', () => {
render(
<PostCard
post={{
...richPost,
text: 'A &gt; B &amp; C &#39;D&#39; https://example.com/?a=1&amp;b=2',
}}
/>,
)
expect(
screen.getByText((_, element) =>
Boolean(
element?.classList.contains('post-text') &&
element.textContent ===
"A > B & C 'D' https://example.com/?a=1&b=2",
),
),
).toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
})
it('links deliberately to the internal detail page', () => { it('links deliberately to the internal detail page', () => {
render(<PostCard post={richPost} />) render(<PostCard post={richPost} />)
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
expect(detail).toHaveAttribute('href', '/yuta/status/123')
expect(detail.parentElement).toHaveClass('post-actions')
expect( expect(
screen.getByRole('link', { name: '詳細・スレッド' }), screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
).toHaveAttribute('href', '/yuta/status/123') ).toBe(detail.parentElement)
}) })
it('marks the focal post without linking to its current page', () => { it('marks the focal post without linking to its current page', () => {
@@ -103,4 +134,26 @@ describe('PostCard', () => {
expect(screen.getByText('Yuta')).toBeInTheDocument() expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(container.querySelector('time')).not.toBeInTheDocument() expect(container.querySelector('time')).not.toBeInTheDocument()
}) })
it('does not try to play a poster image when Bird has no video URL', () => {
const { container } = render(
<PostCard
post={{
...richPost,
media: [
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
previewUrl: 'https://media.invalid/video-preview.jpg',
},
],
}}
/>,
)
expect(container.querySelector('video')).not.toBeInTheDocument()
expect(
screen.getByRole('img', { name: '動画のプレビュー' }),
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
})
}) })
+25 -3
View File
@@ -3,19 +3,39 @@ import type { Post } from '../types'
import { PostMedia } from './post-media' import { PostMedia } from './post-media'
const URL = /(https?:\/\/[^\s]+)/g const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
const dateFormatter = new Intl.DateTimeFormat('ja-JP', { const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
timeZone: 'Asia/Tokyo', timeZone: 'Asia/Tokyo',
}) })
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
}
function linkedText(text: string): ReactNode[] { function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = [] const parts: ReactNode[] = []
let cursor = 0 let cursor = 0
for (const match of text.matchAll(URL)) { for (const match of decoded.matchAll(URL)) {
const href = match[0] const href = match[0]
const start = match.index const start = match.index
if (start > cursor) parts.push(text.slice(cursor, start)) if (start > cursor) parts.push(decoded.slice(cursor, start))
parts.push( parts.push(
<a <a
href={href} href={href}
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
) )
cursor = start + href.length cursor = start + href.length
} }
if (cursor < text.length) parts.push(text.slice(cursor)) if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts return parts
} }
@@ -90,6 +110,7 @@ export function PostCard({
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '} 返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0} {post.likeCount ?? 0}
</span> </span>
<span className="post-actions">
{!current ? ( {!current ? (
<a <a
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`} href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
@@ -100,6 +121,7 @@ export function PostCard({
<a href={original} rel="noreferrer noopener" target="_blank"> <a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く 元の投稿を開く
</a> </a>
</span>
</footer> </footer>
) : null} ) : null}
</article> </article>
+13 -9
View File
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
return ( return (
<div className="media-grid"> <div className="media-grid">
{media.map((item) => {media.map((item) => {
item.type === 'photo' ? ( if (item.type === 'photo' || !item.videoUrl) {
return (
<img <img
alt="投稿画像" alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
className="media" className="media"
height={item.height} height={item.height}
key={item.url} key={item.url}
loading="lazy" loading="lazy"
src={item.url} src={item.previewUrl ?? item.url}
width={item.width} width={item.width}
/> />
) : ( )
}
return (
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs. // biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
<video <video
className="media" className="media"
controls controls
key={item.videoUrl ?? item.url} key={item.videoUrl}
loop={item.type === 'animated_gif'} loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url} poster={item.previewUrl ?? item.url}
preload="metadata" preload="metadata"
src={item.videoUrl ?? item.url} src={item.videoUrl}
/> />
), )
)} })}
</div> </div>
) )
} }
+58
View File
@@ -168,6 +168,64 @@ describe('loadThreadPage', () => {
}) })
}) })
it('does not expose a continuation when the focal post has no replies', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
replyCount: 0,
author: { username: 'focus', name: 'Focus' },
},
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it.each([
{
name: 'an empty final page',
tweets: [],
returnedCursor: 'thread-final',
},
{
name: 'the same cursor again',
tweets: [
{
id: '101',
text: 'last reply',
author: { username: 'reply', name: 'Reply' },
},
],
returnedCursor: 'thread-next',
},
])('stops after $name', async ({ tweets, returnedCursor }) => {
const client = reader()
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets,
nextCursor: returnedCursor,
})
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it('removes raw data from focal, conversation, and quotes', async () => { it('removes raw data from focal, conversation, and quotes', async () => {
const client = reader() const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({ vi.mocked(client.getTweet).mockResolvedValue({
+10 -2
View File
@@ -215,14 +215,22 @@ export async function loadThreadPage(
if (!result.success) { if (!result.success) {
return upstreamFailure(result.error, 'post') return upstreamFailure(result.error, 'post')
} }
const tweets = result.tweets ?? []
const nextCursor =
result.nextCursor &&
tweets.length > 0 &&
result.nextCursor !== input.cursor &&
focalPost?.replyCount !== 0
? result.nextCursor
: undefined
return { return {
ok: true, ok: true,
page: { page: {
tweets: (result.tweets ?? []).map(publicPost), tweets: tweets.map(publicPost),
focalPost, focalPost,
conversationId, conversationId,
nextCursor: result.nextCursor, nextCursor,
}, },
} }
} catch (error) { } catch (error) {
+1
View File
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
name: 'viewport', name: 'viewport',
content: 'width=device-width, initial-scale=1', content: 'width=device-width, initial-scale=1',
}, },
{ name: 'referrer', content: 'no-referrer' },
{ title: 'Twitter Lite' }, { title: 'Twitter Lite' },
], ],
links: [ links: [
+13
View File
@@ -1023,6 +1023,15 @@ button[data-state="success"] {
white-space: nowrap; white-space: nowrap;
} }
.post-actions {
display: flex;
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-sm) var(--space-lg);
margin-inline-start: auto;
}
.state { .state {
margin: var(--space-xl) 0 0; margin: var(--space-xl) 0 0;
padding: var(--space-lg); padding: var(--space-lg);
@@ -1111,6 +1120,10 @@ button[data-state="success"] {
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
} }
.post-actions {
width: auto;
}
} }
@media (min-width: 52rem) { @media (min-width: 52rem) {