@micha.bigler/ui-core-micha 2.17.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,7 +127,31 @@ export function applyFrame(state, frame, activeConversationId = null) {
127
127
  const message = { ...payload, conversation_id: conversationId };
128
128
  const requestId = clientRequestIdOf(message);
129
129
  const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
130
- const messages = reconcileMessage(state.messages, message);
130
+ let messages = reconcileMessage(state.messages, message);
131
+ // reply_count/last_reply_at are viewer-independent facts (serialize_message
132
+ // always includes them, live-recomputed when not annotated) — bump the
133
+ // cached root unconditionally, regardless of who sent the reply. Spread
134
+ // the existing root first and only override these two fields, never
135
+ // rebuild the object from frame data, so thread_last_read_at (REST-only,
136
+ // never present on this frame) is never wiped — the merge-trap discipline
137
+ // MSG-3c's fixes established for exactly this failure shape.
138
+ //
139
+ // No self-send suppression here: a viewer never receives a "message"
140
+ // frame for their own reply in the first place — dcm's
141
+ // resolve_live_recipients(sender=actor) excludes the sender from the
142
+ // live WS fan-out (django_core_micha/messaging/services.py), so this
143
+ // branch structurally cannot fire for the viewer's own send. That is a
144
+ // dcm-side guarantee this code relies on, not something ucm re-derives —
145
+ // if dcm's recipient exclusion ever changes, this would need revisiting.
146
+ //
147
+ // reply_to_id only, never a client-shaped reply_to fallback — a live
148
+ // "message" frame is always server-shaped (serialize_message), so a
149
+ // reply_to fallback here would be dead-code-guessing at a payload shape
150
+ // no server ever sends, exactly the kind of unverified compatibility
151
+ // guess this workstream has twice shipped as a real bug.
152
+ const rootId = message.reply_to_id;
153
+ const root = rootId != null ? messages[rootId] : null;
154
+ if (root) messages = { ...messages, [rootId]: { ...root, reply_count: (root.reply_count || 0) + 1, last_reply_at: message.created_at || root.last_reply_at } };
131
155
  if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage) return { ...state, messages };
132
156
  const unread = state.unread || EMPTY_CACHE.unread;
133
157
  const previousCount = unread.by_conversation?.[conversationId] || 0;
@@ -141,16 +165,46 @@ export function applyFrame(state, frame, activeConversationId = null) {
141
165
  },
142
166
  };
143
167
  }
144
- if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
168
+ if (frame.type === 'message_edited') {
169
+ // dcm's publish_messaging_event always attaches a full frame.message
170
+ // (serialize_message) for this event type, so `payload` already carries
171
+ // the message's own `.id` in practice. The explicit `id:` override below
172
+ // is defensive only — it keeps this branch correct even if a future
173
+ // producer ever omits `.message` and falls through to the raw frame,
174
+ // whose `conversation_id` would otherwise win idOf()'s fallback order.
175
+ const messageId = frame.message_id ?? payload.message_id;
176
+ return { ...state, messages: mergeById(state.messages, { ...payload, id: messageId }) };
177
+ }
145
178
  if (frame.type === 'reaction') {
146
179
  const messageId = frame.message_id ?? payload.message_id;
147
180
  const previous = state.messages[messageId] || { id: messageId };
148
- return { ...state, messages: mergeById(state.messages, { ...previous, reactions: payload.reactions || frame.reactions || previous.reactions || [] }) };
181
+ // serialize_reactions (dcm) returns [{emoji, count}] only, never a
182
+ // per-viewer `reacted` flag, in any context — not REST, not frames, by
183
+ // design (aggregate-only). Wholesale-replacing `reactions` with the
184
+ // incoming server list would silently wipe every previously-known
185
+ // `reacted` flag on this message, not just whichever emoji changed.
186
+ // Merge by emoji instead: carry forward a previously-known `reacted`
187
+ // flag for any emoji still present in the incoming aggregate.
188
+ const incoming = payload.reactions || frame.reactions || previous.reactions || [];
189
+ const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
190
+ const reactions = incoming.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? previousByEmoji.get(reaction.emoji)?.reacted ?? false }));
191
+ return { ...state, messages: mergeById(state.messages, { ...previous, reactions }) };
149
192
  }
150
193
  if (frame.type === 'poll_updated') {
151
194
  const messageId = frame.message_id ?? payload.message_id;
152
195
  const previous = state.messages[messageId] || { id: messageId };
153
- const poll = payload.poll || frame.poll || payload;
196
+ const incomingPoll = payload.poll || frame.poll || payload;
197
+ // dcm's poll_updated frame is a viewer-independent projection
198
+ // (serialize_poll) — it never carries voted_option_ids, which dcm only
199
+ // ever adds to this viewer's own vote/create/close REST response. A
200
+ // frame arriving after a REST-derived vote is known must not wipe that
201
+ // locally-known selection; carry it forward when the frame is silent
202
+ // about it, never invert it from frame data that structurally can't
203
+ // contain it.
204
+ const previousVotedOptionIds = previous.poll?.voted_option_ids;
205
+ const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
206
+ ? { ...incomingPoll, voted_option_ids: previousVotedOptionIds }
207
+ : incomingPoll;
154
208
  return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
155
209
  }
156
210
  if (frame.type === 'message_deleted') {
@@ -229,7 +283,6 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
229
283
  dispatch({ type: 'conversationRead', conversationId });
230
284
  return result;
231
285
  }, [api]);
232
- const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
233
286
  const openGroupConversation = useCallback(async (payload) => {
234
287
  const conversation = await api.createGroupConversation(payload);
235
288
  return updateConversation(conversation);
@@ -288,6 +341,16 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
288
341
  }
289
342
  }, [api, user]);
290
343
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
344
+ const markReplyThreadRead = useCallback(async (rootId, readAt) => {
345
+ const result = await api.markThreadRead(rootId, readAt);
346
+ // The mark-read response's `last_read_at` maps onto the cached root
347
+ // message's `thread_last_read_at` field — different key names, same
348
+ // value; this is the only place the client ever learns this viewer's own
349
+ // thread receipt, since it's REST-only and never rides a realtime frame.
350
+ const previous = cache.messages[rootId];
351
+ if (previous && result?.last_read_at !== undefined) patchMessage({ ...previous, thread_last_read_at: result.last_read_at });
352
+ return result;
353
+ }, [api, cache.messages, patchMessage]);
291
354
  // Keep this API-backed action deliberately distinct from the cache-merge
292
355
  // helper above. The server remains the authority for author/moderator rights.
293
356
  const editMessage = useCallback(async (messageId, patch) => {
@@ -324,7 +387,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
324
387
  patchMessage({ ...message, reactions: nextReactions });
325
388
  try {
326
389
  const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
327
- patchMessage({ ...message, ...(result?.message || result), reactions: result?.reactions || result?.message?.reactions || nextReactions });
390
+ // Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
391
+ // never returns a per-viewer `reacted` flag) — merge the server's confirmed
392
+ // counts with the reacted flags nextReactions already computed correctly,
393
+ // rather than letting the reacted-less server array wipe them.
394
+ const confirmedCounts = result?.reactions || result?.message?.reactions;
395
+ const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
396
+ const reactions = confirmedCounts ? confirmedCounts.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? nextByEmoji.get(reaction.emoji)?.reacted ?? false })) : nextReactions;
397
+ patchMessage({ ...message, ...(result?.message || result), reactions });
328
398
  return result;
329
399
  } catch (error) { patchMessage(message); throw error; }
330
400
  }, [api, cache.messages, patchMessage]);
@@ -12,10 +12,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
12
12
  const [selected, setSelected] = useState([]);
13
13
  const [error, setError] = useState(null);
14
14
  const [busy, setBusy] = useState(false);
15
- useEffect(() => setSelected((poll?.options || []).filter((option) => option.selected || option.voted).map((option) => option.id)), [poll]);
15
+ // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
+ // create/vote/close poll REST responses (serialize_poll itself, every
17
+ // message-list/thread load, and every poll_updated realtime frame never
18
+ // carry it) — so it's only ever present right after this viewer's own
19
+ // vote/create/close action. When it's absent (a freshly loaded or
20
+ // frame-updated poll), there's no way to know this viewer's vote from the
21
+ // payload alone; default to no selection rather than guessing or carrying
22
+ // over stale local state.
23
+ useEffect(() => setSelected(poll?.voted_option_ids ? [...poll.voted_option_ids] : []), [poll]);
16
24
  if (!poll) return null;
17
25
  const closed = Boolean(poll.closed_at);
18
- const creatorId = poll.created_by?.id ?? poll.created_by;
26
+ const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
19
27
  const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
20
28
  const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
21
29
  const vote = async () => {
@@ -29,7 +37,15 @@ export function PollCard({ message, canClose: canCloseProp }) {
29
37
  };
30
38
  return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
31
39
  <Typography variant="subtitle2">{poll.question}</Typography>
32
- {(poll.options || []).map((option) => <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? option.votes_count ?? 0})`} />)}
40
+ {(poll.options || []).map((option) => {
41
+ // dcm's poll projection gives voter user ids only, never names — there
42
+ // is no user-directory endpoint (same structural limit already
43
+ // established for the DM candidate list). A count is always correct;
44
+ // marking the viewer's own vote is derivable from voted_option_ids.
45
+ const votedByMe = Boolean(poll.voted_option_ids?.includes(option.id));
46
+ const voterCount = option.voters?.length ?? option.vote_count ?? option.votes_count ?? 0;
47
+ return <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? voterCount ?? 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}`} />;
48
+ })}
33
49
  {error && <Alert severity="error" role="alert">{error}</Alert>}
34
50
  <Stack direction="row" spacing={1}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
35
51
  </Stack></Paper>;
@@ -1,4 +1,4 @@
1
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
1
+ import { Alert, Badge, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
2
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
 
@@ -9,6 +9,15 @@ import { ReadTicks } from './ReadTicks';
9
9
 
10
10
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
11
11
  function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
12
+ // last_reply_at/reply_count are viewer-independent (always on serialize_message);
13
+ // thread_last_read_at is viewer-specific and REST-only (never on a frame — see
14
+ // MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
15
+ // at least one reply counts as unread; no replies means no marker at all.
16
+ function hasUnreadReplies(message) {
17
+ if (!message.reply_count) return false;
18
+ if (message.thread_last_read_at == null) return true;
19
+ return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(message.thread_last_read_at);
20
+ }
12
21
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
13
22
  // and no durable server row yet — mounting ReadTicks against it would fire a
14
23
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -92,7 +101,15 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
92
101
  <Stack spacing={1}>{roots.map((message) => {
93
102
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
94
103
  return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
95
- {(message.reply_count || replies.length) > 0 && <Button size="small" onClick={() => toggleReplies(message)}>{openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length })}</Button>}
104
+ {(message.reply_count || replies.length) > 0 && (() => {
105
+ const unread = hasUnreadReplies(message);
106
+ const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
107
+ // MUI's Badge spreads unrecognized props (incl. aria-label) onto
108
+ // its own non-interactive wrapping span, not the inner control —
109
+ // a screen reader focuses the Button, so the accessible name
110
+ // must live there, not on the purely-visual dot.
111
+ return <Badge color="error" variant="dot" invisible={!unread}><Button size="small" aria-label={unread ? `${label} — ${t('MessagingThread.UNREAD_REPLIES')}` : undefined} onClick={() => toggleReplies(message)}>{label}</Button></Badge>;
112
+ })()}
96
113
  {openThreads[message.id] && <Stack spacing={0.75} sx={{ pl: 3, borderLeft: 2, borderColor: 'divider' }}>{replies.map((item) => <MessageBubble key={item.id} message={item} replyTo={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
97
114
  <Divider /></Stack>;
98
115
  })}</Stack>
@@ -17,7 +17,7 @@ describe('ConversationList', () => {
17
17
  let api;
18
18
  beforeEach(() => {
19
19
  api = {
20
- listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { body: 'Hello' } }], next_cursor: 'opaque-cursor' }),
20
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { excerpt: 'Hello' } }], next_cursor: 'opaque-cursor' }),
21
21
  getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
22
22
  listMessages: vi.fn().mockResolvedValue({ results: [] }),
23
23
  archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
@@ -21,6 +21,33 @@ const FRAME_EXEMPTIONS = {
21
21
  thread_read_state: 'Receipt synchronisation is outside MSG-3b.',
22
22
  };
23
23
 
24
+ const BLOCKED_LINE = /\*\*BLOCKED\*\*/;
25
+ const VERSION_PIN = /dcm \d+\.\d+\.\d+/;
26
+
27
+ // A BLOCKED row without a version pin is exactly how MSG-3b's seven blocked
28
+ // rows went stale unnoticed: dcm shipped what they needed mid-flight and the
29
+ // WO-end review had no cheap way to tell. This check can't know whether dcm
30
+ // has since shipped — it only guarantees the pin exists, which is what turns
31
+ // the re-check into a two-minute job instead of an archaeology exercise.
32
+ function unpinnedBlockedLines(markdown) {
33
+ return markdown.split('\n').filter((line) => BLOCKED_LINE.test(line) && !VERSION_PIN.test(line));
34
+ }
35
+
36
+ describe('messaging deviation-doc BLOCKED entries carry a dependency version pin', () => {
37
+ it('every BLOCKED line in docs/messaging-deviations.md names the dcm version it was blocked at', () => {
38
+ const markdown = fs.readFileSync(path.join(root, 'docs', 'messaging-deviations.md'), 'utf8');
39
+ expect(unpinnedBlockedLines(markdown)).toEqual([]);
40
+ });
41
+
42
+ // Required negative case (the WO is explicit: prove the check can fail,
43
+ // not only that the real file happens to pass).
44
+ it('flags a BLOCKED line that omits the version pin', () => {
45
+ const unpinned = '99. **BLOCKED** — some capability with no dependency version named.';
46
+ const pinned = '100. **BLOCKED** (blocked as of dcm 2.37.0) — some other capability.';
47
+ expect(unpinnedBlockedLines(`${unpinned}\n${pinned}`)).toEqual([unpinned]);
48
+ });
49
+ });
50
+
24
51
  describe('messaging adapter and realtime contract conformance', () => {
25
52
  it('has a caller for every API export unless it has an explicit rationale', () => {
26
53
  const apiSource = fs.readFileSync(path.join(messagingRoot, 'api.js'), 'utf8');
@@ -19,7 +19,9 @@ afterEach(cleanup);
19
19
 
20
20
  describe('messaging reactions and polls', () => {
21
21
  it('round-trips add and remove reactions through the cache-backed API', async () => {
22
- const api = baseApi(); api.addReaction.mockResolvedValue({ reactions: [{ emoji: '👍', count: 1, reacted: true }] }); api.removeReaction.mockResolvedValue({ reactions: [] });
22
+ // serialize_reactions (dcm) never returns a per-viewer `reacted` flag —
23
+ // {emoji, count} only, in every context.
24
+ const api = baseApi(); api.addReaction.mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }); api.removeReaction.mockResolvedValue({ reactions: [] });
23
25
  const { rerender } = render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
24
26
  fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith(7, '👍'));
25
27
  rerender(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: true }] }} /></MessagingProvider>);
@@ -0,0 +1,93 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
7
+
8
+ const realtimeSubscribers = new Map();
9
+ vi.mock('../src/notifications/realtime', () => ({
10
+ useRealtime: () => ({
11
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
12
+ onReconnect: () => () => {},
13
+ }),
14
+ }));
15
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
16
+
17
+ import { ConversationList } from '../src/messaging/ConversationList';
18
+ import { applyFrame, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
19
+
20
+ function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
21
+
22
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
23
+
24
+ describe('last-message preview and conversation reorder', () => {
25
+ it('moves a conversation to the top and updates its preview from a server-shaped conversation_upsert frame', async () => {
26
+ const api = {
27
+ listConversations: vi.fn().mockResolvedValue({
28
+ results: [
29
+ { id: 1, title: 'Older', last_message_at: '2026-07-31T09:00:00Z', last_message: { excerpt: 'Old preview' } },
30
+ { id: 2, title: 'Newer', last_message_at: '2026-07-31T10:00:00Z', last_message: { excerpt: 'Newer preview' } },
31
+ ],
32
+ next_cursor: null,
33
+ }),
34
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
35
+ listMessages: vi.fn().mockResolvedValue({ results: [] }),
36
+ };
37
+ render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
38
+ await screen.findByText('Newer preview');
39
+ // Server-shaped conversation_upsert payload: serialize_conversation_core's
40
+ // real keys (id, last_message_at, last_message: {excerpt}), not a
41
+ // client-invented shape.
42
+ dispatchMessagingFrame({
43
+ envelope: 'messaging', type: 'conversation_upsert', event_id: 'evt-1', conversation_id: 1,
44
+ id: '1', last_message_at: '2026-07-31T12:00:00Z', last_message: { excerpt: 'Fresh reply' },
45
+ });
46
+ await screen.findByText('Fresh reply');
47
+ const rows = screen.getAllByRole('button').map((el) => el.textContent);
48
+ const olderIndex = rows.findIndex((text) => text.includes('Older'));
49
+ const newerIndex = rows.findIndex((text) => text.includes('Newer'));
50
+ expect(olderIndex).toBeGreaterThanOrEqual(0);
51
+ expect(olderIndex).toBeLessThan(newerIndex);
52
+ });
53
+
54
+ it('reflects an edited or deleted last message through a REST refresh', async () => {
55
+ const api = {
56
+ listConversations: vi.fn()
57
+ .mockResolvedValueOnce({ results: [{ id: 1, title: 'Chat', last_message: { excerpt: 'Original text' } }], next_cursor: null })
58
+ .mockResolvedValueOnce({ results: [{ id: 1, title: 'Chat', last_message: { excerpt: '' } }], next_cursor: null }),
59
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
60
+ listMessages: vi.fn().mockResolvedValue({ results: [] }),
61
+ };
62
+ function Refresher() {
63
+ const { refreshConversations } = useMessaging();
64
+ return <button onClick={() => refreshConversations()}>refresh</button>;
65
+ }
66
+ render(<MessagingProvider api={api}><ConversationList /><Refresher /></MessagingProvider>);
67
+ await screen.findByText('Original text');
68
+ screen.getByText('refresh').click();
69
+ await waitFor(() => expect(screen.queryByText('Original text')).toBeNull());
70
+ await screen.findByText('MessagingList.NO_MESSAGES');
71
+ });
72
+
73
+ it('applies a real message_edited frame (dcm always attaches frame.message) and updates the edited body', () => {
74
+ const state = { ...cache(), messages: { 'msg-1': { id: 'msg-1', conversation_id: 'conv-1', body: 'Before' } } };
75
+ // dcm's publish_messaging_event (realtime.py) always attaches a full
76
+ // frame.message = serialize_message(message) for message_edited, never
77
+ // just {message_id} alone — verified against django_core_micha's actual
78
+ // realtime.py. This is the real wire shape, not a client-invented one.
79
+ const next = applyFrame(state, {
80
+ envelope: 'messaging', type: 'message_edited', event_id: 'evt-2', conversation_id: 'conv-1', message_id: 'msg-1',
81
+ message: { id: 'msg-1', conversation_id: 'conv-1', body: 'After edit', edited_at: '2026-07-31T12:00:00Z' },
82
+ }, null);
83
+ expect(next.messages['msg-1'].body).toBe('After edit');
84
+ expect(next.messages['msg-1'].edited_at).toBe('2026-07-31T12:00:00Z');
85
+ });
86
+
87
+ it('keys a message_edited frame by message_id defensively, even if a producer ever omitted frame.message', () => {
88
+ const state = { ...cache(), messages: { 'msg-1': { id: 'msg-1', conversation_id: 'conv-1', body: 'Before' } } };
89
+ const next = applyFrame(state, { envelope: 'messaging', type: 'message_edited', event_id: 'evt-3', conversation_id: 'conv-1', message_id: 'msg-1' }, null);
90
+ expect(next.messages['msg-1']).toBeTruthy();
91
+ expect(next.messages['conv-1']).toBeUndefined();
92
+ });
93
+ });
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : key }) }));
7
+
8
+ const realtimeSubscribers = new Map();
9
+ vi.mock('../src/notifications/realtime', () => ({
10
+ useRealtime: () => ({
11
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
12
+ onReconnect: () => () => {},
13
+ }),
14
+ }));
15
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
16
+
17
+ import { AuthContext } from '../src/auth/AuthContext';
18
+ import { PollCard } from '../src/messaging/PollCard';
19
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
20
+
21
+ function baseApi(overrides = {}) {
22
+ return {
23
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
24
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
25
+ votePoll: vi.fn(), closePoll: vi.fn(),
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ // A genuinely server-shaped serialize_poll payload (django_core_micha
31
+ // serializers.py:52-64): id/question/allow_multiple/closed_at/created_by_id,
32
+ // options carry {id,text,order,vote_count,voters} — never selected/voted
33
+ // flags, never created_by as an object. voted_option_ids is added ONLY on
34
+ // the vote/create/close REST responses (views.py _poll_response), never here.
35
+ function serverPoll(overrides = {}) {
36
+ return {
37
+ id: 'poll-1', question: 'Where should we meet?', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
38
+ options: [
39
+ { id: 'opt-a', text: 'Library', order: 0, vote_count: 2, voters: ['user-1', 'user-2'] },
40
+ { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] },
41
+ ],
42
+ ...overrides,
43
+ };
44
+ }
45
+ function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
46
+ // PollCard is a "dumb" prop-driven component (matches Thread.jsx's usage,
47
+ // which passes down cache.messages[id] directly) — it does not subscribe to
48
+ // the cache itself. A live-frame test must read the message back out of the
49
+ // cache, not hold a static prop, or it can never observe a frame update.
50
+ function ConnectedPollCard({ messageId }) {
51
+ const { cache } = useMessaging();
52
+ return <PollCard message={cache.messages[messageId]} />;
53
+ }
54
+
55
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
56
+
57
+ describe('poll rendering against the real server contract', () => {
58
+ it('renders question, options, vote counts and per-option voters from a server-shaped payload', () => {
59
+ render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
60
+ expect(screen.getByText('Where should we meet?')).toBeTruthy();
61
+ expect(screen.getByText(/Library \(2\)/)).toBeTruthy();
62
+ expect(screen.getByText(/Cafe \(1\)/)).toBeTruthy();
63
+ });
64
+
65
+ it('derives selection from voted_option_ids (REST-only) and marks the viewer\'s own vote', () => {
66
+ const poll = serverPoll({ voted_option_ids: ['opt-a'] });
67
+ render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
68
+ const checkboxes = screen.getAllByRole('checkbox');
69
+ expect(checkboxes[0].checked).toBe(true);
70
+ expect(checkboxes[1].checked).toBe(false);
71
+ expect(screen.getByText(/Library.*MessagingPoll\.YOU_VOTED/)).toBeTruthy();
72
+ });
73
+
74
+ it('does not clear or invert the viewer\'s own vote when a poll_updated frame carries no voted_option_ids', async () => {
75
+ const api = baseApi();
76
+ const votedPoll = serverPoll({ voted_option_ids: ['opt-a'] });
77
+ render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /><State /></MessagingProvider>);
78
+ // Seed the cache the way a real poll message arrives: a `message` frame
79
+ // (kind: 'poll') carrying the just-voted poll, exactly as this viewer's
80
+ // own castPollVote reconciliation would leave it — not a static prop the
81
+ // component can never observe a later frame update against.
82
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: votedPoll } });
83
+ await waitFor(() => expect(screen.getAllByRole('checkbox')[0].checked).toBe(true));
84
+ // The real poll_updated frame shape: payload.poll is a bare serialize_poll
85
+ // result (verified against services.py's _poll_updated_payload) — never
86
+ // carries voted_option_ids. A vote_count bump from another viewer must
87
+ // not touch this viewer's own selection.
88
+ dispatchMessagingFrame({
89
+ envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
90
+ poll: { ...serverPoll(), options: [{ id: 'opt-a', text: 'Library', order: 0, vote_count: 3, voters: ['user-1', 'user-2', 'user-4'] }, { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] }] },
91
+ });
92
+ await waitFor(() => expect(screen.getByText(/Library \(3\)/)).toBeTruthy());
93
+ // The frame carries no voted_option_ids at all — the viewer's REST-known
94
+ // vote must be carried forward, not wiped, by this update.
95
+ expect(screen.getAllByRole('checkbox')[0].checked).toBe(true);
96
+ });
97
+
98
+ it('grants close only to the poll creator (created_by_id) or a host override, never a guess from an object shape', () => {
99
+ const poll = serverPoll({ created_by_id: 'user-9' });
100
+ const { rerender } = render(<AuthContext.Provider value={{ user: { id: 'user-9' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
101
+ expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
102
+ rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
103
+ expect(screen.queryByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeNull();
104
+ rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard canClose message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
105
+ expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
106
+ });
107
+
108
+ it('disables voting once the poll is closed', () => {
109
+ const poll = serverPoll({ closed_at: '2026-07-31T10:00:00Z' });
110
+ render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
111
+ screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox.disabled).toBe(true));
112
+ expect(screen.getByRole('button', { name: 'MessagingPoll.VOTE' }).disabled).toBe(true);
113
+ expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
114
+ });
115
+ });
@@ -0,0 +1,79 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.emoji ? `${key}:${options.emoji}:${options.count}` : key }) }));
7
+
8
+ const realtimeSubscribers = new Map();
9
+ vi.mock('../src/notifications/realtime', () => ({
10
+ useRealtime: () => ({
11
+ subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
12
+ onReconnect: () => () => {},
13
+ }),
14
+ }));
15
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
16
+
17
+ import { ReactionBar } from '../src/messaging/ReactionBar';
18
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
19
+
20
+ function baseApi(overrides = {}) {
21
+ return {
22
+ listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
23
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
24
+ addReaction: vi.fn(), removeReaction: vi.fn(),
25
+ ...overrides,
26
+ };
27
+ }
28
+ // ReactionBar is prop-driven (matches every other messaging leaf component) —
29
+ // a live-frame test must read the message back out of the cache.
30
+ function ConnectedReactionBar({ messageId }) {
31
+ const { cache } = useMessaging();
32
+ return <ReactionBar message={cache.messages[messageId]} />;
33
+ }
34
+
35
+ afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
36
+
37
+ describe('reaction frame/REST merge against the real aggregate-only contract', () => {
38
+ it('an unrelated reaction frame updates its own emoji without touching the viewer\'s known reacted state on other emoji', async () => {
39
+ const api = baseApi();
40
+ render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
41
+ // Seed: the viewer has already reacted with 👍 (locally known — e.g. from
42
+ // an earlier REST confirmation), the server aggregate never carries that.
43
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1, reacted: true }] } });
44
+ await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' })).toBeTruthy());
45
+ // A different viewer reacts with 🎉 — dcm's real reaction frame shape
46
+ // (serialize_reactions): {emoji, count} only, never reacted, for the
47
+ // WHOLE aggregate list including 👍.
48
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 1 }, { emoji: '🎉', count: 1 }] });
49
+ await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:🎉:1' })).toBeTruthy());
50
+ const thumbsUp = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
51
+ expect(thumbsUp.className.includes('MuiChip-colorPrimary')).toBe(true); // still marked as the viewer's own reaction
52
+ });
53
+
54
+ it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
55
+ const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }) });
56
+ render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
57
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
58
+ await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
59
+ fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
60
+ fireEvent.click(screen.getByText('👍'));
61
+ await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', '👍'));
62
+ await waitFor(() => {
63
+ const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
64
+ expect(chip.className.includes('MuiChip-colorPrimary')).toBe(true);
65
+ });
66
+ });
67
+
68
+ it('a duplicate event_id changes nothing', async () => {
69
+ const api = baseApi();
70
+ render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
71
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1 }] } });
72
+ await waitFor(() => screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' }));
73
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 2 }] });
74
+ await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy());
75
+ dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 99 }] });
76
+ expect(screen.queryByRole('button', { name: 'MessagingReactions.TOGGLE:👍:99' })).toBeNull();
77
+ expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy();
78
+ });
79
+ });