diff --git a/src/api/schema.ts b/src/api/schema.ts index 349268a..cdd48ca 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -112,6 +112,11 @@ export interface paths { export type webhooks = Record export interface components { schemas: { + AuthoPostAuthorDto: { + /** Format: uuid */ + authorId: string + username: string + } AuthorPostDto: { /** Format: uuid */ postId: string @@ -119,6 +124,7 @@ export interface components { media: string[] /** Format: date-time */ createdAt: string + author: components['schemas']['AuthoPostAuthorDto'] } CreatePostRequest: { /** Format: uuid */ @@ -142,7 +148,7 @@ export interface components { username: string } PublicPostDto: { - authorDto: components['schemas']['PublicPostAuthorDto'] + author: components['schemas']['PublicPostAuthorDto'] /** Format: uuid */ postId: string content: string diff --git a/src/components/PostsFeed.tsx b/src/components/PostsFeed.tsx deleted file mode 100644 index 9de45a0..0000000 --- a/src/components/PostsFeed.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Post } from '../model/posts/posts.ts' -import { Temporal } from '@js-temporal/polyfill' - -interface PostsFeedProps { - posts: Post[] -} - -export default function PostsFeed({ posts }: PostsFeedProps) { - return ( -
- {posts.map((post) => ( -
-
{formatDate(post.createdAt)}
- -
{post.content}
- - {post.media.length > 0 && ( -
- {post.media.map((src) => ( - - ))} -
- )} -
- ))} -
- ) -} - -function formatDate(date: Temporal.PlainDateTime) { - return date.toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) -} diff --git a/src/components/PostsList.tsx b/src/components/PostsList.tsx new file mode 100644 index 0000000..78e1416 --- /dev/null +++ b/src/components/PostsList.tsx @@ -0,0 +1,16 @@ +import { Post } from '../model/posts/posts.ts' +import PostItem from './PostItem' + +interface PostsFeedProps { + posts: Post[] +} + +export default function PostsList({ posts }: PostsFeedProps) { + return ( +
+ {posts.map((post) => ( + + ))} +
+ ) +} diff --git a/src/hooks/useIntersectionLoad.ts b/src/hooks/useIntersectionLoad.ts new file mode 100644 index 0000000..9ba4720 --- /dev/null +++ b/src/hooks/useIntersectionLoad.ts @@ -0,0 +1,93 @@ +import { RefObject, useEffect, useRef } from 'react' + +interface UseIntersectionLoadOptions extends IntersectionObserverInit { + earlyTriggerPx?: number + debounceMs?: number +} + +export function useIntersectionLoad( + callback: () => Promise, + elementRef: RefObject, + { + earlyTriggerPx = 1800, + debounceMs = 300, + root = null, + threshold = 0.1, + rootMargin = '0px', + }: UseIntersectionLoadOptions = {}, +) { + const observerRef = useRef(null) + const loading = useRef(false) + const timeoutRef = useRef(null) + + useEffect(() => { + const el = elementRef.current + if (!el) return + + const margin = computeAdjustedRootMargin(rootMargin, earlyTriggerPx) + + const debouncedCallback = () => { + if (timeoutRef.current) return + timeoutRef.current = setTimeout(async () => { + timeoutRef.current = null + if (!loading.current) { + loading.current = true + try { + await callback() + } finally { + loading.current = false + if (elementRef.current && observerRef.current) { + observerRef.current.unobserve(elementRef.current) + observerRef.current.observe(elementRef.current) + } + } + } + }, debounceMs) + } + + observerRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry.isIntersecting) { + debouncedCallback() + } + }, + { + root, + threshold, + rootMargin: margin, + }, + ) + + observerRef.current.observe(el) + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + if (el && observerRef.current) { + observerRef.current.unobserve(el) + observerRef.current.disconnect() + } + } + }, [callback, elementRef, root, rootMargin, threshold, earlyTriggerPx, debounceMs]) +} + +// Utility to adjust rootMargin's top value +function computeAdjustedRootMargin(baseMargin: string, extraTopPx: number): string { + const parts = baseMargin.split(' ') + const top = parts[0] || '0px' + const adjustedTop = `${parseInt(top, 10) + extraTopPx}px` + + // Maintain format: top [right bottom left] + if (parts.length === 1) { + return `${adjustedTop}` + } else if (parts.length === 2) { + return `${adjustedTop} ${parts[1]}` + } else if (parts.length === 3) { + return `${adjustedTop} ${parts[1]} ${parts[2]}` + } else { + return `${adjustedTop} ${parts[1]} ${parts[2]} ${parts[3]}` + } +} diff --git a/src/model/posts/posts.ts b/src/model/posts/posts.ts index d07360a..69ab531 100644 --- a/src/model/posts/posts.ts +++ b/src/model/posts/posts.ts @@ -6,15 +6,31 @@ export class Post { public readonly content: string public readonly media: string[] public readonly createdAt: Temporal.PlainDateTime + public readonly authorName: string - constructor(postId: string, content: string, media: string[], createdAt: Temporal.PlainDateTime) { + constructor( + postId: string, + content: string, + media: string[], + createdAt: Temporal.PlainDateTime, + authorName: string, + ) { this.postId = postId this.content = content this.media = media this.createdAt = createdAt + this.authorName = authorName } - public static fromDto(dto: components['schemas']['AuthorPostDto']): Post { - return new Post(dto.postId, dto.content, dto.media, Temporal.PlainDateTime.from(dto.createdAt)) + public static fromDto( + dto: components['schemas']['AuthorPostDto'] | components['schemas']['PublicPostDto'], + ): Post { + return new Post( + dto.postId, + dto.content, + dto.media, + Temporal.PlainDateTime.from(dto.createdAt), + dto.author.username, + ) } } diff --git a/src/pages/FeedView.tsx b/src/pages/FeedView.tsx index 2353ec9..58ba097 100644 --- a/src/pages/FeedView.tsx +++ b/src/pages/FeedView.tsx @@ -1,9 +1,10 @@ import { Post } from '../model/posts/posts.ts' -import PostsFeed from '../components/PostsFeed.tsx' -import { useCallback, useEffect, useRef, useState } from 'react' +import PostsList from '../components/PostsList.tsx' +import { useCallback, useRef, useState } from 'react' import './FeedView.css' +import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts' -const PageSize = 10 +const PageSize = 20 interface FeedViewProps { loadPosts: (cursor: string | null, amount: number) => Promise @@ -26,12 +27,10 @@ export default function FeedView({ loadPosts }: FeedViewProps) { setIsLoading(true) try { - const delay = new Promise((resolve) => setTimeout(resolve, 500)) // minimum delay + const delay = new Promise((resolve) => setTimeout(resolve, 500)) const pagePromise = loadPosts(cursor.current, PageSize) - - const [page] = await Promise.all([pagePromise, delay]) // wait for both - - if (page.length < PageSize) setHasMore(false) + const [page] = await Promise.all([pagePromise, delay]) + setHasMore(page.length >= PageSize) cursor.current = page.at(-1)?.postId ?? null setPages((prev) => [...prev, page]) } finally { @@ -40,47 +39,11 @@ export default function FeedView({ loadPosts }: FeedViewProps) { } }, [loadPosts, hasMore]) - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] - if (entry.isIntersecting) { - loadNextPage() - } - }, - { - root: null, - rootMargin: '100px', - threshold: 0.1, - }, - ) - - const sentinel = sentinelRef.current - if (sentinel) observer.observe(sentinel) - return () => { - if (sentinel) observer.unobserve(sentinel) - } - }, [loadNextPage]) - - // Ensure content fills viewport after initial render - useEffect(() => { - const checkContentHeight = async () => { - while ( - document.documentElement.scrollHeight <= window.innerHeight && - hasMore && - !loading.current - ) { - await loadNextPage() - } - } - - checkContentHeight() - }, [posts.length, loadNextPage, hasMore]) - + useIntersectionLoad(loadNextPage, sentinelRef) return (
- + {isLoading && (