@micha.bigler/ui-core-micha 2.21.2 → 2.22.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.
@@ -30,7 +30,7 @@ function quoteLabel(message, t) {
30
30
 
31
31
  /** Provider-backed timeline collaborator, including per-message mutations. */
32
32
  export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
33
- const { t } = useTranslation();
33
+ const { t, i18n } = useTranslation();
34
34
  const messaging = useOptionalMessaging();
35
35
  const { currentUser, editMessage, removeMessage } = messaging || {};
36
36
  const deleted = Boolean(message.deleted_at);
@@ -130,7 +130,7 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
130
130
  {message.poll && <PollCard message={message} />}
131
131
  </Stack>}
132
132
  {hasMeta && <Stack data-testid="message-meta" direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" flexWrap="wrap" sx={{ color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }}>
133
- {message.created_at && <Typography component="span" variant="caption" color="inherit" sx={{ whiteSpace: 'nowrap' }}>{new Date(message.created_at).toLocaleString()}</Typography>}
133
+ {message.created_at && <Typography component="span" variant="caption" color="inherit" sx={{ whiteSpace: 'nowrap' }}>{new Date(message.created_at).toLocaleTimeString(i18n?.language, { hour: '2-digit', minute: '2-digit' })}</Typography>}
134
134
  {message.edited_at && !deleted && <Typography component="span" variant="caption" color="inherit">{t('MessagingThread.EDITED')}</Typography>}
135
135
  {children}
136
136
  </Stack>}
@@ -207,6 +207,21 @@ export function applyFrame(state, frame, activeConversationId = null) {
207
207
  : incomingPoll;
208
208
  return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
209
209
  }
210
+ if (frame.type === 'read_state') {
211
+ const userId = frame.user_id ?? payload.user_id;
212
+ const lastReadAt = frame.last_read_at ?? payload.last_read_at;
213
+ if (conversationId == null || userId == null || lastReadAt == null) return state;
214
+ const key = `${conversationId}:${userId}`;
215
+ return { ...state, receipts: { ...state.receipts, [key]: { ...state.receipts[key], conversation_id: conversationId, user_id: userId, last_read_at: lastReadAt } } };
216
+ }
217
+ if (frame.type === 'thread_read_state') {
218
+ const rootId = frame.root_id ?? payload.root_id;
219
+ const userId = frame.user_id ?? payload.user_id;
220
+ const lastReadAt = frame.last_read_at ?? payload.last_read_at;
221
+ if (rootId == null || userId == null || lastReadAt == null) return state;
222
+ const key = `thread:${rootId}:${userId}`;
223
+ return { ...state, receipts: { ...state.receipts, [key]: { ...state.receipts[key], root_id: rootId, user_id: userId, last_read_at: lastReadAt } } };
224
+ }
210
225
  if (frame.type === 'message_deleted') {
211
226
  const previous = state.messages[frame.message_id] || {};
212
227
  return { ...state, messages: { ...state.messages, [frame.message_id]: { ...previous, id: frame.message_id, deleted_at: frame.deleted_at, deleted_by: frame.deleted_by, body: null, title: null, link_target: null } } };
@@ -400,8 +415,9 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
400
415
  }, [api, cache.messages, patchMessage]);
401
416
  const createConversationPoll = useCallback(async (conversationId, payload, { clientRequestId } = {}) => {
402
417
  const poll = await api.createPoll(conversationId, { ...payload, client_request_id: clientRequestId }, { idempotencyKey: clientRequestId });
403
- const message = poll?.message || poll;
404
- if (message?.id != null) patchMessage({ ...message, conversation_id: message.conversation_id ?? conversationId, poll: message.poll || poll.poll || poll });
418
+ const messageId = poll?.message_id;
419
+ if (messageId == null) throw new Error('Poll response is missing message_id');
420
+ patchMessage({ id: messageId, conversation_id: poll.conversation_id ?? conversationId, created_at: new Date().toISOString(), kind: 'poll', poll });
405
421
  return poll;
406
422
  }, [api, patchMessage]);
407
423
  const castPollVote = useCallback(async (messageId, poll, optionIds) => {
@@ -1,15 +1,36 @@
1
1
  import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
2
2
  import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
3
- import { ButtonBase, List, ListItem, Popover } from '@mui/material';
3
+ import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
4
4
  import { useEffect, useState } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
 
7
7
  import { useMessaging } from './MessagingProvider';
8
8
 
9
- /** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
9
+ // Explicit tokens, not `inherit`/`primary` -- `inherit` inside the message
10
+ // meta Stack resolved to `text.disabled`, which read too close to the "read"
11
+ // state at the rendered 1rem icon size. The icon shape (Done vs DoneAll)
12
+ // already differs, so the colour distinction is a second, independent signal
13
+ // rather than the only one.
14
+ const DM_SENT_COLOR = 'grey.500';
15
+ const DM_READ_COLOR = 'primary.main';
16
+
17
+ /**
18
+ * Shape-driven, no client-side role/permission branching: dcm only ever
19
+ * includes `read_count`/`recipient_count` inside the same `read_receipt_detail`-
20
+ * gated block as `recipient_detail`, and never for a `direct` conversation
21
+ * (the DM privacy carve-out). So "counts present" already means "team member,
22
+ * non-direct" -- checking anything else here would re-implement a decision
23
+ * dcm's response shape already makes.
24
+ *
25
+ * - counts present -> a read ratio ("18/40"), numeric, no icon ladder.
26
+ * - no counts, `direct` -> a two-state tick (sent / read).
27
+ * - no counts, non-direct -> no indicator at all (an ordinary participant in
28
+ * a group; a permanently-grey tick here would be the "looks always
29
+ * delivered" defect this component exists to remove).
30
+ */
10
31
  export function ReadTicks({ messageId, conversation }) {
11
32
  const { t } = useTranslation();
12
- const { getMessageReadStatus } = useMessaging();
33
+ const { cache, getMessageReadStatus } = useMessaging();
13
34
  const [status, setStatus] = useState(null);
14
35
  const [anchorEl, setAnchorEl] = useState(null);
15
36
  useEffect(() => {
@@ -18,11 +39,43 @@ export function ReadTicks({ messageId, conversation }) {
18
39
  return () => { mounted = false; };
19
40
  }, [getMessageReadStatus, messageId]);
20
41
  if (!status) return null;
21
- const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
22
- const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
42
+
43
+ const recipientDetail = status.recipient_detail?.map((recipient) => ({ ...recipient, last_read_at: cache.receipts[`${conversation?.id}:${recipient.user_id}`]?.last_read_at ?? recipient.last_read_at })) || status.recipient_detail;
44
+ const directReceipt = conversation?.kind === 'direct' && conversation?.other_user_id != null
45
+ ? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
46
+ : null;
47
+ const allRead = status.all_read || Boolean(directReceipt?.last_read_at) || Boolean(recipientDetail?.length && recipientDetail.every((recipient) => recipient.last_read_at));
48
+ const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
49
+
50
+ if (!hasCounts && conversation?.kind !== 'direct') return null;
51
+
23
52
  // The server never returns DM detail; enforce the same carve-out defensively.
24
- const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
53
+ const detail = conversation?.kind === 'direct' ? null : recipientDetail;
25
54
  const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
26
- if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={status.all_read ? 'primary' : 'inherit'} />;
27
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: status.all_read ? 'primary.main' : 'inherit' }}><StatusIcon aria-hidden="true" /></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></>;
55
+
56
+ if (hasCounts) {
57
+ // Read count/recipient_count exactly as dcm computed them -- do NOT
58
+ // recompute from recipientDetail here. dcm thresholds each participant's
59
+ // last_read_at against THIS message's created_at
60
+ // (`last_read_at__gte=message.created_at`, services.py); recipientDetail
61
+ // only carries each participant's raw, unthresholded last_read_at, and
62
+ // ReadTicks has no access to the message's own created_at to reproduce
63
+ // that comparison. Recomputing from recipientDetail (tried during
64
+ // implementation, caught in review) silently counted anyone who had ever
65
+ // read the conversation at any point as "read this message" -- in a real
66
+ // group with history, that reads as close to N/N regardless of who
67
+ // actually saw this specific message, the opposite of the WO's intent.
68
+ // The trade-off: unlike the DM tick and the per-person popover below
69
+ // (both viewer/timestamp-relative facts, not message-relative, so they
70
+ // safely reuse cache.receipts), this numeric ratio only updates on the
71
+ // next read-status fetch, not live from a read_state frame.
72
+ const label = t('MessagingReadTicks.READ_RATIO', { read: status.read_count, total: status.recipient_count });
73
+ const ratio = <Typography component="span" variant="caption" color="inherit">{status.read_count}/{status.recipient_count}</Typography>;
74
+ if (!recipients.length) return <span role="img" aria-label={label}>{ratio}</span>;
75
+ return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex' }}>{ratio}</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></>;
76
+ }
77
+
78
+ const label = allRead ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.SENT');
79
+ const StatusIcon = allRead ? DoneAllOutlinedIcon : DoneOutlinedIcon;
80
+ return <StatusIcon role="img" aria-label={label} sx={{ color: allRead ? DM_READ_COLOR : DM_SENT_COLOR }} />;
28
81
  }
@@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
4
4
 
5
5
  import { AuthContext } from '../auth/AuthContext';
6
6
  import { MessageBubble } from './MessageBubble';
7
- import { useMessaging } from './MessagingProvider';
7
+ import { MESSAGING_ENVELOPE, useMessaging } from './MessagingProvider';
8
8
  import { ReadTicks } from './ReadTicks';
9
+ import { useRealtime } from '../notifications/realtime';
9
10
 
10
11
  function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
11
12
  function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
@@ -13,10 +14,11 @@ function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
13
14
  // thread_last_read_at is viewer-specific and REST-only (never on a frame — see
14
15
  // MessagingProvider.jsx's applyFrame merge-trap handling). A null receipt with
15
16
  // at least one reply counts as unread; no replies means no marker at all.
16
- function hasUnreadReplies(message) {
17
+ function hasUnreadReplies(message, receipts, userId) {
17
18
  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);
19
+ const lastReadAt = receipts[`thread:${message.id}:${userId}`]?.last_read_at ?? message.thread_last_read_at;
20
+ if (lastReadAt == null) return true;
21
+ return Boolean(message.last_reply_at) && new Date(message.last_reply_at) > new Date(lastReadAt);
20
22
  }
21
23
  // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
22
24
  // and no durable server row yet — mounting ReadTicks against it would fire a
@@ -38,9 +40,10 @@ function canShowReadTicks(message, user) {
38
40
  * message shown, not one per message in the conversation.
39
41
  */
40
42
  export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
41
- const { t } = useTranslation();
43
+ const { t, i18n } = useTranslation();
42
44
  const { user } = useContext(AuthContext) || {};
43
45
  const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
46
+ const { subscribe } = useRealtime();
44
47
  const scrollRef = useRef(null);
45
48
  const wasNearBottomRef = useRef(true);
46
49
  const messageCountRef = useRef(0);
@@ -52,8 +55,30 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
52
55
  const conversation = cache.conversations[conversationId];
53
56
  const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
54
57
  const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
58
+ const datedRoots = useMemo(() => roots.map((message, index) => ({ message, showDateSeparator: index > 0 && new Date(roots[index - 1].created_at).toDateString() !== new Date(message.created_at).toDateString() })), [roots]);
55
59
 
56
60
  useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
61
+ // Re-mark read when a live message frame arrives for the conversation
62
+ // currently open -- without this, `read_count` biases downward for the
63
+ // person paying the most attention: the mount-effect above only fires once
64
+ // per open, so a viewer already sitting in the thread when a new message
65
+ // lands is recorded as "not read" until they navigate away and back.
66
+ // Subscribes independently of MessagingProvider's own subscription to the
67
+ // same envelope (the registry supports multiple handlers per envelope) so
68
+ // this stays a Thread-local concern, not a MessagingProvider reducer change.
69
+ // No sender check needed: dcm's `resolve_live_recipients(sender=actor)`
70
+ // already excludes the sender from the live fan-out, so a `message` frame
71
+ // for this conversation structurally cannot be the viewer's own send.
72
+ useEffect(() => {
73
+ if (conversationId == null) return undefined;
74
+ return subscribe(MESSAGING_ENVELOPE, (frame) => {
75
+ if (frame.type !== 'message') return;
76
+ const frameConversationId = frame.conversation_id ?? frame.message?.conversation_id;
77
+ if (String(frameConversationId) !== String(conversationId)) return;
78
+ if (document.visibilityState === 'hidden') return;
79
+ markConversationRead(conversationId).catch(() => {});
80
+ });
81
+ }, [conversationId, markConversationRead, subscribe]);
57
82
  useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
58
83
  // Nothing else in this component ever fetches messages for the conversation
59
84
  // actually being opened — `cache.messages` is otherwise only populated by a
@@ -136,11 +161,11 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
136
161
  (the scrollbar fix above) would clip the icon whenever there's no
137
162
  vertical scrollbar reserving extra width (a short conversation,
138
163
  no vertical overflow). */}
139
- <Stack spacing={1} sx={{ pr: 2 }}>{roots.map((message) => {
164
+ <Stack spacing={1} sx={{ pr: 2 }}>{datedRoots.map(({ message, showDateSeparator }) => {
140
165
  const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
141
- 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>
166
+ return <Stack key={message.id} spacing={0.75}>{showDateSeparator && <Stack data-testid="day-separator" direction="row" alignItems="center" spacing={1}><Divider sx={{ flex: 1 }} /><Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleDateString(i18n?.language)}</Typography><Divider sx={{ flex: 1 }} /></Stack>}<MessageBubble message={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
142
167
  {(message.reply_count || replies.length) > 0 && (() => {
143
- const unread = hasUnreadReplies(message);
168
+ const unread = hasUnreadReplies(message, cache.receipts, user?.id);
144
169
  const label = openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length });
145
170
  // MUI's Badge spreads unrecognized props (incl. aria-label) onto
146
171
  // its own non-interactive wrapping span, not the inner control —
@@ -79,6 +79,58 @@ describe('ConversationList', () => {
79
79
  expect(resolveDirectUserName).not.toHaveBeenCalled();
80
80
  });
81
81
 
82
+ it('calls resolveManagedLabel for a broadcast conversation, not just managed -- must fail against the kind-gated code (MSG-6f scope A)', async () => {
83
+ api.listConversations.mockResolvedValueOnce({
84
+ results: [{ id: 9, kind: 'broadcast', external_key: 'broadcast', title: null, last_message: { excerpt: 'Hi all' } }],
85
+ next_cursor: null,
86
+ });
87
+ const resolveManagedLabel = vi.fn((conversation) => (conversation.kind === 'broadcast' ? 'Event-Ankündigungen' : null));
88
+ renderList(api, { resolveManagedLabel });
89
+ await screen.findByText('Event-Ankündigungen');
90
+ expect(resolveManagedLabel).toHaveBeenCalledWith(expect.objectContaining({ id: 9, kind: 'broadcast' }));
91
+ });
92
+
93
+ it('falls through to the existing title chain unchanged when resolveManagedLabel returns null, for any kind', async () => {
94
+ api.listConversations.mockResolvedValueOnce({
95
+ results: [{ id: 10, kind: 'broadcast', title: null, last_message: { excerpt: 'Hi' } }],
96
+ next_cursor: null,
97
+ });
98
+ const resolveManagedLabel = vi.fn(() => null);
99
+ renderList(api, { resolveManagedLabel });
100
+ await screen.findByText('MessagingList.UNTITLED');
101
+ expect(resolveManagedLabel).toHaveBeenCalled();
102
+ });
103
+
104
+ it('a truthy resolveManagedLabel wins over resolveDirectUserName for a direct conversation -- documents the new precedence, not previously reachable when the gate excluded every kind but managed', async () => {
105
+ api.listConversations.mockResolvedValueOnce({
106
+ results: [{ id: 12, kind: 'direct', other_user_id: 42, title: null, last_message: { excerpt: 'Hi' } }],
107
+ next_cursor: null,
108
+ });
109
+ const resolveManagedLabel = vi.fn(() => 'Host label wins');
110
+ const resolveDirectUserName = vi.fn(() => 'Alex');
111
+ renderList(api, { resolveManagedLabel, resolveDirectUserName });
112
+ await screen.findByText('Host label wins');
113
+ // resolveManagedLabel is checked first, unconditionally -- a host whose
114
+ // resolver ever returns non-null for kind: 'direct' pre-empts the
115
+ // id-based resolveDirectUserName lookup entirely. Deliberate per the
116
+ // WO's delegation contract (a host is trusted to return null for kinds
117
+ // it doesn't handle), but was structurally impossible before this WO
118
+ // (the old kind === 'managed' gate excluded every other kind), so it is
119
+ // now a real, if host-controlled, precedence a resolver author must know.
120
+ expect(resolveDirectUserName).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('keeps managed event_all/event_team labelling unchanged (regression guard)', async () => {
124
+ api.listConversations.mockResolvedValueOnce({
125
+ results: [{ id: 11, kind: 'managed', external_key: 'event_all', title: null, last_message: { excerpt: 'Hi' } }],
126
+ next_cursor: null,
127
+ });
128
+ const resolveManagedLabel = vi.fn((conversation) => (conversation.external_key === 'event_all' ? 'Alle - Sommerlager' : null));
129
+ renderList(api, { resolveManagedLabel });
130
+ await screen.findByText('Alle - Sommerlager');
131
+ expect(resolveManagedLabel).toHaveBeenCalledWith(expect.objectContaining({ id: 11, kind: 'managed', external_key: 'event_all' }));
132
+ });
133
+
82
134
  it('wires mute and archive through participant APIs', async () => {
83
135
  renderList(api);
84
136
  await screen.findByText('First');
@@ -18,7 +18,7 @@ function makeApi() {
18
18
  getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
19
19
  listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'Visible message', created_at: '2026-07-31T10:00:00Z' }], next_cursor: 'signed-opaque-cursor' }),
20
20
  listThread: vi.fn().mockResolvedValue({ results: [] }),
21
- getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
21
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
22
22
  };
23
23
  }
24
24
  // No `activeConversationId` on the provider: this matches how jg-ferien
@@ -56,17 +56,17 @@ describe('messaging Thread surfaces', () => {
56
56
  expect(screen.queryByText('Stale secret')).toBeNull();
57
57
  });
58
58
 
59
- it('renders aggregate read state and never exposes supplied recipient details for a direct conversation', async () => {
60
- api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Private recipient' }] });
59
+ it('renders the two-state direct tick and never exposes supplied recipient details for a direct conversation', async () => {
60
+ api.getReadStatus.mockResolvedValue({ all_read: false, recipient_detail: [{ display_name: 'Private recipient' }] });
61
61
  render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'direct' }} /></MessagingProvider>);
62
- await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
62
+ await screen.findByLabelText('MessagingReadTicks.SENT');
63
63
  expect(screen.queryByText('Private recipient')).toBeNull();
64
64
  });
65
65
 
66
- it('renders supplied recipient detail for a non-direct conversation (the permitted case)', async () => {
67
- api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
66
+ it('renders supplied recipient detail for a non-direct conversation with counts (the permitted case)', async () => {
67
+ api.getReadStatus.mockResolvedValue({ all_read: false, read_count: 1, recipient_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
68
68
  render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
69
- const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
69
+ const tick = await screen.findByLabelText('MessagingReadTicks.READ_RATIO');
70
70
  fireEvent.click(tick);
71
71
  await screen.findByText('Visible recipient');
72
72
  });
@@ -100,8 +100,9 @@ describe('messaging Thread surfaces', () => {
100
100
  renderThread(api);
101
101
  await screen.findByLabelText('MessagingThread.TIMELINE');
102
102
  cleanup();
103
+ api.getReadStatus.mockResolvedValue({ all_read: false, read_count: 0, recipient_count: 3 });
103
104
  render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
104
- await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.DELIVERED:2')).toBeTruthy());
105
+ await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.READ_RATIO')).toBeTruthy());
105
106
  });
106
107
 
107
108
  it('fetches and shows a conversation\'s historical messages on open, with no realtime frame or optimistic send involved', async () => {
@@ -3,9 +3,14 @@ import React from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
 
6
- let frameHandler;
6
+ // Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
7
+ // independently of MessagingProvider's own subscription -- a Set of handlers,
8
+ // not a single captured variable, so frameHandler(...) still reaches every
9
+ // current subscriber, matching the real registry's multi-handler support.
10
+ let frameHandlers;
11
+ function frameHandler(frame) { frameHandlers.forEach((handler) => handler(frame)); }
7
12
  vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
8
- vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandler = handler; return () => {}; }, onReconnect: () => () => {} }) }));
13
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); }, onReconnect: () => () => {} }) }));
9
14
 
10
15
  import { Composer } from '../src/messaging/Composer';
11
16
  import { ConversationList } from '../src/messaging/ConversationList';
@@ -20,14 +25,15 @@ function makeApi(overrides = {}) {
20
25
  listMessages: vi.fn().mockResolvedValue({ results: [] }),
21
26
  listThread: vi.fn().mockResolvedValue({ results: [] }),
22
27
  getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
23
- getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
28
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
24
29
  markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn().mockResolvedValue({}),
25
30
  createPoll: vi.fn().mockResolvedValue({}),
26
31
  ...overrides,
27
32
  };
28
33
  }
29
34
 
30
- afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); frameHandler = undefined; });
35
+ beforeEach(() => { frameHandlers = new Set(); });
36
+ afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); frameHandlers = new Set(); });
31
37
 
32
38
  describe('MSG-3b chunk 6', () => {
33
39
  it('renders localized relative list timestamps for now, minutes, hours, and days', async () => {
@@ -84,16 +90,17 @@ describe('MSG-3b chunk 6', () => {
84
90
  expect(screen.queryByText('Ava')).toBeNull();
85
91
  });
86
92
 
87
- it('opens receipt recipients by click for non-direct conversations and never exposes direct details', async () => {
88
- const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Ava' }] }) });
93
+ it('opens receipt recipients by click for non-direct conversations with counts, and never exposes direct details', async () => {
94
+ const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 1, recipient_count: 2, recipient_detail: [{ display_name: 'Ava' }] }) });
89
95
  const view = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
90
- const detail = await screen.findByRole('button', { name: 'MessagingReadTicks.DELIVERED:2' });
96
+ const detail = await screen.findByRole('button', { name: 'MessagingReadTicks.READ_RATIO' });
91
97
  fireEvent.click(detail);
92
98
  expect(await screen.findByText('Ava')).toBeTruthy();
93
99
  view.unmount();
100
+ api.getReadStatus.mockResolvedValue({ all_read: false, recipient_detail: [{ display_name: 'Ava' }] });
94
101
  render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'direct' }} /></MessagingProvider>);
95
- await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
96
- expect(screen.queryByRole('button', { name: 'MessagingReadTicks.DELIVERED:2' })).toBeNull();
102
+ await screen.findByLabelText('MessagingReadTicks.SENT');
103
+ expect(screen.queryByRole('button', { name: 'MessagingReadTicks.READ_RATIO' })).toBeNull();
97
104
  expect(screen.queryByText('Ava')).toBeNull();
98
105
  });
99
106
 
@@ -12,7 +12,7 @@ import { MessagingProvider } from '../src/messaging/MessagingProvider';
12
12
  import { ReadTicks } from '../src/messaging/ReadTicks';
13
13
 
14
14
  const api = () => ({
15
- getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
15
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
16
16
  getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
17
17
  votePoll: vi.fn(), closePoll: vi.fn(),
18
18
  });
@@ -24,8 +24,8 @@ afterEach(cleanup);
24
24
 
25
25
  describe('compact message bubbles', () => {
26
26
  it('keeps the timestamp and own-message status icon inside the bubble and has no standalone plain-message controls', async () => {
27
- const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: 'group' }} /> });
28
- const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
27
+ const { container } = renderBubble({ id: 1, body: 'Hallo', sender: { id: 1, display_name: 'Me' }, created_at: '2026-08-02T10:00:00Z' }, { child: <ReadTicks messageId={1} conversation={{ kind: 'direct' }} /> });
28
+ const tick = await screen.findByLabelText('MessagingReadTicks.SENT');
29
29
  const paper = container.querySelector('[data-message-id="1"] .MuiPaper-root');
30
30
  expect(paper.contains(tick)).toBe(true);
31
31
  expect(paper.querySelector('[data-testid="message-meta"]')).toBeTruthy();
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ // Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
7
+ // independently of MessagingProvider's own subscription -- a Set of handlers,
8
+ // not a single captured variable, so frameHandler(...) still reaches every
9
+ // current subscriber, matching the real registry's multi-handler support.
10
+ let frameHandlers;
11
+ function frameHandler(frame) { frameHandlers.forEach((handler) => handler(frame)); }
12
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'de-DE' } }) }));
13
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); }, onReconnect: () => () => {} }) }));
14
+
15
+ import { MessageBubble } from '../src/messaging/MessageBubble';
16
+ import { ConversationList } from '../src/messaging/ConversationList';
17
+ import { applyFrame, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
18
+ import { ReadTicks } from '../src/messaging/ReadTicks';
19
+ import { Thread } from '../src/messaging/Thread';
20
+ import { messagingTranslations } from '../src/i18n/messagingTranslations';
21
+ import { AuthContext } from '../src/auth/AuthContext';
22
+
23
+ const api = (overrides = {}) => ({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 'c1', kind: 'group' }] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }), getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 0, recipient_count: 1, recipient_detail: [{ user_id: 'u2', display_name: 'Ava' }] }), markConversationRead: vi.fn().mockResolvedValue({}), ...overrides });
24
+ const state = () => ({ conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } });
25
+ beforeEach(() => { frameHandlers = new Set(); });
26
+ afterEach(cleanup);
27
+
28
+ function PollHarness() { const { createConversationPoll, cache } = useMessaging(); return <><button onClick={() => createConversationPoll('c1', { question: 'Q' })}>create</button><output>{JSON.stringify(cache.messages)}</output></>; }
29
+
30
+ describe('MSG-6 messaging regressions', () => {
31
+ it('uses the server message_id, never the poll id, for a newly-created poll cache row', async () => {
32
+ const client = api({ createPoll: vi.fn().mockResolvedValue({ id: 'poll-9', message_id: 'message-9', question: 'Q', options: [] }) });
33
+ render(<MessagingProvider api={client} active={false}><PollHarness /></MessagingProvider>);
34
+ await act(async () => screen.getByText('create').click());
35
+ const messages = JSON.parse(screen.getByText((_, node) => node.tagName === 'OUTPUT').textContent);
36
+ expect(Object.keys(messages)).toEqual(['message-9']);
37
+ expect(messages['message-9'].created_at).toBeTruthy();
38
+ expect(messages['poll-9']).toBeUndefined();
39
+ });
40
+
41
+ it('renders time-only metadata in the active locale and one separator per changed root day', async () => {
42
+ const time = vi.spyOn(Date.prototype, 'toLocaleTimeString'); time.mockReturnValue('10:41');
43
+ render(<MessageBubble message={{ id: 1, body: 'Hi', created_at: '2026-08-03T10:41:00Z' }} />);
44
+ expect(await screen.findByText('10:41')).toBeTruthy();
45
+ expect(time).toHaveBeenCalledWith('de-DE', { hour: '2-digit', minute: '2-digit' });
46
+ time.mockRestore();
47
+ const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 1, conversation_id: 'c1', body: 'one', created_at: '2026-08-01T10:00:00Z' }, { id: 2, conversation_id: 'c1', body: 'two', created_at: '2026-08-01T11:00:00Z' }, { id: 3, conversation_id: 'c1', body: 'three', created_at: '2026-08-02T10:00:00Z' }] }) });
48
+ render(<MessagingProvider api={client}><Thread conversationId="c1" /></MessagingProvider>);
49
+ await screen.findByText('three');
50
+ expect(screen.getAllByTestId('day-separator')).toHaveLength(1);
51
+ });
52
+
53
+ it('applies read frames to cache (for the per-person popover) but the group read ratio stays dcm\'s own numbers, not recomputed', async () => {
54
+ const client = api();
55
+ render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
56
+ const tick = await screen.findByLabelText('MessagingReadTicks.READ_RATIO');
57
+ expect(tick.textContent).toBe('0/1');
58
+ act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
59
+ // Deliberate, documented trade-off (see ReadTicks.jsx): the numeric ratio
60
+ // is dcm's own read_count/recipient_count verbatim, never recomputed from
61
+ // recipientDetail -- recipientDetail has no way to threshold against
62
+ // this specific message's created_at, only dcm's own aggregate does.
63
+ // So a read_state frame updates the *popover's* per-person list (proven
64
+ // by the receipts assertion below) but the ratio digits only change on
65
+ // the next read-status fetch, same as before this frame arrived.
66
+ await new Promise((resolve) => setTimeout(resolve, 20));
67
+ expect(screen.getByLabelText('MessagingReadTicks.READ_RATIO').textContent).toBe('0/1');
68
+ expect(client.getReadStatus).toHaveBeenCalledTimes(1);
69
+ const next = applyFrame(state(), { type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' });
70
+ expect(next.receipts['thread:root-1:u2'].last_read_at).toBe('2026-08-03T10:00:00Z');
71
+ });
72
+
73
+ it('updates direct-conversation ReadTicks from a read frame without another REST call', async () => {
74
+ const client = api({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
75
+ render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct', other_user_id: 'u2' }} /></MessagingProvider>);
76
+ await screen.findByLabelText('MessagingReadTicks.SENT');
77
+ act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
78
+ await screen.findByLabelText('MessagingReadTicks.ALL_READ');
79
+ expect(client.getReadStatus).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('keeps Thread as the sole read-mark owner for direct mounts and list opens', async () => {
83
+ const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [] }) });
84
+ function Surface() { const [conversationId, setConversationId] = React.useState(null); return <MessagingProvider api={client}><ConversationList onOpen={(conversation) => setConversationId(conversation.id)} />{conversationId && <Thread conversationId={conversationId} />}</MessagingProvider>; }
85
+ render(<Surface />);
86
+ await screen.findByText('MessagingList.UNTITLED');
87
+ fireEvent.click(screen.getByText('MessagingList.UNTITLED'));
88
+ await waitFor(() => expect(client.markConversationRead).toHaveBeenCalledTimes(1));
89
+ client.markConversationRead.mockClear();
90
+ render(<MessagingProvider api={client} active={false}><Thread conversationId="c1" /></MessagingProvider>);
91
+ await waitFor(() => expect(client.markConversationRead).toHaveBeenCalledTimes(1));
92
+ });
93
+
94
+ it('updates the rendered thread unread state from a thread_read_state frame', async () => {
95
+ const client = api({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 'root-1', conversation_id: 'c1', body: 'Root', sender: { id: 'u1' }, reply_count: 1, last_reply_at: '2026-08-03T10:00:00Z', thread_last_read_at: null }] }) });
96
+ render(<AuthContext.Provider value={{ user: { id: 'u2' } }}><MessagingProvider api={client}><Thread conversationId="c1" /></MessagingProvider></AuthContext.Provider>);
97
+ await screen.findByLabelText(/MessagingThread.UNREAD_REPLIES/);
98
+ act(() => frameHandler({ type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T11:00:00Z' }));
99
+ await waitFor(() => expect(screen.queryByLabelText(/MessagingThread.UNREAD_REPLIES/)).toBeNull());
100
+ });
101
+
102
+ it('defines singular reply labels for de, en, and fr', () => {
103
+ expect(messagingTranslations['MessagingThread.SHOW_REPLIES_one']).toEqual({ de: '{{count}} Antwort anzeigen', en: 'Show {{count}} reply', fr: 'Afficher {{count}} réponse' });
104
+ });
105
+ });