@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.
- package/WORK_ORDERS.md +3 -0
- package/dist/components/SupportRecoveryRequestsTab.js +3 -3
- package/dist/i18n/messagingTranslations.js +8 -4
- package/dist/messaging/ConversationList.js +10 -3
- package/dist/messaging/MessageBubble.js +2 -2
- package/dist/messaging/MessagingProvider.js +22 -4
- package/dist/messaging/ReadTicks.js +59 -10
- package/dist/messaging/Thread.js +39 -8
- package/package.json +1 -1
- package/src/components/SupportRecoveryRequestsTab.jsx +3 -3
- package/src/i18n/messagingTranslations.ts +8 -4
- package/src/messaging/ConversationList.jsx +10 -3
- package/src/messaging/MessageBubble.jsx +2 -2
- package/src/messaging/MessagingProvider.jsx +18 -2
- package/src/messaging/ReadTicks.jsx +61 -8
- package/src/messaging/Thread.jsx +33 -8
- 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/messagingMsg6.test.jsx +105 -0
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/tests/messagingUnreadLifecycle.test.jsx +14 -2
- package/work-orders/MSG-6e.md +308 -0
- package/work-orders/MSG-6f.md +214 -0
|
@@ -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).
|
|
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
|
|
404
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
const
|
|
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 :
|
|
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
|
-
|
|
27
|
-
|
|
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
|
}
|
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; }
|
|
@@ -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
|
-
|
|
19
|
-
|
|
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 }}>{
|
|
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');
|
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();
|
|
@@ -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
|
+
});
|