From 52980560dd2414d38b9994061ffd711857871344 Mon Sep 17 00:00:00 2001 From: jmug Date: Wed, 4 Mar 2026 13:40:45 +0000 Subject: [PATCH] [feat] Message reaction visualization. (#2) Allow message user reaction list by hovering on desktop and long-tapping on mobile. Co-authored-by: jmug Reviewed-on: https://git.handmadecities.com/HMC/handmade-revolt/pulls/2 --- .../messaging/attachments/Reactions.tsx | 126 +++++++++++++++++- src/controllers/modals/ModalController.tsx | 2 + .../modals/components/ReactionUsers.tsx | 92 +++++++++++++ src/controllers/modals/types.ts | 5 + 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/controllers/modals/components/ReactionUsers.tsx diff --git a/src/components/common/messaging/attachments/Reactions.tsx b/src/components/common/messaging/attachments/Reactions.tsx index 9d8f0471..249b9ecb 100644 --- a/src/components/common/messaging/attachments/Reactions.tsx +++ b/src/components/common/messaging/attachments/Reactions.tsx @@ -16,6 +16,11 @@ import { IconButton } from "@revoltchat/ui"; import { emojiDictionary } from "../../../../assets/emojis"; import { useClient } from "../../../../controllers/client/ClientController"; +import { modalController } from "../../../../controllers/modals/ModalController"; +import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; +import Tooltip from "../../Tooltip"; +import UserIcon from "../../user/UserIcon"; +import { Username } from "../../user/UserShort"; import { RenderEmoji } from "../../../markdown/plugins/emoji"; import { HackAlertThisFileWillBeReplaced } from "../MessageBox"; @@ -85,6 +90,38 @@ const Reaction = styled.div<{ active: boolean }>` `} `; +/** + * Tooltip content for reaction users + */ +const TooltipUserList = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; + max-width: 200px; +`; + +const TooltipUserRow = styled.div` + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + padding: 4px 6px; + margin: -4px -6px; + border-radius: var(--border-radius); + cursor: pointer; + + &:hover { + background: var(--secondary-background); + } +`; + +const TooltipMoreText = styled.div` + font-size: 0.85em; + color: var(--secondary-foreground); + padding-top: 2px; +`; + /** * Render reactions on a message */ @@ -98,16 +135,97 @@ export const Reactions = observer(({ message }: Props) => { const Entry = useCallback( observer(({ id, user_ids }: { id: string; user_ids?: Set }) => { const active = user_ids?.has(client.user!._id) || false; + const userIds = user_ids ? Array.from(user_ids) : []; + const longPressTimer = useRef(null); + const didLongPress = useRef(false); - return ( + const handleClick = () => { + if (didLongPress.current) { + didLongPress.current = false; + return; + } + active ? message.unreact(id) : message.react(id); + }; + + const openModal = () => { + modalController.push({ + type: "reaction_users", + emoji: id, + userIds, + }); + }; + + const handleTouchStart = (e: TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + didLongPress.current = false; + longPressTimer.current = window.setTimeout(() => { + didLongPress.current = true; + openModal(); + }, 500); + }; + + const handleTouchEnd = (e: TouchEvent) => { + e.stopPropagation(); + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + // Short tap - trigger manually since preventDefault blocks click + if (!didLongPress.current) { + active ? message.unreact(id) : message.react(id); + } + } + didLongPress.current = false; + }; + + const openUserProfile = (userId: string) => { + modalController.push({ type: "user_profile", user_id: userId }); + }; + + // Build tooltip content showing up to 10 users + const tooltipContent = ( + + {userIds.slice(0, 10).map((userId) => { + const user = client.users.get(userId); + return ( + openUserProfile(userId)}> + + + + ); + })} + {userIds.length > 10 && ( + + and {userIds.length - 10} more... + + )} + + ); + + const reactionElement = ( - active ? message.unreact(id) : message.react(id) - }> + onClick={handleClick} + onTouchStart={isTouchscreenDevice ? handleTouchStart : undefined} + onTouchEnd={isTouchscreenDevice ? handleTouchEnd : undefined} + onTouchCancel={isTouchscreenDevice ? handleTouchEnd : undefined}> {user_ids?.size || 0} ); + + // Desktop: show tooltip on hover + // Mobile: long-press opens modal, handled by touch events + if (!isTouchscreenDevice && userIds.length > 0) { + return ( + + {reactionElement} + + ); + } + + return reactionElement; }), [], ); diff --git a/src/controllers/modals/ModalController.tsx b/src/controllers/modals/ModalController.tsx index 676795f8..e84b8b5d 100644 --- a/src/controllers/modals/ModalController.tsx +++ b/src/controllers/modals/ModalController.tsx @@ -33,6 +33,7 @@ import CreateServer from "./components/CreateServer"; import CustomStatus from "./components/CustomStatus"; import DeleteMessage from "./components/DeleteMessage"; import ReactMessage from "./components/ReactMessage"; +import ReactionUsers from "./components/ReactionUsers"; import Error from "./components/Error"; import ImageViewer from "./components/ImageViewer"; import KickMember from "./components/KickMember"; @@ -277,6 +278,7 @@ export const modalController = new ModalControllerExtended({ custom_status: CustomStatus, delete_message: DeleteMessage, react_message: ReactMessage, + reaction_users: ReactionUsers, error: Error, image_viewer: ImageViewer, kick_member: KickMember, diff --git a/src/controllers/modals/components/ReactionUsers.tsx b/src/controllers/modals/components/ReactionUsers.tsx new file mode 100644 index 00000000..d22d899f --- /dev/null +++ b/src/controllers/modals/components/ReactionUsers.tsx @@ -0,0 +1,92 @@ +import styled from "styled-components"; + +import { Modal } from "@revoltchat/ui"; + +import UserIcon from "../../../components/common/user/UserIcon"; +import { Username } from "../../../components/common/user/UserShort"; +import { RenderEmoji } from "../../../components/markdown/plugins/emoji"; +import { useClient } from "../../client/ClientController"; +import { modalController } from "../ModalController"; +import { ModalProps } from "../types"; + +const List = styled.div` + max-width: 100%; + max-height: 360px; + overflow-y: auto; +`; + +const UserRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + cursor: pointer; + border-radius: var(--border-radius); + + &:hover { + background: var(--secondary-background); + } +`; + +const Header = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 1.1em; + + img { + width: 1.5em; + height: 1.5em; + object-fit: contain; + } +`; + +export default function ReactionUsers({ + emoji, + userIds, + onClose, + ...props +}: ModalProps<"reaction_users">) { + const client = useClient(); + + const openProfile = (userId: string) => { + onClose(); + modalController.push({ type: "user_profile", user_id: userId }); + }; + + // Modal must be nonDismissable, it conflicts with the message context menu otherwise. + return ( + + + {userIds.length} + + } + actions={[ + { + onClick: () => { + onClose(); + return true; + }, + children: "Close", + }, + ]}> + + {userIds.map((userId) => { + const user = client.users.get(userId); + return ( + openProfile(userId)}> + + + + ); + })} + + + ); +} diff --git a/src/controllers/modals/types.ts b/src/controllers/modals/types.ts index ff46fb69..d5e3fa76 100644 --- a/src/controllers/modals/types.ts +++ b/src/controllers/modals/types.ts @@ -157,6 +157,11 @@ export type Modal = { type: "react_message", target: Message; } + | { + type: "reaction_users"; + emoji: string; + userIds: string[]; + } | { type: "kick_member"; member: Member;