feat: render rich read-only posts
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+109
@@ -188,6 +188,102 @@ input:focus-visible {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #c1cfd2;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle,
|
||||||
|
.post-header time {
|
||||||
|
display: block;
|
||||||
|
color: #506970;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header time {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-text {
|
||||||
|
margin: 0.9rem 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.75;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post a {
|
||||||
|
color: #255f6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post a:focus-visible {
|
||||||
|
outline: 3px solid #506970;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid > :only-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 32rem;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #dfe7e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card,
|
||||||
|
.quote {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid #c1cfd2;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #f8faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #506970;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #506970;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counts {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 36rem) {
|
@media (max-width: 36rem) {
|
||||||
.app {
|
.app {
|
||||||
width: min(100% - 1rem, 52rem);
|
width: min(100% - 1rem, 52rem);
|
||||||
@@ -210,4 +306,17 @@ input:focus-visible {
|
|||||||
.intent-form button {
|
.intent-form button {
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header time {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user