diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index f03c6b0..b809ef0 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -75,30 +75,7 @@ export interface paths { path?: never cookie?: never } - get: { - parameters: { - query?: never - header?: never - path: { - postId: string - } - cookie?: never - } - requestBody?: never - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'text/plain': components['schemas']['GetPostResponse'] - 'application/json': components['schemas']['GetPostResponse'] - 'text/json': components['schemas']['GetPostResponse'] - } - } - } - } + get?: never put?: never post?: never delete: { @@ -211,8 +188,7 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - /** Format: binary */ - file?: string + file?: components['schemas']['IFormFile'] } } } @@ -357,78 +333,6 @@ export interface paths { patch?: never trace?: never } - '/auth/change-password': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['ChangePasswordRequestBody'] - 'text/json': components['schemas']['ChangePasswordRequestBody'] - 'application/*+json': components['schemas']['ChangePasswordRequestBody'] - } - } - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/auth/delete-current-session': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/auth/session': { parameters: { query?: never @@ -486,9 +390,9 @@ export interface paths { [name: string]: unknown } content: { - 'text/plain': components['schemas']['GetUserInfoResult'] - 'application/json': components['schemas']['GetUserInfoResult'] - 'text/json': components['schemas']['GetUserInfoResult'] + 'text/plain': components['schemas']['RefreshUserResult'] + 'application/json': components['schemas']['RefreshUserResult'] + 'text/json': components['schemas']['RefreshUserResult'] } } } @@ -561,82 +465,6 @@ export interface paths { patch?: never trace?: never } - '/auth/create-signup-code': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateSignupCodeRequest'] - 'text/json': components['schemas']['CreateSignupCodeRequest'] - 'application/*+json': components['schemas']['CreateSignupCodeRequest'] - } - } - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/auth/list-signup-codes': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'text/plain': components['schemas']['ListSignupCodesResult'] - 'application/json': components['schemas']['ListSignupCodesResult'] - 'text/json': components['schemas']['ListSignupCodesResult'] - } - } - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } } export type webhooks = Record export interface components { @@ -644,11 +472,6 @@ export interface components { AddPostReactionRequest: { emoji: string } - ChangePasswordRequestBody: { - /** Format: uuid */ - userId: string - newPassword: string - } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -678,25 +501,20 @@ export interface components { DeletePostReactionRequest: { emoji: string } - GetPostResponse: { - post: components['schemas']['PostDto'] - } - GetUserInfoResult: { - /** Format: uuid */ - userId: string - username: string - isSuperUser: boolean - } + /** Format: binary */ + IFormFile: string ListSignupCodesResult: { signupCodes: components['schemas']['SignupCodeDto'][] } LoadPostsResponse: { posts: components['schemas']['PostDto'][] + /** Format: uuid */ + next: string | null } LoginRequest: { username: string password: string - rememberMe: boolean + rememberMe: boolean | null } LoginResponse: { /** Format: uuid */ @@ -730,15 +548,21 @@ export interface components { } PostReactionDto: { emoji: string - authorName: string - /** Format: date-time */ - reactedOn: string + /** Format: int32 */ + count: number + didReact: boolean + } + RefreshUserResult: { + /** Format: uuid */ + userId: string + username: string + isSuperUser: boolean } RegisterRequest: { username: string password: string signupCode: string - rememberMe: boolean + rememberMe: boolean | null } RegisterResponse: { /** Format: uuid */ diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 464f90f..05fcbb3 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,24 +1,15 @@ -import { PostMedia, PostReaction } from '../posts/posts.ts' +import { Post, PostMedia } from '../posts/posts.ts' import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { PostInfo } from '../posts/usePostViewModel.ts' -import { useUserStore } from '../../user/user.ts' interface PostItemProps { - post: PostInfo - reactions: PostReaction[] + post: Post addReaction: (emoji: string) => void clearReaction: (emoji: string) => void hideViewButton?: boolean } -export default function PostItem({ - post, - reactions, - addReaction, - clearReaction, - hideViewButton = false, -}: PostItemProps) { +export default function PostItem({ post, addReaction, clearReaction, hideViewButton = false }: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -62,30 +53,26 @@ export default function PostItem({ )} - + ) } interface PostReactionsProps { - post: PostInfo - reactions: PostReaction[] + post: Post addReaction: (emoji: string) => void clearReaction: (emoji: string) => void } -function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) { - const username = useUserStore((state) => state.user?.username) +function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) { + const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r])) + return (
{post.possibleReactions.map((emoji) => { - const count = reactions.filter((r) => r.emoji === emoji).length - const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username) + const reaction = reactionMap.get(emoji) + const count = reaction?.count ?? 0 + const didReact = reaction?.didReact ?? false const onClick = () => { if (didReact) { clearReaction(emoji) diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index edef521..35d994d 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -7,11 +7,11 @@ 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' import PostItem from '../components/PostItem.tsx' import { useIntersectionLoad } from '../../../hooks/useIntersectionLoad.ts' import { delay } from '../../../utils/delay.ts' -import { usePostViewModel } from '../posts/usePostViewModel.ts' -import { Temporal } from '@js-temporal/polyfill' interface HomePageProps { postsService: PostsService @@ -25,8 +25,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) useSaveSignupCodeToLocalStorage() const [isSubmitting, setIsSubmitting] = useState(false) - const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() - + const [posts, setPosts] = useState([]) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) @@ -38,14 +37,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) loading.current = true try { - const [{ posts }] = await Promise.all([ + const [{ posts, next }] = await Promise.all([ postsService.loadPublicFeed(cursor.current, PageSize), delay(500), ]) setHasMore(posts.length >= PageSize) - cursor.current = posts.at(-1)?.postId ?? null - addPosts(posts) + cursor.current = next + setPosts((prev) => [...prev, ...posts]) } catch (e: unknown) { setError((e as Error).message) } finally { @@ -74,7 +73,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) }), ) const post = await postsService.createNew(user.id, content, media, isPublic) - addPosts([post]) + setPosts((pages) => [post, ...pages]) } catch (error) { console.error('Failed to create post:', error) } finally { @@ -87,13 +86,37 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) const onAddReaction = async (postId: string, emoji: string) => { await postsService.addReaction(postId, emoji) - addReaction(postId, emoji, user!.username, Temporal.Now.instant()) + 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 onClearReaction = async (postId: string, emoji: string) => { await postsService.removeReaction(postId, emoji) - removeReaction(postId, emoji, user!.username) + 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 + } + }), + ) } const sentinelRef = useRef(null) @@ -116,7 +139,6 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) onAddReaction(post.postId, emoji)} clearReaction={(emoji) => onClearReaction(post.postId, emoji)} /> diff --git a/src/app/feed/pages/PostPage.tsx b/src/app/feed/pages/PostPage.tsx index f81b89a..ed4c7cc 100644 --- a/src/app/feed/pages/PostPage.tsx +++ b/src/app/feed/pages/PostPage.tsx @@ -1,15 +1,11 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' +import { Post } from '../posts/posts.ts' import { PostsService } from '../posts/postsService.ts' import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx' import NavBar from '../../../components/NavBar.tsx' import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx' import PostItem from '../components/PostItem.tsx' -import NavButton from '../../../components/buttons/NavButton.tsx' -import { useTranslations } from '../../i18n/translations.ts' -import { usePostViewModel } from '../posts/usePostViewModel.ts' -import { Temporal } from '@js-temporal/polyfill' -import { useUserStore } from '../../user/user.ts' interface PostPageProps { postsService: PostsService @@ -17,49 +13,103 @@ interface PostPageProps { export default function PostPage({ postsService }: PostPageProps) { const { postId } = useParams<{ postId: string }>() - const { posts, setPosts, addReaction, reactions, removeReaction } = usePostViewModel() - const { t } = useTranslations() - const username = useUserStore((state) => state.user?.username) - - const post = posts.at(0) + const [post, setPost] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) useEffect(() => { - if (!postId) return - postsService.load(postId).then((post) => setPosts(post ? [post] : [])) - }, [postId, postsService, setPosts]) + const fetchPost = async () => { + if (!postId) { + setError('Post ID is required') + setLoading(false) + return + } + + try { + // Load posts and find the one with matching ID + const { posts } = await postsService.loadPublicFeed(null, 100) + const foundPost = posts.find((p) => p.postId === postId) + + if (foundPost) { + setPost(foundPost) + } else { + setError('Post not found') + } + } catch (e: unknown) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + fetchPost() + }, [postId, postsService]) const onAddReaction = async (emoji: string) => { - if (!username) return if (!post) return await postsService.addReaction(post.postId, emoji) - addReaction(post.postId, emoji, username, Temporal.Now.instant()) + setPost((prevPost) => { + if (!prevPost) return null + + const updatedReactions = [...prevPost.reactions] + const theReaction = updatedReactions.find((r) => r.emoji === emoji) + + if (theReaction) { + theReaction.count++ + theReaction.didReact = true + } else { + updatedReactions.push({ emoji, count: 1, didReact: true }) + } + + return { + ...prevPost, + reactions: updatedReactions, + } + }) } const onClearReaction = async (emoji: string) => { - if (!username) return if (!post) return await postsService.removeReaction(post.postId, emoji) - removeReaction(post.postId, emoji, username) + + setPost((prevPost) => { + if (!prevPost) return null + + const updatedReactions = [...prevPost.reactions] + const theReaction = updatedReactions.find((r) => r.emoji === emoji) + + if (theReaction) { + theReaction.count = Math.max(theReaction.count - 1, 0) + theReaction.didReact = false + } + + return { + ...prevPost, + reactions: updatedReactions, + } + }) } return ( - {t('nav.home')} } >
+ {loading &&
Loading...
} + + {error &&
Error: {error}
} + {post && (
new PostMediaImpl(new URL(m.url), m.width, m.height)), Temporal.Instant.from(dto.createdAt), dto.author.username, - dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })), + dto.reactions.map((r) => ({ + emoji: r.emoji, + count: r.count, + didReact: r.didReact, + })), dto.possibleReactions, ) } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 11272e9..06fa2ff 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -29,34 +29,22 @@ export class PostsService { return Post.fromDto(response.data.post) } - async load(postId: string): Promise { - const response = await this.client.GET('/posts/{postId}', { - params: { - path: { postId }, - }, - credentials: 'include', - }) - - if (!response.data?.post) { - return null - } - - return Post.fromDto(response.data.post) - } - - async loadPublicFeed(cursor: string | null, amount: number | null): Promise<{ posts: Post[] }> { + async loadPublicFeed( + cursor: string | null, + amount: number | null, + ): Promise<{ posts: Post[]; next: string | null }> { const response = await this.client.GET('/posts', { params: { - query: { After: cursor ?? undefined, Amount: amount ?? undefined }, + query: { From: cursor ?? undefined, Amount: amount ?? undefined }, }, credentials: 'include', }) if (!response.data) { - return { posts: [] } + return { posts: [], next: null } } - return { posts: response.data.posts.map(Post.fromDto) } + return { posts: response.data.posts.map(Post.fromDto), next: response.data.next } } async addReaction(postId: string, emoji: string): Promise { diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts deleted file mode 100644 index 74004ef..0000000 --- a/src/app/feed/posts/usePostViewModel.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useState } from 'react' -import { Post, PostMedia, PostReaction } from './posts.ts' -import { Temporal } from '@js-temporal/polyfill' -import { produce } from 'immer' - -export interface PostInfo { - postId: string - authorName: string - content: string - createdAt: Temporal.Instant - media: PostMedia[] - possibleReactions: string[] -} - -type ReactionMap = Record - -export function usePostViewModel() { - const [posts, _setPosts] = useState([]) - const [reactions, setReactions] = useState({}) - - const setPosts = useCallback((posts: Post[]) => { - _setPosts([...posts]) - - setReactions( - posts.reduce((acc, post) => { - acc[post.postId] = [...post.reactions] - return acc - }, {} as ReactionMap), - ) - }, []) - - const addPosts = useCallback((posts: Post[]) => { - _setPosts((current) => { - return [...current, ...posts] - }) - - setReactions((current) => - produce(current, (draft) => { - for (const post of posts) { - draft[post.postId] = [...post.reactions] - } - }), - ) - }, []) - - function addReaction( - postId: string, - emoji: string, - authorName: string, - reactedOn: Temporal.Instant, - ) { - setReactions((current) => - produce(current, (draft) => { - if (draft[postId]?.some((r) => r.emoji === emoji && r.authorName == authorName)) { - return - } - - const reaction: PostReaction = { emoji, authorName, reactedOn } - - if (!draft[postId]) { - draft[postId] = [{ ...reaction }] - } else { - draft[postId].push({ ...reaction }) - } - }), - ) - } - - function removeReaction(postId: string, emoji: string, authorName: string) { - setReactions((current) => - produce(current, (draft) => { - if (!draft[postId]) return - - draft[postId] = draft[postId].filter( - (r) => r.emoji !== emoji || r.authorName !== authorName, - ) - }), - ) - } - - return { posts, reactions, addPosts, setPosts, addReaction, removeReaction } -} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 89691c9..d8fb4f7 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,10 +1,8 @@ -import { PropsWithChildren, ReactNode } from 'react' +import { PropsWithChildren } from 'react' import { Role, useUserStore } from '../app/user/user.ts' import NavButton from './buttons/NavButton.tsx' -type NavBarProps = { - leftChildren?: ReactNode -} +type NavBarProps = unknown export default function NavBar({ children }: PropsWithChildren) { const user = useUserStore((state) => state.user) diff --git a/src/utils/groupByAndMap.ts b/src/utils/groupByAndMap.ts deleted file mode 100644 index 7f30995..0000000 --- a/src/utils/groupByAndMap.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function groupByAndMap( - items: T[], - groupBy: (item: T) => string, - map: (item: T) => U, -): Record { - const groupings: Record = {} - - for (const item of items) { - const key = groupBy(item) - - if (!groupings[key]) { - groupings[key] = [] - } - - groupings[key].push(map(item)) - } - - return groupings -}