@micha.bigler/ui-core-micha 2.16.0 → 2.17.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.
Files changed (38) hide show
  1. package/WORK_ORDERS.md +2 -1
  2. package/dev/entries.jsx +4 -0
  3. package/dist/i18n/messagingTranslations.js +26 -0
  4. package/dist/index.js +1 -0
  5. package/dist/messaging/Composer.js +41 -5
  6. package/dist/messaging/ConversationList.js +10 -3
  7. package/dist/messaging/DirectMessageLauncher.js +48 -0
  8. package/dist/messaging/MessageBubble.js +84 -4
  9. package/dist/messaging/MessagingProvider.js +62 -16
  10. package/dist/messaging/ReactionBar.js +1 -1
  11. package/dist/messaging/ReadTicks.js +7 -4
  12. package/dist/messaging/Thread.js +45 -8
  13. package/dist/messaging/api.js +3 -3
  14. package/dist/messaging/composerImageCompression.js +36 -0
  15. package/docs/messaging-deviations.md +91 -26
  16. package/package.json +1 -1
  17. package/src/i18n/messagingTranslations.ts +26 -0
  18. package/src/index.js +1 -0
  19. package/src/messaging/Composer.jsx +54 -8
  20. package/src/messaging/ConversationList.jsx +10 -4
  21. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  22. package/src/messaging/MessageBubble.jsx +74 -7
  23. package/src/messaging/MessagingProvider.jsx +83 -11
  24. package/src/messaging/ReactionBar.jsx +1 -1
  25. package/src/messaging/ReadTicks.jsx +5 -3
  26. package/src/messaging/Thread.jsx +39 -8
  27. package/src/messaging/api.js +3 -2
  28. package/src/messaging/composerImageCompression.js +35 -0
  29. package/tests/Thread.test.jsx +1 -1
  30. package/tests/messagingChunk6.test.jsx +109 -0
  31. package/tests/messagingComposerChunk5.test.jsx +110 -0
  32. package/tests/messagingContractConformance.test.js +42 -0
  33. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  34. package/tests/messagingExports.test.js +2 -1
  35. package/tests/messagingMessageActions.test.jsx +93 -0
  36. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  37. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  38. package/work-orders/MSG-3b.md +1002 -0
@@ -1,13 +1,18 @@
1
- import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Stack, TextField, Typography } from '@mui/material';
1
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
2
2
  import AttachFileIcon from '@mui/icons-material/AttachFile';
3
3
  import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
4
+ import CloseIcon from '@mui/icons-material/Close';
5
+ import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined';
4
6
  import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
5
- import { useRef, useState } from 'react';
7
+ import { useEffect, useRef, useState } from 'react';
6
8
  import { useTranslation } from 'react-i18next';
7
9
 
10
+ import { compressImageForUpload } from './composerImageCompression';
8
11
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
+ import { QUICK_EMOJIS } from './ReactionBar';
9
13
 
10
14
  function newRequestId() { return globalThis.crypto?.randomUUID?.() || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
15
+ function isImage(file) { return file.type?.startsWith('image/'); }
11
16
 
12
17
  /**
13
18
  * A provider-backed, independently mountable REST composer.
@@ -34,7 +39,20 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
34
39
  const [announcementOpen, setAnnouncementOpen] = useState(false);
35
40
  const [announcementTitle, setAnnouncementTitle] = useState('');
36
41
  const [announcementBody, setAnnouncementBody] = useState('');
42
+ const [emojiAnchor, setEmojiAnchor] = useState(null);
43
+ const [previews, setPreviews] = useState([]);
44
+ const [uploadProgress, setUploadProgress] = useState(null);
37
45
  const inputRef = useRef(null);
46
+ const messageInputRef = useRef(null);
47
+ useEffect(() => {
48
+ if (replyTarget?.deleted_at) onReplyTargetChange?.(null);
49
+ }, [replyTarget, onReplyTargetChange]);
50
+ useEffect(() => { setBody(''); setFiles([]); }, [conversationId]);
51
+ useEffect(() => {
52
+ const urls = files.map((file) => isImage(file) ? URL.createObjectURL(file) : null);
53
+ setPreviews(urls);
54
+ return () => urls.filter(Boolean).forEach((url) => URL.revokeObjectURL(url));
55
+ }, [files]);
38
56
  const submit = async (requestId = newRequestId(), retry = false) => {
39
57
  if (!body.trim() && !files.length) return;
40
58
  setSending(true); setError(null);
@@ -42,18 +60,29 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
42
60
  try {
43
61
  if (files.length) {
44
62
  const formData = new FormData();
45
- files.forEach((file) => formData.append('files[]', file));
63
+ const uploadFiles = await Promise.all(files.map(compressImageForUpload));
64
+ uploadFiles.forEach((file) => formData.append('files[]', file));
46
65
  if (payload.body) formData.append('body', payload.body);
47
66
  if (payload.reply_to) formData.append('reply_to', String(payload.reply_to));
48
67
  formData.append('client_request_id', requestId);
49
- await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
68
+ setUploadProgress(0);
69
+ const onUploadProgress = (event) => setUploadProgress(event.total ? Math.round((event.loaded / event.total) * 100) : null);
70
+ await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, onUploadProgress, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
50
71
  } else await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
51
72
  setBody(''); setFiles([]); setFailed(null); onReplyTargetChange?.(null);
52
73
  } catch (sendError) {
53
74
  const message = extractApiErrorMessage(sendError);
54
75
  setError(files.length ? t('MessagingComposer.UPLOAD_ERROR', { message }) : t('MessagingComposer.SEND_ERROR', { message }));
55
76
  setFailed({ requestId, retry: true });
56
- } finally { setSending(false); }
77
+ } finally { setSending(false); setUploadProgress(null); }
78
+ };
79
+ const removeFile = (index) => setFiles((current) => current.filter((_, fileIndex) => fileIndex !== index));
80
+ const insertEmoji = (emoji) => {
81
+ const input = messageInputRef.current?.querySelector?.('textarea') || messageInputRef.current;
82
+ const start = input?.selectionStart ?? body.length;
83
+ const end = input?.selectionEnd ?? start;
84
+ setBody(`${body.slice(0, start)}${emoji}${body.slice(end)}`); setEmojiAnchor(null);
85
+ requestAnimationFrame(() => { input?.focus(); input?.setSelectionRange(start + emoji.length, start + emoji.length); });
57
86
  };
58
87
  const submitPoll = async () => {
59
88
  const options = pollOptions.map((option) => option.trim()).filter(Boolean);
@@ -82,21 +111,38 @@ export function Composer({ conversationId, replyTarget = null, onReplyTargetChan
82
111
  return <Stack component="form" spacing={1} onSubmit={(event) => { event.preventDefault(); submit(); }} aria-label={t('MessagingComposer.LABEL')}>
83
112
  {replyTarget && <Alert severity="info" onClose={() => onReplyTargetChange?.(null)}>{t('MessagingComposer.REPLYING_TO', { sender: replyTarget.sender?.display_name || t('MessagingThread.UNKNOWN_SENDER') })}</Alert>}
84
113
  {error && <Alert severity="error" role="alert">{error}</Alert>}
114
+ {files.length > 0 && <Stack direction="row" spacing={0.5} flexWrap="wrap" aria-label={t('MessagingComposer.STAGED_FILES')}>
115
+ {files.map((file, index) => <Stack key={`${file.name}-${index}`} direction="row" alignItems="center" spacing={0.25} sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }}>
116
+ {previews[index] && <Box component="img" src={previews[index]} alt={file.name} width={36} height={36} sx={{ objectFit: 'cover' }} />}
117
+ <Typography variant="caption">{file.name}</Typography><IconButton type="button" size="small" aria-label={t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name })} onClick={() => removeFile(index)}><CloseIcon fontSize="inherit" /></IconButton>
118
+ </Stack>)}
119
+ </Stack>}
85
120
  {files.length > 0 && <Typography variant="caption">{t('MessagingComposer.FILES_SELECTED', { count: files.length })}</Typography>}
86
121
  <Stack direction="row" spacing={1} alignItems="flex-end">
87
122
  <input ref={inputRef} hidden type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files || []))} />
88
123
  <IconButton type="button" aria-label={t('MessagingComposer.ADD_ATTACHMENT')} onClick={() => inputRef.current?.click()}><AttachFileIcon /></IconButton>
89
- <IconButton type="button" aria-label={t('MessagingPoll.CREATE')} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
124
+ <IconButton type="button" aria-label={t('MessagingComposer.ADD_EMOJI')} onClick={(event) => setEmojiAnchor(event.currentTarget)}><EmojiEmotionsOutlinedIcon /></IconButton>
125
+ <IconButton type="button" aria-label={t('MessagingPoll.CREATE')} disabled={pollOpen || files.some(isImage)} onClick={() => setPollOpen(true)}><PollOutlinedIcon /></IconButton>
90
126
  {allowAnnouncement && <IconButton type="button" aria-label={t('MessagingAnnouncement.CREATE')} onClick={() => setAnnouncementOpen(true)}><CampaignOutlinedIcon /></IconButton>}
91
- <TextField fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
127
+ <TextField inputRef={messageInputRef} fullWidth multiline minRows={2} value={body} onChange={(event) => setBody(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); } }} label={t('MessagingComposer.MESSAGE')} disabled={sending} />
92
128
  <Button type="submit" variant="contained" disabled={sending || (!body.trim() && !files.length)}>{t('MessagingComposer.SEND')}</Button>
93
129
  </Stack>
130
+ {sending && files.length > 0 && <Stack spacing={0.5} role="status">
131
+ <Stack direction="row" spacing={1} alignItems="center">
132
+ {uploadProgress == null ? <CircularProgress size={16} /> : <CircularProgress size={16} variant="determinate" value={uploadProgress} />}
133
+ <Typography variant="caption">{t('MessagingComposer.UPLOADING')}</Typography>
134
+ </Stack>
135
+ {uploadProgress != null && <LinearProgress variant="determinate" value={uploadProgress} />}
136
+ </Stack>}
94
137
  {failed && <Button type="button" onClick={() => submit(failed.requestId, true)} disabled={sending}>{t('MessagingComposer.RETRY')}</Button>}
138
+ <Menu anchorEl={emojiAnchor} open={Boolean(emojiAnchor)} onClose={() => setEmojiAnchor(null)}>
139
+ {QUICK_EMOJIS.map((emoji) => <MenuItem key={emoji} onClick={() => insertEmoji(emoji)}>{emoji}</MenuItem>)}
140
+ </Menu>
95
141
  <Dialog open={pollOpen} onClose={() => !sending && setPollOpen(false)} fullWidth maxWidth="sm">
96
142
  <DialogTitle>{t('MessagingPoll.CREATE')}</DialogTitle><DialogContent><Stack spacing={1} sx={{ pt: 1 }}>
97
143
  <TextField label={t('MessagingPoll.QUESTION')} value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} autoFocus />
98
144
  {pollOptions.map((option, index) => <TextField key={index} label={t('MessagingPoll.OPTION', { count: index + 1 })} value={option} onChange={(event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value))} />)}
99
- <Button type="button" onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
145
+ <Button type="button" disabled={pollOptions.length >= 10} onClick={() => setPollOptions((current) => [...current, ''])}>{t('MessagingPoll.ADD_OPTION')}</Button>
100
146
  <FormControlLabel control={<Checkbox checked={allowMultiple} onChange={(event) => setAllowMultiple(event.target.checked)} />} label={t('MessagingPoll.ALLOW_MULTIPLE')} />
101
147
  </Stack></DialogContent><DialogActions><Button onClick={() => setPollOpen(false)}>{t('MessagingPoll.CANCEL')}</Button><Button variant="contained" onClick={submitPoll} disabled={sending}>{t('MessagingPoll.CREATE')}</Button></DialogActions>
102
148
  </Dialog>
@@ -10,6 +10,12 @@ function titleOf(conversation, t) { return conversation.title || conversation.ot
10
10
  function ordered(conversations) {
11
11
  return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
12
12
  }
13
+ function relativeTime(value, locale) {
14
+ if (!value) return null;
15
+ const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000));
16
+ const [amount, unit] = seconds < 60 ? [0, 'second'] : seconds < 3600 ? [Math.floor(seconds / 60), 'minute'] : seconds < 86400 ? [Math.floor(seconds / 3600), 'hour'] : [Math.floor(seconds / 86400), 'day'];
17
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }).format(-amount, unit);
18
+ }
13
19
  // jg's ConversationList renders a distinct icon per conversation kind so a
14
20
  // user can identify type/person at a glance (getConversationAvatar); that
15
21
  // glanceable identification is a feature, not layout, so it's reproduced
@@ -22,8 +28,8 @@ function KindIcon({ conversation }) {
22
28
 
23
29
  /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
24
30
  export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
25
- const { t } = useTranslation();
26
- const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences } = useMessaging();
31
+ const { t, i18n } = useTranslation();
32
+ const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
27
33
  const [menuConversation, setMenuConversation] = useState(null);
28
34
  const [menuAnchor, setMenuAnchor] = useState(null);
29
35
  const [loadingMore, setLoadingMore] = useState(false);
@@ -58,11 +64,11 @@ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, au
58
64
  {conversations.map((conversation) => {
59
65
  const unread = unreadFor(conversation);
60
66
  const archived = Boolean(conversation.archived || conversation.archived_at);
61
- return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => onOpen?.(conversation)} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
67
+ return <ListItemButton key={conversation.id} selected={conversation.id === activeConversationId} onClick={() => { markConversationRead(conversation.id).catch(() => {}); onOpen?.(conversation); }} sx={{ minHeight: 64, opacity: archived ? 0.6 : 1 }}>
62
68
  <Badge color="error" badgeContent={unread || null} max={99} sx={{ mr: 2 }}>
63
69
  {archived ? <Archive fontSize="small" color="disabled" /> : <KindIcon conversation={conversation} />}
64
70
  </Badge>
65
- <ListItemText primary={titleOf(conversation, t)} secondary={conversation.last_message?.body || t('MessagingList.NO_MESSAGES')} primaryTypographyProps={{ fontWeight: unread ? 700 : 400, noWrap: true }} secondaryTypographyProps={{ noWrap: true }} />
71
+ <ListItemText primary={<Stack direction="row" spacing={1} justifyContent="space-between"><Typography component="span" fontWeight={unread ? 700 : 400} noWrap>{titleOf(conversation, t)}</Typography>{relativeTime(conversation.last_message_at, i18n?.language) && <Typography component="span" variant="caption" color="text.secondary" noWrap>{relativeTime(conversation.last_message_at, i18n?.language)}</Typography>}</Stack>} secondary={conversation.last_message?.body || t('MessagingList.NO_MESSAGES')} secondaryTypographyProps={{ noWrap: true }} />
66
72
  <IconButton aria-label={t('MessagingList.ACTIONS')} onClick={(event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }}><MoreVert /></IconButton>
67
73
  </ListItemButton>;
68
74
  })}
@@ -0,0 +1,96 @@
1
+ import { useId, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ CircularProgress,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ List,
11
+ ListItemButton,
12
+ ListItemText,
13
+ Typography,
14
+ useMediaQuery,
15
+ } from '@mui/material';
16
+ import { useTheme } from '@mui/material/styles';
17
+ import { useTranslation } from 'react-i18next';
18
+
19
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
20
+
21
+ /**
22
+ * Starts direct conversations from host-provided people. Candidate discovery is
23
+ * intentionally outside messaging because memberships are host-domain data.
24
+ */
25
+ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
26
+ const { t } = useTranslation();
27
+ const { openDirectConversation } = useMessaging();
28
+ const theme = useTheme();
29
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
30
+ const [open, setOpen] = useState(false);
31
+ const [selectedId, setSelectedId] = useState(null);
32
+ const [starting, setStarting] = useState(false);
33
+ const [error, setError] = useState(null);
34
+ const titleId = useId();
35
+
36
+ const showPicker = () => {
37
+ setSelectedId(null);
38
+ setError(null);
39
+ setOpen(true);
40
+ };
41
+ const closePicker = () => {
42
+ if (!starting) setOpen(false);
43
+ };
44
+ const start = async () => {
45
+ if (selectedId == null || starting) return;
46
+ setStarting(true);
47
+ setError(null);
48
+ try {
49
+ const conversation = await openDirectConversation({
50
+ target_user_id: selectedId,
51
+ ...(scope == null ? {} : { scope }),
52
+ });
53
+ setOpen(false);
54
+ onOpen?.(conversation);
55
+ } catch (requestError) {
56
+ setError(extractApiErrorMessage(requestError) || t('MessagingDirect.START_ERROR'));
57
+ } finally {
58
+ setStarting(false);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <>
64
+ <Button variant="outlined" onClick={showPicker}>{t('MessagingDirect.LAUNCH')}</Button>
65
+ <Dialog open={open} onClose={closePicker} fullScreen={fullScreen} aria-labelledby={titleId}>
66
+ <DialogTitle id={titleId}>{t('MessagingDirect.TITLE')}</DialogTitle>
67
+ <DialogContent>
68
+ {error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
69
+ {starting && <Typography role="status" sx={{ mb: 1 }}><CircularProgress size={16} sx={{ mr: 1 }} />{t('MessagingDirect.STARTING')}</Typography>}
70
+ {candidates.length === 0 ? (
71
+ <Typography>{t('MessagingDirect.EMPTY')}</Typography>
72
+ ) : (
73
+ <List aria-label={t('MessagingDirect.CANDIDATES')}>
74
+ {candidates.map((candidate) => (
75
+ <ListItemButton
76
+ key={candidate.id}
77
+ selected={candidate.id === selectedId}
78
+ disabled={starting}
79
+ onClick={() => setSelectedId(candidate.id)}
80
+ >
81
+ <ListItemText primary={candidate.display_name || candidate.name || candidate.label || candidate.id} />
82
+ </ListItemButton>
83
+ ))}
84
+ </List>
85
+ )}
86
+ </DialogContent>
87
+ <DialogActions>
88
+ <Button onClick={closePicker} disabled={starting}>{t('MessagingDirect.CANCEL')}</Button>
89
+ <Button variant="contained" onClick={start} disabled={selectedId == null || starting}>
90
+ {t('MessagingDirect.START')}
91
+ </Button>
92
+ </DialogActions>
93
+ </Dialog>
94
+ </>
95
+ );
96
+ }
@@ -1,37 +1,104 @@
1
- import { Button, Paper, Stack, Typography } from '@mui/material';
1
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
2
+ import MoreVertIcon from '@mui/icons-material/MoreVert';
3
+ import { useEffect, useState } from 'react';
2
4
  import { useTranslation } from 'react-i18next';
3
5
  import { AttachmentList } from './AttachmentList';
6
+ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
4
7
  import { ReactionBar } from './ReactionBar';
5
8
  import { PollCard } from './PollCard';
6
9
 
7
10
  function senderName(message) {
8
11
  return message.sender?.display_name || message.sender?.username || message.sender_name || message.sender?.name;
9
12
  }
13
+ function quoteSnippet(message) {
14
+ const content = message.body || message.title;
15
+ if (!content) return null;
16
+ return content.length > 120 ? `${content.slice(0, 117)}...` : content;
17
+ }
18
+ // A quoted source can be textless for two different reasons: it was deleted
19
+ // (server blanks body/title/link_target on soft delete), or it always had no
20
+ // text — an attachment-only chat send is a real, reachable case (Composer
21
+ // only requires body OR files). Collapsing both into "This message was
22
+ // deleted" would mislabel the second case, so they're distinguished here.
23
+ function quoteLabel(message, t) {
24
+ if (message.deleted_at) return t('MessagingThread.DELETED');
25
+ const snippet = quoteSnippet(message);
26
+ if (snippet) return snippet;
27
+ return message.attachments?.length ? t('MessagingThread.QUOTE_ATTACHMENT') : '';
28
+ }
10
29
 
11
- /** Provider-backed timeline collaborator; it deliberately has no mutation controls. */
12
- export function MessageBubble({ message, replyTo, onReply, children }) {
30
+ /** Provider-backed timeline collaborator, including per-message mutations. */
31
+ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
13
32
  const { t } = useTranslation();
33
+ const messaging = useOptionalMessaging();
34
+ const { currentUser, editMessage, removeMessage } = messaging || {};
14
35
  const deleted = Boolean(message.deleted_at);
15
36
  const attachments = message.attachments || [];
37
+ const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser?.id) && message.sender?.id === currentUser?.id) || canModerateMessages);
38
+ const [menuAnchor, setMenuAnchor] = useState(null);
39
+ const [editing, setEditing] = useState(false);
40
+ const [draft, setDraft] = useState(message.body || '');
41
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
42
+ const [saving, setSaving] = useState(false);
43
+ const [error, setError] = useState(null);
44
+ useEffect(() => {
45
+ if (deleted) { setEditing(false); setConfirmingDelete(false); setMenuAnchor(null); }
46
+ }, [deleted]);
47
+ const closeMenu = () => setMenuAnchor(null);
48
+ const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
49
+ const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
50
+ const saveEdit = async () => {
51
+ setSaving(true); setError(null);
52
+ try { await editMessage?.(message.id, { body: draft.trim() }); setEditing(false); }
53
+ catch (saveError) { setError(t('MessagingActions.EDIT_ERROR', { message: extractApiErrorMessage(saveError) })); } finally { setSaving(false); }
54
+ };
55
+ const confirmDelete = async () => {
56
+ setSaving(true); setError(null);
57
+ try { await removeMessage?.(message.id); setConfirmingDelete(false); }
58
+ catch (deleteError) { setError(t('MessagingActions.DELETE_ERROR', { message: extractApiErrorMessage(deleteError) })); } finally { setSaving(false); }
59
+ };
60
+ const copyMessage = async () => {
61
+ closeMenu();
62
+ try { await navigator.clipboard.writeText(message.body || ''); }
63
+ catch (copyError) { setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) })); }
64
+ };
16
65
  return (
17
- <Paper component="article" variant="outlined" sx={{ p: 1.25, maxWidth: 'min(100%, 680px)' }} aria-label={t('MessagingThread.MESSAGE')}>
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}>
18
67
  <Stack spacing={0.75}>
19
- {replyTo && <Typography variant="caption" color="text.secondary">{t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') })}</Typography>}
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>}
20
74
  <Stack direction="row" spacing={1} alignItems="baseline">
21
- <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>
75
+ {conversation?.kind !== 'direct' && <Typography variant="subtitle2">{senderName(message) || t('MessagingThread.UNKNOWN_SENDER')}</Typography>}
22
76
  {message.created_at && <Typography variant="caption" color="text.secondary">{new Date(message.created_at).toLocaleString()}</Typography>}
23
77
  {message.edited_at && !deleted && <Typography variant="caption" color="text.secondary">{t('MessagingThread.EDITED')}</Typography>}
24
78
  </Stack>
25
79
  {!deleted && message.kind === 'announcement' && message.title && <Typography variant="subtitle1" fontWeight={700}>{message.title}</Typography>}
26
- <Typography sx={{ whiteSpace: 'pre-wrap' }}>{deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || 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>}
27
83
  {!deleted && <Stack spacing={0.75}>
28
84
  {attachments.length > 0 && <AttachmentList attachments={attachments} />}
29
85
  <ReactionBar message={message} />
30
86
  {message.poll && <PollCard message={message} />}
31
87
  </Stack>}
32
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>}
33
90
  {children}
34
91
  </Stack>
92
+ <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>
95
+ <MenuItem onClick={copyMessage}>{t('MessagingActions.COPY')}</MenuItem>
96
+ </Menu>
97
+ <Dialog open={confirmingDelete} onClose={() => !saving && setConfirmingDelete(false)}>
98
+ <DialogTitle>{t('MessagingActions.DELETE_CONFIRM_TITLE')}</DialogTitle>
99
+ <DialogContent>{t('MessagingActions.DELETE_CONFIRM_BODY')}</DialogContent>
100
+ <DialogActions><Button onClick={() => setConfirmingDelete(false)} disabled={saving}>{t('MessagingActions.CANCEL')}</Button><Button onClick={confirmDelete} disabled={saving} color="error">{t('MessagingActions.DELETE')}</Button></DialogActions>
101
+ </Dialog>
35
102
  </Paper>
36
103
  );
37
104
  }
@@ -4,6 +4,7 @@ import { AuthContext } from '../auth/AuthContext';
4
4
  import {
5
5
  archiveConversation,
6
6
  createBroadcastConversation,
7
+ createDirectConversation,
7
8
  createGroupConversation,
8
9
  getReadStatus,
9
10
  getUnreadCount,
@@ -11,6 +12,8 @@ import {
11
12
  listMessages,
12
13
  listThread,
13
14
  patchConversationPreferences,
15
+ markConversationRead,
16
+ markThreadRead,
14
17
  createMessage,
15
18
  uploadAttachments,
16
19
  getAttachment,
@@ -22,6 +25,8 @@ import {
22
25
  closePoll,
23
26
  getConversationConfig,
24
27
  patchConversationConfig,
28
+ patchMessage as patchMessageRequest,
29
+ deleteMessage as deleteMessageRequest,
25
30
  } from './api';
26
31
  import { useRealtime } from '../notifications/realtime';
27
32
 
@@ -30,9 +35,11 @@ export const MESSAGING_ENVELOPE = 'messaging';
30
35
  const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
31
36
  const DEFAULT_API = {
32
37
  listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
33
- patchConversationPreferences, createGroupConversation, createBroadcastConversation,
38
+ patchConversationPreferences, createGroupConversation, createBroadcastConversation, createDirectConversation,
39
+ markConversationRead, markThreadRead,
34
40
  createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
35
41
  addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
42
+ patchMessage: patchMessageRequest, deleteMessage: deleteMessageRequest,
36
43
  };
37
44
 
38
45
  function idOf(item) { return item?.id ?? item?.conversation_id ?? item?.message_id ?? item?.poll_id; }
@@ -96,17 +103,43 @@ export function messagingReducer(state, action) {
96
103
  return { ...state, messages: { ...state.messages, [key]: { ...message, status: 'error', error: action.error } } };
97
104
  }
98
105
  case 'messagePatched': return { ...state, messages: mergeById(state.messages, action.message) };
99
- case 'frame': return applyFrame(state, action.frame);
106
+ case 'conversationRead': {
107
+ const previousCount = state.unread?.by_conversation?.[action.conversationId] || 0;
108
+ if (!previousCount) return state;
109
+ return {
110
+ ...state,
111
+ unread: {
112
+ ...state.unread,
113
+ unread_count: Math.max(0, (state.unread?.unread_count || 0) - previousCount),
114
+ by_conversation: { ...state.unread.by_conversation, [action.conversationId]: 0 },
115
+ },
116
+ };
117
+ }
118
+ case 'frame': return applyFrame(state, action.frame, action.activeConversationId);
100
119
  default: return state;
101
120
  }
102
121
  }
103
122
 
104
- export function applyFrame(state, frame) {
123
+ export function applyFrame(state, frame, activeConversationId = null) {
105
124
  const payload = frame.message || frame.conversation || frame.participant || frame;
106
125
  const conversationId = frame.conversation_id ?? payload.conversation_id;
107
126
  if (frame.type === 'message') {
108
127
  const message = { ...payload, conversation_id: conversationId };
109
- return { ...state, messages: reconcileMessage(state.messages, message) };
128
+ const requestId = clientRequestIdOf(message);
129
+ const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
130
+ const messages = reconcileMessage(state.messages, message);
131
+ if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage) return { ...state, messages };
132
+ const unread = state.unread || EMPTY_CACHE.unread;
133
+ const previousCount = unread.by_conversation?.[conversationId] || 0;
134
+ return {
135
+ ...state,
136
+ messages,
137
+ unread: {
138
+ ...unread,
139
+ unread_count: (unread.unread_count || 0) + 1,
140
+ by_conversation: { ...unread.by_conversation, [conversationId]: previousCount + 1 },
141
+ },
142
+ };
110
143
  }
111
144
  if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
112
145
  if (frame.type === 'reaction') {
@@ -191,6 +224,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
191
224
  const conversation = await api.patchConversationPreferences(conversationId, patch);
192
225
  return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, ...patch });
193
226
  }, [api, updateConversation]);
227
+ const markConversationAsRead = useCallback(async (conversationId, readAt) => {
228
+ const result = await api.markConversationRead(conversationId, readAt);
229
+ dispatch({ type: 'conversationRead', conversationId });
230
+ return result;
231
+ }, [api]);
232
+ const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
194
233
  const openGroupConversation = useCallback(async (payload) => {
195
234
  const conversation = await api.createGroupConversation(payload);
196
235
  return updateConversation(conversation);
@@ -199,6 +238,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
199
238
  const conversation = await api.createBroadcastConversation(payload);
200
239
  return updateConversation(conversation);
201
240
  }, [api, updateConversation]);
241
+ const openDirectConversation = useCallback(async (payload) => {
242
+ const conversation = await api.createDirectConversation(payload);
243
+ return updateConversation(conversation);
244
+ }, [api, updateConversation]);
202
245
  const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
203
246
  const requestId = clientRequestId || payload.client_request_id;
204
247
  const optimistic = {
@@ -226,7 +269,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
226
269
  throw error;
227
270
  }
228
271
  }, [api, user]);
229
- const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
272
+ const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
230
273
  const optimistic = {
231
274
  id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: optimisticMessage?.body || '',
232
275
  reply_to: optimisticMessage?.reply_to || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
@@ -236,7 +279,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
236
279
  if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
237
280
  else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
238
281
  try {
239
- const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
282
+ const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
240
283
  dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? clientRequestId } });
241
284
  return message;
242
285
  } catch (error) {
@@ -245,6 +288,30 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
245
288
  }
246
289
  }, [api, user]);
247
290
  const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
291
+ // Keep this API-backed action deliberately distinct from the cache-merge
292
+ // helper above. The server remains the authority for author/moderator rights.
293
+ const editMessage = useCallback(async (messageId, patch) => {
294
+ const result = await api.patchMessage(messageId, patch);
295
+ const message = result?.message || result;
296
+ patchMessage({ ...(cache.messages[messageId] || { id: messageId }), ...(message || {}), id: message?.id ?? messageId });
297
+ return result;
298
+ }, [api, cache.messages, patchMessage]);
299
+ const removeMessage = useCallback(async (messageId) => {
300
+ const result = await api.deleteMessage(messageId);
301
+ const message = result?.message || result;
302
+ const previous = cache.messages[messageId] || { id: messageId };
303
+ patchMessage({
304
+ ...previous,
305
+ ...(message || {}),
306
+ id: message?.id ?? messageId,
307
+ deleted_at: message?.deleted_at ?? previous.deleted_at ?? new Date().toISOString(),
308
+ deleted_by: message?.deleted_by ?? previous.deleted_by ?? user?.id,
309
+ body: null,
310
+ title: null,
311
+ link_target: null,
312
+ });
313
+ return result;
314
+ }, [api, cache.messages, patchMessage, user]);
248
315
  const toggleReaction = useCallback(async (messageId, emoji, active) => {
249
316
  const message = cache.messages[messageId] || { id: messageId };
250
317
  const reactions = message.reactions || [];
@@ -296,7 +363,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
296
363
  seenEventsRef.current.add(frame.event_id);
297
364
  if (seenEventsRef.current.size > 500) seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
298
365
  }
299
- dispatch({ type: 'frame', frame });
366
+ dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
300
367
  }), [subscribe]);
301
368
  useEffect(() => {
302
369
  if (!active || !onReconnect) return undefined;
@@ -306,18 +373,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
306
373
  const value = useMemo(() => ({
307
374
  cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
308
375
  setConversationArchived, setConversationPreferences, openGroupConversation,
309
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
376
+ markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
377
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
310
378
  loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
311
379
  getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
312
380
  }), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
313
- setConversationArchived, setConversationPreferences, openGroupConversation,
314
- openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
381
+ setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
382
+ openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
315
383
  loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
316
384
  return <MessagingContext.Provider value={value}>{children}</MessagingContext.Provider>;
317
385
  }
318
386
 
319
387
  export function useMessaging() {
320
- const context = useContext(MessagingContext);
388
+ const context = useOptionalMessaging();
321
389
  if (!context) throw new Error('useMessaging must be used within a MessagingProvider');
322
390
  return context;
323
391
  }
392
+
393
+ export function useOptionalMessaging() {
394
+ return useContext(MessagingContext);
395
+ }
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
 
6
6
  import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
7
 
8
- const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
+ export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
9
9
 
10
10
  /** Independently mountable aggregate reaction controls for one message. */
11
11
  export function ReactionBar({ message }) {
@@ -1,4 +1,4 @@
1
- import { Tooltip, Typography } from '@mui/material';
1
+ import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
 
@@ -9,6 +9,7 @@ export function ReadTicks({ messageId, conversation }) {
9
9
  const { t } = useTranslation();
10
10
  const { getMessageReadStatus } = useMessaging();
11
11
  const [status, setStatus] = useState(null);
12
+ const [anchorEl, setAnchorEl] = useState(null);
12
13
  useEffect(() => {
13
14
  let mounted = true;
14
15
  getMessageReadStatus(messageId).then((next) => { if (mounted) setStatus(next); }).catch(() => { if (mounted) setStatus(null); });
@@ -18,6 +19,7 @@ export function ReadTicks({ messageId, conversation }) {
18
19
  const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
19
20
  // The server never returns DM detail; enforce the same carve-out defensively.
20
21
  const detail = conversation?.kind === 'direct' ? null : status.recipient_detail;
21
- const detailText = detail?.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean).join(', ');
22
- return <Tooltip title={detailText || label}><Typography component="span" variant="caption" color="text.secondary" aria-label={label}>{label}</Typography></Tooltip>;
22
+ 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></>;
23
25
  }