@micha.bigler/ui-core-micha 2.20.0 → 2.21.1
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 +2 -1
- package/dev/entries.jsx +12 -5
- package/dist/messaging/Composer.js +8 -3
- package/dist/messaging/ConversationList.js +12 -3
- package/dist/messaging/MessageBubble.js +46 -5
- package/dist/messaging/ReactionBar.js +7 -9
- package/dist/messaging/ReadTicks.js +6 -3
- package/dist/messaging/Thread.js +1 -1
- package/package.json +1 -1
- package/src/messaging/Composer.jsx +8 -3
- package/src/messaging/ConversationList.jsx +11 -3
- package/src/messaging/MessageBubble.jsx +82 -32
- package/src/messaging/ReactionBar.jsx +9 -12
- package/src/messaging/ReadTicks.jsx +6 -3
- package/src/messaging/Thread.jsx +12 -2
- package/tests/ConversationList.test.jsx +26 -0
- package/tests/messagingCompactBubble.test.jsx +62 -0
- package/tests/messagingInteractions.test.jsx +15 -0
- package/tests/messagingMessageActions.test.jsx +80 -10
- package/tests/messagingReactionMerge.test.jsx +3 -5
- package/tests/messagingThreadingQuoting.test.jsx +2 -1
- package/work-orders/MSG-3f.md +391 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
|
1
|
+
import { Alert, Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
|
2
2
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
|
3
|
-
import {
|
|
3
|
+
import { alpha } from '@mui/material/styles';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
5
|
import { useTranslation } from 'react-i18next';
|
|
5
6
|
import { AttachmentList } from './AttachmentList';
|
|
6
7
|
import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
|
|
@@ -34,19 +35,52 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
34
35
|
const { currentUser, editMessage, removeMessage } = messaging || {};
|
|
35
36
|
const deleted = Boolean(message.deleted_at);
|
|
36
37
|
const attachments = message.attachments || [];
|
|
37
|
-
const
|
|
38
|
+
const isOwn = Boolean(currentUser?.id) && message.sender?.id === currentUser.id;
|
|
39
|
+
const canAct = !deleted && message.kind !== 'poll' && (isOwn || canModerateMessages);
|
|
40
|
+
const canOpenMenu = !deleted;
|
|
41
|
+
const reactions = message.reactions || [];
|
|
42
|
+
const hasMeta = Boolean(message.created_at || (message.edited_at && !deleted) || children);
|
|
38
43
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
44
|
+
const [actionsVisible, setActionsVisible] = useState(false);
|
|
45
|
+
const [reactionPickerVisible, setReactionPickerVisible] = useState(false);
|
|
39
46
|
const [editing, setEditing] = useState(false);
|
|
40
47
|
const [draft, setDraft] = useState(message.body || '');
|
|
41
48
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
42
49
|
const [saving, setSaving] = useState(false);
|
|
43
50
|
const [error, setError] = useState(null);
|
|
51
|
+
const bubbleRef = useRef(null);
|
|
44
52
|
useEffect(() => {
|
|
45
|
-
if (deleted) {
|
|
53
|
+
if (deleted) {
|
|
54
|
+
setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); setReactionPickerVisible(false);
|
|
55
|
+
}
|
|
46
56
|
}, [deleted]);
|
|
57
|
+
// The touch/tap reveal is a one-way state flip with no dismiss path of its
|
|
58
|
+
// own (unlike hover/focus, which un-reveal on mouseleave/blur automatically)
|
|
59
|
+
// — without this, any ordinary tap on the bubble (selecting text, opening a
|
|
60
|
+
// poll option, following the reply quote) would leave the overflow icon
|
|
61
|
+
// permanently visible, contradicting the at-rest "no visible affordance"
|
|
62
|
+
// requirement. Dismiss on any pointerdown outside this message's own DOM
|
|
63
|
+
// subtree (bubbleRef spans the Paper plus the reaction-picker sibling, so
|
|
64
|
+
// interacting with either never counts as "outside"); a pointerdown that
|
|
65
|
+
// lands on the Menu/Dialog portals does count as outside, but that's
|
|
66
|
+
// harmless — they already close themselves via their own handlers on the
|
|
67
|
+
// same interaction.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!actionsVisible) return undefined;
|
|
70
|
+
const dismissIfOutside = (event) => {
|
|
71
|
+
if (bubbleRef.current && !bubbleRef.current.contains(event.target)) {
|
|
72
|
+
setActionsVisible(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
document.addEventListener('pointerdown', dismissIfOutside, true);
|
|
76
|
+
return () => document.removeEventListener('pointerdown', dismissIfOutside, true);
|
|
77
|
+
}, [actionsVisible]);
|
|
47
78
|
const closeMenu = () => setMenuAnchor(null);
|
|
48
|
-
const
|
|
79
|
+
const revealActions = () => setActionsVisible(true);
|
|
80
|
+
const openMenu = (event) => { event.preventDefault(); revealActions(); setMenuAnchor(event.currentTarget); };
|
|
49
81
|
const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
|
|
82
|
+
const startReply = () => { onReply?.(message); closeMenu(); };
|
|
83
|
+
const startReact = () => { setReactionPickerVisible(true); closeMenu(); };
|
|
50
84
|
const saveEdit = async () => {
|
|
51
85
|
setSaving(true); setError(null);
|
|
52
86
|
try { await editMessage?.(message.id, { body: draft.trim() }); setEditing(false); }
|
|
@@ -63,35 +97,51 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
63
97
|
catch (copyError) { setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) })); }
|
|
64
98
|
};
|
|
65
99
|
return (
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
<Box ref={bubbleRef} component="article" data-message-id={message.id} data-message-side={isOwn ? 'own' : 'incoming'} aria-label={t('MessagingThread.MESSAGE')} sx={{ alignSelf: isOwn ? 'flex-end' : 'flex-start', width: 'fit-content', maxWidth: 'min(75%, 680px)', minWidth: 0 }}>
|
|
101
|
+
<Paper
|
|
102
|
+
variant="outlined"
|
|
103
|
+
data-actions-visible={actionsVisible || undefined}
|
|
104
|
+
onClick={revealActions}
|
|
105
|
+
onContextMenu={canOpenMenu ? openMenu : undefined}
|
|
106
|
+
sx={{
|
|
107
|
+
position: 'relative', p: 1.25, minWidth: 0,
|
|
108
|
+
bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
|
|
109
|
+
borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
|
|
110
|
+
overflowWrap: 'anywhere',
|
|
111
|
+
'& .message-actions': { opacity: 0, pointerEvents: 'none', transition: 'opacity 120ms ease-in-out' },
|
|
112
|
+
'&:hover .message-actions, &:focus-within .message-actions, &[data-actions-visible="true"] .message-actions': { opacity: 1, pointerEvents: 'auto' },
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
{canOpenMenu && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu} sx={{ position: 'absolute', top: -12, right: -12, bgcolor: 'background.paper', boxShadow: 1 }}><MoreVertIcon fontSize="small" /></IconButton>}
|
|
116
|
+
<Stack spacing={0.75}>
|
|
117
|
+
{replyTo && <ButtonBase type="button" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }}>
|
|
118
|
+
<Stack spacing={0} alignItems="flex-start">
|
|
119
|
+
<Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
|
|
120
|
+
<Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
|
|
121
|
+
</Stack>
|
|
122
|
+
</ButtonBase>}
|
|
75
123
|
{conversation?.kind !== 'direct' && <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>}
|
|
76
|
-
{message.
|
|
77
|
-
{message.
|
|
124
|
+
{!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
|
|
125
|
+
{!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
|
|
126
|
+
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
127
|
+
{editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
|
|
128
|
+
{!deleted && <Stack spacing={0.75}>
|
|
129
|
+
{attachments.length > 0 && <AttachmentList attachments={attachments} />}
|
|
130
|
+
{message.poll && <PollCard message={message} />}
|
|
131
|
+
</Stack>}
|
|
132
|
+
{hasMeta && <Stack data-testid="message-meta" direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center" flexWrap="wrap" sx={{ color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }}>
|
|
133
|
+
{message.created_at && <Typography component="span" variant="caption" color="inherit" sx={{ whiteSpace: 'nowrap' }}>{new Date(message.created_at).toLocaleString()}</Typography>}
|
|
134
|
+
{message.edited_at && !deleted && <Typography component="span" variant="caption" color="inherit">{t('MessagingThread.EDITED')}</Typography>}
|
|
135
|
+
{children}
|
|
136
|
+
</Stack>}
|
|
78
137
|
</Stack>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
82
|
-
{editing ? <Stack spacing={0.75}><TextField label={t('MessagingActions.EDIT')} value={draft} onChange={(event) => setDraft(event.target.value)} multiline minRows={2} autoFocus disabled={saving} /><Stack direction="row" spacing={1}><Button type="button" onClick={saveEdit} disabled={saving}>{t('MessagingActions.SAVE')}</Button><Button type="button" onClick={() => setEditing(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button></Stack></Stack> : <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || ''}</Typography>}
|
|
83
|
-
{!deleted && <Stack spacing={0.75}>
|
|
84
|
-
{attachments.length > 0 && <AttachmentList attachments={attachments} />}
|
|
85
|
-
<ReactionBar message={message} />
|
|
86
|
-
{message.poll && <PollCard message={message} />}
|
|
87
|
-
</Stack>}
|
|
88
|
-
{!deleted && onReply && <Button type="button" size="small" onClick={() => onReply(message)}>{t('MessagingThread.REPLY')}</Button>}
|
|
89
|
-
{canAct && <IconButton className="message-actions" size="small" aria-label={t('MessagingActions.MENU')} onClick={openMenu}><MoreVertIcon /></IconButton>}
|
|
90
|
-
{children}
|
|
91
|
-
</Stack>
|
|
138
|
+
</Paper>
|
|
139
|
+
{!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }} />}
|
|
92
140
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
|
|
93
|
-
<MenuItem onClick={
|
|
94
|
-
<MenuItem onClick={
|
|
141
|
+
<MenuItem onClick={startReply}>{t('MessagingThread.REPLY')}</MenuItem>
|
|
142
|
+
<MenuItem onClick={startReact}>{t('MessagingReactions.ADD')}</MenuItem>
|
|
143
|
+
{canAct && <MenuItem onClick={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>}
|
|
144
|
+
{canAct && <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>}
|
|
95
145
|
<MenuItem onClick={copyMessage}>{t('MessagingActions.COPY')}</MenuItem>
|
|
96
146
|
</Menu>
|
|
97
147
|
<Dialog open={confirmingDelete} onClose={() => !saving && setConfirmingDelete(false)}>
|
|
@@ -99,6 +149,6 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
99
149
|
<DialogContent>{t('MessagingActions.DELETE_CONFIRM_BODY')}</DialogContent>
|
|
100
150
|
<DialogActions><Button onClick={() => setConfirmingDelete(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button><Button onClick={confirmDelete} disabled={saving} color="error">{t('MessagingActions.DELETE')}</Button></DialogActions>
|
|
101
151
|
</Dialog>
|
|
102
|
-
</
|
|
152
|
+
</Box>
|
|
103
153
|
);
|
|
104
154
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Alert, Chip, IconButton, Stack } from '@mui/material';
|
|
1
|
+
import { Alert, Chip, Stack } from '@mui/material';
|
|
3
2
|
import { useState } from 'react';
|
|
4
3
|
import { useTranslation } from 'react-i18next';
|
|
5
4
|
|
|
@@ -7,18 +6,17 @@ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
|
7
6
|
|
|
8
7
|
export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
9
8
|
|
|
10
|
-
/**
|
|
11
|
-
export function ReactionBar({ message }) {
|
|
9
|
+
/** Compact aggregate reactions; MessageBubble opens the quick picker from its action menu. */
|
|
10
|
+
export function ReactionBar({ message, expanded = false, onExpandedChange, sx }) {
|
|
12
11
|
const { t } = useTranslation();
|
|
13
12
|
const { toggleReaction } = useMessaging();
|
|
14
|
-
const [expanded, setExpanded] = useState(false);
|
|
15
13
|
const [error, setError] = useState(null);
|
|
16
14
|
// Tracks in-flight emoji toggles for this message so a rapid double-click
|
|
17
15
|
// can't fire two concurrent requests for the same emoji — toggleReaction's
|
|
18
16
|
// rollback-on-failure reverts to a pre-optimistic snapshot, which would
|
|
19
17
|
// silently discard a second, already-succeeded change if both were in
|
|
20
|
-
// flight
|
|
21
|
-
//
|
|
18
|
+
// flight. Disabling the chip while its own request is pending removes the
|
|
19
|
+
// race instead of trying to make concurrent toggles safe.
|
|
22
20
|
const [pending, setPending] = useState(() => new Set());
|
|
23
21
|
const reactions = message?.reactions || [];
|
|
24
22
|
const react = async (emoji, active = false) => {
|
|
@@ -29,17 +27,16 @@ export function ReactionBar({ message }) {
|
|
|
29
27
|
catch (reactionError) { setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) })); }
|
|
30
28
|
finally { setPending((current) => { const next = new Set(current); next.delete(emoji); return next; }); }
|
|
31
29
|
};
|
|
32
|
-
return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')}>
|
|
33
|
-
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
|
30
|
+
return <Stack spacing={0.5} aria-label={t('MessagingReactions.LABEL')} sx={sx}>
|
|
31
|
+
{reactions.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap">
|
|
34
32
|
{reactions.map((reaction) => <Chip key={reaction.emoji} size="small" disabled={pending.has(reaction.emoji)} color={reaction.reacted ? 'primary' : 'default'} label={`${reaction.emoji} ${reaction.count || 0}`} onClick={() => react(reaction.emoji, Boolean(reaction.reacted))} aria-label={t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 })} />)}
|
|
35
|
-
|
|
36
|
-
</Stack>
|
|
33
|
+
</Stack>}
|
|
37
34
|
{expanded && <Stack direction="row" spacing={0.25} flexWrap="wrap">{QUICK_EMOJIS.map((emoji) => {
|
|
38
35
|
// A quick-emoji already reflected in the aggregate row must toggle it
|
|
39
36
|
// off, not re-add it — otherwise clicking an already-reacted emoji here
|
|
40
37
|
// optimistically double-increments the count before the API call.
|
|
41
38
|
const existing = reactions.find((reaction) => reaction.emoji === emoji);
|
|
42
|
-
return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted));
|
|
39
|
+
return <Chip key={emoji} size="small" disabled={pending.has(emoji)} color={existing?.reacted ? 'primary' : 'default'} label={emoji} onClick={() => { react(emoji, Boolean(existing?.reacted)); onExpandedChange?.(false); }} />;
|
|
43
40
|
})}</Stack>}
|
|
44
41
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
45
42
|
</Stack>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
2
|
+
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
3
|
+
import { ButtonBase, List, ListItem, Popover } from '@mui/material';
|
|
2
4
|
import { useEffect, useState } from 'react';
|
|
3
5
|
import { useTranslation } from 'react-i18next';
|
|
4
6
|
|
|
@@ -17,9 +19,10 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
17
19
|
}, [getMessageReadStatus, messageId]);
|
|
18
20
|
if (!status) return null;
|
|
19
21
|
const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
|
|
22
|
+
const StatusIcon = status.all_read ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
20
23
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
21
24
|
const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
|
|
22
25
|
const recipients = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean) || [];
|
|
23
|
-
if (!recipients.length) return <
|
|
24
|
-
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{
|
|
26
|
+
if (!recipients.length) return <StatusIcon role="img" aria-label={label} color={status.all_read ? 'primary' : 'inherit'} />;
|
|
27
|
+
return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ display: 'inline-flex', color: status.all_read ? 'primary.main' : 'inherit' }}><StatusIcon aria-hidden="true" /></ButtonBase><Popover open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={() => setAnchorEl(null)}><List aria-label={t('MessagingReadTicks.RECIPIENTS')}>{recipients.map((recipient) => <ListItem key={recipient}>{recipient}</ListItem>)}</List></Popover></>;
|
|
25
28
|
}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -95,10 +95,20 @@ export function Thread({ conversationId, onReplyTargetChange, canModerateMessage
|
|
|
95
95
|
<Stack spacing={1} sx={{ minWidth: 0 }}>
|
|
96
96
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
97
97
|
{replyingTo && <Alert severity="info" onClose={() => { setReplyingTo(null); onReplyTargetChange?.(null); }}>{t('MessagingThread.REPLYING_TO', { sender: replyingTo.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
|
|
98
|
-
<Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
|
|
98
|
+
<Box ref={scrollRef} onScroll={onScroll} sx={{ maxHeight: 560, overflowY: 'auto', overflowX: 'hidden', px: 0.5 }} aria-label={t('MessagingThread.TIMELINE')}>
|
|
99
99
|
{cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
|
|
100
100
|
{!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>}
|
|
101
|
-
|
|
101
|
+
{/* pr wider than pl, on this Stack only (not the scroll container
|
|
102
|
+
above, which stays symmetric so the centered LOAD_OLDER button
|
|
103
|
+
isn't skewed): an own (right-aligned) bubble sits flush against
|
|
104
|
+
this Stack's right edge, and MessageBubble's hover/tap "more
|
|
105
|
+
actions" icon (MessageBubble.jsx, `top: -12, right: -12`) floats
|
|
106
|
+
12px past the bubble's own right edge — without enough padding to
|
|
107
|
+
absorb that overhang, the scroll container's overflowX: hidden
|
|
108
|
+
(the scrollbar fix above) would clip the icon whenever there's no
|
|
109
|
+
vertical scrollbar reserving extra width (a short conversation,
|
|
110
|
+
no vertical overflow). */}
|
|
111
|
+
<Stack spacing={1} sx={{ pr: 2 }}>{roots.map((message) => {
|
|
102
112
|
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
103
113
|
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>
|
|
104
114
|
{(message.reply_count || replies.length) > 0 && (() => {
|
|
@@ -53,6 +53,32 @@ describe('ConversationList', () => {
|
|
|
53
53
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Announcements' })).toBeNull());
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it('resolves a direct conversation\'s title via the host-supplied resolveDirectUserName, falling back to the generic label when absent', async () => {
|
|
57
|
+
api.listConversations.mockResolvedValueOnce({
|
|
58
|
+
results: [
|
|
59
|
+
{ id: 6, kind: 'direct', other_user_id: 42, last_message: { excerpt: 'Hi' } },
|
|
60
|
+
{ id: 7, kind: 'direct', other_user_id: 99, last_message: { excerpt: 'Hey' } },
|
|
61
|
+
],
|
|
62
|
+
next_cursor: null,
|
|
63
|
+
});
|
|
64
|
+
const resolveDirectUserName = vi.fn((userId) => (userId === 42 ? 'Alex' : null));
|
|
65
|
+
renderList(api, { resolveDirectUserName });
|
|
66
|
+
await screen.findByText('Alex');
|
|
67
|
+
expect(resolveDirectUserName).toHaveBeenCalledWith(42);
|
|
68
|
+
expect(screen.getByText('MessagingList.UNTITLED')).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not call resolveDirectUserName for non-direct conversations', async () => {
|
|
72
|
+
api.listConversations.mockResolvedValueOnce({
|
|
73
|
+
results: [{ id: 8, kind: 'group', title: 'Ops', other_user_id: 42, last_message: { excerpt: 'Hi' } }],
|
|
74
|
+
next_cursor: null,
|
|
75
|
+
});
|
|
76
|
+
const resolveDirectUserName = vi.fn(() => 'Should not be used');
|
|
77
|
+
renderList(api, { resolveDirectUserName });
|
|
78
|
+
await screen.findByText('Ops');
|
|
79
|
+
expect(resolveDirectUserName).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
56
82
|
it('wires mute and archive through participant APIs', async () => {
|
|
57
83
|
renderList(api);
|
|
58
84
|
await screen.findByText('First');
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : options?.sender ? `${key}:${options.sender}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
10
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
11
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
12
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
13
|
+
|
|
14
|
+
const api = () => ({
|
|
15
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
|
|
16
|
+
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
17
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
18
|
+
});
|
|
19
|
+
function renderBubble(message, { conversation = { kind: 'group' }, replyTo, onJumpToMessage, child, ownId = 1 } = {}) {
|
|
20
|
+
return render(<AuthContext.Provider value={{ user: { id: ownId } }}><MessagingProvider api={api()} active={false}><MessageBubble message={message} conversation={conversation} replyTo={replyTo} onJumpToMessage={onJumpToMessage}>{child}</MessageBubble></MessagingProvider></AuthContext.Provider>);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
|
|
25
|
+
describe('compact message bubbles', () => {
|
|
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');
|
|
29
|
+
const paper = container.querySelector('[data-message-id="1"] .MuiPaper-root');
|
|
30
|
+
expect(paper.contains(tick)).toBe(true);
|
|
31
|
+
expect(paper.querySelector('[data-testid="message-meta"]')).toBeTruthy();
|
|
32
|
+
expect(screen.queryByText('MessagingThread.REPLY')).toBeNull();
|
|
33
|
+
expect(screen.queryByLabelText('MessagingReactions.LABEL')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('marks own and incoming bubbles differently and only shows a sender in non-direct conversations', () => {
|
|
37
|
+
const { container, rerender } = renderBubble({ id: 2, body: 'Mine', sender: { id: 1, display_name: 'Me' } });
|
|
38
|
+
expect(container.querySelector('[data-message-id="2"]').getAttribute('data-message-side')).toBe('own');
|
|
39
|
+
expect(screen.getByText('Me')).toBeTruthy();
|
|
40
|
+
rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 3, body: 'Theirs', sender: { id: 9, display_name: 'Ava' } }} conversation={{ kind: 'direct' }} /></MessagingProvider></AuthContext.Provider>);
|
|
41
|
+
expect(container.querySelector('[data-message-id="3"]').getAttribute('data-message-side')).toBe('incoming');
|
|
42
|
+
expect(screen.queryByText('Ava')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps a reply quote clickable inside the bubble', () => {
|
|
46
|
+
const onJumpToMessage = vi.fn();
|
|
47
|
+
const { container } = renderBubble({ id: 5, body: 'Reply', sender: { id: 1 } }, { replyTo: { id: 4, body: 'Quoted words', sender: { display_name: 'Ava' } }, onJumpToMessage });
|
|
48
|
+
fireEvent.click(screen.getByText('Quoted words'));
|
|
49
|
+
expect(onJumpToMessage).toHaveBeenCalledWith(4);
|
|
50
|
+
expect(container.querySelector('[data-message-id="5"] .MuiPaper-root').contains(screen.getByText('Quoted words'))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('continues to render announcement, poll and attachment content inside the bubble', async () => {
|
|
54
|
+
const { container, rerender } = renderBubble({ id: 6, kind: 'announcement', title: 'Notice', body: 'Update', sender: { id: 1 } });
|
|
55
|
+
expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Notice'))).toBe(true);
|
|
56
|
+
rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 7, kind: 'poll', body: 'Poll', sender: { id: 1 }, poll: { id: 1, question: 'Where?', options: [], allow_multiple: false } }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
|
|
57
|
+
expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
|
|
58
|
+
rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 8, body: 'File', sender: { id: 1 }, attachments: [{ id: 1, filename: 'notes.pdf', content_type: 'application/pdf' }] }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
|
|
59
|
+
await waitFor(() => expect(screen.getByText(/MessagingAttachments\.DOWNLOAD/)).toBeTruthy());
|
|
60
|
+
expect(container.querySelector('.MuiPaper-root').contains(screen.getByText(/MessagingAttachments\.DOWNLOAD/))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -92,6 +92,21 @@ describe('messaging reactions and polls', () => {
|
|
|
92
92
|
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it('never offers announcement composing in a direct conversation, even when the host allows it', () => {
|
|
96
|
+
// A host's allowAnnouncement is typically an event-manager permission check
|
|
97
|
+
// with no notion of which conversation is currently open — broadcasting to
|
|
98
|
+
// a 1:1 direct chat is never meaningful regardless of that permission.
|
|
99
|
+
const api = baseApi();
|
|
100
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement conversation={{ kind: 'direct' }} /></MessagingProvider>);
|
|
101
|
+
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still offers announcement composing for a non-direct conversation when the host allows it', () => {
|
|
105
|
+
const api = baseApi();
|
|
106
|
+
render(<MessagingProvider api={api} active={false}><Composer conversationId={4} allowAnnouncement conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
107
|
+
expect(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' })).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
95
110
|
it('disables the chip while pending, then rolls back and re-enables it on API failure', async () => {
|
|
96
111
|
let rejectAdd; const api = baseApi(); api.addReaction.mockReturnValue(new Promise((_, reject) => { rejectAdd = reject; }));
|
|
97
112
|
render(<MessagingProvider api={api} active={false}><ReactionBar message={{ id: 7, reactions: [{ emoji: '👍', count: 1, reacted: false }] }} /></MessagingProvider>);
|
|
@@ -17,8 +17,8 @@ function makeApi() {
|
|
|
17
17
|
deleteMessage: vi.fn().mockImplementation(async (id) => ({ id, deleted_at: '2026-07-31T10:01:00Z' })),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
-
function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi() } = {}) {
|
|
21
|
-
const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} /></MessagingProvider></AuthContext.Provider>);
|
|
20
|
+
function renderBubble(message, { userId = 1, canModerateMessages = false, api = makeApi(), onReply } = {}) {
|
|
21
|
+
const result = render(<AuthContext.Provider value={{ user: { id: userId } }}><MessagingProvider api={api} active={false}><MessageBubble message={message} canModerateMessages={canModerateMessages} onReply={onReply} /></MessagingProvider></AuthContext.Provider>);
|
|
22
22
|
return { ...result, api };
|
|
23
23
|
}
|
|
24
24
|
function openActions() { fireEvent.click(screen.getByRole('button', { name: 'MessagingActions.MENU' })); }
|
|
@@ -26,19 +26,89 @@ function openActions() { fireEvent.click(screen.getByRole('button', { name: 'Mes
|
|
|
26
26
|
afterEach(cleanup);
|
|
27
27
|
|
|
28
28
|
describe('message actions', () => {
|
|
29
|
-
it('
|
|
29
|
+
it('offers Reply, React and Copy for incoming messages, adding Edit and Delete only for authors or moderators', () => {
|
|
30
30
|
const mine = { id: 1, body: 'Mine', sender: { id: 1 } };
|
|
31
31
|
const theirs = { id: 2, body: 'Theirs', sender: { id: 2 } };
|
|
32
|
-
const {
|
|
32
|
+
const { unmount } = renderBubble(mine);
|
|
33
33
|
expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
|
|
34
|
-
|
|
35
|
-
expect(screen.
|
|
36
|
-
|
|
34
|
+
openActions();
|
|
35
|
+
['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.EDIT', 'MessagingActions.DELETE', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
|
|
36
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingReactions.ADD' }));
|
|
37
|
+
expect(screen.getByLabelText('MessagingReactions.LABEL')).toBeTruthy();
|
|
38
|
+
expect(screen.getByText('👍')).toBeTruthy();
|
|
39
|
+
unmount();
|
|
40
|
+
renderBubble(theirs);
|
|
37
41
|
expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
|
|
38
|
-
|
|
39
|
-
expect(screen.
|
|
40
|
-
|
|
42
|
+
openActions();
|
|
43
|
+
['MessagingThread.REPLY', 'MessagingReactions.ADD', 'MessagingActions.COPY'].forEach((name) => expect(screen.getByRole('menuitem', { name })).toBeTruthy());
|
|
44
|
+
expect(screen.queryByRole('menuitem', { name: 'MessagingActions.EDIT' })).toBeNull();
|
|
45
|
+
expect(screen.queryByRole('menuitem', { name: 'MessagingActions.DELETE' })).toBeNull();
|
|
46
|
+
cleanup();
|
|
47
|
+
renderBubble(theirs, { canModerateMessages: true });
|
|
48
|
+
expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
|
|
49
|
+
cleanup();
|
|
50
|
+
renderBubble({ ...mine, deleted_at: '2026-07-31T10:00:00Z' });
|
|
41
51
|
expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
|
|
52
|
+
cleanup();
|
|
53
|
+
renderBubble({ ...mine, kind: 'poll' });
|
|
54
|
+
expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('opens the affordance from a tap and lets a keyboard user reveal and operate it', () => {
|
|
58
|
+
const onReply = vi.fn();
|
|
59
|
+
const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 } }, { onReply });
|
|
60
|
+
const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
|
|
61
|
+
fireEvent.click(bubble);
|
|
62
|
+
expect(bubble.getAttribute('data-actions-visible')).toBe('true');
|
|
63
|
+
const actions = screen.getByRole('button', { name: 'MessagingActions.MENU' });
|
|
64
|
+
fireEvent.focus(actions);
|
|
65
|
+
fireEvent.click(actions);
|
|
66
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'MessagingThread.REPLY' }));
|
|
67
|
+
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('dismisses the tap-revealed affordance again on an outside interaction, but not on a reaction-picker click inside the same bubble', () => {
|
|
71
|
+
const { container } = renderBubble({ id: 3, body: 'Touch me', sender: { id: 2 }, reactions: [{ emoji: '👍', count: 1, reacted: false }] });
|
|
72
|
+
const bubble = container.querySelector('[data-message-id="3"] .MuiPaper-root');
|
|
73
|
+
fireEvent.click(bubble);
|
|
74
|
+
expect(bubble.getAttribute('data-actions-visible')).toBe('true');
|
|
75
|
+
// A pointerdown inside the same message's own reaction row (a sibling of
|
|
76
|
+
// the Paper, still within the article) must not count as "outside" —
|
|
77
|
+
// otherwise every reaction click would prematurely hide the affordance.
|
|
78
|
+
fireEvent.pointerDown(screen.getByText('👍 1'));
|
|
79
|
+
expect(bubble.getAttribute('data-actions-visible')).toBe('true');
|
|
80
|
+
// A pointerdown genuinely outside the message dismisses it — this is the
|
|
81
|
+
// U1 fix: without it, the affordance stayed pinned visible forever after
|
|
82
|
+
// any click that bubbled up to the bubble (Part A's "no visible
|
|
83
|
+
// affordance at rest" requirement).
|
|
84
|
+
fireEvent.pointerDown(document.body);
|
|
85
|
+
expect(bubble.getAttribute('data-actions-visible')).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('keeps each message bubble\'s tap-reveal state independent — revealing one does not dismiss another', () => {
|
|
89
|
+
const api = makeApi();
|
|
90
|
+
const { container } = render(
|
|
91
|
+
<AuthContext.Provider value={{ user: { id: 1 } }}>
|
|
92
|
+
<MessagingProvider api={api} active={false}>
|
|
93
|
+
<MessageBubble message={{ id: 10, body: 'First', sender: { id: 2 } }} />
|
|
94
|
+
<MessageBubble message={{ id: 11, body: 'Second', sender: { id: 2 } }} />
|
|
95
|
+
</MessagingProvider>
|
|
96
|
+
</AuthContext.Provider>
|
|
97
|
+
);
|
|
98
|
+
const bubbleA = container.querySelector('[data-message-id="10"] .MuiPaper-root');
|
|
99
|
+
const bubbleB = container.querySelector('[data-message-id="11"] .MuiPaper-root');
|
|
100
|
+
fireEvent.pointerDown(bubbleA);
|
|
101
|
+
fireEvent.click(bubbleA);
|
|
102
|
+
expect(bubbleA.getAttribute('data-actions-visible')).toBe('true');
|
|
103
|
+
expect(bubbleB.getAttribute('data-actions-visible')).toBeNull();
|
|
104
|
+
// Tapping bubble B is, from bubble A's perspective, an outside
|
|
105
|
+
// interaction — A dismisses, B reveals; the two must never share state.
|
|
106
|
+
// (A real tap fires pointerdown before click; fireEvent.click alone
|
|
107
|
+
// wouldn't exercise the pointerdown-driven dismiss listener.)
|
|
108
|
+
fireEvent.pointerDown(bubbleB);
|
|
109
|
+
fireEvent.click(bubbleB);
|
|
110
|
+
expect(bubbleA.getAttribute('data-actions-visible')).toBeNull();
|
|
111
|
+
expect(bubbleB.getAttribute('data-actions-visible')).toBe('true');
|
|
42
112
|
});
|
|
43
113
|
|
|
44
114
|
it('edits an author message through the API and auto-cancels when deleted elsewhere', async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.emoji ? `${key}:${options.emoji}:${options.count}` : key }) }));
|
|
7
7
|
|
|
@@ -29,7 +29,7 @@ function baseApi(overrides = {}) {
|
|
|
29
29
|
// a live-frame test must read the message back out of the cache.
|
|
30
30
|
function ConnectedReactionBar({ messageId }) {
|
|
31
31
|
const { cache } = useMessaging();
|
|
32
|
-
return <ReactionBar message={cache.messages[messageId]} />;
|
|
32
|
+
return <ReactionBar message={cache.messages[messageId]} expanded />;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
@@ -54,9 +54,7 @@ describe('reaction frame/REST merge against the real aggregate-only contract', (
|
|
|
54
54
|
it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
|
|
55
55
|
const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }) });
|
|
56
56
|
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
57
|
-
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
|
|
58
|
-
await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
|
|
59
|
-
fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
|
|
57
|
+
act(() => { dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } }); });
|
|
60
58
|
fireEvent.click(screen.getByText('👍'));
|
|
61
59
|
await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', '👍'));
|
|
62
60
|
await waitFor(() => {
|
|
@@ -68,7 +68,8 @@ describe('messaging threading and quoting', () => {
|
|
|
68
68
|
api.deleteMessage = vi.fn().mockResolvedValue({});
|
|
69
69
|
render(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider></AuthContext.Provider>);
|
|
70
70
|
await screen.findByText('Reply target');
|
|
71
|
-
fireEvent.click(screen.getByRole('button', { name: /
|
|
71
|
+
fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
|
|
72
|
+
fireEvent.click(screen.getByRole('menuitem', { name: /MessagingThread\.REPLY\b/ }));
|
|
72
73
|
expect(screen.getByText(/MessagingThread\.REPLYING_TO/)).toBeTruthy();
|
|
73
74
|
fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
|
|
74
75
|
fireEvent.click(screen.getByRole('menuitem', { name: /MessagingActions\.DELETE/ }));
|