diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx index 552ab84..6067746 100644 --- a/frontend/src/components/social/chat/Message.tsx +++ b/frontend/src/components/social/chat/Message.tsx @@ -12,6 +12,8 @@ import { canDeleteMessage } from "@/hooks/usePermissions"; import { formatRelative } from "@/utils/relativeTime"; import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat"; +type MemberInfo = { id: number; username: string; avatar: string | null }; + interface Props { message: MessageModel; chat: Chat | null; @@ -59,13 +61,26 @@ export default function Message({ message, chat, onReply, onReact, highlighted, const hasMedia = (message.media_files?.length ?? 0) > 0; const hasText = !!message.content; - // Group reactions by emoji { "👍": { count, mine } } - const reactionGroups = (message.reactions ?? []).reduce>( + // Build a username/avatar lookup from the chat member list (members_detail is + // returned by the API but not yet in the generated type, so we cast). + const membersMap = ( + (chat as unknown as { members_detail?: MemberInfo[] })?.members_detail ?? [] + ).reduce>((acc, m) => { acc[m.id] = m; return acc; }, {}); + if (user != null) { + membersMap[user.id] ??= { id: user.id, username: user.username, avatar: user.avatar ?? null }; + } + + // Group reactions by emoji { "👍": { count, mine, reactors } } + const reactionGroups = (message.reactions ?? []).reduce< + Record + >( (acc, r) => { const emoji = r.emoji; - if (!acc[emoji]) acc[emoji] = { count: 0, mine: false }; + const userId = r.user as unknown as number; + if (!acc[emoji]) acc[emoji] = { count: 0, mine: false, reactors: [] }; acc[emoji].count++; - if ((r.user as unknown as number) === user?.id) acc[emoji].mine = true; + if (userId === user?.id) acc[emoji].mine = true; + acc[emoji].reactors.push(membersMap[userId] ?? { id: userId, username: `User ${userId}`, avatar: null }); return acc; }, {}, @@ -228,23 +243,161 @@ export default function Message({ message, chat, onReply, onReact, highlighted, ].join(" ")} > {Object.entries(reactionGroups).map(([emoji, { count, mine }]) => ( - + emoji={emoji} + count={count} + mine={mine} + allGroups={reactionGroups} + currentUserId={user?.id} + isOwn={isOwn} + onRemove={(ej) => onReact?.(message, ej)} + /> ))} )} ); } + +// --------------------------------------------------------------------------- +// ReactionPill — self-contained pill + tabbed who-reacted popover +// --------------------------------------------------------------------------- + +type AllReactor = MemberInfo & { reactionEmoji: string }; + +interface ReactionPillProps { + emoji: string; + count: number; + mine: boolean; + allGroups: Record; + currentUserId?: number; + isOwn: boolean; + onRemove: (emoji: string) => void; +} + +function ReactionPill({ emoji, count, mine, allGroups, currentUserId, isOwn, onRemove }: ReactionPillProps) { + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleOutside(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleOutside); + return () => document.removeEventListener("mousedown", handleOutside); + }, [open]); + + function handleToggle() { + if (!open) { + setActiveTab(emoji); + setOpen(true); + } else { + setOpen(false); + } + } + + const emojiTabs = Object.keys(allGroups); + + const allReactors: AllReactor[] = emojiTabs.flatMap((ej) => + allGroups[ej].reactors.map((r) => ({ ...r, reactionEmoji: ej })), + ); + + const displayReactors: AllReactor[] = + activeTab === "all" + ? allReactors + : (allGroups[activeTab]?.reactors ?? []).map((r) => ({ ...r, reactionEmoji: activeTab })); + + return ( +
+ {open && ( +
+ {/* Tab bar */} +
+ + {emojiTabs.map((ej) => ( + + ))} +
+ + {/* Reactor list */} +
    + {displayReactors.map((r) => { + const isMe = r.id === currentUserId; + return ( +
  • + {isMe ? ( + + ) : ( +
    + + {r.username} + {activeTab === "all" && {r.reactionEmoji}} +
    + )} +
  • + ); + })} +
+
+ )} + +
+ ); +} diff --git a/frontend/src/hooks/useChatSocket.ts b/frontend/src/hooks/useChatSocket.ts index bfeb8a0..dc6e68b 100644 --- a/frontend/src/hooks/useChatSocket.ts +++ b/frontend/src/hooks/useChatSocket.ts @@ -36,6 +36,9 @@ export function useChatSocket({ chatId, onEvent }: Opts) { useEffect(() => { if (chatId == null || !Number.isFinite(chatId)) return; closedByUserRef.current = false; + // Guards against React StrictMode double-invoke: events from the first + // (torn-down) WebSocket are silently dropped after cleanup runs. + let mounted = true; let reconnectTimer: number | undefined; @@ -45,11 +48,13 @@ export function useChatSocket({ chatId, onEvent }: Opts) { wsRef.current = ws; ws.addEventListener("open", () => { + if (!mounted) return; setStatus("open"); reconnectAttemptRef.current = 0; }); ws.addEventListener("message", (e) => { + if (!mounted) return; try { const data = JSON.parse(e.data) as ChatSocketEvent; setLastEvent(data); @@ -59,9 +64,10 @@ export function useChatSocket({ chatId, onEvent }: Opts) { } }); - ws.addEventListener("error", () => setStatus("error")); + ws.addEventListener("error", () => { if (mounted) setStatus("error"); }); ws.addEventListener("close", () => { + if (!mounted) return; setStatus("closed"); if (closedByUserRef.current) return; const attempt = reconnectAttemptRef.current++; @@ -73,6 +79,7 @@ export function useChatSocket({ chatId, onEvent }: Opts) { connect(); return () => { + mounted = false; closedByUserRef.current = true; if (reconnectTimer) window.clearTimeout(reconnectTimer); wsRef.current?.close(); diff --git a/frontend/src/pages/social/chat/ChatRoomPage.tsx b/frontend/src/pages/social/chat/ChatRoomPage.tsx index 14dcb65..0564dd6 100644 --- a/frontend/src/pages/social/chat/ChatRoomPage.tsx +++ b/frontend/src/pages/social/chat/ChatRoomPage.tsx @@ -198,7 +198,12 @@ export default function ChatRoomPage() { if (m.id !== event.message_id) return m; let reactions = [...(m.reactions ?? [])] as typeof m.reactions; if (event.action === "added") { - reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never]; + const alreadyExists = reactions.some( + (r) => (r.user as unknown as number) === event.user_id && r.emoji === event.emoji, + ); + if (!alreadyExists) { + reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never]; + } } else if (event.action === "removed") { reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji)); } else if (event.action === "switched") {