diff --git a/e2e/integrations/reader.test.ts b/e2e/integrations/reader.test.ts index 81d4aa3..2ce7e5e 100644 --- a/e2e/integrations/reader.test.ts +++ b/e2e/integrations/reader.test.ts @@ -24,6 +24,10 @@ test('requires intent and infinitely loads a user timeline', async ({ }) => { await homePage.goTo() await expect(page).toHaveURL(/\/$/) + await expect(page.locator('meta[name="referrer"]')).toHaveAttribute( + 'content', + 'no-referrer', + ) await expect(homePage.articlesLocator).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) }) +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 ({ page, searchPage, diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png index 3e7c43c..9f685a2 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png index 6c53251..f9efcde 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png index 8d3c312..dec2712 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png index a5c8e11..b2dfab8 100644 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png and b/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png differ diff --git a/e2e/mock-relay.mjs b/e2e/mock-relay.mjs index 8341338..cf4fc93 100644 --- a/e2e/mock-relay.mjs +++ b/e2e/mock-relay.mjs @@ -11,10 +11,12 @@ const failedRetryQueries = new Set() const rootByTarget = new Map([ ['1001', '1000'], ['2001', '2000'], + ['3001', '3000'], ]) const targetByRoot = new Map([ ['1000', '1001'], ['2000', '2001'], + ['3000', '3001'], ]) const failedThreadRoots = new Set() @@ -25,13 +27,13 @@ const tweetResult = ( id, text, username = 'yuta', - { conversationId = id, inReplyTo } = {}, + { conversationId = id, inReplyTo, replyCount = 1 } = {}, ) => ({ rest_id: id, legacy: { full_text: text, created_at: 'Mon Jul 13 00:00:00 +0000 2026', - reply_count: 1, + reply_count: replyCount, retweet_count: 2, favorite_count: 3, conversation_id_str: conversationId, @@ -288,10 +290,16 @@ const server = createServer(async (request, response) => { const targetRoot = rootByTarget.get(focalTweetId) 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', { conversationId: targetRoot, inReplyTo: targetRoot, + replyCount: noReplies ? 0 : 1, }) send(response, { data: { @@ -303,6 +311,7 @@ const server = createServer(async (request, response) => { tweet(focalTweetId, text, 'focus', { conversationId: targetRoot, inReplyTo: targetRoot, + replyCount: noReplies ? 0 : 1, }), ], }, @@ -323,6 +332,10 @@ const server = createServer(async (request, response) => { fail(response, 400, 'invalid thread cursor') return } + if (variables.cursor && focalTweetId === '3000') { + fail(response, 500, 'zero-reply thread must not request a continuation') + return + } if ( variables.cursor && focalTweetId === '2000' && @@ -340,25 +353,37 @@ const server = createServer(async (request, response) => { inReplyTo: targetId, }), ] - : [ - tweet(focalTweetId, 'thread root', 'root', { - conversationId: focalTweetId, - }), - tweet( - targetId, - focalTweetId === '1000' ? 'user page 1' : 'retry focal', - 'focus', - { + : focalTweetId === '3000' + ? [ + tweet(focalTweetId, 'thread root', 'root', { + conversationId: focalTweetId, + }), + tweet(targetId, 'no replies', 'focus', { conversationId: focalTweetId, inReplyTo: focalTweetId, - }, - ), - tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', { - conversationId: focalTweetId, - inReplyTo: targetId, - }), - cursor(expectedCursor), - ] + replyCount: 0, + }), + cursor(expectedCursor), + ] + : [ + tweet(focalTweetId, 'thread root', 'root', { + conversationId: focalTweetId, + }), + tweet( + targetId, + focalTweetId === '1000' ? 'user page 1' : 'retry focal', + 'focus', + { + conversationId: focalTweetId, + inReplyTo: focalTweetId, + }, + ), + tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', { + conversationId: focalTweetId, + inReplyTo: targetId, + }), + cursor(expectedCursor), + ] send(response, { data: { tweetResult: { diff --git a/flake.nix b/flake.nix index 7f45eff..6cbc5fe 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_11; fetcherVersion = 4; - hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE="; + hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A="; }; nativeBuildInputs = with pkgs; [ diff --git a/src/features/posts/components/post-card.test.tsx b/src/features/posts/components/post-card.test.tsx index 859acca..d789a9f 100644 --- a/src/features/posts/components/post-card.test.tsx +++ b/src/features/posts/components/post-card.test.tsx @@ -55,6 +55,10 @@ describe('PostCard', () => { expect(container.querySelectorAll('video')).toHaveLength(2) expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls') 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('Preview')).toBeInTheDocument() expect(screen.getByText('quoted')).toBeInTheDocument() @@ -80,12 +84,39 @@ describe('PostCard', () => { expect(screen.getByText('@yuta').closest('a')).toBeNull() }) + it('decodes HTML entities in the post body before linking URLs', () => { + render( + , + ) + + 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', () => { render() + const detail = screen.getByRole('link', { name: '詳細・スレッド' }) + expect(detail).toHaveAttribute('href', '/yuta/status/123') + expect(detail.parentElement).toHaveClass('post-actions') expect( - screen.getByRole('link', { name: '詳細・スレッド' }), - ).toHaveAttribute('href', '/yuta/status/123') + screen.getByRole('link', { name: '元の投稿を開く' }).parentElement, + ).toBe(detail.parentElement) }) it('marks the focal post without linking to its current page', () => { @@ -103,4 +134,26 @@ describe('PostCard', () => { expect(screen.getByText('Yuta')).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( + , + ) + + expect(container.querySelector('video')).not.toBeInTheDocument() + expect( + screen.getByRole('img', { name: '動画のプレビュー' }), + ).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg') + }) }) diff --git a/src/features/posts/components/post-card.tsx b/src/features/posts/components/post-card.tsx index cab7154..6c4fafb 100644 --- a/src/features/posts/components/post-card.tsx +++ b/src/features/posts/components/post-card.tsx @@ -3,19 +3,39 @@ import type { Post } from '../types' import { PostMedia } from './post-media' const URL = /(https?:\/\/[^\s]+)/g +const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi +const NAMED_ENTITIES: Record = { + '&': '&', + ''': "'", + '>': '>', + '<': '<', + '"': '"', +} const dateFormatter = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeStyle: 'short', 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[] { + const decoded = decodeHtmlEntities(text) const parts: ReactNode[] = [] let cursor = 0 - for (const match of text.matchAll(URL)) { + for (const match of decoded.matchAll(URL)) { const href = match[0] const start = match.index - if (start > cursor) parts.push(text.slice(cursor, start)) + if (start > cursor) parts.push(decoded.slice(cursor, start)) parts.push( - {!current ? ( - - 詳細・スレッド + + {!current ? ( + + 詳細・スレッド + + ) : null} + + 元の投稿を開く - ) : null} - - 元の投稿を開く - + ) : null} diff --git a/src/features/posts/components/post-media.tsx b/src/features/posts/components/post-media.tsx index ec81ba8..954ed52 100644 --- a/src/features/posts/components/post-media.tsx +++ b/src/features/posts/components/post-media.tsx @@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) { return (
- {media.map((item) => - item.type === 'photo' ? ( - 投稿画像 - ) : ( + {media.map((item) => { + if (item.type === 'photo' || !item.videoUrl) { + return ( + {item.type + ) + } + + return ( // biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
) } diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts index 5e386a9..49948d7 100644 --- a/src/features/posts/post-service.test.ts +++ b/src/features/posts/post-service.test.ts @@ -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 () => { const client = reader() vi.mocked(client.getTweet).mockResolvedValue({ diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts index af36f11..9a0205f 100644 --- a/src/features/posts/post-service.ts +++ b/src/features/posts/post-service.ts @@ -215,14 +215,22 @@ export async function loadThreadPage( if (!result.success) { 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 { ok: true, page: { - tweets: (result.tweets ?? []).map(publicPost), + tweets: tweets.map(publicPost), focalPost, conversationId, - nextCursor: result.nextCursor, + nextCursor, }, } } catch (error) { diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 1593d6b..f11c82c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext()({ name: 'viewport', content: 'width=device-width, initial-scale=1', }, + { name: 'referrer', content: 'no-referrer' }, { title: 'Twitter Lite' }, ], links: [ diff --git a/src/styles.css b/src/styles.css index e193492..6c89a70 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1023,6 +1023,15 @@ button[data-state="success"] { 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 { margin: var(--space-xl) 0 0; padding: var(--space-lg); @@ -1111,6 +1120,10 @@ button[data-state="success"] { align-items: center; flex-direction: row; } + + .post-actions { + width: auto; + } } @media (min-width: 52rem) {