diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index b809ef0..f03c6b0 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -75,7 +75,30 @@ export interface paths { path?: never cookie?: never } - get?: 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'] + } + } + } + } put?: never post?: never delete: { @@ -188,7 +211,8 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - file?: components['schemas']['IFormFile'] + /** Format: binary */ + file?: string } } } @@ -333,6 +357,78 @@ 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 @@ -390,9 +486,9 @@ export interface paths { [name: string]: unknown } content: { - 'text/plain': components['schemas']['RefreshUserResult'] - 'application/json': components['schemas']['RefreshUserResult'] - 'text/json': components['schemas']['RefreshUserResult'] + 'text/plain': components['schemas']['GetUserInfoResult'] + 'application/json': components['schemas']['GetUserInfoResult'] + 'text/json': components['schemas']['GetUserInfoResult'] } } } @@ -465,6 +561,82 @@ 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 { @@ -472,6 +644,11 @@ export interface components { AddPostReactionRequest: { emoji: string } + ChangePasswordRequestBody: { + /** Format: uuid */ + userId: string + newPassword: string + } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -501,20 +678,25 @@ export interface components { DeletePostReactionRequest: { emoji: string } - /** Format: binary */ - IFormFile: string + GetPostResponse: { + post: components['schemas']['PostDto'] + } + GetUserInfoResult: { + /** Format: uuid */ + userId: string + username: string + isSuperUser: boolean + } ListSignupCodesResult: { signupCodes: components['schemas']['SignupCodeDto'][] } LoadPostsResponse: { posts: components['schemas']['PostDto'][] - /** Format: uuid */ - next: string | null } LoginRequest: { username: string password: string - rememberMe: boolean | null + rememberMe: boolean } LoginResponse: { /** Format: uuid */ @@ -548,21 +730,15 @@ export interface components { } PostReactionDto: { emoji: string - /** Format: int32 */ - count: number - didReact: boolean - } - RefreshUserResult: { - /** Format: uuid */ - userId: string - username: string - isSuperUser: boolean + authorName: string + /** Format: date-time */ + reactedOn: string } RegisterRequest: { username: string password: string signupCode: string - rememberMe: boolean | null + rememberMe: boolean } RegisterResponse: { /** Format: uuid */ diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index 05fcbb3..464f90f 100644 --- a/src/app/feed/components/PostItem.tsx +++ b/src/app/feed/components/PostItem.tsx @@ -1,15 +1,24 @@ -import { Post, PostMedia } from '../posts/posts.ts' +import { PostMedia, PostReaction } 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: Post + post: PostInfo + reactions: PostReaction[] addReaction: (emoji: string) => void clearReaction: (emoji: string) => void hideViewButton?: boolean } -export default function PostItem({ post, addReaction, clearReaction, hideViewButton = false }: PostItemProps) { +export default function PostItem({ + post, + reactions, + addReaction, + clearReaction, + hideViewButton = false, +}: PostItemProps) { const formattedDate = post.createdAt.toLocaleString('en-US', { year: 'numeric', month: 'short', @@ -53,26 +62,30 @@ export default function PostItem({ post, addReaction, clearReaction, hideViewBut )} - + ) } interface PostReactionsProps { - post: Post + post: PostInfo + reactions: PostReaction[] addReaction: (emoji: string) => void clearReaction: (emoji: string) => void } -function PostReactions({ post, addReaction, clearReaction }: PostReactionsProps) { - const reactionMap = new Map(post.reactions.map((r) => [r.emoji, r])) - +function PostReactions({ post, reactions, addReaction, clearReaction }: PostReactionsProps) { + const username = useUserStore((state) => state.user?.username) return (
{post.possibleReactions.map((emoji) => { - const reaction = reactionMap.get(emoji) - const count = reaction?.count ?? 0 - const didReact = reaction?.didReact ?? false + const count = reactions.filter((r) => r.emoji === emoji).length + const didReact = reactions.some((r) => r.emoji == emoji && r.authorName == username) const onClick = () => { if (didReact) { clearReaction(emoji) diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index 35d994d..edef521 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,7 +25,8 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) useSaveSignupCodeToLocalStorage() const [isSubmitting, setIsSubmitting] = useState(false) - const [posts, setPosts] = useState([]) + const { posts, addPosts, addReaction, removeReaction, reactions } = usePostViewModel() + const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) @@ -37,14 +38,14 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) loading.current = true try { - const [{ posts, next }] = await Promise.all([ + const [{ posts }] = await Promise.all([ postsService.loadPublicFeed(cursor.current, PageSize), delay(500), ]) setHasMore(posts.length >= PageSize) - cursor.current = next - setPosts((prev) => [...prev, ...posts]) + cursor.current = posts.at(-1)?.postId ?? null + addPosts(posts) } catch (e: unknown) { setError((e as Error).message) } finally { @@ -73,7 +74,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) }), ) const post = await postsService.createNew(user.id, content, media, isPublic) - setPosts((pages) => [post, ...pages]) + addPosts([post]) } catch (error) { console.error('Failed to create post:', error) } finally { @@ -86,37 +87,13 @@ export default function HomePage({ postsService, mediaService }: HomePageProps) 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 }) - } - }), - ) + addReaction(postId, emoji, user!.username, Temporal.Now.instant()) } 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 - } - }), - ) + removeReaction(postId, emoji, user!.username) } const sentinelRef = useRef(null) @@ -139,6 +116,7 @@ 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 3569e6b..f81b89a 100644 --- a/src/app/feed/pages/PostPage.tsx +++ b/src/app/feed/pages/PostPage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react' +import { useEffect } 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' @@ -8,6 +7,9 @@ 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 @@ -15,85 +17,32 @@ interface PostPageProps { export default function PostPage({ postsService }: PostPageProps) { const { postId } = useParams<{ postId: string }>() - const [post, setPost] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { posts, setPosts, addReaction, reactions, removeReaction } = usePostViewModel() const { t } = useTranslations() + const username = useUserStore((state) => state.user?.username) + + const post = posts.at(0) useEffect(() => { - 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]) + if (!postId) return + postsService.load(postId).then((post) => setPosts(post ? [post] : [])) + }, [postId, postsService, setPosts]) const onAddReaction = async (emoji: string) => { + if (!username) return if (!post) return await postsService.addReaction(post.postId, emoji) - 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, - } - }) + addReaction(post.postId, emoji, username, Temporal.Now.instant()) } const onClearReaction = async (emoji: string) => { + if (!username) return if (!post) return await postsService.removeReaction(post.postId, emoji) - - 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, - } - }) + removeReaction(post.postId, emoji, username) } return ( @@ -106,14 +55,11 @@ export default function PostPage({ postsService }: PostPageProps) { } >
- {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) => ({ - emoji: r.emoji, - count: r.count, - didReact: r.didReact, - })), + dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })), dto.possibleReactions, ) } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 06fa2ff..11272e9 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -29,22 +29,34 @@ export class PostsService { return Post.fromDto(response.data.post) } - async loadPublicFeed( - cursor: string | null, - amount: number | null, - ): Promise<{ posts: Post[]; next: string | null }> { + 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[] }> { const response = await this.client.GET('/posts', { params: { - query: { From: cursor ?? undefined, Amount: amount ?? undefined }, + query: { After: cursor ?? undefined, Amount: amount ?? undefined }, }, credentials: 'include', }) if (!response.data) { - return { posts: [], next: null } + return { posts: [] } } - return { posts: response.data.posts.map(Post.fromDto), next: response.data.next } + return { posts: response.data.posts.map(Post.fromDto) } } async addReaction(postId: string, emoji: string): Promise { diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts new file mode 100644 index 0000000..74004ef --- /dev/null +++ b/src/app/feed/posts/usePostViewModel.ts @@ -0,0 +1,82 @@ +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/utils/groupByAndMap.ts b/src/utils/groupByAndMap.ts new file mode 100644 index 0000000..7f30995 --- /dev/null +++ b/src/utils/groupByAndMap.ts @@ -0,0 +1,19 @@ +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 +}