@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.
@@ -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 { useEffect, useState } from 'react';
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 canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) && message.sender?.id === currentUser?.id) || canModerateMessages);
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) { setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); }
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 openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
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
- <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }} aria-label={t('MessagingThread.MESSAGE')} data-message-id={message.id} onContextMenu={canAct ? openMenu : undefined}>
67
- <Stack spacing={0.75}>
68
- {replyTo && <Button type="button" variant="text" size="small" onClick={() => onJumpToMessage?.(replyTo.id)} sx={{ justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }}>
69
- <Stack spacing={0} alignItems="flex-start">
70
- <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>
71
- <Typography variant="caption" color="text.secondary">{quoteLabel(replyTo, t)}</Typography>
72
- </Stack>
73
- </Button>}
74
- <Stack direction="row" spacing={1} alignItems="baseline">
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.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
77
- {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
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
- {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
80
- {!deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && <Button type="button" size="small" onClick={() => onAnnouncementLink(message.link_target)}>{t('MessagingAnnouncement.OPEN_LINK')}</Button>}
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={startEdit}>{t('MessagingActions.EDIT')}</MenuItem>
94
- <MenuItem onClick={() => { setConfirmingDelete(true); closeMenu(); }}>{t('MessagingActions.DELETE')}</MenuItem>
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
- </Paper>
152
+ </Box>
103
153
  );
104
154
  }
@@ -1,5 +1,4 @@
1
- import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
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
- /** Independently mountable aggregate reaction controls for one message. */
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 at once. Disabling the chip while its own request is pending
21
- // removes the race instead of trying to make concurrent toggles safe.
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
- <IconButton size="small" aria-label={t('MessagingReactions.ADD')} onClick={() => setExpanded((value) => !value)}><AddReactionOutlinedIcon fontSize="small" /></IconButton>
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)); setExpanded(false); }} />;
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 { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
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 <Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography>;
24
- return <><ButtonBase aria-label={label} aria-haspopup="dialog" aria-expanded={Boolean(anchorEl)} onClick={(event) => setAnchorEl(event.currentTarget)} sx={{ typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }}>{label}</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></>;
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
  }
@@ -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
- <Stack spacing={1}>{roots.map((message) => {
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('gates the menu to the author or host-supplied moderator capability, never deleted or poll messages', () => {
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 { rerender } = renderBubble(mine);
32
+ const { unmount } = renderBubble(mine);
33
33
  expect(screen.getByRole('button', { name: 'MessagingActions.MENU' })).toBeTruthy();
34
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} /></MessagingProvider></AuthContext.Provider>);
35
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
36
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={theirs} canModerateMessages /></MessagingProvider></AuthContext.Provider>);
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
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, deleted_at: '2026-07-31T10:00:00Z' }} /></MessagingProvider></AuthContext.Provider>);
39
- expect(screen.queryByRole('button', { name: 'MessagingActions.MENU' })).toBeNull();
40
- rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ ...mine, kind: 'poll' }} /></MessagingProvider></AuthContext.Provider>);
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: /MessagingThread\.REPLY\b/ }));
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/ }));