feat: render rich read-only posts

This commit is contained in:
2026-07-13 11:47:50 +09:00
parent a3c39d33b7
commit 80a1f66b26
4 changed files with 331 additions and 0 deletions
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Post } from '../types'
import { PostCard } from './post-card'
const richPost: Post = {
id: '123',
text: '詳細 https://content.invalid/article',
author: {
username: 'yuta',
name: 'Yuta',
profileImageUrl: 'https://media.invalid/avatar.jpg',
},
createdAt: '2026-07-13T00:00:00.000Z',
replyCount: 3,
retweetCount: 4,
likeCount: 5,
media: [
{
type: 'photo',
url: 'https://media.invalid/photo.jpg',
width: 1200,
height: 800,
},
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
videoUrl: 'https://media.invalid/video.mp4',
},
{
type: 'animated_gif',
url: 'https://media.invalid/gif-poster.jpg',
videoUrl: 'https://media.invalid/animation.mp4',
},
],
article: { title: 'Article title', previewText: 'Preview' },
quotedTweet: {
id: '122',
text: 'quoted',
author: { username: 'other', name: 'Other' },
},
}
describe('PostCard', () => {
it('renders rich read-only content without mutation controls', () => {
const { container } = render(<PostCard post={richPost} />)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(screen.getByText('@yuta')).toBeInTheDocument()
expect(container.querySelector('time')).toHaveAttribute(
'datetime',
richPost.createdAt,
)
expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument()
expect(container.querySelectorAll('video')).toHaveLength(2)
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
expect(screen.getByText('Article title')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
expect(screen.getByText('quoted')).toBeInTheDocument()
expect(screen.getByText(/返信 3.*再投稿 4.*いいね 5/)).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('uses deliberate safe external links without linking the author', () => {
render(<PostCard post={richPost} />)
const original = screen.getByRole('link', { name: '元の投稿を開く' })
expect(original).toHaveAttribute('href', 'https://x.com/yuta/status/123')
expect(original).toHaveAttribute('target', '_blank')
expect(original).toHaveAttribute('rel', 'noreferrer noopener')
const textLink = screen.getByRole('link', {
name: 'https://content.invalid/article',
})
expect(textLink).toHaveAttribute('target', '_blank')
expect(textLink).toHaveAttribute('rel', 'noreferrer noopener')
expect(screen.getByText('Yuta').closest('a')).toBeNull()
expect(screen.getByText('@yuta').closest('a')).toBeNull()
})
it('collapses a malformed optional timestamp without losing the post', () => {
const { container } = render(
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(container.querySelector('time')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,96 @@
import type { ReactNode } from 'react'
import type { Post } from '../types'
import { PostMedia } from './post-media'
const URL = /(https?:\/\/[^\s]+)/g
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
function linkedText(text: string): ReactNode[] {
const parts: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(text.slice(cursor, start))
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
{href}
</a>,
)
cursor = start + href.length
}
if (cursor < text.length) parts.push(text.slice(cursor))
return parts
}
function formattedTime(createdAt?: string): string | undefined {
if (!createdAt) return undefined
const date = new Date(createdAt)
return Number.isNaN(date.getTime()) ? undefined : dateFormatter.format(date)
}
export function PostCard({
post,
quoted = false,
}: {
post: Post
quoted?: boolean
}) {
const time = formattedTime(post.createdAt)
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
return (
<article className={quoted ? 'post quote' : 'post'}>
<header className="post-header">
{post.author.profileImageUrl ? (
<img
alt=""
className="avatar"
height="40"
loading="lazy"
src={post.author.profileImageUrl}
width="40"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">@{post.author.username}</span>
</div>
{time && post.createdAt ? (
<time dateTime={post.createdAt}>{time}</time>
) : null}
</header>
<p className="post-text">{linkedText(post.text)}</p>
{post.media ? <PostMedia media={post.media} /> : null}
{post.article ? (
<section className="article-card">
<strong>{post.article.title}</strong>
{post.article.previewText ? <p>{post.article.previewText}</p> : null}
</section>
) : null}
{post.quotedTweet && !quoted ? (
<PostCard post={post.quotedTweet} quoted />
) : null}
{!quoted ? (
<footer className="post-footer">
<span className="counts">
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0}
</span>
<a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く
</a>
</footer>
) : null}
</article>
)
}
@@ -0,0 +1,36 @@
import type { Post } from '../types'
type Media = NonNullable<Post['media']>[number]
export function PostMedia({ media }: { media: Media[] }) {
if (media.length === 0) return null
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}
/>
) : (
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
<video
className="media"
controls
key={item.videoUrl ?? item.url}
loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url}
preload="metadata"
src={item.videoUrl ?? item.url}
/>
),
)}
</div>
)
}