diff --git a/src/components/Post.tsx b/src/components/Post.tsx index bb742b3..a8aa428 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -16,6 +16,7 @@ import { Markdown } from "./Markdown"; import { Mention } from "./Mention"; import { MarkdownInput } from "./MarkdownInput"; import { ProfilePicture, ProfilePictureBase } from "./ProfilePicture"; +import { ReactionUsers } from "./ReactionUsers"; import { RelativeTime } from "./RelativeTime"; import { twMerge } from "tailwind-merge"; import { EmojiPicker } from "./EmojiPicker"; @@ -271,15 +272,6 @@ const PostBase = memo((props: PostBaseProps) => { : "View source"} : undefined} - { - navigator.clipboard.writeText( - `https://mybearworld.github.io/roarer-2?post=${props.post.post_id}`, - ); - }} - > - Copy link - {credentials.username === props.post.u ? <> { Delete : undefined} + { + navigator.clipboard.writeText( + `https://mybearworld.github.io/roarer-2?post=${props.post.post_id}`, + ); + }} + > + Copy link + + {props.post.reactions.length ? + + Reactions + + : undefined} : undefined} diff --git a/src/components/ReactionUsers.tsx b/src/components/ReactionUsers.tsx new file mode 100644 index 0000000..cb83285 --- /dev/null +++ b/src/components/ReactionUsers.tsx @@ -0,0 +1,156 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import * as Tabs from "@radix-ui/react-tabs"; +import { ReactNode, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useAPI } from "../lib/api"; +import { Popup } from "./Popup"; +import { UserView } from "./UserView"; +import { Button } from "./Button"; + +export type ReactionUsersProps = { + post: string; + children: ReactNode; +}; +export const ReactionUsers = (props: ReactionUsersProps) => { + const [posts, loadPost] = useAPI( + useShallow((state) => [state.posts, state.loadPost]), + ); + loadPost(props.post); + const post = posts[props.post]; + + return ( + + {!post ? + Loading post... + : post.error ? + + Failed to get post. +
+ Message: {post.message} +
+ : post.isDeleted ? + This was post was deleted. + : !post.reactions.length ? + + This post doesn't have any reactions yet. Be the first to react! + + :
+ Reactions + + + {post.reactions.map((reaction) => ( + + {reaction.emoji} + + ))} + + {post.reactions.map((reaction) => ( + + + + ))} + +
+ } +
+ ); +}; + +type IndividualReactionUsersProps = { + post: string; + emoji: string; +}; +const IndividualReactionUsers = (props: IndividualReactionUsersProps) => { + const [ + credentials, + reactionUsers, + loadReactionUsers, + loadMoreReactionUsers, + reactToPost, + ] = useAPI( + useShallow((state) => [ + state.credentials, + state.reactionUsers, + state.loadReactionUsers, + state.loadMoreReactionUsers, + state.reactToPost, + ]), + ); + const [error, setError] = useState(); + const [loadingMore, setLoadingMore] = useState(false); + loadReactionUsers(props.post, props.emoji); + const users = reactionUsers[`${props.post}/${props.emoji}`]; + + const handleRemove = async () => { + const response = await reactToPost(props.post, props.emoji, "delete"); + if (response.error) { + setError(response.message); + } + }; + const handleLoadMore = async () => { + setLoadingMore(true); + const response = await loadMoreReactionUsers(props.post, props.emoji); + setLoadingMore(false); + if (response.error) { + setError(response.message); + } + }; + + return ( +
+ {!users ? + "Loading..." + : users.error ? +
+ Failed getting users. +
+ Message: {users.message} +
+ :
+ {error ? +
{error}
+ : undefined} +
+ {users.users.map((user) => ( + + ))} +
+ {users.stopLoadingMore ? + undefined + : + } +
+ } +
+ ); +}; diff --git a/src/components/UserView.tsx b/src/components/UserView.tsx index 870963b..099a09e 100644 --- a/src/components/UserView.tsx +++ b/src/components/UserView.tsx @@ -11,6 +11,7 @@ export type UserViewProps = { secondary?: boolean; className?: string; force?: boolean; + rightText?: string; }; export const UserView = forwardRef( (props: UserViewProps, ref) => { @@ -22,29 +23,35 @@ export const UserView = forwardRef( ); }, diff --git a/src/lib/api/posts.ts b/src/lib/api/posts.ts index 53e92db..efc4091 100644 --- a/src/lib/api/posts.ts +++ b/src/lib/api/posts.ts @@ -4,6 +4,7 @@ import { getCloudlink } from "./cloudlink"; import { Errorable, loadMore, request } from "./utils"; import { api } from "../servers"; import { getReply } from "../reply"; +import { USER_SCHEMA } from "./users"; export type Attachment = z.infer; const ATTACHMENT_SCHEMA = z.object({ @@ -64,6 +65,10 @@ const POST_PACKET_SCHEMA = z.object({ cmd: z.literal("post"), val: POST_SCHEMA, }); +const REACTION_USERS_SCHEMA = z.object({ + autoget: USER_SCHEMA.array(), + pages: z.number(), +}); const POST_REACTION_PACKET_SCHEMA = z.object({ cmd: z.literal("post_reaction_add").or(z.literal("post_reaction_remove")), @@ -84,6 +89,10 @@ export type PostsSlice = { }> >; posts: Record>; + reactionUsers: Record< + `${string}/${string}`, + Errorable<{ users: string[]; stopLoadingMore: boolean }> + >; addPost: (post: SchemaPost) => SchemaPost; loadChatPosts: (id: string) => Promise; loadMorePosts: ( @@ -115,6 +124,13 @@ export type PostsSlice = { emoji: string, type: "add" | "delete", ) => Promise; + loadMoreReactionUsers: (id: string, emoji: string) => Promise; + loadReactionUsersByAmount: ( + id: string, + emoji: string, + current: number, + ) => Promise>; + loadReactionUsers: (id: string, emoji: string) => Promise; }; export const createPostsSlice: Slice = (set, get) => { getCloudlink().then((cloudlink) => { @@ -195,37 +211,62 @@ export const createPostsSlice: Slice = (set, get) => { if (!parsed.success) { return; } + const { post_id: postID, username, emoji } = parsed.data.val; set((draft) => { - const post = draft.posts[parsed.data.val.post_id]; + const post = draft.posts[postID]; if (!post || post.error || post.isDeleted) { return; } const existingReaction = post.reactions.find( - (reaction) => reaction.emoji === parsed.data.val.emoji, + (reaction) => reaction.emoji === emoji, ); + const reactionUsersKey = `${postID}/${emoji}` as const; + const add = parsed.data.cmd === "post_reaction_add"; if (existingReaction) { - const newReactionCount = - existingReaction.count + - (parsed.data.cmd === "post_reaction_add" ? 1 : -1); + const newReactionCount = existingReaction.count + (add ? 1 : -1); if (newReactionCount === 0) { post.reactions = post.reactions.filter( (reaction) => reaction !== existingReaction, ); } else { - existingReaction.count += - parsed.data.cmd === "post_reaction_add" ? 1 : -1; - if (parsed.data.val.username === draft.credentials?.username) { - existingReaction.user_reacted = - parsed.data.cmd === "post_reaction_add"; + existingReaction.count = newReactionCount; + if (username === draft.credentials?.username) { + existingReaction.user_reacted = add; + } + } + if ( + draft.reactionUsers[reactionUsersKey] && + !draft.reactionUsers[reactionUsersKey].error + ) { + if (add) { + if ( + draft.reactionUsers[reactionUsersKey].users.includes(username) + ) { + return; + } + draft.reactionUsers[reactionUsersKey].users.unshift(username); + } else { + const index = draft.reactionUsers[ + reactionUsersKey + ].users.findIndex((user) => user === username); + const users = draft.reactionUsers[reactionUsersKey].users; + draft.reactionUsers[reactionUsersKey].users = users + .slice(0, index) + .concat(users.slice(index + 1)); } } } else { post.reactions.push({ - count: parsed.data.cmd === "post_reaction_add" ? 1 : -1, + count: add ? 1 : -1, emoji: parsed.data.val.emoji, user_reacted: parsed.data.val.username === draft.credentials?.username, }); + draft.reactionUsers[reactionUsersKey] = { + users: [parsed.data.val.username], + stopLoadingMore: true, + error: false, + }; } }); }); @@ -236,6 +277,7 @@ export const createPostsSlice: Slice = (set, get) => { const loadingPosts = new Set(); const loadingChats = new Set(); + const loadingReactionUsers = new Set(); return { posts: {}, chatPosts: { @@ -246,6 +288,7 @@ export const createPostsSlice: Slice = (set, get) => { error: false, }, }, + reactionUsers: {}, addPost: (post) => { const state = get(); const replies = post.reply_to.filter((reply) => reply !== null); @@ -461,5 +504,79 @@ export const createPostsSlice: Slice = (set, get) => { ); return response; }, + loadReactionUsers: async (id: string, emoji: string) => { + const state = get(); + if (state.reactionUsers[`${id}/${emoji}`]) { + return; + } + const response = await state.loadMoreReactionUsers(id, emoji); + if (response.error) { + set((draft) => { + draft.reactionUsers[`${id}/${emoji}`] = response; + }); + } + }, + loadMoreReactionUsers: async (id, emoji) => { + const state = get(); + const reactionUsers = state.reactionUsers[`${id}/${emoji}`]; + if ( + loadingReactionUsers.has(`${id}/${emoji}`) || + (reactionUsers && reactionUsers.error) + ) { + return { error: false }; + } + loadingReactionUsers.add(`${id}/${emoji}`); + const response = await state.loadReactionUsersByAmount( + id, + emoji, + reactionUsers?.users.length ?? 0, + ); + if (response.error) { + return response; + } + set((draft) => { + const reactionUsers = draft.reactionUsers[`${id}/${emoji}`]; + draft.reactionUsers[`${id}/${emoji}`] = { + users: [ + ...(reactionUsers && !reactionUsers.error ? + reactionUsers.users + : []), + ...response.users, + ], + stopLoadingMore: response.stop, + error: false, + }; + }); + loadingReactionUsers.delete(`${id}/${emoji}`); + return { error: false }; + }, + loadReactionUsersByAmount: async (id, emoji, current) => { + const { page, remove } = loadMore(current); + const state = get(); + const response = await request( + fetch( + `${api}/posts/${encodeURIComponent(id)}/reactions/${encodeURIComponent(emoji)}?page=${encodeURIComponent(page)}`, + { + headers: + state.credentials ? + { Token: state.credentials.token } + : undefined, + }, + ), + REACTION_USERS_SCHEMA, + ); + if (response.error) { + return response; + } + const users = response.response.autoget.slice(remove); + users.forEach((user) => { + state.addUser(user); + }); + return { + error: false, + stop: page === response.response.pages, + users: users.map((user) => user._id), + }; + }, }; };