@micha.bigler/ui-core-micha 2.16.0 → 2.18.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.
Files changed (46) hide show
  1. package/WORK_ORDERS.md +3 -1
  2. package/dev/entries.jsx +5 -1
  3. package/dist/i18n/messagingTranslations.js +27 -0
  4. package/dist/index.js +1 -0
  5. package/dist/messaging/Composer.js +41 -5
  6. package/dist/messaging/ConversationList.js +10 -3
  7. package/dist/messaging/DirectMessageLauncher.js +48 -0
  8. package/dist/messaging/MessageBubble.js +84 -4
  9. package/dist/messaging/MessagingProvider.js +102 -21
  10. package/dist/messaging/PollCard.js +22 -5
  11. package/dist/messaging/ReactionBar.js +1 -1
  12. package/dist/messaging/ReadTicks.js +7 -4
  13. package/dist/messaging/Thread.js +45 -8
  14. package/dist/messaging/api.js +3 -3
  15. package/dist/messaging/composerImageCompression.js +36 -0
  16. package/docs/messaging-deviations.md +108 -26
  17. package/package.json +1 -1
  18. package/src/i18n/messagingTranslations.ts +27 -0
  19. package/src/index.js +1 -0
  20. package/src/messaging/Composer.jsx +54 -8
  21. package/src/messaging/ConversationList.jsx +10 -4
  22. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  23. package/src/messaging/MessageBubble.jsx +74 -7
  24. package/src/messaging/MessagingProvider.jsx +124 -15
  25. package/src/messaging/PollCard.jsx +19 -3
  26. package/src/messaging/ReactionBar.jsx +1 -1
  27. package/src/messaging/ReadTicks.jsx +5 -3
  28. package/src/messaging/Thread.jsx +39 -8
  29. package/src/messaging/api.js +3 -2
  30. package/src/messaging/composerImageCompression.js +35 -0
  31. package/tests/ConversationList.test.jsx +1 -1
  32. package/tests/Thread.test.jsx +1 -1
  33. package/tests/messagingChunk6.test.jsx +109 -0
  34. package/tests/messagingComposerChunk5.test.jsx +110 -0
  35. package/tests/messagingContractConformance.test.js +69 -0
  36. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  37. package/tests/messagingExports.test.js +2 -1
  38. package/tests/messagingInteractions.test.jsx +3 -1
  39. package/tests/messagingLastMessagePreview.test.jsx +93 -0
  40. package/tests/messagingMessageActions.test.jsx +93 -0
  41. package/tests/messagingPollRendering.test.jsx +115 -0
  42. package/tests/messagingReactionMerge.test.jsx +79 -0
  43. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  44. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  45. package/work-orders/MSG-3b.md +1002 -0
  46. package/work-orders/MSG-3c.md +378 -0
@@ -1,37 +1,104 @@
1
- import { Button, Paper, Stack, Typography } from '@mui/material';
1
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
2
+ import MoreVertIcon from '@mui/icons-material/MoreVert';
3
+ import { useEffect, useState } from 'react';
2
4
  import { useTranslation } from 'react-i18next';
3
5
  import { AttachmentList } from './AttachmentList';
6
+ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
4
7
  import { ReactionBar } from './ReactionBar';
5
8
  import { PollCard } from './PollCard';
6
9
 
7
10
  function senderName(message) {
8
11
  return message.sender?.display_name || message.sender?.username || message.sender_name || message.sender?.name;
9
12
  }
13
+ function quoteSnippet(message) {
14
+ const content = message.body || message.title;
15
+ if (!content) return null;
16
+ return content.length > 120 ? `${content.slice(0, 117)}...` : content;
17
+ }
18
+ // A quoted source can be textless for two different reasons: it was deleted
19
+ // (server blanks body/title/link_target on soft delete), or it always had no
20
+ // text — an attachment-only chat send is a real, reachable case (Composer
21
+ // only requires body OR files). Collapsing both into "This message was
22
+ // deleted" would mislabel the second case, so they're distinguished here.
23
+ function quoteLabel(message, t) {
24
+ if (message.deleted_at) return t('MessagingThread.DELETED');
25
+ const snippet = quoteSnippet(message);
26
+ if (snippet) return snippet;
27
+ return message.attachments?.length ? t('MessagingThread.QUOTE_ATTACHMENT') : '';
28
+ }
10
29
 
11
- /** Provider-backed timeline collaborator; it deliberately has no mutation controls. */
12
- export function MessageBubble({ message, replyTo, onReply, children }) {
30
+ /** Provider-backed timeline collaborator, including per-message mutations. */
31
+ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
13
32
  const { t } = useTranslation();
33
+ const messaging = useOptionalMessaging();
34
+ const { currentUser, editMessage, removeMessage } = messaging || {};
14
35
  const deleted = Boolean(message.deleted_at);
15
36
  const attachments = message.attachments || [];
37
+ const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) && message.sender?.id === currentUser?.id) || canModerateMessages);
38
+ const [menuAnchor, setMenuAnchor] = useState(null);
39
+ const [editing, setEditing] = useState(false);
40
+ const [draft, setDraft] = useState(message.body || '');
41
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
42
+ const [saving, setSaving] = useState(false);
43
+ const [error, setError] = useState(null);
44
+ useEffect(() => {
45
+ if (deleted) { setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); }
46
+ }, [deleted]);
47
+ const closeMenu = () => setMenuAnchor(null);
48
+ const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
49
+ const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
50
+ const saveEdit = async () => {
51
+ setSaving(true); setError(null);
52
+ try { await editMessage?.(message.id, { body: draft.trim() }); setEditing(false); }
53
+ catch (saveError) { setError(t('MessagingActions.EDIT_ERROR', { message: extractApiErrorMessage(saveError) })); } finally { setSaving(false); }
54
+ };
55
+ const confirmDelete = async () => {
56
+ setSaving(true); setError(null);
57
+ try { await removeMessage?.(message.id); setConfirmingDelete(false); }
58
+ catch (deleteError) { setError(t('MessagingActions.DELETE_ERROR', { message: extractApiErrorMessage(deleteError) })); } finally { setSaving(false); }
59
+ };
60
+ const copyMessage = async () => {
61
+ closeMenu();
62
+ try { await navigator.clipboard.writeText(message.body || ''); }
63
+ catch (copyError) { setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) })); }
64
+ };
16
65
  return (
17
- <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)' }} aria-label={t('MessagingThread.MESSAGE')}>
66
+ <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }} aria-label={t('MessagingThread.MESSAGE')} data-message-id={message.id} onContextMenu={canAct ? openMenu : undefined}>
18
67
  <Stack spacing={0.75}>
19
- {replyTo && <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>}
68
+ {replyTo && <Button type="button" variant="text" size="small" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }}>
69
+ <Stack spacing={0} alignItems="flex-start">
70
+ <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
71
+ <Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
72
+ </Stack>
73
+ </Button>}
20
74
  <Stack direction="row" spacing={1} alignItems="baseline">
21
- <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>
75
+ {conversation?.kind !== 'direct' && <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>}
22
76
  {message.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
23
77
  {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
24
78
  </Stack>
25
79
  {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
26
- <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>
80
+ {!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
81
+ {error && <Alert severity="error" role="alert">{error}</Alert>}
82
+ {editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
27
83
  {!deleted && <Stack spacing={0.75}>
28
84
  {attachments.length > 0 && <AttachmentList attachments={attachments} />}
29
85
  <ReactionBar message={message} />
30
86
  {message.poll && <PollCard message={message} />}
31
87
  </Stack>}
32
88
  {!deleted && onReply && <Button type="button" size="small" onClick={() => onReply(message)}>{t('MessagingThread.REPLY')}</Button>}
89
+ {canAct && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu}><MoreVertIcon /></IconButton>}
33
90
  {children}
34
91
  </Stack>
92
+ <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
93
+ <MenuItem onClick={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>
94
+ <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>
95
+ <MenuItem onClick={copyMessage}>{t('MessagingActions.COPY')}</MenuItem>
96
+ </Menu>
97
+ <Dialog open={confirmingDelete} onClose={() => !saving && setConfirmingDelete(false)}>
98
+ <DialogTitle>{t('MessagingActions.DELETE_CONFIRM_TITLE')}</DialogTitle>
99
+ <DialogContent>{t('MessagingActions.DELETE_CONFIRM_BODY')}</DialogContent>
100
+ <DialogActions><Button onClick={() => setConfirmingDelete(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button><Button onClick={confirmDelete} disabled={saving} color="error">{t('MessagingActions.DELETE')}</Button></DialogActions>
101
+ </Dialog>
35
102
  </Paper>
36
103
  );
37
104
  }
@@ -4,6 +4,7 @@ import { AuthContext } from '../auth/AuthContext';
4
4
  import {
5
5
  archiveConversation,
6
6
  createBroadcastConversation,
7
+ createDirectConversation,
7
8
  createGroupConversation,
8
9
  getReadStatus,
9
10
  getUnreadCount,
@@ -11,6 +12,8 @@ import {
11
12
  listMessages,
12
13
  listThread,
13
14
  patchConversationPreferences,
15
+ markConversationRead,
16
+ markThreadRead,
14
17
  createMessage,
15
18
  uploadAttachments,
16
19
  getAttachment,
@@ -22,6 +25,8 @@ import {
22
25
  closePoll,
23
26
  getConversationConfig,
24
27
  patchConversationConfig,
28
+ patchMessage as patchMessageRequest,
29
+ deleteMessage as deleteMessageRequest,
25
30
  } from './api';
26
31
  import { useRealtime } from '../notifications/realtime';
27
32
 
@@ -30,9 +35,11 @@ export const MESSAGING_ENVELOPE = 'messaging';
30
35
  const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
31
36
  const DEFAULT_API = {
32
37
  listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
33
- patchConversationPreferences, createGroupConversation, createBroadcastConversation,
38
+ patchConversationPreferences, createGroupConversation, createBroadcastConversation, createDirectConversation,
39
+ markConversationRead, markThreadRead,
34
40
  createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
35
41
  addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
42
+ patchMessage: patchMessageRequest, deleteMessage: deleteMessageRequest,
36
43
  };
37
44
 
38
45
  function idOf(item) { return item?.id ?? item?.conversation_id ?? item?.message_id ?? item?.poll_id; }
@@ -96,28 +103,84 @@ export function messagingReducer(state, action) {
96
103
  return { ...state, messages: { ...state.messages, [key]: { ...message, status: 'error', error: action.error } } };
97
104
  }
98
105
  case 'messagePatched': return { ...state, messages: mergeById(state.messages, action.message) };
99
- case 'frame': return applyFrame(state, action.frame);
106
+ case 'conversationRead': {
107
+ const previousCount = state.unread?.by_conversation?.[action.conversationId] || 0;
108
+ if (!previousCount) return state;
109
+ return {
110
+ ...state,
111
+ unread: {
112
+ ...state.unread,
113
+ unread_count: Math.max(0, (state.unread?.unread_count || 0) - previousCount),
114
+ by_conversation: { ...state.unread.by_conversation, [action.conversationId]: 0 },
115
+ },
116
+ };
117
+ }
118
+ case 'frame': return applyFrame(state, action.frame, action.activeConversationId);
100
119
  default: return state;
101
120
  }
102
121
  }
103
122
 
104
- export function applyFrame(state, frame) {
123
+ export function applyFrame(state, frame, activeConversationId = null) {
105
124
  const payload = frame.message || frame.conversation || frame.participant || frame;
106
125
  const conversationId = frame.conversation_id ?? payload.conversation_id;
107
126
  if (frame.type === 'message') {
108
127
  const message = { ...payload, conversation_id: conversationId };
109
- return { ...state, messages: reconcileMessage(state.messages, message) };
128
+ const requestId = clientRequestIdOf(message);
129
+ const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
130
+ const messages = reconcileMessage(state.messages, message);
131
+ if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage) return { ...state, messages };
132
+ const unread = state.unread || EMPTY_CACHE.unread;
133
+ const previousCount = unread.by_conversation?.[conversationId] || 0;
134
+ return {
135
+ ...state,
136
+ messages,
137
+ unread: {
138
+ ...unread,
139
+ unread_count: (unread.unread_count || 0) + 1,
140
+ by_conversation: { ...unread.by_conversation, [conversationId]: previousCount + 1 },
141
+ },
142
+ };
143
+ }
144
+ if (frame.type === 'message_edited') {
145
+ // dcm's publish_messaging_event always attaches a full frame.message
146
+ // (serialize_message) for this event type, so `payload` already carries
147
+ // the message's own `.id` in practice. The explicit `id:` override below
148
+ // is defensive only — it keeps this branch correct even if a future
149
+ // producer ever omits `.message` and falls through to the raw frame,
150
+ // whose `conversation_id` would otherwise win idOf()'s fallback order.
151
+ const messageId = frame.message_id ?? payload.message_id;
152
+ return { ...state, messages: mergeById(state.messages, { ...payload, id: messageId }) };
110
153
  }
111
- if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
112
154
  if (frame.type === 'reaction') {
113
155
  const messageId = frame.message_id ?? payload.message_id;
114
156
  const previous = state.messages[messageId] || { id: messageId };
115
- return { ...state, messages: mergeById(state.messages, { ...previous, reactions: payload.reactions || frame.reactions || previous.reactions || [] }) };
157
+ // serialize_reactions (dcm) returns [{emoji, count}] only, never a
158
+ // per-viewer `reacted` flag, in any context — not REST, not frames, by
159
+ // design (aggregate-only). Wholesale-replacing `reactions` with the
160
+ // incoming server list would silently wipe every previously-known
161
+ // `reacted` flag on this message, not just whichever emoji changed.
162
+ // Merge by emoji instead: carry forward a previously-known `reacted`
163
+ // flag for any emoji still present in the incoming aggregate.
164
+ const incoming = payload.reactions || frame.reactions || previous.reactions || [];
165
+ const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
166
+ const reactions = incoming.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? previousByEmoji.get(reaction.emoji)?.reacted ?? false }));
167
+ return { ...state, messages: mergeById(state.messages, { ...previous, reactions }) };
116
168
  }
117
169
  if (frame.type === 'poll_updated') {
118
170
  const messageId = frame.message_id ?? payload.message_id;
119
171
  const previous = state.messages[messageId] || { id: messageId };
120
- const poll = payload.poll || frame.poll || payload;
172
+ const incomingPoll = payload.poll || frame.poll || payload;
173
+ // dcm's poll_updated frame is a viewer-independent projection
174
+ // (serialize_poll) — it never carries voted_option_ids, which dcm only
175
+ // ever adds to this viewer's own vote/create/close REST response. A
176
+ // frame arriving after a REST-derived vote is known must not wipe that
177
+ // locally-known selection; carry it forward when the frame is silent
178
+ // about it, never invert it from frame data that structurally can't
179
+ // contain it.
180
+ const previousVotedOptionIds = previous.poll?.voted_option_ids;
181
+ const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
182
+ ? { ...incomingPoll, voted_option_ids: previousVotedOptionIds }
183
+ : incomingPoll;
121
184
  return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
122
185
  }
123
186
  if (frame.type === 'message_deleted') {
@@ -191,6 +254,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
191
254
  const conversation = await api.patchConversationPreferences(conversationId, patch);
192
255
  return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, ...patch });
193
256
  }, [api, updateConversation]);
257
+ const markConversationAsRead = useCallback(async (conversationId, readAt) => {
258
+ const result = await api.markConversationRead(conversationId, readAt);
259
+ dispatch({ type: 'conversationRead', conversationId });
260
+ return result;
261
+ }, [api]);
262
+ const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
194
263
  const openGroupConversation = useCallback(async (payload) => {
195
264
  const conversation = await api.createGroupConversation(payload);
196
265
  return updateConversation(conversation);
@@ -199,6 +268,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
199
268
  const conversation = await api.createBroadcastConversation(payload);
200
269
  return updateConversation(conversation);
201
270
  }, [api, updateConversation]);
271
+ const openDirectConversation = useCallback(async (payload) => {
272
+ const conversation = await api.createDirectConversation(payload);
273
+ return updateConversation(conversation);
274
+ }, [api, updateConversation]);
202
275
  const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
203
276
  const requestId = clientRequestId || payload.client_request_id;
204
277
  const optimistic = {
@@ -226,7 +299,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
226
299
  throw error;
227
300
  }
228
301
  }, [api, user]);
229
- const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
302
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
230
303
  const optimistic = {
231
304
  id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: optimisticMessage?.body || '',
232
305
  reply_to: optimisticMessage?.reply_to || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
@@ -236,7 +309,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
236
309
  if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
237
310
  else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
238
311
  try {
239
- const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
312
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
240
313
  dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? clientRequestId } });
241
314
  return message;
242
315
  } catch (error) {
@@ -245,6 +318,30 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
245
318
  }
246
319
  }, [api, user]);
247
320
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
321
+ // Keep this API-backed action deliberately distinct from the cache-merge
322
+ // helper above. The server remains the authority for author/moderator rights.
323
+ const editMessage = useCallback(async (messageId, patch) => {
324
+ const result = await api.patchMessage(messageId, patch);
325
+ const message = result?.message || result;
326
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), ...(message || {}), id: message?.id ?? messageId });
327
+ return result;
328
+ }, [api, cache.messages, patchMessage]);
329
+ const removeMessage = useCallback(async (messageId) => {
330
+ const result = await api.deleteMessage(messageId);
331
+ const message = result?.message || result;
332
+ const previous = cache.messages[messageId] || { id: messageId };
333
+ patchMessage({
334
+ ...previous,
335
+ ...(message || {}),
336
+ id: message?.id ?? messageId,
337
+ deleted_at: message?.deleted_at ?? previous.deleted_at ?? new Date().toISOString(),
338
+ deleted_by: message?.deleted_by ?? previous.deleted_by ?? user?.id,
339
+ body: null,
340
+ title: null,
341
+ link_target: null,
342
+ });
343
+ return result;
344
+ }, [api, cache.messages, patchMessage, user]);
248
345
  const toggleReaction = useCallback(async (messageId, emoji, active) => {
249
346
  const message = cache.messages[messageId] || { id: messageId };
250
347
  const reactions = message.reactions || [];
@@ -257,7 +354,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
257
354
  patchMessage({ ...message, reactions: nextReactions });
258
355
  try {
259
356
  const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
260
- patchMessage({ ...message, ...(result?.message || result), reactions: result?.reactions || result?.message?.reactions || nextReactions });
357
+ // Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
358
+ // never returns a per-viewer `reacted` flag) — merge the server's confirmed
359
+ // counts with the reacted flags nextReactions already computed correctly,
360
+ // rather than letting the reacted-less server array wipe them.
361
+ const confirmedCounts = result?.reactions || result?.message?.reactions;
362
+ const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
363
+ const reactions = confirmedCounts ? confirmedCounts.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? nextByEmoji.get(reaction.emoji)?.reacted ?? false })) : nextReactions;
364
+ patchMessage({ ...message, ...(result?.message || result), reactions });
261
365
  return result;
262
366
  } catch (error) { patchMessage(message); throw error; }
263
367
  }, [api, cache.messages, patchMessage]);
@@ -296,7 +400,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
296
400
  seenEventsRef.current.add(frame.event_id);
297
401
  if (seenEventsRef.current.size > 500) seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
298
402
  }
299
- dispatch({ type: 'frame', frame });
403
+ dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
300
404
  }), [subscribe]);
301
405
  useEffect(() => {
302
406
  if (!active || !onReconnect) return undefined;
@@ -306,18 +410,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
306
410
  const value = useMemo(() => ({
307
411
  cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
308
412
  setConversationArchived, setConversationPreferences, openGroupConversation,
309
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
413
+ markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
414
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
310
415
  loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
311
416
  getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
312
417
  }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
313
- setConversationArchived, setConversationPreferences, openGroupConversation,
314
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
418
+ setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
419
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
315
420
  loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
316
421
  return <MessagingContext.Provider value={value}>{children}</MessagingContext.Provider>;
317
422
  }
318
423
 
319
424
  export function useMessaging() {
320
- const context = useContext(MessagingContext);
425
+ const context = useOptionalMessaging();
321
426
  if (!context) throw new Error('useMessaging must be used within a MessagingProvider');
322
427
  return context;
323
428
  }
429
+
430
+ export function useOptionalMessaging() {
431
+ return useContext(MessagingContext);
432
+ }
@@ -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>;
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
 
6
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
7
 
8
- const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
+ export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
9
9
 
10
10
  /** Independently mountable aggregate reaction controls for one message. */
11
11
  export function ReactionBar({ message }) {
@@ -1,4 +1,4 @@
1
- import { Tooltip, Typography } from '@mui/material';
1
+ import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
 
@@ -9,6 +9,7 @@ export function ReadTicks({ messageId, conversation }) {
9
9
  const { t } = useTranslation();
10
10
  const { getMessageReadStatus } = useMessaging();
11
11
  const [status, setStatus] = useState(null);
12
+ const [anchorEl, setAnchorEl] = useState(null);
12
13
  useEffect(() => {
13
14
  let mounted = true;
14
15
  getMessageReadStatus(messageId).then((next) => { if (mounted) setStatus(next); }).catch(() => { if (mounted) setStatus(null); });
@@ -18,6 +19,7 @@ export function ReadTicks({ messageId, conversation }) {
18
19
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
19
20
  // The server never returns DM detail; enforce the same carve-out defensively.
20
21
  const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
21
- const detailText = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean).join(', ');
22
- return <Tooltip title={detailText || label}><Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography></Tooltip>;
22
+ const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
23
+ if (!recipients.length) return <Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography>;
24
+ return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }}>{label}</ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
23
25
  }
@@ -1,5 +1,5 @@
1
1
  import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
2
- import { useCallback, useContext, useMemo, useRef, useState } from 'react';
2
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
 
5
5
  import { AuthContext } from '../auth/AuthContext';
@@ -8,6 +8,7 @@ import { useMessaging } from './MessagingProvider';
8
8
  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
+ function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
11
12
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
12
13
  // and no durable server row yet — mounting ReadTicks against it would fire a
13
14
  // getMessageReadStatus REST call for an id the server has never heard of.
@@ -27,27 +28,57 @@ function canShowReadTicks(message, user) {
27
28
  * `getMessageReadStatus` REST calls a rendered timeline fires — one per own
28
29
  * message shown, not one per message in the conversation.
29
30
  */
30
- export function Thread({ conversationId, onReplyTargetChange }) {
31
+ export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
31
32
  const { t } = useTranslation();
32
33
  const { user } = useContext(AuthContext) || {};
33
- const { cache, loadMoreMessages, loadThreadReplies } = useMessaging();
34
+ const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
34
35
  const scrollRef = useRef(null);
36
+ const wasNearBottomRef = useRef(true);
37
+ const messageCountRef = useRef(0);
35
38
  const [loadingOlder, setLoadingOlder] = useState(false);
36
39
  const [error, setError] = useState(null);
37
40
  const [replyingTo, setReplyingTo] = useState(null);
38
41
  const [openThreads, setOpenThreads] = useState({});
39
42
  const conversation = cache.conversations[conversationId];
40
- const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !message.reply_to)), [cache.messages, conversationId]);
43
+ const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
44
+ const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
45
+
46
+ useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
47
+ useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
48
+ useEffect(() => {
49
+ if (replyingTo && cache.messages[replyingTo.id]?.deleted_at) {
50
+ setReplyingTo(null);
51
+ onReplyTargetChange?.(null);
52
+ }
53
+ }, [cache.messages, onReplyTargetChange, replyingTo]);
54
+ useEffect(() => {
55
+ const isNewMessage = conversationMessages.length > messageCountRef.current;
56
+ messageCountRef.current = conversationMessages.length;
57
+ if (!isNewMessage || !wasNearBottomRef.current || !scrollRef.current) return;
58
+ const timeline = scrollRef.current;
59
+ if (typeof timeline.scrollTo === 'function') timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'smooth' });
60
+ else timeline.scrollTop = timeline.scrollHeight;
61
+ }, [conversationMessages]);
41
62
 
42
63
  const loadOlder = useCallback(async () => {
43
64
  setLoadingOlder(true); setError(null);
44
65
  try { await loadMoreMessages(conversationId); } catch { setError(t('MessagingThread.LOAD_ERROR')); } finally { setLoadingOlder(false); }
45
66
  }, [conversationId, loadMoreMessages, t]);
46
- const onScroll = () => { if (scrollRef.current?.scrollTop === 0 && cache.cursors.messages[conversationId] && !loadingOlder) loadOlder(); };
67
+ const onScroll = () => {
68
+ const timeline = scrollRef.current;
69
+ if (timeline) wasNearBottomRef.current = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
70
+ if (timeline?.scrollTop === 0 && cache.cursors.messages[conversationId] && !loadingOlder) loadOlder();
71
+ };
47
72
  const reply = (message) => { setReplyingTo(message); onReplyTargetChange?.(message); };
73
+ const jumpToMessage = (messageId) => {
74
+ const target = [...(scrollRef.current?.querySelectorAll('[data-message-id]') || [])]
75
+ .find((element) => String(element.dataset.messageId) === String(messageId));
76
+ target?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
77
+ };
48
78
  const toggleReplies = async (root) => {
49
79
  if (!openThreads[root.id]) {
50
80
  try { await loadThreadReplies(root.id); } catch { setError(t('MessagingThread.REPLIES_ERROR')); return; }
81
+ markThreadRead(root.id).catch(() => {});
51
82
  }
52
83
  setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
53
84
  };
@@ -59,10 +90,10 @@ export function Thread({ conversationId, onReplyTargetChange }) {
59
90
  {cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
60
91
  {!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>}
61
92
  <Stack spacing={1}>{roots.map((message) => {
62
- const replies = chronological(Object.values(cache.messages).filter((item) => item.reply_to === message.id));
63
- return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} onReply={reply}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
93
+ const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
94
+ 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>
64
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>}
65
- {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} onReply={reply}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
96
+ {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>}
66
97
  <Divider /></Stack>;
67
98
  })}</Stack>
68
99
  </Box>
@@ -7,11 +7,12 @@ function data(response) {
7
7
  return response.data;
8
8
  }
9
9
 
10
- function requestConfig({ cursor, params, headers } = {}) {
10
+ function requestConfig({ cursor, params, headers, onUploadProgress } = {}) {
11
11
  return {
12
12
  ...(params ? { params } : {}),
13
13
  ...(cursor ? { params: { ...(params || {}), cursor } } : {}),
14
14
  ...(headers ? { headers } : {}),
15
+ ...(onUploadProgress ? { onUploadProgress } : {}),
15
16
  };
16
17
  }
17
18
 
@@ -29,7 +30,7 @@ export function archiveConversation(conversationId, archived) { return apiClient
29
30
  export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
30
31
  export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
31
32
  export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
32
- export function uploadAttachments(conversationId, formData, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
33
+ export function uploadAttachments(conversationId, formData, { idempotencyKey, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
33
34
  export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
34
35
  export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
35
36
  export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
@@ -0,0 +1,35 @@
1
+ const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
2
+ const MAX_IMAGE_EDGE = 2560;
3
+
4
+ function imageDimensions(file) {
5
+ return new Promise((resolve, reject) => {
6
+ const url = URL.createObjectURL(file);
7
+ const image = new Image();
8
+ image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
9
+ image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Unable to decode image')); };
10
+ image.src = url;
11
+ });
12
+ }
13
+
14
+ function canvasBlob(canvas) {
15
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
16
+ }
17
+
18
+ /** Compresses large images opportunistically; callers always receive a sendable file. */
19
+ export async function compressImageForUpload(file) {
20
+ if (!file?.type?.startsWith('image/') || file.size <= MAX_IMAGE_BYTES) return file;
21
+ try {
22
+ const image = await imageDimensions(file);
23
+ const largestEdge = Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height);
24
+ if (!largestEdge || largestEdge <= MAX_IMAGE_EDGE) return file;
25
+ const scale = MAX_IMAGE_EDGE / largestEdge;
26
+ const canvas = document.createElement('canvas');
27
+ canvas.width = Math.round((image.naturalWidth || image.width) * scale);
28
+ canvas.height = Math.round((image.naturalHeight || image.height) * scale);
29
+ canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
30
+ const blob = await canvasBlob(canvas);
31
+ return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
32
+ } catch {
33
+ return file;
34
+ }
35
+ }
@@ -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 }),
@@ -61,7 +61,7 @@ describe('messaging Thread surfaces', () => {
61
61
  api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
62
62
  render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
63
63
  const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
64
- fireEvent.mouseOver(tick);
64
+ fireEvent.click(tick);
65
65
  await screen.findByText('Visible recipient');
66
66
  });
67
67