Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions frontend/src/v2/__tests__/recomputeReactionMine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Guards the socket `mine`-broadcast fix (2026-07-24). emitReactionChange sends
// one user's `mine` view to the whole room, so other clients must recompute it
// from the reaction's user list — otherwise clicking your own reaction re-adds
// instead of removing (the "un-react didn't revert" report) and chips
// mis-highlight.
import { recomputeReactionMine } from '../hooks/useV2PodDetail';

describe('recomputeReactionMine', () => {
test('mine=true when the current user is in the reaction user list', () => {
const out = recomputeReactionMine(
[{ emoji: '👍', count: 2, mine: false, users: [{ id: 'a' }, { id: 'me' }] }],
'me',
);
expect(out[0].mine).toBe(true);
});

test('mine=false when the user is NOT in the list — even if the wire said true (the bug)', () => {
const out = recomputeReactionMine(
[{ emoji: '👍', count: 1, mine: true, users: [{ id: 'other' }] }],
'me',
);
expect(out[0].mine).toBe(false);
});

test('falls back to the wire mine when users is absent (older server / Mongo fallback)', () => {
const out = recomputeReactionMine([{ emoji: '👍', count: 1, mine: true }], 'me');
expect(out[0].mine).toBe(true);
});

test('falls back to the wire mine when userId is missing', () => {
const out = recomputeReactionMine(
[{ emoji: '👍', count: 1, mine: true, users: [{ id: 'other' }] }],
undefined,
);
expect(out[0].mine).toBe(true);
});
});
5 changes: 5 additions & 0 deletions frontend/src/v2/__tests__/useV2PodDetailReply.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ jest.mock('../../context/SocketContext', () => ({
leavePod: jest.fn(),
}),
}));
// useV2PodDetail now reads currentUser (to recompute reaction `mine` per-client);
// useAuth throws without an AuthProvider, so stub it here.
jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'me' } }),
}));

describe('useV2PodDetail reply threading (#646)', () => {
beforeEach(() => {
Expand Down
32 changes: 29 additions & 3 deletions frontend/src/v2/hooks/useV2PodDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useV2Api } from './useV2Api';
import { V2Pod, V2PodMember } from './useV2Pods';
import { useSocket } from '../../context/SocketContext';
import { useAuth } from '../../context/AuthContext';

export interface V2Message {
id: string;
Expand Down Expand Up @@ -136,9 +137,26 @@ const chronologicalMessages = (messages: V2Message[]): V2Message[] => (
))
);

// `emitReactionChange` computes `mine` for the user who triggered the change and
// broadcasts that single view to the whole room — so every OTHER client gets a
// wrong `mine`, which flips the add/remove toggle (clicking your own reaction
// re-adds instead of removing) and mis-highlights chips (2026-07-24). Recompute
// `mine` for THIS client from each reaction's user list; fall back to the wire
// value only when `users` is absent (older server / Mongo fallback path).
export const recomputeReactionMine = <T extends { mine: boolean; users?: Array<{ id: string }> }>(
reactions: T[],
userId: string | undefined | null,
): T[] => reactions.map((r) => ({
...r,
mine: Array.isArray(r.users) && userId
? r.users.some((u) => String(u.id) === String(userId))
: r.mine,
}));

export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
const api = useV2Api();
const { socket, connected, joinPod, leavePod } = useSocket();
const { currentUser } = useAuth();
const [pod, setPod] = useState<V2Pod | null>(null);
const [messages, setMessages] = useState<V2Message[]>([]);
const [agents, setAgents] = useState<V2Agent[]>([]);
Expand Down Expand Up @@ -241,12 +259,20 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
// `messageReaction` with the new aggregated list after every add/remove
// by any user. We patch only the matching message's `reactions` array
// — no other state churn.
const handleReactionChange = (payload: { messageId: string; podId?: string; reactions: Array<{ emoji: string; count: number; mine: boolean }> }) => {
const handleReactionChange = (payload: { messageId: string; podId?: string; reactions: Array<{ emoji: string; count: number; mine: boolean; users?: Array<{ id: string }> }> }) => {
if (!payload || !payload.messageId) return;
if (payload.podId && payload.podId !== podId) return;
// `emitReactionChange` computes `mine` for the user who triggered the
// change and broadcasts that single view to the whole room. Trusting it
// gives every OTHER client a wrong `mine` — which flips the add/remove
// toggle (clicking your own reaction re-adds instead of removing) and
// mis-highlights chips. Recompute `mine` for THIS client from the
// reaction's user list (2026-07-24). Fall back to the wire value only
// when `users` is absent (older server / Mongo fallback).
const reactions = recomputeReactionMine(payload.reactions, currentUser?._id);
setMessages((prev) => prev.map((m) => (
String(m.id) === String(payload.messageId)
? { ...m, reactions: payload.reactions }
? { ...m, reactions }
: m
)));
};
Expand All @@ -257,7 +283,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
socket.off('messageReaction', handleReactionChange);
leavePod(podId);
};
}, [podId, socket, connected, joinPod, leavePod]);
}, [podId, socket, connected, joinPod, leavePod, currentUser?._id]);

const sendMessage = useCallback(async (content: string, messageType = 'text', replyToMessageId?: string): Promise<V2Message | null> => {
if (!podId || !content.trim()) return null;
Expand Down
Loading