Add reaction popover, socket guards, dedupe reactions

Build a members lookup and enrich reaction groups with reactor info; introduce a ReactionPill component that shows a tabbed popover listing who reacted (with ability to remove your own reaction). Move reaction button into the new component and adapt styling/UX.

Fix WebSocket handling in useChatSocket by adding a mounted guard to avoid handling events from torn-down sockets (addresses React StrictMode double-invoke issues) and avoid setting state after unmount.

Prevent duplicate local reaction entries in ChatRoomPage by skipping "added" events when the same user/emoji reaction already exists.
This commit is contained in:
2026-06-04 23:44:26 +02:00
parent b1f88ca501
commit c0af4c2349
3 changed files with 184 additions and 19 deletions

View File

@@ -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<Record<string, { count: number; mine: boolean }>>(
// 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<Record<number, MemberInfo>>((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<string, { count: number; mine: boolean; reactors: MemberInfo[] }>
>(
(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,10 +243,151 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
].join(" ")}
>
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
<button
<ReactionPill
key={emoji}
emoji={emoji}
count={count}
mine={mine}
allGroups={reactionGroups}
currentUserId={user?.id}
isOwn={isOwn}
onRemove={(ej) => onReact?.(message, ej)}
/>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ReactionPill — self-contained pill + tabbed who-reacted popover
// ---------------------------------------------------------------------------
type AllReactor = MemberInfo & { reactionEmoji: string };
interface ReactionPillProps {
emoji: string;
count: number;
mine: boolean;
allGroups: Record<string, { count: number; mine: boolean; reactors: MemberInfo[] }>;
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<string>("all");
const wrapperRef = useRef<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative">
{open && (
<div
className={[
"absolute bottom-full mb-1.5 z-30 w-72 rounded-2xl border border-brand-lines/20 bg-brand-bgLight shadow-2xl",
isOwn ? "right-0" : "left-0",
].join(" ")}
>
{/* Tab bar */}
<div className="flex items-center gap-0.5 overflow-x-auto border-b border-brand-lines/15 px-3 pt-2.5 pb-2 scrollbar-none">
<button
type="button"
onClick={() => onReact?.(message, emoji)}
onClick={() => setActiveTab("all")}
className={[
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
activeTab === "all"
? "bg-brand-accent/20 text-brand-accent"
: "text-brand-text/50 hover:bg-brand-lines/10 hover:text-brand-text/80",
].join(" ")}
>
Vše <span className="opacity-60">{allReactors.length}</span>
</button>
{emojiTabs.map((ej) => (
<button
key={ej}
type="button"
onClick={() => setActiveTab(ej)}
className={[
"flex shrink-0 items-center gap-1 rounded-lg px-2.5 py-1 text-xs transition-colors",
activeTab === ej
? "bg-brand-accent/20 text-brand-accent"
: "text-brand-text/50 hover:bg-brand-lines/10 hover:text-brand-text/80",
].join(" ")}
>
<span>{ej}</span>
<span className="opacity-60">{allGroups[ej].count}</span>
</button>
))}
</div>
{/* Reactor list */}
<ul className="max-h-56 overflow-y-auto py-1.5">
{displayReactors.map((r) => {
const isMe = r.id === currentUserId;
return (
<li key={`${r.id}-${r.reactionEmoji}`}>
{isMe ? (
<button
type="button"
onClick={() => { onRemove(r.reactionEmoji); setOpen(false); }}
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-brand-lines/10"
>
<div className="flex items-center gap-3">
<Avatar name={r.username} src={r.avatar} size={24} />
<span className="flex-1 text-sm font-medium text-brand-text">{r.username}</span>
{activeTab === "all" && <span className="text-base leading-none">{r.reactionEmoji}</span>}
</div>
<span className="mt-1 pl-[36px] text-[10px] text-brand-text/40">Klikni pro odebrání reakce</span>
</button>
) : (
<div className="flex items-center gap-3 px-4 py-2.5">
<Avatar name={r.username} src={r.avatar} size={24} />
<span className="flex-1 text-sm text-brand-text/80">{r.username}</span>
{activeTab === "all" && <span className="text-base leading-none">{r.reactionEmoji}</span>}
</div>
)}
</li>
);
})}
</ul>
</div>
)}
<button
type="button"
onClick={handleToggle}
className={[
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition-colors",
mine
@@ -242,9 +398,6 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
<span>{emoji}</span>
{count > 1 && <span>{count}</span>}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -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();

View File

@@ -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") {
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") {