From 80a1f66b26129afa1a29a5087f4eec60afe3e6fc Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Mon, 13 Jul 2026 11:47:50 +0900 Subject: [PATCH] feat: render rich read-only posts --- .../posts/components/post-card.test.tsx | 90 +++++++++++++++ src/features/posts/components/post-card.tsx | 96 +++++++++++++++ src/features/posts/components/post-media.tsx | 36 ++++++ src/styles.css | 109 ++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 src/features/posts/components/post-card.test.tsx create mode 100644 src/features/posts/components/post-card.tsx create mode 100644 src/features/posts/components/post-media.tsx diff --git a/src/features/posts/components/post-card.test.tsx b/src/features/posts/components/post-card.test.tsx new file mode 100644 index 0000000..8aea4f5 --- /dev/null +++ b/src/features/posts/components/post-card.test.tsx @@ -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() + + 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() + + 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( + , + ) + + expect(screen.getByText('Yuta')).toBeInTheDocument() + expect(container.querySelector('time')).not.toBeInTheDocument() + }) +}) diff --git a/src/features/posts/components/post-card.tsx b/src/features/posts/components/post-card.tsx new file mode 100644 index 0000000..f508129 --- /dev/null +++ b/src/features/posts/components/post-card.tsx @@ -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( + + {href} + , + ) + 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 ( +
+
+ {post.author.profileImageUrl ? ( + + ) : null} +
+ {post.author.name} + @{post.author.username} +
+ {time && post.createdAt ? ( + + ) : null} +
+

{linkedText(post.text)}

+ {post.media ? : null} + {post.article ? ( +
+ {post.article.title} + {post.article.previewText ?

{post.article.previewText}

: null} +
+ ) : null} + {post.quotedTweet && !quoted ? ( + + ) : null} + {!quoted ? ( +
+ + 返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '} + {post.likeCount ?? 0} + + + 元の投稿を開く + +
+ ) : null} +
+ ) +} diff --git a/src/features/posts/components/post-media.tsx b/src/features/posts/components/post-media.tsx new file mode 100644 index 0000000..ec81ba8 --- /dev/null +++ b/src/features/posts/components/post-media.tsx @@ -0,0 +1,36 @@ +import type { Post } from '../types' + +type Media = NonNullable[number] + +export function PostMedia({ media }: { media: Media[] }) { + if (media.length === 0) return null + + return ( +
+ {media.map((item) => + item.type === 'photo' ? ( + 投稿画像 + ) : ( + // biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs. +
+ ) +} diff --git a/src/styles.css b/src/styles.css index d9a2e20..4e5d950 100644 --- a/src/styles.css +++ b/src/styles.css @@ -188,6 +188,102 @@ input:focus-visible { 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) { .app { width: min(100% - 1rem, 52rem); @@ -210,4 +306,17 @@ input:focus-visible { .intent-form button { 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; + } }