Fix reader media and thread behavior (#4)
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -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(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
text: 'A > B & C 'D' https://example.com/?a=1&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', () => {
|
||||
render(<PostCard post={richPost} />)
|
||||
|
||||
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(
|
||||
<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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
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(
|
||||
<a
|
||||
href={href}
|
||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -90,16 +110,18 @@ export function PostCard({
|
||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
<span className="post-actions">
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
|
||||
|
||||
return (
|
||||
<div className="media-grid">
|
||||
{media.map((item) =>
|
||||
item.type === 'photo' ? (
|
||||
<img
|
||||
alt="投稿画像"
|
||||
className="media"
|
||||
height={item.height}
|
||||
key={item.url}
|
||||
loading="lazy"
|
||||
src={item.url}
|
||||
width={item.width}
|
||||
/>
|
||||
) : (
|
||||
{media.map((item) => {
|
||||
if (item.type === 'photo' || !item.videoUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||
className="media"
|
||||
height={item.height}
|
||||
key={item.url}
|
||||
loading="lazy"
|
||||
src={item.previewUrl ?? item.url}
|
||||
width={item.width}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||
<video
|
||||
className="media"
|
||||
controls
|
||||
key={item.videoUrl ?? item.url}
|
||||
key={item.videoUrl}
|
||||
loop={item.type === 'animated_gif'}
|
||||
poster={item.previewUrl ?? item.url}
|
||||
preload="metadata"
|
||||
src={item.videoUrl ?? item.url}
|
||||
src={item.videoUrl}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{ name: 'referrer', content: 'no-referrer' },
|
||||
{ title: 'Twitter Lite' },
|
||||
],
|
||||
links: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user