@micha.bigler/ui-core-micha 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -1
- package/dev/entries.jsx +5 -1
- package/dist/i18n/messagingTranslations.js +27 -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 +102 -21
- package/dist/messaging/PollCard.js +22 -5
- 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 +108 -26
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +27 -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 +124 -15
- package/src/messaging/PollCard.jsx +19 -3
- 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/ConversationList.test.jsx +1 -1
- 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 +69 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +2 -1
- package/tests/messagingInteractions.test.jsx +3 -1
- package/tests/messagingLastMessagePreview.test.jsx +93 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingPollRendering.test.jsx +115 -0
- package/tests/messagingReactionMerge.test.jsx +79 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/work-orders/MSG-3b.md +1002 -0
- package/work-orders/MSG-3c.md +378 -0
|
@@ -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,28 +103,84 @@ 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
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (frame.type === 'message_edited') {
|
|
145
|
+
// dcm's publish_messaging_event always attaches a full frame.message
|
|
146
|
+
// (serialize_message) for this event type, so `payload` already carries
|
|
147
|
+
// the message's own `.id` in practice. The explicit `id:` override below
|
|
148
|
+
// is defensive only — it keeps this branch correct even if a future
|
|
149
|
+
// producer ever omits `.message` and falls through to the raw frame,
|
|
150
|
+
// whose `conversation_id` would otherwise win idOf()'s fallback order.
|
|
151
|
+
const messageId = frame.message_id ?? payload.message_id;
|
|
152
|
+
return { ...state, messages: mergeById(state.messages, { ...payload, id: messageId }) };
|
|
110
153
|
}
|
|
111
|
-
if (frame.type === 'message_edited') return { ...state, messages: mergeById(state.messages, payload) };
|
|
112
154
|
if (frame.type === 'reaction') {
|
|
113
155
|
const messageId = frame.message_id ?? payload.message_id;
|
|
114
156
|
const previous = state.messages[messageId] || { id: messageId };
|
|
115
|
-
|
|
157
|
+
// serialize_reactions (dcm) returns [{emoji, count}] only, never a
|
|
158
|
+
// per-viewer `reacted` flag, in any context — not REST, not frames, by
|
|
159
|
+
// design (aggregate-only). Wholesale-replacing `reactions` with the
|
|
160
|
+
// incoming server list would silently wipe every previously-known
|
|
161
|
+
// `reacted` flag on this message, not just whichever emoji changed.
|
|
162
|
+
// Merge by emoji instead: carry forward a previously-known `reacted`
|
|
163
|
+
// flag for any emoji still present in the incoming aggregate.
|
|
164
|
+
const incoming = payload.reactions || frame.reactions || previous.reactions || [];
|
|
165
|
+
const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
|
|
166
|
+
const reactions = incoming.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? previousByEmoji.get(reaction.emoji)?.reacted ?? false }));
|
|
167
|
+
return { ...state, messages: mergeById(state.messages, { ...previous, reactions }) };
|
|
116
168
|
}
|
|
117
169
|
if (frame.type === 'poll_updated') {
|
|
118
170
|
const messageId = frame.message_id ?? payload.message_id;
|
|
119
171
|
const previous = state.messages[messageId] || { id: messageId };
|
|
120
|
-
const
|
|
172
|
+
const incomingPoll = payload.poll || frame.poll || payload;
|
|
173
|
+
// dcm's poll_updated frame is a viewer-independent projection
|
|
174
|
+
// (serialize_poll) — it never carries voted_option_ids, which dcm only
|
|
175
|
+
// ever adds to this viewer's own vote/create/close REST response. A
|
|
176
|
+
// frame arriving after a REST-derived vote is known must not wipe that
|
|
177
|
+
// locally-known selection; carry it forward when the frame is silent
|
|
178
|
+
// about it, never invert it from frame data that structurally can't
|
|
179
|
+
// contain it.
|
|
180
|
+
const previousVotedOptionIds = previous.poll?.voted_option_ids;
|
|
181
|
+
const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
|
|
182
|
+
? { ...incomingPoll, voted_option_ids: previousVotedOptionIds }
|
|
183
|
+
: incomingPoll;
|
|
121
184
|
return { ...state, messages: mergeById(state.messages, { ...previous, poll }), polls: mergeById(state.polls, poll) };
|
|
122
185
|
}
|
|
123
186
|
if (frame.type === 'message_deleted') {
|
|
@@ -191,6 +254,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
191
254
|
const conversation = await api.patchConversationPreferences(conversationId, patch);
|
|
192
255
|
return updateConversation({ ...conversation, id: conversation?.id ?? conversationId, ...patch });
|
|
193
256
|
}, [api, updateConversation]);
|
|
257
|
+
const markConversationAsRead = useCallback(async (conversationId, readAt) => {
|
|
258
|
+
const result = await api.markConversationRead(conversationId, readAt);
|
|
259
|
+
dispatch({ type: 'conversationRead', conversationId });
|
|
260
|
+
return result;
|
|
261
|
+
}, [api]);
|
|
262
|
+
const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
|
|
194
263
|
const openGroupConversation = useCallback(async (payload) => {
|
|
195
264
|
const conversation = await api.createGroupConversation(payload);
|
|
196
265
|
return updateConversation(conversation);
|
|
@@ -199,6 +268,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
199
268
|
const conversation = await api.createBroadcastConversation(payload);
|
|
200
269
|
return updateConversation(conversation);
|
|
201
270
|
}, [api, updateConversation]);
|
|
271
|
+
const openDirectConversation = useCallback(async (payload) => {
|
|
272
|
+
const conversation = await api.createDirectConversation(payload);
|
|
273
|
+
return updateConversation(conversation);
|
|
274
|
+
}, [api, updateConversation]);
|
|
202
275
|
const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
|
|
203
276
|
const requestId = clientRequestId || payload.client_request_id;
|
|
204
277
|
const optimistic = {
|
|
@@ -226,7 +299,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
226
299
|
throw error;
|
|
227
300
|
}
|
|
228
301
|
}, [api, user]);
|
|
229
|
-
const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
|
|
302
|
+
const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
|
|
230
303
|
const optimistic = {
|
|
231
304
|
id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: optimisticMessage?.body || '',
|
|
232
305
|
reply_to: optimisticMessage?.reply_to || null, client_request_id: clientRequestId, created_at: new Date().toISOString(), status: 'pending',
|
|
@@ -236,7 +309,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
236
309
|
if (!retry) dispatch({ type: 'messageOptimistic', message: optimistic });
|
|
237
310
|
else dispatch({ type: 'messageReconciled', message: { ...optimistic, status: 'pending', error: null } });
|
|
238
311
|
try {
|
|
239
|
-
const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
|
|
312
|
+
const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
|
|
240
313
|
dispatch({ type: 'messageReconciled', message: { ...message, conversation_id: message?.conversation_id ?? conversationId, client_request_id: message?.client_request_id ?? clientRequestId } });
|
|
241
314
|
return message;
|
|
242
315
|
} catch (error) {
|
|
@@ -245,6 +318,30 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
245
318
|
}
|
|
246
319
|
}, [api, user]);
|
|
247
320
|
const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
|
|
321
|
+
// Keep this API-backed action deliberately distinct from the cache-merge
|
|
322
|
+
// helper above. The server remains the authority for author/moderator rights.
|
|
323
|
+
const editMessage = useCallback(async (messageId, patch) => {
|
|
324
|
+
const result = await api.patchMessage(messageId, patch);
|
|
325
|
+
const message = result?.message || result;
|
|
326
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), ...(message || {}), id: message?.id ?? messageId });
|
|
327
|
+
return result;
|
|
328
|
+
}, [api, cache.messages, patchMessage]);
|
|
329
|
+
const removeMessage = useCallback(async (messageId) => {
|
|
330
|
+
const result = await api.deleteMessage(messageId);
|
|
331
|
+
const message = result?.message || result;
|
|
332
|
+
const previous = cache.messages[messageId] || { id: messageId };
|
|
333
|
+
patchMessage({
|
|
334
|
+
...previous,
|
|
335
|
+
...(message || {}),
|
|
336
|
+
id: message?.id ?? messageId,
|
|
337
|
+
deleted_at: message?.deleted_at ?? previous.deleted_at ?? new Date().toISOString(),
|
|
338
|
+
deleted_by: message?.deleted_by ?? previous.deleted_by ?? user?.id,
|
|
339
|
+
body: null,
|
|
340
|
+
title: null,
|
|
341
|
+
link_target: null,
|
|
342
|
+
});
|
|
343
|
+
return result;
|
|
344
|
+
}, [api, cache.messages, patchMessage, user]);
|
|
248
345
|
const toggleReaction = useCallback(async (messageId, emoji, active) => {
|
|
249
346
|
const message = cache.messages[messageId] || { id: messageId };
|
|
250
347
|
const reactions = message.reactions || [];
|
|
@@ -257,7 +354,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
257
354
|
patchMessage({ ...message, reactions: nextReactions });
|
|
258
355
|
try {
|
|
259
356
|
const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
|
|
260
|
-
|
|
357
|
+
// Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
|
|
358
|
+
// never returns a per-viewer `reacted` flag) — merge the server's confirmed
|
|
359
|
+
// counts with the reacted flags nextReactions already computed correctly,
|
|
360
|
+
// rather than letting the reacted-less server array wipe them.
|
|
361
|
+
const confirmedCounts = result?.reactions || result?.message?.reactions;
|
|
362
|
+
const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
|
|
363
|
+
const reactions = confirmedCounts ? confirmedCounts.map((reaction) => ({ ...reaction, reacted: reaction.reacted ?? nextByEmoji.get(reaction.emoji)?.reacted ?? false })) : nextReactions;
|
|
364
|
+
patchMessage({ ...message, ...(result?.message || result), reactions });
|
|
261
365
|
return result;
|
|
262
366
|
} catch (error) { patchMessage(message); throw error; }
|
|
263
367
|
}, [api, cache.messages, patchMessage]);
|
|
@@ -296,7 +400,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
296
400
|
seenEventsRef.current.add(frame.event_id);
|
|
297
401
|
if (seenEventsRef.current.size > 500) seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
|
|
298
402
|
}
|
|
299
|
-
dispatch({ type: 'frame', frame });
|
|
403
|
+
dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
|
|
300
404
|
}), [subscribe]);
|
|
301
405
|
useEffect(() => {
|
|
302
406
|
if (!active || !onReconnect) return undefined;
|
|
@@ -306,18 +410,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
306
410
|
const value = useMemo(() => ({
|
|
307
411
|
cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
308
412
|
setConversationArchived, setConversationPreferences, openGroupConversation,
|
|
309
|
-
|
|
413
|
+
markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
|
|
414
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
310
415
|
loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
|
|
311
416
|
getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
|
|
312
417
|
}), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
313
|
-
setConversationArchived, setConversationPreferences, openGroupConversation,
|
|
314
|
-
openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
418
|
+
setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
|
|
419
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
315
420
|
loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
|
|
316
421
|
return <MessagingContext.Provider value={value}>{children}</MessagingContext.Provider>;
|
|
317
422
|
}
|
|
318
423
|
|
|
319
424
|
export function useMessaging() {
|
|
320
|
-
const context =
|
|
425
|
+
const context = useOptionalMessaging();
|
|
321
426
|
if (!context) throw new Error('useMessaging must be used within a MessagingProvider');
|
|
322
427
|
return context;
|
|
323
428
|
}
|
|
429
|
+
|
|
430
|
+
export function useOptionalMessaging() {
|
|
431
|
+
return useContext(MessagingContext);
|
|
432
|
+
}
|
|
@@ -12,10 +12,18 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
12
12
|
const [selected, setSelected] = useState([]);
|
|
13
13
|
const [error, setError] = useState(null);
|
|
14
14
|
const [busy, setBusy] = useState(false);
|
|
15
|
-
|
|
15
|
+
// voted_option_ids is a view-level enrichment dcm adds ONLY to the
|
|
16
|
+
// create/vote/close poll REST responses (serialize_poll itself, every
|
|
17
|
+
// message-list/thread load, and every poll_updated realtime frame never
|
|
18
|
+
// carry it) — so it's only ever present right after this viewer's own
|
|
19
|
+
// vote/create/close action. When it's absent (a freshly loaded or
|
|
20
|
+
// frame-updated poll), there's no way to know this viewer's vote from the
|
|
21
|
+
// payload alone; default to no selection rather than guessing or carrying
|
|
22
|
+
// over stale local state.
|
|
23
|
+
useEffect(() => setSelected(poll?.voted_option_ids ? [...poll.voted_option_ids] : []), [poll]);
|
|
16
24
|
if (!poll) return null;
|
|
17
25
|
const closed = Boolean(poll.closed_at);
|
|
18
|
-
const creatorId = poll.created_by?.id ?? poll.created_by;
|
|
26
|
+
const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
|
|
19
27
|
const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
|
|
20
28
|
const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
|
|
21
29
|
const vote = async () => {
|
|
@@ -29,7 +37,15 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
29
37
|
};
|
|
30
38
|
return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
|
|
31
39
|
<Typography variant="subtitle2">{poll.question}</Typography>
|
|
32
|
-
{(poll.options || []).map((option) =>
|
|
40
|
+
{(poll.options || []).map((option) => {
|
|
41
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
42
|
+
// is no user-directory endpoint (same structural limit already
|
|
43
|
+
// established for the DM candidate list). A count is always correct;
|
|
44
|
+
// marking the viewer's own vote is derivable from voted_option_ids.
|
|
45
|
+
const votedByMe = Boolean(poll.voted_option_ids?.includes(option.id));
|
|
46
|
+
const voterCount = option.voters?.length ?? option.vote_count ?? option.votes_count ?? 0;
|
|
47
|
+
return <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? voterCount ?? 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}`} />;
|
|
48
|
+
})}
|
|
33
49
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
34
50
|
<Stack direction="row" spacing={1}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
|
|
35
51
|
</Stack></Paper>;
|
|
@@ -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
|
}
|
package/src/messaging/Thread.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
2
|
-
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
|
|
5
5
|
import { AuthContext } from '../auth/AuthContext';
|
|
@@ -8,6 +8,7 @@ import { useMessaging } from './MessagingProvider';
|
|
|
8
8
|
import { ReadTicks } from './ReadTicks';
|
|
9
9
|
|
|
10
10
|
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
11
|
+
function replyToId(message) { return message.reply_to_id ?? message.reply_to; }
|
|
11
12
|
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
12
13
|
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
13
14
|
// getMessageReadStatus REST call for an id the server has never heard of.
|
|
@@ -27,27 +28,57 @@ function canShowReadTicks(message, user) {
|
|
|
27
28
|
* `getMessageReadStatus` REST calls a rendered timeline fires — one per own
|
|
28
29
|
* message shown, not one per message in the conversation.
|
|
29
30
|
*/
|
|
30
|
-
export function Thread({ conversationId, onReplyTargetChange }) {
|
|
31
|
+
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
31
32
|
const { t } = useTranslation();
|
|
32
33
|
const { user } = useContext(AuthContext) || {};
|
|
33
|
-
const { cache, loadMoreMessages, loadThreadReplies } = useMessaging();
|
|
34
|
+
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
|
|
34
35
|
const scrollRef = useRef(null);
|
|
36
|
+
const wasNearBottomRef = useRef(true);
|
|
37
|
+
const messageCountRef = useRef(0);
|
|
35
38
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
36
39
|
const [error, setError] = useState(null);
|
|
37
40
|
const [replyingTo, setReplyingTo] = useState(null);
|
|
38
41
|
const [openThreads, setOpenThreads] = useState({});
|
|
39
42
|
const conversation = cache.conversations[conversationId];
|
|
40
|
-
const
|
|
43
|
+
const conversationMessages = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId)), [cache.messages, conversationId]);
|
|
44
|
+
const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !replyToId(message))), [cache.messages, conversationId]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => { if (conversationId != null) markConversationRead(conversationId).catch(() => {}); }, [conversationId, markConversationRead]);
|
|
47
|
+
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (replyingTo && cache.messages[replyingTo.id]?.deleted_at) {
|
|
50
|
+
setReplyingTo(null);
|
|
51
|
+
onReplyTargetChange?.(null);
|
|
52
|
+
}
|
|
53
|
+
}, [cache.messages, onReplyTargetChange, replyingTo]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const isNewMessage = conversationMessages.length > messageCountRef.current;
|
|
56
|
+
messageCountRef.current = conversationMessages.length;
|
|
57
|
+
if (!isNewMessage || !wasNearBottomRef.current || !scrollRef.current) return;
|
|
58
|
+
const timeline = scrollRef.current;
|
|
59
|
+
if (typeof timeline.scrollTo === 'function') timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'smooth' });
|
|
60
|
+
else timeline.scrollTop = timeline.scrollHeight;
|
|
61
|
+
}, [conversationMessages]);
|
|
41
62
|
|
|
42
63
|
const loadOlder = useCallback(async () => {
|
|
43
64
|
setLoadingOlder(true); setError(null);
|
|
44
65
|
try { await loadMoreMessages(conversationId); } catch { setError(t('MessagingThread.LOAD_ERROR')); } finally { setLoadingOlder(false); }
|
|
45
66
|
}, [conversationId, loadMoreMessages, t]);
|
|
46
|
-
const onScroll = () => {
|
|
67
|
+
const onScroll = () => {
|
|
68
|
+
const timeline = scrollRef.current;
|
|
69
|
+
if (timeline) wasNearBottomRef.current = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
|
|
70
|
+
if (timeline?.scrollTop === 0 && cache.cursors.messages[conversationId] && !loadingOlder) loadOlder();
|
|
71
|
+
};
|
|
47
72
|
const reply = (message) => { setReplyingTo(message); onReplyTargetChange?.(message); };
|
|
73
|
+
const jumpToMessage = (messageId) => {
|
|
74
|
+
const target = [...(scrollRef.current?.querySelectorAll('[data-message-id]') || [])]
|
|
75
|
+
.find((element) => String(element.dataset.messageId) === String(messageId));
|
|
76
|
+
target?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
77
|
+
};
|
|
48
78
|
const toggleReplies = async (root) => {
|
|
49
79
|
if (!openThreads[root.id]) {
|
|
50
80
|
try { await loadThreadReplies(root.id); } catch { setError(t('MessagingThread.REPLIES_ERROR')); return; }
|
|
81
|
+
markThreadRead(root.id).catch(() => {});
|
|
51
82
|
}
|
|
52
83
|
setOpenThreads((current) => ({ ...current, [root.id]: !current[root.id] }));
|
|
53
84
|
};
|
|
@@ -59,10 +90,10 @@ export function Thread({ conversationId, onReplyTargetChange }) {
|
|
|
59
90
|
{cache.cursors.messages[conversationId] && <Box textAlign="center" py={1}><Button onClick={loadOlder} disabled={loadingOlder}>{loadingOlder ? <CircularProgress size={18} /> : t('MessagingThread.LOAD_OLDER')}</Button></Box>}
|
|
60
91
|
{!roots.length && <Typography color="text.secondary" textAlign="center" py={4}>{t('MessagingThread.EMPTY')}</Typography>}
|
|
61
92
|
<Stack spacing={1}>{roots.map((message) => {
|
|
62
|
-
const replies = chronological(Object.values(cache.messages).filter((item) => item
|
|
63
|
-
return <Stack key={message.id} spacing={0.75}><MessageBubble message={message} onReply={reply}>{canShowReadTicks(message, user) && <ReadTicks messageId={message.id} conversation={conversation} />}</MessageBubble>
|
|
93
|
+
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
94
|
+
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>
|
|
64
95
|
{(message.reply_count || replies.length) > 0 && <Button size="small" onClick={() => toggleReplies(message)}>{openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length })}</Button>}
|
|
65
|
-
{openThreads[message.id] && <Stack spacing={0.75} sx={{ pl: 3, borderLeft: 2, borderColor: 'divider' }}>{replies.map((item) => <MessageBubble key={item.id} message={item} replyTo={message} onReply={reply}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
|
|
96
|
+
{openThreads[message.id] && <Stack spacing={0.75} sx={{ pl: 3, borderLeft: 2, borderColor: 'divider' }}>{replies.map((item) => <MessageBubble key={item.id} message={item} replyTo={message} conversation={conversation} onReply={reply} onJumpToMessage={jumpToMessage} onAnnouncementLink={onAnnouncementLink} canModerateMessages={canModerateMessages}>{canShowReadTicks(item, user) && <ReadTicks messageId={item.id} conversation={conversation} />}</MessageBubble>)}</Stack>}
|
|
66
97
|
<Divider /></Stack>;
|
|
67
98
|
})}</Stack>
|
|
68
99
|
</Box>
|
package/src/messaging/api.js
CHANGED
|
@@ -7,11 +7,12 @@ function data(response) {
|
|
|
7
7
|
return response.data;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
function requestConfig({ cursor, params, headers } = {}) {
|
|
10
|
+
function requestConfig({ cursor, params, headers, onUploadProgress } = {}) {
|
|
11
11
|
return {
|
|
12
12
|
...(params ? { params } : {}),
|
|
13
13
|
...(cursor ? { params: { ...(params || {}), cursor } } : {}),
|
|
14
14
|
...(headers ? { headers } : {}),
|
|
15
|
+
...(onUploadProgress ? { onUploadProgress } : {}),
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -29,7 +30,7 @@ export function archiveConversation(conversationId, archived) { return apiClient
|
|
|
29
30
|
export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
|
|
30
31
|
export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
|
|
31
32
|
export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
32
|
-
export function uploadAttachments(conversationId, formData, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
33
|
+
export function uploadAttachments(conversationId, formData, { idempotencyKey, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
|
|
33
34
|
export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
34
35
|
export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
35
36
|
export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_IMAGE_EDGE = 2560;
|
|
3
|
+
|
|
4
|
+
function imageDimensions(file) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const url = URL.createObjectURL(file);
|
|
7
|
+
const image = new Image();
|
|
8
|
+
image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
|
|
9
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Unable to decode image')); };
|
|
10
|
+
image.src = url;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canvasBlob(canvas) {
|
|
15
|
+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compresses large images opportunistically; callers always receive a sendable file. */
|
|
19
|
+
export async function compressImageForUpload(file) {
|
|
20
|
+
if (!file?.type?.startsWith('image/') || file.size <= MAX_IMAGE_BYTES) return file;
|
|
21
|
+
try {
|
|
22
|
+
const image = await imageDimensions(file);
|
|
23
|
+
const largestEdge = Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
|
24
|
+
if (!largestEdge || largestEdge <= MAX_IMAGE_EDGE) return file;
|
|
25
|
+
const scale = MAX_IMAGE_EDGE / largestEdge;
|
|
26
|
+
const canvas = document.createElement('canvas');
|
|
27
|
+
canvas.width = Math.round((image.naturalWidth || image.width) * scale);
|
|
28
|
+
canvas.height = Math.round((image.naturalHeight || image.height) * scale);
|
|
29
|
+
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
30
|
+
const blob = await canvasBlob(canvas);
|
|
31
|
+
return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
|
|
32
|
+
} catch {
|
|
33
|
+
return file;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -17,7 +17,7 @@ describe('ConversationList', () => {
|
|
|
17
17
|
let api;
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
api = {
|
|
20
|
-
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: {
|
|
20
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { excerpt: 'Hello' } }], next_cursor: 'opaque-cursor' }),
|
|
21
21
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
|
|
22
22
|
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
23
|
archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
|
package/tests/Thread.test.jsx
CHANGED
|
@@ -61,7 +61,7 @@ describe('messaging Thread surfaces', () => {
|
|
|
61
61
|
api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
|
|
62
62
|
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
63
63
|
const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
|
|
64
|
-
fireEvent.
|
|
64
|
+
fireEvent.click(tick);
|
|
65
65
|
await screen.findByText('Visible recipient');
|
|
66
66
|
});
|
|
67
67
|
|