Files
twitter-lite/src/features/posts/components/post-feed.tsx
T
2026-07-14 00:50:08 +09:00

126 lines
3.5 KiB
TypeScript

import { useEffect, useRef } from 'react'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from '../page'
import type { Post } from '../types'
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
import { PostCard } from './post-card'
function FocalPost({ post }: { post: Post }) {
return (
<section aria-labelledby="focal-post-label" className="focal-post">
<p className="focal-label" id="focal-post-label">
表示中の投稿
</p>
<PostCard current post={post} />
</section>
)
}
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
const query = usePostFeed(request)
const sentinel = useRef<HTMLDivElement>(null)
const pages = query.data?.pages ?? []
const focalPost =
request?.kind === 'thread' ? focalPostFromPages(pages) : undefined
const posts = focalPost
? flattenConversationPages(pages, focalPost.id)
: flattenPostPages(pages)
useEffect(() => {
if (
!sentinel.current ||
!query.hasNextPage ||
query.isFetchNextPageError ||
query.isFetchingNextPage
) {
return
}
let requested = false
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !requested) {
requested = true
void query.fetchNextPage()
}
},
{ rootMargin: '600px 0px' },
)
observer.observe(sentinel.current)
return () => observer.disconnect()
}, [
query.fetchNextPage,
query.hasNextPage,
query.isFetchNextPageError,
query.isFetchingNextPage,
])
if (!request) return null
if (query.isPending) {
return (
<p className="state" role="status">
投稿を取得しています…
</p>
)
}
if (query.isError && !query.isFetchNextPageError && posts.length === 0) {
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
return (
<div className="state" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button onClick={() => void query.refetch()} type="button">
再試行
</button>
) : null}
</div>
)
}
if (posts.length === 0 && !query.hasNextPage) {
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<p className="state">
{focalPost
? '会話にほかの投稿はありません。'
: '条件に一致する投稿はありません。'}
</p>
</>
)
}
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<section aria-live="polite" className="feed">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div aria-hidden="true" ref={sentinel} />
{query.isFetchingNextPage ? (
<div className="loading-rail" role="status">
次の投稿を取得しています…
</div>
) : null}
{query.isFetchNextPageError ? (
<div className="state" role="alert">
<p>続きの投稿を取得できませんでした。</p>
<button onClick={() => void query.fetchNextPage()} type="button">
再試行
</button>
</div>
) : null}
{!query.hasNextPage ? (
<p className="state">これ以上の投稿はありません。</p>
) : null}
</section>
</>
)
}