@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.
- package/WORK_ORDERS.md +2 -1
- package/dev/entries.jsx +4 -0
- package/dist/i18n/messagingTranslations.js +26 -0
- package/dist/index.js +1 -0
- package/dist/messaging/Composer.js +41 -5
- package/dist/messaging/ConversationList.js +10 -3
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +84 -4
- package/dist/messaging/MessagingProvider.js +62 -16
- package/dist/messaging/ReactionBar.js +1 -1
- package/dist/messaging/ReadTicks.js +7 -4
- package/dist/messaging/Thread.js +45 -8
- package/dist/messaging/api.js +3 -3
- package/dist/messaging/composerImageCompression.js +36 -0
- package/docs/messaging-deviations.md +91 -26
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +26 -0
- package/src/index.js +1 -0
- package/src/messaging/Composer.jsx +54 -8
- package/src/messaging/ConversationList.jsx +10 -4
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +74 -7
- package/src/messaging/MessagingProvider.jsx +83 -11
- package/src/messaging/ReactionBar.jsx +1 -1
- package/src/messaging/ReadTicks.jsx +5 -3
- package/src/messaging/Thread.jsx +39 -8
- package/src/messaging/api.js +3 -2
- package/src/messaging/composerImageCompression.js +35 -0
- package/tests/Thread.test.jsx +1 -1
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +42 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +2 -1
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- 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
|
-
|
|
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
|
-
|
|
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('
|
|
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')}
|
|
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
|
|
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 && <
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
22
|
-
return <
|
|
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
|
}
|