@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.
- package/WORK_ORDERS.md +3 -0
- package/dist/i18n/messagingTranslations.js +3 -3
- package/dist/messaging/ConversationList.js +8 -1
- package/dist/messaging/MessagingProvider.js +63 -5
- package/dist/messaging/PollCard.js +83 -23
- package/dist/messaging/ReadTicks.js +52 -10
- package/dist/messaging/Thread.js +29 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +3 -3
- package/src/messaging/ConversationList.jsx +8 -1
- package/src/messaging/MessagingProvider.jsx +60 -5
- package/src/messaging/PollCard.jsx +103 -19
- package/src/messaging/ReadTicks.jsx +55 -8
- package/src/messaging/Thread.jsx +24 -1
- package/tests/ConversationList.test.jsx +52 -0
- package/tests/Thread.test.jsx +9 -8
- package/tests/messagingChunk6.test.jsx +16 -9
- package/tests/messagingCompactBubble.test.jsx +3 -3
- package/tests/messagingInteractions.test.jsx +10 -3
- package/tests/messagingMsg6.test.jsx +24 -9
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingMsg6g.test.jsx +166 -0
- package/tests/messagingPollRendering.test.jsx +24 -14
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/work-orders/MSG-6f.md +214 -0
- package/work-orders/MSG-6g.md +244 -0
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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 :
|
|
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
|
-
|
|
33
|
-
|
|
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
|
}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -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');
|
package/tests/Thread.test.jsx
CHANGED
|
@@ -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
|
|
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
|
|
60
|
-
api.getReadStatus.mockResolvedValue({ all_read: false,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) => {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
96
|
-
expect(screen.queryByRole('button', { name: 'MessagingReadTicks.
|
|
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
|
|
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: '
|
|
28
|
-
const tick = await screen.findByLabelText('MessagingReadTicks.
|
|
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('
|
|
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
|
|
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');
|
|
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
|
-
|
|
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) => {
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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);
|