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:
@@ -12,6 +12,8 @@ import { canDeleteMessage } from "@/hooks/usePermissions";
|
|||||||
import { formatRelative } from "@/utils/relativeTime";
|
import { formatRelative } from "@/utils/relativeTime";
|
||||||
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
|
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
|
||||||
|
|
||||||
|
type MemberInfo = { id: number; username: string; avatar: string | null };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageModel;
|
message: MessageModel;
|
||||||
chat: Chat | null;
|
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 hasMedia = (message.media_files?.length ?? 0) > 0;
|
||||||
const hasText = !!message.content;
|
const hasText = !!message.content;
|
||||||
|
|
||||||
// Group reactions by emoji { "👍": { count, mine } }
|
// Build a username/avatar lookup from the chat member list (members_detail is
|
||||||
const reactionGroups = (message.reactions ?? []).reduce<Record<string, { count: number; mine: boolean }>>(
|
// 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) => {
|
(acc, r) => {
|
||||||
const emoji = r.emoji;
|
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++;
|
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;
|
return acc;
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@@ -228,10 +243,151 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
|||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
|
{Object.entries(reactionGroups).map(([emoji, { count, mine }]) => (
|
||||||
<button
|
<ReactionPill
|
||||||
key={emoji}
|
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"
|
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={[
|
className={[
|
||||||
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition-colors",
|
"flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition-colors",
|
||||||
mine
|
mine
|
||||||
@@ -242,9 +398,6 @@ export default function Message({ message, chat, onReply, onReact, highlighted,
|
|||||||
<span>{emoji}</span>
|
<span>{emoji}</span>
|
||||||
{count > 1 && <span>{count}</span>}
|
{count > 1 && <span>{count}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export function useChatSocket({ chatId, onEvent }: Opts) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatId == null || !Number.isFinite(chatId)) return;
|
if (chatId == null || !Number.isFinite(chatId)) return;
|
||||||
closedByUserRef.current = false;
|
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;
|
let reconnectTimer: number | undefined;
|
||||||
|
|
||||||
@@ -45,11 +48,13 @@ export function useChatSocket({ chatId, onEvent }: Opts) {
|
|||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
|
if (!mounted) return;
|
||||||
setStatus("open");
|
setStatus("open");
|
||||||
reconnectAttemptRef.current = 0;
|
reconnectAttemptRef.current = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("message", (e) => {
|
ws.addEventListener("message", (e) => {
|
||||||
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data) as ChatSocketEvent;
|
const data = JSON.parse(e.data) as ChatSocketEvent;
|
||||||
setLastEvent(data);
|
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", () => {
|
ws.addEventListener("close", () => {
|
||||||
|
if (!mounted) return;
|
||||||
setStatus("closed");
|
setStatus("closed");
|
||||||
if (closedByUserRef.current) return;
|
if (closedByUserRef.current) return;
|
||||||
const attempt = reconnectAttemptRef.current++;
|
const attempt = reconnectAttemptRef.current++;
|
||||||
@@ -73,6 +79,7 @@ export function useChatSocket({ chatId, onEvent }: Opts) {
|
|||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
mounted = false;
|
||||||
closedByUserRef.current = true;
|
closedByUserRef.current = true;
|
||||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
|
|||||||
@@ -198,7 +198,12 @@ export default function ChatRoomPage() {
|
|||||||
if (m.id !== event.message_id) return m;
|
if (m.id !== event.message_id) return m;
|
||||||
let reactions = [...(m.reactions ?? [])] as typeof m.reactions;
|
let reactions = [...(m.reactions ?? [])] as typeof m.reactions;
|
||||||
if (event.action === "added") {
|
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];
|
reactions = [...reactions, { id: -Date.now(), user: event.user_id, emoji: event.emoji, created_at: new Date().toISOString() } as never];
|
||||||
|
}
|
||||||
} else if (event.action === "removed") {
|
} else if (event.action === "removed") {
|
||||||
reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji));
|
reactions = reactions.filter((r) => !((r.user as unknown as number) === event.user_id && r.emoji === event.emoji));
|
||||||
} else if (event.action === "switched") {
|
} else if (event.action === "switched") {
|
||||||
|
|||||||
Reference in New Issue
Block a user