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 { 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} />} />
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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 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>
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
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