diff --git a/package.json b/package.json index ada1684..28aaca5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "femto-webapp", "private": true, - "version": "1.26.6", + "version": "1.26.1", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -20,8 +20,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.0", - "tailwindcss": "^4.1.5", - "zustand": "^5.0.7" + "tailwindcss": "^4.1.5" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/src/App.tsx b/src/App.tsx index 9063592..2e407da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,36 +8,13 @@ import UnauthorizedHandler from './app/auth/components/UnauthorizedHandler.tsx' import AdminPage from './app/admin/pages/AdminPage.tsx' import SignupCodesManagementPage from './app/admin/pages/subpages/SignupCodesManagementPage.tsx' import { useRefreshSessionLoop } from './useRefreshSessionLoop.ts' -import { setGlobal } from './app/femtoApp.ts' -import { PostsService } from './app/feed/posts/postsService.ts' -import { MediaService } from './app/media/mediaService.ts' -import { AuthService } from './app/auth/authService.ts' -import { initClient } from './app/api/client.ts' -import { useEffect } from 'react' -import { useUserStore } from './app/user/user.ts' -import { getUserFromCookie } from './app/auth/getUserFromCookie.ts' +import { initApp } from './initApp.ts' -setGlobal('version', import.meta.env.VITE_FEMTO_VERSION) - -const client = initClient() -const postService = new PostsService(client) -const mediaService = new MediaService(client) -const authService = new AuthService(client) - -setGlobal('postsService', postService) -setGlobal('authService', authService) +const { postService, mediaService, authService } = initApp() export default function App() { - const setUser = useUserStore((state) => state.setUser) - useRefreshSessionLoop(authService) - useEffect(() => { - const user = getUserFromCookie() - console.debug('got user cookie', user) - setUser(user) - }, [setUser]) - return ( @@ -46,7 +23,10 @@ export default function App() { path={'/'} element={} /> - } /> + } + /> } /> } /> } /> diff --git a/src/app/api/client.ts b/src/app/api/client.ts index eebacdf..63cce0c 100644 --- a/src/app/api/client.ts +++ b/src/app/api/client.ts @@ -1,8 +1,6 @@ import { paths } from './schema.ts' import createClient, { Middleware } from 'openapi-fetch' import { dispatchMessage } from '../messageBus/messageBus.ts' -import { useUserStore } from '../user/user.ts' -import { getUserFromCookie } from '../auth/getUserFromCookie.ts' export const initClient = () => { const client = createClient({ baseUrl: import.meta.env.VITE_API_URL }) @@ -11,10 +9,6 @@ export const initClient = () => { if (response.status === 401) { dispatchMessage('auth:unauthorized', null) } - - const user = getUserFromCookie() - console.debug('got user cookie', user) - useUserStore.getState().setUser(user) }, } diff --git a/src/app/api/schema.ts b/src/app/api/schema.ts index 6af29b3..b809ef0 100644 --- a/src/app/api/schema.ts +++ b/src/app/api/schema.ts @@ -9,7 +9,7 @@ export interface paths { get: { parameters: { query?: { - After?: string + From?: string Amount?: number AuthorId?: string Author?: string @@ -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: { @@ -192,47 +169,6 @@ export interface paths { patch?: never trace?: never } - '/posts/{postId}/comments': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post: { - parameters: { - query?: never - header?: never - path: { - postId: string - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AddPostCommentRequest'] - 'text/json': components['schemas']['AddPostCommentRequest'] - 'application/*+json': components['schemas']['AddPostCommentRequest'] - } - } - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/media': { parameters: { query?: never @@ -252,8 +188,7 @@ export interface paths { requestBody: { content: { 'multipart/form-data': { - /** Format: binary */ - file?: string + file?: components['schemas']['IFormFile'] } } } @@ -398,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 @@ -527,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'] } } } @@ -602,99 +465,13 @@ 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 { schemas: { - AddPostCommentRequest: { - /** Format: uuid */ - authorId: string - content: string - } AddPostReactionRequest: { emoji: string } - ChangePasswordRequestBody: { - /** Format: uuid */ - userId: string - newPassword: string - } CreatePostRequest: { /** Format: uuid */ authorId: string @@ -724,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 */ @@ -755,12 +527,6 @@ export interface components { authorId: string username: string } - PostCommentDto: { - author: string - content: string - /** Format: date-time */ - postedOn: string - } PostDto: { author: components['schemas']['PostAuthorDto'] /** Format: uuid */ @@ -771,7 +537,6 @@ export interface components { /** Format: date-time */ createdAt: string possibleReactions: string[] - comments: components['schemas']['PostCommentDto'][] } PostMediaDto: { /** Format: uri */ @@ -783,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/auth/components/AuthNavButtons.tsx b/src/app/auth/components/AuthNavButtons.tsx index 0c5141b..c646888 100644 --- a/src/app/auth/components/AuthNavButtons.tsx +++ b/src/app/auth/components/AuthNavButtons.tsx @@ -1,11 +1,11 @@ +import { useUser } from '../../user/user.ts' import NavButton from '../../../components/buttons/NavButton.tsx' import { useLocation } from 'react-router-dom' import { useTranslations } from '../../i18n/translations.ts' -import { useUserStore } from '../../user/user.ts' export default function AuthNavButtons() { const { t } = useTranslations() - const user = useUserStore((state) => state.user) + const user = useUser() const { pathname } = useLocation() diff --git a/src/app/auth/components/Protected.tsx b/src/app/auth/components/Protected.tsx index 19d71bb..f5ea238 100644 --- a/src/app/auth/components/Protected.tsx +++ b/src/app/auth/components/Protected.tsx @@ -1,9 +1,9 @@ +import { useUser } from '../../user/user.ts' import { useNavigate, Outlet } from 'react-router-dom' import { useEffect } from 'react' -import { useUserStore } from '../../user/user.ts' export default function Protected() { - const user = useUserStore((state) => state.user) + const user = useUser() const navigate = useNavigate() diff --git a/src/app/auth/components/RefreshUser.tsx b/src/app/auth/components/RefreshUser.tsx index 7d1db08..8796381 100644 --- a/src/app/auth/components/RefreshUser.tsx +++ b/src/app/auth/components/RefreshUser.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useEffect, useRef } from 'react' import { AuthService } from '../authService.ts' -import { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/user.ts' interface RefreshUserProps { authService: AuthService @@ -10,7 +10,7 @@ export default function RefreshUser({ authService, children, }: PropsWithChildren) { - const user = useUserStore((state) => state.user) + const user = useUser() const didRefresh = useRef(false) useEffect(() => { diff --git a/src/app/auth/getUserFromCookie.ts b/src/app/auth/getUserFromCookie.ts deleted file mode 100644 index 00bd891..0000000 --- a/src/app/auth/getUserFromCookie.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { User } from '../user/user.ts' -import { getCookie } from './cookies.ts' - -export function getUserFromCookie(): User | null { - const userCookie = getCookie('user') - - if (!userCookie) return null - - // TODO validate but it should be fine - return JSON.parse(decodeURIComponent(userCookie)) as User -} diff --git a/src/app/auth/pages/LoginPage.tsx b/src/app/auth/pages/LoginPage.tsx index 8b914ca..6c072d7 100644 --- a/src/app/auth/pages/LoginPage.tsx +++ b/src/app/auth/pages/LoginPage.tsx @@ -4,7 +4,7 @@ import TextInput from '../../../components/inputs/TextInput.tsx' import Button from '../../../components/buttons/Button.tsx' import { AuthService } from '../authService.ts' import { useNavigate } from 'react-router-dom' -import { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/user.ts' import NavBar from '../../../components/NavBar.tsx' import NavButton from '../../../components/buttons/NavButton.tsx' import LinkButton from '../../../components/buttons/LinkButton.tsx' @@ -26,7 +26,7 @@ export default function LoginPage({ authService }: LoginPageProps) { const passwordInputRef = useRef(null) const navigate = useNavigate() - const user = useUserStore((state) => state.user) + const user = useUser() useEffect(() => { if (user) { diff --git a/src/app/auth/pages/LogoutPage.tsx b/src/app/auth/pages/LogoutPage.tsx index dfceb5c..dea8a72 100644 --- a/src/app/auth/pages/LogoutPage.tsx +++ b/src/app/auth/pages/LogoutPage.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom' import { AuthService } from '../authService.ts' import { useEffect } from 'react' -import { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/user.ts' interface LogoutPageProps { authService: AuthService @@ -9,7 +9,7 @@ interface LogoutPageProps { export default function LogoutPage({ authService }: LogoutPageProps) { const navigate = useNavigate() - const user = useUserStore((state) => state.user) + const user = useUser() useEffect(() => { if (!user) { diff --git a/src/app/feed/components/NewCommentWidget.tsx b/src/app/feed/components/NewCommentWidget.tsx deleted file mode 100644 index 3d2e4ea..0000000 --- a/src/app/feed/components/NewCommentWidget.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from 'react' -import FancyTextEditor, { - TextInputKeyDownEvent, -} from '../../../components/inputs/FancyTextEditor.tsx' -import Button from '../../../components/buttons/Button.tsx' -import { useTranslations } from '../../i18n/translations.ts' - -interface NewCommentWidgetProps { - onSubmit: (content: string) => void - isSubmitting?: boolean -} - -export default function NewCommentWidget({ - onSubmit, - isSubmitting = false, -}: NewCommentWidgetProps) { - const { t } = useTranslations() - const [content, setContent] = useState('') - - const onContentInput = (value: string) => { - setContent(value) - } - - const handleSubmit = () => { - if (!content.trim()) { - return - } - - onSubmit(content) - - setContent('') - } - - const onInputKeyDown = (e: TextInputKeyDownEvent) => { - if (e.key === 'Enter' && e.ctrlKey) { - e.preventDefault() - handleSubmit() - } - } - - return ( -
- - -
- -
-
- ) -} diff --git a/src/app/feed/components/PostItem.tsx b/src/app/feed/components/PostItem.tsx index db766f6..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) @@ -150,6 +137,7 @@ function PostMediaItem({ media }: PostMediaProps) { width={width} height={height} src={url.toString()} + alt="todo sry :(" className="w-full h-auto" loading="lazy" /> diff --git a/src/app/feed/components/PostTimeline.tsx b/src/app/feed/components/PostTimeline.tsx deleted file mode 100644 index c9653f1..0000000 --- a/src/app/feed/components/PostTimeline.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { PostComment, PostReaction } from '../posts/posts.ts' -import { Temporal } from '@js-temporal/polyfill' - -interface PostTimelineProps { - reactions: PostReaction[] - comments: PostComment[] -} - -export function PostTimeline({ reactions, comments }: PostTimelineProps) { - const items = [ - ...reactions.map((reaction) => ({ - timestamp: reaction.reactedOn, - component: ( - - ), - })), - ...comments.map((comment) => ({ - timestamp: comment.postedOn, - component: ( - - ), - })), - ].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp)) - - return ( -
{items.map((item) => item.component)}
- ) -} - -function ReactionItem({ reaction }: { reaction: PostReaction }) { - return ( -
- {formatItemDate(reaction.reactedOn)} -
- @{reaction.authorName}  - clicked  - {reaction.emoji} -
-
- ) -} - -function CommentItem({ comment }: { comment: PostComment }) { - return ( -
-
{formatItemDate(comment.postedOn)}
-
- @{comment.author}  -
-
{comment.content}
-
- ) -} - -function formatItemDate(date: Temporal.Instant) { - return date.toLocaleString('en-AU', { - year: 'numeric', - month: 'numeric', - day: 'numeric', - }) -} diff --git a/src/app/feed/pages/HomePage.tsx b/src/app/feed/pages/HomePage.tsx index b505d83..c470d39 100644 --- a/src/app/feed/pages/HomePage.tsx +++ b/src/app/feed/pages/HomePage.tsx @@ -1,17 +1,17 @@ import { useRef, useState } from 'react' import { PostsService } from '../posts/postsService.ts' -import { useUserStore } from '../../user/user.ts' +import { useUser } from '../../user/user.ts' import { MediaService } from '../../media/mediaService.ts' -import NewPostWidget from '../components/NewPostWidget.tsx' +import NewPostWidget from '../../../components/NewPostWidget.tsx' 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 @@ -21,12 +21,11 @@ interface HomePageProps { const PageSize = 20 export default function HomePage({ postsService, mediaService }: HomePageProps) { - const user = useUserStore((state) => state.user) + const user = useUser() 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 b275b68..ed4c7cc 100644 --- a/src/app/feed/pages/PostPage.tsx +++ b/src/app/feed/pages/PostPage.tsx @@ -1,17 +1,11 @@ -import { useCallback, useEffect, useState } 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' -import { PostTimeline } from '../components/PostTimeline.tsx' -import NewCommentWidget from '../components/NewCommentWidget.tsx' interface PostPageProps { postsService: PostsService @@ -19,75 +13,107 @@ interface PostPageProps { export default function PostPage({ postsService }: PostPageProps) { const { postId } = useParams<{ postId: string }>() - const { posts, setPosts, addReaction, reactions: _reactions, removeReaction } = usePostViewModel() - const { t } = useTranslations() - const username = useUserStore((state) => state.user?.username) - const post = posts.at(0) - const reactions = (post?.postId ? _reactions[post.postId] : []) ?? [] - - const loadPost = useCallback(() => { - if (!postId) return - postsService.load(postId).then((post) => setPosts(post ? [post] : [])) - }, [postId, postsService, setPosts]) + const [post, setPost] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) useEffect(() => { - loadPost() - }, [loadPost]) + 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, + } + }) } - async function onSubmitComment(content: string) { - if (!postId) return - if (!content.trim()) return - - try { - setIsSubmittingComment(true) - await postsService.addComment(postId, content) - } finally { - setIsSubmittingComment(false) - } - - loadPost() - } - - const [isSubmittingComment, setIsSubmittingComment] = useState(false) - return ( - {t('nav.home')} } >
+ {loading &&
Loading...
} + + {error &&
Error: {error}
} + {post && (
- -
)}
diff --git a/src/app/feed/posts/posts.ts b/src/app/feed/posts/posts.ts index 837e2b8..bcd1b0b 100644 --- a/src/app/feed/posts/posts.ts +++ b/src/app/feed/posts/posts.ts @@ -2,16 +2,10 @@ import { Temporal } from '@js-temporal/polyfill' import { components } from '../../api/schema.ts' import { immerable } from 'immer' -export interface PostReaction { +export interface EmojiReaction { emoji: string - authorName: string - reactedOn: Temporal.Instant -} - -export interface PostComment { - author: string - content: string - postedOn: Temporal.Instant + count: number + didReact: boolean } export class Post { @@ -22,9 +16,8 @@ export class Post { public readonly media: PostMedia[] public readonly createdAt: Temporal.Instant public readonly authorName: string - public readonly reactions: PostReaction[] + public readonly reactions: EmojiReaction[] public readonly possibleReactions: string[] - public readonly comments: PostComment[] constructor( postId: string, @@ -32,9 +25,8 @@ export class Post { media: PostMedia[], createdAt: string | Temporal.Instant, authorName: string, - reactions: PostReaction[], - possibleReactions: string[], - comments: PostComment[], + reactions: EmojiReaction[] = [], + possibleReactions: string[] = [], ) { this.postId = postId this.content = content @@ -43,7 +35,6 @@ export class Post { this.authorName = authorName this.reactions = reactions this.possibleReactions = possibleReactions - this.comments = comments } public static fromDto(dto: components['schemas']['PostDto']): Post { @@ -53,9 +44,12 @@ export class Post { dto.media.map((m) => 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, - dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })), ) } } diff --git a/src/app/feed/posts/postsService.ts b/src/app/feed/posts/postsService.ts index 72e55f7..06fa2ff 100644 --- a/src/app/feed/posts/postsService.ts +++ b/src/app/feed/posts/postsService.ts @@ -1,6 +1,5 @@ import { Post } from './posts.ts' import { ApiClient } from '../../api/client.ts' -import { useUserStore } from '../../user/user.ts' export class PostsService { constructor(private readonly client: ApiClient) {} @@ -30,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 { @@ -79,17 +66,6 @@ export class PostsService { credentials: 'include', }) } - - async addComment(postId: string, content: string): Promise { - const authorId = useUserStore.getState().user?.id - if (!authorId) return - - await this.client.POST('/posts/{postId}/comments', { - params: { path: { postId } }, - body: { content, authorId }, - credentials: 'include', - }) - } } interface CreatePostMedia { diff --git a/src/app/feed/posts/usePostViewModel.ts b/src/app/feed/posts/usePostViewModel.ts deleted file mode 100644 index 2fb06bd..0000000 --- a/src/app/feed/posts/usePostViewModel.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useCallback, useState } from 'react' -import { Post, PostComment, 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[] - comments: PostComment[] -} - -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/app/user/user.ts b/src/app/user/user.ts index 8ce3446..fb5b520 100644 --- a/src/app/user/user.ts +++ b/src/app/user/user.ts @@ -1,4 +1,8 @@ -import { create } from 'zustand' +import { addMessageListener, dispatchMessage } from '../messageBus/messageBus.ts' +import { getCookie } from '../auth/cookies.ts' +import { useMessageListener } from '../../hooks/useMessageListener.ts' +import { useState } from 'react' +import { setGlobal } from '../femtoApp.ts' export interface User { id: string @@ -11,12 +15,38 @@ export enum Role { SuperUser = 1, } -interface UserState { - user: User | null - setUser: (user: User | null) => void +let globalUser: User | null + +export function initUser() { + updateUser() + + addMessageListener('auth:logged-in', updateUser) + addMessageListener('auth:registered', updateUser) + addMessageListener('auth:logged-out', updateUser) + addMessageListener('auth:refreshed', updateUser) } -export const useUserStore = create()((set) => ({ - user: null, - setUser: (user: User | null) => set({ user }), -})) +function updateUser() { + globalUser = getUserFromCookie() + setGlobal('user', globalUser) + dispatchMessage('user:updated', globalUser) +} + +export function useUser(): User | null { + const [user, setUser] = useState(globalUser) + + useMessageListener('user:updated', (u) => { + setUser(u) + }) + + return user +} + +function getUserFromCookie(): User | null { + const userCookie = getCookie('user') + + if (!userCookie) return null + + // TODO validate but it should be fine + return JSON.parse(decodeURIComponent(userCookie)) as User +} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 89691c9..330c7aa 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,13 +1,11 @@ -import { PropsWithChildren, ReactNode } from 'react' -import { Role, useUserStore } from '../app/user/user.ts' +import { PropsWithChildren } from 'react' +import { Role, useUser } 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) + const user = useUser() const isSuperUser = user?.roles.includes(Role.SuperUser) return (