From c21e193fbf667d922423d932442e2c6e5d0f8201 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 28 May 2025 22:05:51 +0200 Subject: [PATCH] reactions --- src/App.tsx | 2 - src/app/feed/components/FeedView.ts | 33 +----------- src/app/feed/components/FeedView.tsx | 5 +- src/app/feed/components/PostItem.tsx | 9 ++-- src/app/feed/pages/AuthorPage.tsx | 52 ------------------- src/app/feed/pages/HomePage.tsx | 75 ++++++++++++++++++++++++---- src/app/feed/posts/posts.ts | 7 ++- src/app/feed/posts/postsService.ts | 16 ++---- src/utils/debounce.ts | 17 +++++++ 9 files changed, 97 insertions(+), 119 deletions(-) delete mode 100644 src/app/feed/pages/AuthorPage.tsx create mode 100644 src/utils/debounce.ts diff --git a/src/App.tsx b/src/App.tsx index c1406bc..3694fdf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import HomePage from './app/feed/pages/HomePage.tsx' -import AuthorPage from './app/feed/pages/AuthorPage.tsx' import SignupPage from './app/auth/pages/SignupPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx' import LogoutPage from './app/auth/pages/LogoutPage.tsx' @@ -23,7 +22,6 @@ export default function App() { path={'/'} element={} /> - } /> } /> } /> } /> diff --git a/src/app/feed/components/FeedView.ts b/src/app/feed/components/FeedView.ts index b79acb9..d8ed6bd 100644 --- a/src/app/feed/components/FeedView.ts +++ b/src/app/feed/components/FeedView.ts @@ -1,36 +1,5 @@ import { useCallback, useRef, useState } from 'react' import { Post } from '../posts/posts.ts' +import { produce, WritableDraft } from 'immer' const PageSize = 20 - -export function useFeedViewModel( - loadMore: (cursor: string | null, amount: number) => Promise, -) { - const [pages, setPages] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [error, setError] = useState(null) - - const cursor = useRef(null) - const loading = useRef(false) - - const loadNextPage = useCallback(async () => { - if (loading.current || !hasMore || error) return - loading.current = true - - try { - const delay = new Promise((resolve) => setTimeout(resolve, 500)) - const pagePromise = loadMore(cursor.current, PageSize) - const [page] = await Promise.all([pagePromise, delay]) - setHasMore(page.length >= PageSize) - cursor.current = page.at(-1)?.postId ?? null - setPages((prev) => [...prev, page]) - } catch (e: unknown) { - const err = e as Error - setError(err.message) - } finally { - loading.current = false - } - }, [loadMore, hasMore, error]) - - return { pages, setPages, loadNextPage, error } as const -} diff --git a/src/app/feed/components/FeedView.tsx b/src/app/feed/components/FeedView.tsx index 2ae7839..2b814b3 100644 --- a/src/app/feed/components/FeedView.tsx +++ b/src/app/feed/components/FeedView.tsx @@ -4,15 +4,14 @@ import { Post } from '../posts/posts.ts' import PostItem from './PostItem.tsx' interface FeedViewProps { - pages: Post[][] + posts: Post[] onLoadMore: () => Promise addReaction: (postId: string, emoji: string) => void clearReaction: (postId: string, emoji: string) => void } -export default function FeedView({ pages, onLoadMore, addReaction, clearReaction }: FeedViewProps) { +export default function FeedView({ posts, onLoadMore, addReaction, clearReaction }: FeedViewProps) { const sentinelRef = useRef(null) - const posts = pages.flat() useIntersectionLoad(onLoadMore, sentinelRef) diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index fc92658..a0a541c 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,5 +1,4 @@ import { Post, PostMedia } from '../posts/posts.ts' -import { Link } from 'react-router-dom' import { useEffect, useState } from 'react' interface PostItemProps { @@ -31,10 +30,7 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP return (
- - @{post.authorName} - - • {formattedDate} + @{post.authorName}• {formattedDate}
{post.content}
@@ -77,10 +73,11 @@ function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) return ( onClick()} + onClick={onClick} /> ) })} diff --git a/src/app/feed/pages/AuthorPage.tsx b/src/app/feed/pages/AuthorPage.tsx deleted file mode 100644 index 1cec207..0000000 --- a/src/app/feed/pages/AuthorPage.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback } from 'react' -import FeedView from '../components/FeedView.tsx' -import { PostsService } from '../posts/postsService.ts' -import { useParams } from 'react-router-dom' -import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' -import NavBar from '../../../components/NavBar.tsx' -import { useFeedViewModel } from '../components/FeedView.ts' -import NavButton from '../../../components/buttons/NavButton.tsx' -import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' - -interface AuthorPageParams { - postsService: PostsService -} - -export default function AuthorPage({ postsService }: AuthorPageParams) { - const { username } = useParams() - - const fetchPosts = useCallback( - async (cursor: string | null, amount: number | null) => { - return postsService.loadByAuthor(username!, cursor, amount) - }, - [postsService, username], - ) - - const { pages, loadNextPage } = useFeedViewModel(fetchPosts) - - const addReaction = async (postId: string, emoji: string) => { - await postsService.addReaction(postId, emoji) - } - - const clearReaction = async (postId: string, emoji: string) => { - await postsService.removeReaction(postId, emoji) - } - - return ( - - home - - - } - > - - - ) -} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index f4f6827..e0b73b8 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,20 +1,23 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import FeedView from '../components/FeedView.tsx' import { PostsService } from '../posts/postsService.ts' import { useUser } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' import NewPostWidget from '../../../components/NewPostWidget.tsx' -import { useFeedViewModel } from '../components/FeedView.ts' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' +import { Post } from '../posts/posts.ts' +import { produce, WritableDraft } from 'immer' interface HomePageProps { postsService: PostsService mediaService: MediaService } +const PageSize = 20 + export default function HomePage({ postsService, mediaService }: HomePageProps) { const user = useUser() useSaveSignupCodeToLocalStorage() @@ -27,7 +30,31 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) [postsService], ) - const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts) + const [posts, setPosts] = useState([]) + const [hasMore, setHasMore] = useState(true) + const [error, setError] = useState(null) + + const cursor = useRef(null) + const loading = useRef(false) + + const loadNextPage = useCallback(async () => { + if (loading.current || !hasMore || error) return + loading.current = true + + try { + const delay = new Promise((resolve) => setTimeout(resolve, 500)) + const pagePromise = fetchPosts(cursor.current, PageSize) + const [page] = await Promise.all([pagePromise, delay]) + setHasMore(page.length >= PageSize) + cursor.current = page.at(-1)?.postId ?? null + setPosts((prev) => [...prev, ...page]) + } catch (e: unknown) { + const err = e as Error + setError(err.message) + } finally { + loading.current = false + } + }, [fetchPosts, hasMore, error]) const onCreatePost = useCallback( async ( @@ -51,24 +78,52 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) }), ) const post = await postsService.createNew(user.id, content, media, isPublic) - setPages((pages) => [[post], ...pages]) + setPosts((pages) => [post, ...pages]) } catch (error) { console.error('Failed to create post:', error) } finally { setIsSubmitting(false) } }, - [mediaService, postsService, setPages, user], + [mediaService, postsService, setPosts, user], ) const isLoggedIn = user != null - const addReaction = async (postId: string, emoji: string) => { + const onAddReaction = async (postId: string, emoji: string) => { await postsService.addReaction(postId, emoji) + + setPosts((prev) => + produce(prev, (draft: WritableDraft) => { + const post = draft.find((p) => p.postId === postId) + if (!post) return + + const theReaction = post.reactions.find((r) => r.emoji === emoji) + if (theReaction) { + theReaction.count++ + theReaction.didReact = true + } else { + post.reactions.push({ emoji, count: 1, didReact: true }) + } + }), + ) } - const clearReaction = async (postId: string, emoji: string) => { + const onClearReaction = async (postId: string, emoji: string) => { await postsService.removeReaction(postId, emoji) + + setPosts((prev) => + produce(prev, (draft: WritableDraft) => { + const post = draft.find((p) => p.postId === postId) + if (!post) return + + const theReaction = post.reactions.find((r) => r.emoji === emoji) + if (theReaction) { + theReaction.count = Math.max(theReaction.count - 1, 0) + theReaction.didReact = false + } + }), + ) } return ( @@ -82,10 +137,10 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
{isLoggedIn && }
diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index b25dba3..bcd1b0b 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -1,5 +1,6 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' +import { immerable } from 'immer' export interface EmojiReaction { emoji: string @@ -8,6 +9,8 @@ export interface EmojiReaction { } export class Post { + [immerable] = true + public readonly postId: string public readonly content: string public readonly media: PostMedia[] @@ -44,9 +47,9 @@ export class Post { dto.reactions.map((r) => ({ emoji: r.emoji, count: r.count, - didReact: r.didReact + didReact: r.didReact, })), - dto.possibleReactions + dto.possibleReactions, ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index d00c99b..e6c2c88 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -60,31 +60,23 @@ export class PostsService { } async addReaction(postId: string, emoji: string): Promise { - const response = await this.client.POST('/posts/{postId}/reactions', { + await this.client.POST('/posts/{postId}/reactions', { params: { - path: { postId } + path: { postId }, }, body: { emoji }, credentials: 'include', }) - - if (!response.data) { - throw new Error('Failed to add reaction') - } } async removeReaction(postId: string, emoji: string): Promise { - const response = await this.client.DELETE('/posts/{postId}/reactions', { + await this.client.DELETE('/posts/{postId}/reactions', { params: { - path: { postId } + path: { postId }, }, body: { emoji }, credentials: 'include', }) - - if (!response.data) { - throw new Error('Failed to remove reaction') - } } } diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 0000000..9f74991 --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,17 @@ +import { useCallback, useRef } from 'react' + +export function useDebounce( + fn: (...args: Args) => Promise, + delay: number, +) { + const timeout = useRef | null>(null) + + return useCallback( + (...args: Args) => { + if (timeout.current) clearTimeout(timeout.current) + + setTimeout(() => fn(...args), delay) + }, + [delay, fn], + ) +}