@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.
- package/WORK_ORDERS.md +3 -1
- package/dev/entries.jsx +17 -2
- package/dist/i18n/messagingTranslations.js +2 -0
- package/dist/messaging/ConversationList.js +15 -3
- package/dist/messaging/MessagingProvider.js +80 -10
- package/dist/messaging/PollCard.js +22 -5
- package/dist/messaging/Thread.js +21 -2
- package/docs/messaging-deviations.md +36 -19
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +2 -0
- package/src/messaging/ConversationList.jsx +13 -3
- package/src/messaging/MessagingProvider.jsx +76 -6
- package/src/messaging/PollCard.jsx +19 -3
- package/src/messaging/Thread.jsx +19 -2
- package/tests/ConversationList.test.jsx +1 -1
- package/tests/messagingContractConformance.test.js +27 -0
- package/tests/messagingInteractions.test.jsx +3 -1
- package/tests/messagingLastMessagePreview.test.jsx +93 -0
- package/tests/messagingPollRendering.test.jsx +115 -0
- package/tests/messagingReactionMerge.test.jsx +79 -0
- package/tests/messagingThreadReceipts.test.jsx +170 -0
- package/tests/messagingUnreadLifecycle.test.jsx +4 -1
- package/work-orders/MSG-3c.md +378 -0
- package/work-orders/MSG-3d.md +268 -0
|
@@ -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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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>;
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -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 &&
|
|
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: {
|
|
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
|
-
|
|
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
|
+
});
|