@micha.bigler/ui-core-micha 2.21.3 → 2.23.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.
@@ -1,10 +1,26 @@
1
- import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
2
- import { useEffect, useState } from 'react';
1
+ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
2
+ import { Alert, Button, Checkbox, Chip, FormControlLabel, LinearProgress, Paper, Radio, RadioGroup, Stack, Typography } from '@mui/material';
3
+ import { useEffect, useRef, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
5
 
5
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
7
 
7
- /** Independently mountable live poll view. Server authorization remains authoritative. */
8
+ /**
9
+ * Independently mountable live poll view. Server authorization remains authoritative.
10
+ *
11
+ * Tap-to-vote (MSG-6g): each option is its own control, cast immediately on tap — no
12
+ * separate submit step. Single-choice uses a real `RadioGroup` (native "tap a different
13
+ * option moves the vote, tap the same one is a no-op" semantics, plus the group's own
14
+ * arrow-key navigation and screen-reader group announcement — a bare ungrouped `Radio`
15
+ * per option, tried first, loses both); multi-select uses `Checkbox` (each tap
16
+ * independently toggles that option). `FormControlLabel` wraps each control, matching
17
+ * this repo's existing radio convention (`AuthFactorRequirementCard.jsx`) — clicking
18
+ * anywhere in the label natively activates the control, no manual click-handling needed.
19
+ * Every tap is optimistic (see `castPollVote` in `MessagingProvider`), dims while
20
+ * in flight (matching `ReactionBar`'s `pending` convention) so an optimistic vote is
21
+ * never visually indistinguishable from a confirmed one, and visibly reverts on a
22
+ * rejected vote.
23
+ */
8
24
  export function PollCard({ message, canClose: canCloseProp }) {
9
25
  const { t } = useTranslation();
10
26
  const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
@@ -12,6 +28,10 @@ export function PollCard({ message, canClose: canCloseProp }) {
12
28
  const [selected, setSelected] = useState([]);
13
29
  const [error, setError] = useState(null);
14
30
  const [busy, setBusy] = useState(false);
31
+ const [pending, setPending] = useState(() => new Set());
32
+ // Guards a rejected vote's revert against a newer tap that has since
33
+ // superseded it -- see the matching token in MessagingProvider.castPollVote.
34
+ const voteTokenRef = useRef(null);
15
35
  // voted_option_ids is a view-level enrichment dcm adds ONLY to the
16
36
  // create/vote/close poll REST responses (serialize_poll itself, every
17
37
  // message-list/thread load, and every poll_updated realtime frame never
@@ -25,28 +45,92 @@ export function PollCard({ message, canClose: canCloseProp }) {
25
45
  const closed = Boolean(poll.closed_at);
26
46
  const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
27
47
  const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
28
- const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
29
- const vote = async () => {
30
- if (!selected.length) { setError(t('MessagingPoll.SELECT_OPTION')); return; }
31
- setBusy(true); setError(null);
32
- try { await castPollVote(message.id, poll, selected); } catch (voteError) { setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) })); } finally { setBusy(false); }
48
+ const totalVotes = (poll.options || []).reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0);
49
+
50
+ const tap = async (id) => {
51
+ if (closed) return;
52
+ const previousSelected = selected;
53
+ const nextSelected = poll.allow_multiple
54
+ ? (selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id])
55
+ : [id];
56
+ // Belt-and-suspenders: native radios already don't fire a change event
57
+ // when re-clicking the checked one, but this guards any caller shape.
58
+ if (nextSelected.length === previousSelected.length && nextSelected.every((value) => previousSelected.includes(value))) return;
59
+ const token = {};
60
+ voteTokenRef.current = token;
61
+ setSelected(nextSelected);
62
+ setError(null);
63
+ // Rapid re-taps are allowed and race deliberately (last write wins via
64
+ // the token above) -- `pending` is a visual signal only, never gates
65
+ // further taps, so it must not disable the control.
66
+ setPending((current) => new Set(current).add(id));
67
+ try {
68
+ await castPollVote(message.id, poll, nextSelected);
69
+ } catch (voteError) {
70
+ // A later tap already moved the selection on; a stale failure must not
71
+ // revert past it.
72
+ if (voteTokenRef.current === token) {
73
+ setSelected(previousSelected);
74
+ setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
75
+ }
76
+ } finally {
77
+ setPending((current) => { const next = new Set(current); next.delete(id); return next; });
78
+ }
33
79
  };
34
80
  const close = async () => {
35
81
  setBusy(true); setError(null);
36
82
  try { await closeConversationPoll(message.id, poll); } catch (closeError) { setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) })); } finally { setBusy(false); }
37
83
  };
38
- return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
84
+
85
+ const optionLabel = (option) => {
86
+ // dcm's poll projection gives voter user ids only, never names — there
87
+ // is no user-directory endpoint (same structural limit already
88
+ // established for the DM candidate list).
89
+ const voteCount = option.vote_count ?? option.voters?.length ?? 0;
90
+ const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
91
+ const votedByMe = selected.includes(option.id);
92
+ const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
93
+ // The bar is decorative; count/percent live in the accessible name (via
94
+ // aria-label on the control itself) as well as in adjacent visible text —
95
+ // width alone must never be the only signal (WO risk note).
96
+ const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
97
+ const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.25}>
98
+ <Stack direction="row" justifyContent="space-between">
99
+ <Typography variant="body2" noWrap>{option.text || option.label}</Typography>
100
+ <Typography variant="caption" color="text.secondary">{resultText}</Typography>
101
+ </Stack>
102
+ <LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 6 }} color={votedByMe ? 'primary' : 'inherit'} />
103
+ </Stack>;
104
+ return { accessibleLabel, richLabel };
105
+ };
106
+ const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
107
+
108
+ return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.75}>
39
109
  <Typography variant="subtitle2">{poll.question}</Typography>
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
- })}
110
+ {poll.allow_multiple
111
+ ? (poll.options || []).map((option) => {
112
+ const { accessibleLabel, richLabel } = optionLabel(option);
113
+ return <FormControlLabel key={option.id} disabled={closed} sx={formControlSx} label={richLabel}
114
+ control={<Checkbox checked={selected.includes(option.id)} inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
115
+ })
116
+ // No RadioGroup-level onChange: it would only ever hand back
117
+ // `event.target.value`, a DOM string -- coercing a non-string option id
118
+ // (dcm's are UUID strings today, but the id type isn't a guaranteed
119
+ // contract). `value` alone still drives each Radio's `checked` via the
120
+ // group's own comparison; each Radio's own `onChange` below fires
121
+ // `tap` with the option's real, correctly-typed id, matching the
122
+ // Checkbox branch above exactly.
123
+ : <RadioGroup value={selected[0] ?? ''} name={`poll-${poll.id}-options`}>
124
+ {(poll.options || []).map((option) => {
125
+ const { accessibleLabel, richLabel } = optionLabel(option);
126
+ return <FormControlLabel key={option.id} value={option.id} disabled={closed} sx={formControlSx} label={richLabel}
127
+ control={<Radio inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
128
+ })}
129
+ </RadioGroup>}
49
130
  {error && <Alert severity="error" role="alert">{error}</Alert>}
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>
131
+ <Stack direction="row" spacing={1} alignItems="center">
132
+ {canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}
133
+ {closed && <Chip size="small" icon={<LockOutlinedIcon fontSize="small" />} label={t('MessagingPoll.CLOSED')} />}
134
+ </Stack>
51
135
  </Stack></Paper>;
52
136
  }
@@ -1,12 +1,33 @@
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
33
  const { cache, getMessageReadStatus } = useMessaging();
@@ -18,17 +39,43 @@ export function ReadTicks({ messageId, conversation }) {
18
39
  return () => { mounted = false; };
19
40
  }, [getMessageReadStatus, messageId]);
20
41
  if (!status) return null;
42
+
21
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;
22
44
  const directReceipt = conversation?.kind === 'direct' && conversation?.other_user_id != null
23
45
  ? cache.receipts[`${conversation.id}:${conversation.other_user_id}`]
24
46
  : null;
25
47
  const allRead = status.all_read || Boolean(directReceipt?.last_read_at) || Boolean(recipientDetail?.length && recipientDetail.every((recipient) => recipient.last_read_at));
26
- const renderedStatus = { ...status, recipient_detail: recipientDetail, all_read: allRead };
27
- const label = renderedStatus.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 });
28
- const StatusIcon = renderedStatus.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
48
+ const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
49
+
50
+ if (!hasCounts && conversation?.kind !== 'direct') return null;
51
+
29
52
  // The server never returns DM detail; enforce the same carve-out defensively.
30
- const detail = conversation?.kind === 'direct' ? null : renderedStatus.recipient_detail;
53
+ const detail = conversation?.kind === 'direct' ? null : recipientDetail;
31
54
  const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
32
- if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={renderedStatus.all_read ? 'primary' : 'inherit'} />;
33
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: renderedStatus.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 }} />;
34
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; }
@@ -42,6 +43,7 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
42
43
  const { t, i18n } = useTranslation();
43
44
  const { user } = useContext(AuthContext) || {};
44
45
  const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead, refreshThread } = useMessaging();
46
+ const { subscribe } = useRealtime();
45
47
  const scrollRef = useRef(null);
46
48
  const wasNearBottomRef = useRef(true);
47
49
  const messageCountRef = useRef(0);
@@ -56,6 +58,27 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
56
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]);
57
59
 
58
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]);
59
82
  useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
60
83
  // Nothing else in this component ever fetches messages for the conversation
61
84
  // actually being opened — `cache.messages` is otherwise only populated by a
@@ -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();
@@ -28,12 +28,19 @@ describe('messaging reactions and polls', () => {
28
28
  fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.removeReaction).toHaveBeenCalledWith(7, '👍'));
29
29
  });
30
30
 
31
- it('uses single selection for a single poll and preserves selections for a multi poll', async () => {
31
+ it('taps vote immediately (no submit step): single-choice uses radios and only ever sends the last-tapped option, multi-select uses checkboxes and accumulates', async () => {
32
+ // MSG-6g: tap-to-vote, no separate "Abstimmen" button.
32
33
  const single = baseApi(); single.votePoll.mockResolvedValue({ poll: { id: 1, options: [] } });
33
34
  const { unmount } = render(<MessagingProvider api={single} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'One', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: false }}} /></MessagingProvider>);
34
- const choices = screen.getAllByRole('checkbox'); fireEvent.click(choices[0]); fireEvent.click(choices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2])); unmount();
35
+ const radios = screen.getAllByRole('radio');
36
+ fireEvent.click(radios[0]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [1]));
37
+ fireEvent.click(radios[1]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2]));
38
+ expect(single.votePoll).toHaveBeenCalledTimes(2);
39
+ unmount();
35
40
  const multiple = baseApi(); multiple.votePoll.mockResolvedValue({ poll: { id: 2, options: [] } }); render(<MessagingProvider api={multiple} active={false}><PollCard message={{ id: 8, poll: { id: 2, question: 'Many', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: true }}} /></MessagingProvider>);
36
- const multiChoices = screen.getAllByRole('checkbox'); fireEvent.click(multiChoices[0]); fireEvent.click(multiChoices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
41
+ const multiChoices = screen.getAllByRole('checkbox');
42
+ fireEvent.click(multiChoices[0]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1]));
43
+ fireEvent.click(multiChoices[1]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
37
44
  });
38
45
 
39
46
  it('only exposes close to the creator/capability and surfaces a forbidden close', async () => {
@@ -1,11 +1,16 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { act, 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: 'de-DE' } }) }));
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 { MessageBubble } from '../src/messaging/MessageBubble';
11
16
  import { ConversationList } from '../src/messaging/ConversationList';
@@ -15,8 +20,9 @@ import { Thread } from '../src/messaging/Thread';
15
20
  import { messagingTranslations } from '../src/i18n/messagingTranslations';
16
21
  import { AuthContext } from '../src/auth/AuthContext';
17
22
 
18
- 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, delivered_count: 1, recipient_detail: [{ user_id: 'u2', display_name: 'Ava' }] }), markConversationRead: vi.fn().mockResolvedValue({}), ...overrides });
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 });
19
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(); });
20
26
  afterEach(cleanup);
21
27
 
22
28
  function PollHarness() { const { createConversationPoll, cache } = useMessaging(); return <><button onClick={() => createConversationPoll('c1', { question: 'Q' })}>create</button><output>{JSON.stringify(cache.messages)}</output></>; }
@@ -44,21 +50,30 @@ describe('MSG-6 messaging regressions', () => {
44
50
  expect(screen.getAllByTestId('day-separator')).toHaveLength(1);
45
51
  });
46
52
 
47
- it('applies read and thread-read frames to cache and lets ReadTicks change without another REST call', async () => {
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 () => {
48
54
  const client = api();
49
55
  render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
50
- await screen.findByLabelText('MessagingReadTicks.DELIVERED:1');
56
+ const tick = await screen.findByLabelText('MessagingReadTicks.READ_RATIO');
57
+ expect(tick.textContent).toBe('0/1');
51
58
  act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
52
- await screen.findByLabelText('MessagingReadTicks.ALL_READ');
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');
53
68
  expect(client.getReadStatus).toHaveBeenCalledTimes(1);
54
69
  const next = applyFrame(state(), { type: 'thread_read_state', root_id: 'root-1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' });
55
70
  expect(next.receipts['thread:root-1:u2'].last_read_at).toBe('2026-08-03T10:00:00Z');
56
71
  });
57
72
 
58
73
  it('updates direct-conversation ReadTicks from a read frame without another REST call', async () => {
59
- const client = api();
74
+ const client = api({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
60
75
  render(<MessagingProvider api={client} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct', other_user_id: 'u2' }} /></MessagingProvider>);
61
- await screen.findByLabelText('MessagingReadTicks.DELIVERED:1');
76
+ await screen.findByLabelText('MessagingReadTicks.SENT');
62
77
  act(() => frameHandler({ type: 'read_state', conversation_id: 'c1', user_id: 'u2', last_read_at: '2026-08-03T10:00:00Z' }));
63
78
  await screen.findByLabelText('MessagingReadTicks.ALL_READ');
64
79
  expect(client.getReadStatus).toHaveBeenCalledTimes(1);