reactions
This commit is contained in:
parent
48e7094c5e
commit
c21e193fbf
9 changed files with 97 additions and 119 deletions
|
@ -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={<HomePage postsService={postService} mediaService={mediaService} />}
|
||||
/>
|
||||
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
|
||||
<Route path="/login" element={<LoginPage authService={authService} />} />
|
||||
<Route path="/logout" element={<LogoutPage authService={authService} />} />
|
||||
<Route path="/signup/:code?" element={<SignupPage authService={authService} />} />
|
||||
|
|
|
@ -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<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
|
||||
}
|
||||
|
|
|
@ -4,15 +4,14 @@ import { Post } from '../posts/posts.ts'
|
|||
import PostItem from './PostItem.tsx'
|
||||
|
||||
interface FeedViewProps {
|
||||
pages: Post[][]
|
||||
posts: Post[]
|
||||
onLoadMore: () => Promise<void>
|
||||
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<HTMLDivElement | null>(null)
|
||||
const posts = pages.flat()
|
||||
|
||||
useIntersectionLoad(onLoadMore, sentinelRef)
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
|
||||
<div className="text-sm text-gray-500 mb-3">
|
||||
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
|
||||
@{post.authorName}
|
||||
</Link>
|
||||
• {formattedDate}
|
||||
<span className="text-gray-400 mr-2">@{post.authorName}</span>• {formattedDate}
|
||||
</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 (
|
||||
<PostReactionButton
|
||||
key={emoji}
|
||||
emoji={emoji}
|
||||
didReact={didReact}
|
||||
count={count}
|
||||
onClick={() => onClick()}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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<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(
|
||||
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<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)
|
||||
|
||||
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 (
|
||||
|
@ -82,10 +137,10 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
|||
<main className={`w-full max-w-3xl mx-auto`}>
|
||||
{isLoggedIn && <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />}
|
||||
<FeedView
|
||||
pages={pages}
|
||||
posts={posts}
|
||||
onLoadMore={loadNextPage}
|
||||
addReaction={addReaction}
|
||||
clearReaction={clearReaction}
|
||||
addReaction={onAddReaction}
|
||||
clearReaction={onClearReaction}
|
||||
/>
|
||||
</main>
|
||||
</SingleColumnLayout>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,31 +60,23 @@ export class PostsService {
|
|||
}
|
||||
|
||||
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: {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
17
src/utils/debounce.ts
Normal file
17
src/utils/debounce.ts
Normal 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],
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue