reactions

This commit is contained in:
john 2025-05-28 22:05:51 +02:00
parent 48e7094c5e
commit c21e193fbf
9 changed files with 97 additions and 119 deletions

View file

@ -1,6 +1,5 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './app/feed/pages/HomePage.tsx' 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 SignupPage from './app/auth/pages/SignupPage.tsx'
import LoginPage from './app/auth/pages/LoginPage.tsx' import LoginPage from './app/auth/pages/LoginPage.tsx'
import LogoutPage from './app/auth/pages/LogoutPage.tsx' import LogoutPage from './app/auth/pages/LogoutPage.tsx'
@ -23,7 +22,6 @@ export default function App() {
path={'/'} path={'/'}
element={<HomePage postsService={postService} mediaService={mediaService} />} element={<HomePage postsService={postService} mediaService={mediaService} />}
/> />
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
<Route path="/login" element={<LoginPage authService={authService} />} /> <Route path="/login" element={<LoginPage authService={authService} />} />
<Route path="/logout" element={<LogoutPage authService={authService} />} /> <Route path="/logout" element={<LogoutPage authService={authService} />} />
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} /> <Route path="/signup/:code?" element={<SignupPage authService={authService} />} />

View file

@ -1,36 +1,5 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { Post } from '../posts/posts.ts' import { Post } from '../posts/posts.ts'
import { produce, WritableDraft } from 'immer'
const PageSize = 20 const PageSize = 20
export function useFeedViewModel(
loadMore: (cursor: string | null, amount: number) => Promise<Post[]>,
) {
const [pages, setPages] = useState<Post[][]>([])
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const cursor = useRef<string | null>(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
}

View file

@ -4,15 +4,14 @@ import { Post } from '../posts/posts.ts'
import PostItem from './PostItem.tsx' import PostItem from './PostItem.tsx'
interface FeedViewProps { interface FeedViewProps {
pages: Post[][] posts: Post[]
onLoadMore: () => Promise<void> onLoadMore: () => Promise<void>
addReaction: (postId: string, emoji: string) => void addReaction: (postId: string, emoji: string) => void
clearReaction: (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<HTMLDivElement | null>(null) const sentinelRef = useRef<HTMLDivElement | null>(null)
const posts = pages.flat()
useIntersectionLoad(onLoadMore, sentinelRef) useIntersectionLoad(onLoadMore, sentinelRef)

View file

@ -1,5 +1,4 @@
import { Post, PostMedia } from '../posts/posts.ts' import { Post, PostMedia } from '../posts/posts.ts'
import { Link } from 'react-router-dom'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
interface PostItemProps { interface PostItemProps {
@ -31,10 +30,7 @@ export default function PostItem({ post, addReaction, clearReaction }: PostItemP
return ( return (
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}> <article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
<div className="text-sm text-gray-500 mb-3"> <div className="text-sm text-gray-500 mb-3">
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2"> <span className="text-gray-400 mr-2">@{post.authorName}</span> {formattedDate}
@{post.authorName}
</Link>
{formattedDate}
</div> </div>
<div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div> <div className="text-gray-800 mb-4 whitespace-pre-wrap">{post.content}</div>
@ -77,10 +73,11 @@ function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps)
return ( return (
<PostReactionButton <PostReactionButton
key={emoji}
emoji={emoji} emoji={emoji}
didReact={didReact} didReact={didReact}
count={count} count={count}
onClick={() => onClick()} onClick={onClick}
/> />
) )
})} })}

View file

@ -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 (
<SingleColumnLayout
navbar={
<NavBar>
<NavButton to={'/'}>home</NavButton>
<AuthNavButtons />
</NavBar>
}
>
<FeedView
pages={pages}
onLoadMore={loadNextPage}
addReaction={addReaction}
clearReaction={clearReaction}
/>
</SingleColumnLayout>
)
}

View file

@ -1,20 +1,23 @@
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import FeedView from '../components/FeedView.tsx' import FeedView from '../components/FeedView.tsx'
import { PostsService } from '../posts/postsService.ts' import { PostsService } from '../posts/postsService.ts'
import { useUser } from '../../user/user.ts' import { useUser } from '../../user/user.ts'
import { MediaService } from '../../media/mediaService.ts' import { MediaService } from '../../media/mediaService.ts'
import NewPostWidget from '../../../components/NewPostWidget.tsx' import NewPostWidget from '../../../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../components/FeedView.ts'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import NavBar from '../../../components/NavBar.tsx' import NavBar from '../../../components/NavBar.tsx'
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts' import { useSaveSignupCodeToLocalStorage } from '../../../hooks/useSaveSignupCodeToLocalStorage.ts'
import { Post } from '../posts/posts.ts'
import { produce, WritableDraft } from 'immer'
interface HomePageProps { interface HomePageProps {
postsService: PostsService postsService: PostsService
mediaService: MediaService mediaService: MediaService
} }
const PageSize = 20
export default function HomePage({ postsService, mediaService }: HomePageProps) { export default function HomePage({ postsService, mediaService }: HomePageProps) {
const user = useUser() const user = useUser()
useSaveSignupCodeToLocalStorage() useSaveSignupCodeToLocalStorage()
@ -27,7 +30,31 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
[postsService], [postsService],
) )
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts) const [posts, setPosts] = useState<Post[]>([])
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const cursor = useRef<string | null>(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( const onCreatePost = useCallback(
async ( async (
@ -51,24 +78,52 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
}), }),
) )
const post = await postsService.createNew(user.id, content, media, isPublic) const post = await postsService.createNew(user.id, content, media, isPublic)
setPages((pages) => [[post], ...pages]) setPosts((pages) => [post, ...pages])
} catch (error) { } catch (error) {
console.error('Failed to create post:', error) console.error('Failed to create post:', error)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
}, },
[mediaService, postsService, setPages, user], [mediaService, postsService, setPosts, user],
) )
const isLoggedIn = user != null const isLoggedIn = user != null
const addReaction = async (postId: string, emoji: string) => { const onAddReaction = async (postId: string, emoji: string) => {
await postsService.addReaction(postId, emoji) await postsService.addReaction(postId, emoji)
setPosts((prev) =>
produce(prev, (draft: WritableDraft<Post[]>) => {
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) await postsService.removeReaction(postId, emoji)
setPosts((prev) =>
produce(prev, (draft: WritableDraft<Post[]>) => {
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 ( return (
@ -82,10 +137,10 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
<main className={`w-full max-w-3xl mx-auto`}> <main className={`w-full max-w-3xl mx-auto`}>
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />} {isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
<FeedView <FeedView
pages={pages} posts={posts}
onLoadMore={loadNextPage} onLoadMore={loadNextPage}
addReaction={addReaction} addReaction={onAddReaction}
clearReaction={clearReaction} clearReaction={onClearReaction}
/> />
</main> </main>
</SingleColumnLayout> </SingleColumnLayout>

View file

@ -1,5 +1,6 @@
import { Temporal } from '@js-temporal/polyfill' import { Temporal } from '@js-temporal/polyfill'
import { components } from '../../api/schema.ts' import { components } from '../../api/schema.ts'
import { immerable } from 'immer'
export interface EmojiReaction { export interface EmojiReaction {
emoji: string emoji: string
@ -8,6 +9,8 @@ export interface EmojiReaction {
} }
export class Post { export class Post {
[immerable] = true
public readonly postId: string public readonly postId: string
public readonly content: string public readonly content: string
public readonly media: PostMedia[] public readonly media: PostMedia[]
@ -44,9 +47,9 @@ export class Post {
dto.reactions.map((r) => ({ dto.reactions.map((r) => ({
emoji: r.emoji, emoji: r.emoji,
count: r.count, count: r.count,
didReact: r.didReact didReact: r.didReact,
})), })),
dto.possibleReactions dto.possibleReactions,
) )
} }
} }

View file

@ -60,31 +60,23 @@ export class PostsService {
} }
async addReaction(postId: string, emoji: string): Promise<void> { async addReaction(postId: string, emoji: string): Promise<void> {
const response = await this.client.POST('/posts/{postId}/reactions', { await this.client.POST('/posts/{postId}/reactions', {
params: { params: {
path: { postId } path: { postId },
}, },
body: { emoji }, body: { emoji },
credentials: 'include', credentials: 'include',
}) })
if (!response.data) {
throw new Error('Failed to add reaction')
}
} }
async removeReaction(postId: string, emoji: string): Promise<void> { async removeReaction(postId: string, emoji: string): Promise<void> {
const response = await this.client.DELETE('/posts/{postId}/reactions', { await this.client.DELETE('/posts/{postId}/reactions', {
params: { params: {
path: { postId } path: { postId },
}, },
body: { emoji }, body: { emoji },
credentials: 'include', credentials: 'include',
}) })
if (!response.data) {
throw new Error('Failed to remove reaction')
}
} }
} }

17
src/utils/debounce.ts Normal file
View file

@ -0,0 +1,17 @@
import { useCallback, useRef } from 'react'
export function useDebounce<Args extends unknown[]>(
fn: (...args: Args) => Promise<void>,
delay: number,
) {
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null)
return useCallback(
(...args: Args) => {
if (timeout.current) clearTimeout(timeout.current)
setTimeout(() => fn(...args), delay)
},
[delay, fn],
)
}