@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,16 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
-
import { archiveConversation, createBroadcastConversation, createGroupConversation, getReadStatus, getUnreadCount, listConversations, listMessages, listThread, patchConversationPreferences, createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail, addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig, } from './api';
|
|
4
|
+
import { archiveConversation, createBroadcastConversation, createDirectConversation, createGroupConversation, getReadStatus, getUnreadCount, listConversations, listMessages, listThread, patchConversationPreferences, markConversationRead, markThreadRead, createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail, addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig, patchMessage as patchMessageRequest, deleteMessage as deleteMessageRequest, } from './api';
|
|
5
5
|
import { useRealtime } from '../notifications/realtime';
|
|
6
6
|
const MessagingContext = createContext(null);
|
|
7
7
|
export const MESSAGING_ENVELOPE = 'messaging';
|
|
8
8
|
const EMPTY_CACHE = { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
|
|
9
9
|
const DEFAULT_API = {
|
|
10
10
|
listConversations, listMessages, listThread, getReadStatus, getUnreadCount, archiveConversation,
|
|
11
|
-
patchConversationPreferences, createGroupConversation, createBroadcastConversation,
|
|
11
|
+
patchConversationPreferences, createGroupConversation, createBroadcastConversation, createDirectConversation,
|
|
12
|
+
markConversationRead, markThreadRead,
|
|
12
13
|
createMessage, uploadAttachments, getAttachment, getAttachmentThumbnail,
|
|
13
14
|
addReaction, removeReaction, createPoll, votePoll, closePoll, getConversationConfig, patchConversationConfig,
|
|
15
|
+
patchMessage: patchMessageRequest, deleteMessage: deleteMessageRequest,
|
|
14
16
|
};
|
|
15
17
|
function idOf(item) { var _a, _b, _c; return (_c = (_b = (_a = item === null || item === void 0 ? void 0 : item.id) !== null && _a !== void 0 ? _a : item === null || item === void 0 ? void 0 : item.conversation_id) !== null && _b !== void 0 ? _b : item === null || item === void 0 ? void 0 : item.message_id) !== null && _c !== void 0 ? _c : item === null || item === void 0 ? void 0 : item.poll_id; }
|
|
16
18
|
function mergeById(slice, item) { const id = idOf(item); return id == null ? slice : Object.assign(Object.assign({}, slice), { [id]: Object.assign(Object.assign({}, slice[id]), item) }); }
|
|
@@ -53,7 +55,7 @@ function reconcileMessage(messages, message) {
|
|
|
53
55
|
return next;
|
|
54
56
|
}
|
|
55
57
|
export function messagingReducer(state, action) {
|
|
56
|
-
var _a, _b, _c;
|
|
58
|
+
var _a, _b, _c, _d, _e, _f;
|
|
57
59
|
switch (action.type) {
|
|
58
60
|
case 'conversationsLoaded': {
|
|
59
61
|
const conversations = action.results.reduce((next, item) => mergeById(next, item), state.conversations);
|
|
@@ -79,29 +81,70 @@ export function messagingReducer(state, action) {
|
|
|
79
81
|
return Object.assign(Object.assign({}, state), { messages: Object.assign(Object.assign({}, state.messages), { [key]: Object.assign(Object.assign({}, message), { status: 'error', error: action.error }) }) });
|
|
80
82
|
}
|
|
81
83
|
case 'messagePatched': return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, action.message) });
|
|
82
|
-
case '
|
|
84
|
+
case 'conversationRead': {
|
|
85
|
+
const previousCount = ((_e = (_d = state.unread) === null || _d === void 0 ? void 0 : _d.by_conversation) === null || _e === void 0 ? void 0 : _e[action.conversationId]) || 0;
|
|
86
|
+
if (!previousCount)
|
|
87
|
+
return state;
|
|
88
|
+
return Object.assign(Object.assign({}, state), { unread: Object.assign(Object.assign({}, state.unread), { unread_count: Math.max(0, (((_f = state.unread) === null || _f === void 0 ? void 0 : _f.unread_count) || 0) - previousCount), by_conversation: Object.assign(Object.assign({}, state.unread.by_conversation), { [action.conversationId]: 0 }) }) });
|
|
89
|
+
}
|
|
90
|
+
case 'frame': return applyFrame(state, action.frame, action.activeConversationId);
|
|
83
91
|
default: return state;
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
|
-
export function applyFrame(state, frame) {
|
|
87
|
-
var _a, _b, _c;
|
|
94
|
+
export function applyFrame(state, frame, activeConversationId = null) {
|
|
95
|
+
var _a, _b, _c, _d, _e, _f;
|
|
88
96
|
const payload = frame.message || frame.conversation || frame.participant || frame;
|
|
89
97
|
const conversationId = (_a = frame.conversation_id) !== null && _a !== void 0 ? _a : payload.conversation_id;
|
|
90
98
|
if (frame.type === 'message') {
|
|
91
99
|
const message = Object.assign(Object.assign({}, payload), { conversation_id: conversationId });
|
|
92
|
-
|
|
100
|
+
const requestId = clientRequestIdOf(message);
|
|
101
|
+
const reconcilesOptimisticMessage = Boolean(requestId && Object.values(state.messages).some((item) => clientRequestIdOf(item) === requestId));
|
|
102
|
+
const messages = reconcileMessage(state.messages, message);
|
|
103
|
+
if (conversationId == null || String(conversationId) === String(activeConversationId) || reconcilesOptimisticMessage)
|
|
104
|
+
return Object.assign(Object.assign({}, state), { messages });
|
|
105
|
+
const unread = state.unread || EMPTY_CACHE.unread;
|
|
106
|
+
const previousCount = ((_b = unread.by_conversation) === null || _b === void 0 ? void 0 : _b[conversationId]) || 0;
|
|
107
|
+
return Object.assign(Object.assign({}, state), { messages, unread: Object.assign(Object.assign({}, unread), { unread_count: (unread.unread_count || 0) + 1, by_conversation: Object.assign(Object.assign({}, unread.by_conversation), { [conversationId]: previousCount + 1 }) }) });
|
|
108
|
+
}
|
|
109
|
+
if (frame.type === 'message_edited') {
|
|
110
|
+
// dcm's publish_messaging_event always attaches a full frame.message
|
|
111
|
+
// (serialize_message) for this event type, so `payload` already carries
|
|
112
|
+
// the message's own `.id` in practice. The explicit `id:` override below
|
|
113
|
+
// is defensive only — it keeps this branch correct even if a future
|
|
114
|
+
// producer ever omits `.message` and falls through to the raw frame,
|
|
115
|
+
// whose `conversation_id` would otherwise win idOf()'s fallback order.
|
|
116
|
+
const messageId = (_c = frame.message_id) !== null && _c !== void 0 ? _c : payload.message_id;
|
|
117
|
+
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, payload), { id: messageId })) });
|
|
93
118
|
}
|
|
94
|
-
if (frame.type === 'message_edited')
|
|
95
|
-
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, payload) });
|
|
96
119
|
if (frame.type === 'reaction') {
|
|
97
|
-
const messageId = (
|
|
120
|
+
const messageId = (_d = frame.message_id) !== null && _d !== void 0 ? _d : payload.message_id;
|
|
98
121
|
const previous = state.messages[messageId] || { id: messageId };
|
|
99
|
-
|
|
122
|
+
// serialize_reactions (dcm) returns [{emoji, count}] only, never a
|
|
123
|
+
// per-viewer `reacted` flag, in any context — not REST, not frames, by
|
|
124
|
+
// design (aggregate-only). Wholesale-replacing `reactions` with the
|
|
125
|
+
// incoming server list would silently wipe every previously-known
|
|
126
|
+
// `reacted` flag on this message, not just whichever emoji changed.
|
|
127
|
+
// Merge by emoji instead: carry forward a previously-known `reacted`
|
|
128
|
+
// flag for any emoji still present in the incoming aggregate.
|
|
129
|
+
const incoming = payload.reactions || frame.reactions || previous.reactions || [];
|
|
130
|
+
const previousByEmoji = new Map((previous.reactions || []).map((reaction) => [reaction.emoji, reaction]));
|
|
131
|
+
const reactions = incoming.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = previousByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); });
|
|
132
|
+
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { reactions })) });
|
|
100
133
|
}
|
|
101
134
|
if (frame.type === 'poll_updated') {
|
|
102
|
-
const messageId = (
|
|
135
|
+
const messageId = (_e = frame.message_id) !== null && _e !== void 0 ? _e : payload.message_id;
|
|
103
136
|
const previous = state.messages[messageId] || { id: messageId };
|
|
104
|
-
const
|
|
137
|
+
const incomingPoll = payload.poll || frame.poll || payload;
|
|
138
|
+
// dcm's poll_updated frame is a viewer-independent projection
|
|
139
|
+
// (serialize_poll) — it never carries voted_option_ids, which dcm only
|
|
140
|
+
// ever adds to this viewer's own vote/create/close REST response. A
|
|
141
|
+
// frame arriving after a REST-derived vote is known must not wipe that
|
|
142
|
+
// locally-known selection; carry it forward when the frame is silent
|
|
143
|
+
// about it, never invert it from frame data that structurally can't
|
|
144
|
+
// contain it.
|
|
145
|
+
const previousVotedOptionIds = (_f = previous.poll) === null || _f === void 0 ? void 0 : _f.voted_option_ids;
|
|
146
|
+
const poll = incomingPoll.voted_option_ids === undefined && previousVotedOptionIds
|
|
147
|
+
? Object.assign(Object.assign({}, incomingPoll), { voted_option_ids: previousVotedOptionIds }) : incomingPoll;
|
|
105
148
|
return Object.assign(Object.assign({}, state), { messages: mergeById(state.messages, Object.assign(Object.assign({}, previous), { poll })), polls: mergeById(state.polls, poll) });
|
|
106
149
|
}
|
|
107
150
|
if (frame.type === 'message_deleted') {
|
|
@@ -180,6 +223,12 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
180
223
|
const conversation = await api.patchConversationPreferences(conversationId, patch);
|
|
181
224
|
return updateConversation(Object.assign(Object.assign(Object.assign({}, conversation), { id: (_a = conversation === null || conversation === void 0 ? void 0 : conversation.id) !== null && _a !== void 0 ? _a : conversationId }), patch));
|
|
182
225
|
}, [api, updateConversation]);
|
|
226
|
+
const markConversationAsRead = useCallback(async (conversationId, readAt) => {
|
|
227
|
+
const result = await api.markConversationRead(conversationId, readAt);
|
|
228
|
+
dispatch({ type: 'conversationRead', conversationId });
|
|
229
|
+
return result;
|
|
230
|
+
}, [api]);
|
|
231
|
+
const markReplyThreadRead = useCallback((rootId, readAt) => api.markThreadRead(rootId, readAt), [api]);
|
|
183
232
|
const openGroupConversation = useCallback(async (payload) => {
|
|
184
233
|
const conversation = await api.createGroupConversation(payload);
|
|
185
234
|
return updateConversation(conversation);
|
|
@@ -188,6 +237,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
188
237
|
const conversation = await api.createBroadcastConversation(payload);
|
|
189
238
|
return updateConversation(conversation);
|
|
190
239
|
}, [api, updateConversation]);
|
|
240
|
+
const openDirectConversation = useCallback(async (payload) => {
|
|
241
|
+
const conversation = await api.createDirectConversation(payload);
|
|
242
|
+
return updateConversation(conversation);
|
|
243
|
+
}, [api, updateConversation]);
|
|
191
244
|
const sendMessage = useCallback(async (conversationId, payload, { clientRequestId, retry = false } = {}) => {
|
|
192
245
|
var _a, _b;
|
|
193
246
|
const requestId = clientRequestId || payload.client_request_id;
|
|
@@ -206,7 +259,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
206
259
|
throw error;
|
|
207
260
|
}
|
|
208
261
|
}, [api, user]);
|
|
209
|
-
const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false } = {}) => {
|
|
262
|
+
const sendAttachments = useCallback(async (conversationId, formData, { clientRequestId, optimisticMessage, retry = false, onUploadProgress } = {}) => {
|
|
210
263
|
var _a, _b;
|
|
211
264
|
const optimistic = {
|
|
212
265
|
id: `local-${clientRequestId}`, conversation_id: conversationId, kind: 'chat', body: (optimisticMessage === null || optimisticMessage === void 0 ? void 0 : optimisticMessage.body) || '',
|
|
@@ -219,7 +272,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
219
272
|
else
|
|
220
273
|
dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, optimistic), { status: 'pending', error: null }) });
|
|
221
274
|
try {
|
|
222
|
-
const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId });
|
|
275
|
+
const message = await api.uploadAttachments(conversationId, formData, { idempotencyKey: clientRequestId, onUploadProgress });
|
|
223
276
|
dispatch({ type: 'messageReconciled', message: Object.assign(Object.assign({}, message), { conversation_id: (_a = message === null || message === void 0 ? void 0 : message.conversation_id) !== null && _a !== void 0 ? _a : conversationId, client_request_id: (_b = message === null || message === void 0 ? void 0 : message.client_request_id) !== null && _b !== void 0 ? _b : clientRequestId }) });
|
|
224
277
|
return message;
|
|
225
278
|
}
|
|
@@ -229,6 +282,23 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
229
282
|
}
|
|
230
283
|
}, [api, user]);
|
|
231
284
|
const patchMessage = useCallback((message) => dispatch({ type: 'messagePatched', message }), []);
|
|
285
|
+
// Keep this API-backed action deliberately distinct from the cache-merge
|
|
286
|
+
// helper above. The server remains the authority for author/moderator rights.
|
|
287
|
+
const editMessage = useCallback(async (messageId, patch) => {
|
|
288
|
+
var _a;
|
|
289
|
+
const result = await api.patchMessage(messageId, patch);
|
|
290
|
+
const message = (result === null || result === void 0 ? void 0 : result.message) || result;
|
|
291
|
+
patchMessage(Object.assign(Object.assign(Object.assign({}, (cache.messages[messageId] || { id: messageId })), (message || {})), { id: (_a = message === null || message === void 0 ? void 0 : message.id) !== null && _a !== void 0 ? _a : messageId }));
|
|
292
|
+
return result;
|
|
293
|
+
}, [api, cache.messages, patchMessage]);
|
|
294
|
+
const removeMessage = useCallback(async (messageId) => {
|
|
295
|
+
var _a, _b, _c, _d, _e;
|
|
296
|
+
const result = await api.deleteMessage(messageId);
|
|
297
|
+
const message = (result === null || result === void 0 ? void 0 : result.message) || result;
|
|
298
|
+
const previous = cache.messages[messageId] || { id: messageId };
|
|
299
|
+
patchMessage(Object.assign(Object.assign(Object.assign({}, previous), (message || {})), { id: (_a = message === null || message === void 0 ? void 0 : message.id) !== null && _a !== void 0 ? _a : messageId, deleted_at: (_c = (_b = message === null || message === void 0 ? void 0 : message.deleted_at) !== null && _b !== void 0 ? _b : previous.deleted_at) !== null && _c !== void 0 ? _c : new Date().toISOString(), deleted_by: (_e = (_d = message === null || message === void 0 ? void 0 : message.deleted_by) !== null && _d !== void 0 ? _d : previous.deleted_by) !== null && _e !== void 0 ? _e : user === null || user === void 0 ? void 0 : user.id, body: null, title: null, link_target: null }));
|
|
300
|
+
return result;
|
|
301
|
+
}, [api, cache.messages, patchMessage, user]);
|
|
232
302
|
const toggleReaction = useCallback(async (messageId, emoji, active) => {
|
|
233
303
|
var _a;
|
|
234
304
|
const message = cache.messages[messageId] || { id: messageId };
|
|
@@ -242,7 +312,14 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
242
312
|
patchMessage(Object.assign(Object.assign({}, message), { reactions: nextReactions }));
|
|
243
313
|
try {
|
|
244
314
|
const result = active ? await api.removeReaction(messageId, emoji) : await api.addReaction(messageId, emoji);
|
|
245
|
-
|
|
315
|
+
// Same aggregate-only shape as the realtime `reaction` frame (serialize_reactions
|
|
316
|
+
// never returns a per-viewer `reacted` flag) — merge the server's confirmed
|
|
317
|
+
// counts with the reacted flags nextReactions already computed correctly,
|
|
318
|
+
// rather than letting the reacted-less server array wipe them.
|
|
319
|
+
const confirmedCounts = (result === null || result === void 0 ? void 0 : result.reactions) || ((_a = result === null || result === void 0 ? void 0 : result.message) === null || _a === void 0 ? void 0 : _a.reactions);
|
|
320
|
+
const nextByEmoji = new Map(nextReactions.map((reaction) => [reaction.emoji, reaction]));
|
|
321
|
+
const reactions = confirmedCounts ? confirmedCounts.map((reaction) => { var _a, _b, _c; return (Object.assign(Object.assign({}, reaction), { reacted: (_c = (_a = reaction.reacted) !== null && _a !== void 0 ? _a : (_b = nextByEmoji.get(reaction.emoji)) === null || _b === void 0 ? void 0 : _b.reacted) !== null && _c !== void 0 ? _c : false })); }) : nextReactions;
|
|
322
|
+
patchMessage(Object.assign(Object.assign(Object.assign({}, message), ((result === null || result === void 0 ? void 0 : result.message) || result)), { reactions }));
|
|
246
323
|
return result;
|
|
247
324
|
}
|
|
248
325
|
catch (error) {
|
|
@@ -290,7 +367,7 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
290
367
|
if (seenEventsRef.current.size > 500)
|
|
291
368
|
seenEventsRef.current.delete(seenEventsRef.current.values().next().value);
|
|
292
369
|
}
|
|
293
|
-
dispatch({ type: 'frame', frame });
|
|
370
|
+
dispatch({ type: 'frame', frame, activeConversationId: activeIdRef.current });
|
|
294
371
|
}), [subscribe]);
|
|
295
372
|
useEffect(() => {
|
|
296
373
|
if (!active || !onReconnect)
|
|
@@ -300,18 +377,22 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
300
377
|
const value = useMemo(() => ({
|
|
301
378
|
cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
302
379
|
setConversationArchived, setConversationPreferences, openGroupConversation,
|
|
303
|
-
|
|
380
|
+
markConversationRead: markConversationAsRead, markThreadRead: markReplyThreadRead,
|
|
381
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
304
382
|
loadConversationConfig, saveConversationConfig, currentUser: user, getAttachment: api.getAttachment,
|
|
305
383
|
getAttachmentThumbnail: api.getAttachmentThumbnail, activeConversationId,
|
|
306
384
|
}), [cache, refresh, refreshConversations, refreshUnread, refreshThread, loadMoreMessages, loadThreadReplies, getMessageReadStatus, loadMoreConversations,
|
|
307
|
-
setConversationArchived, setConversationPreferences, openGroupConversation,
|
|
308
|
-
openBroadcastConversation, sendMessage, sendAttachments, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
385
|
+
setConversationArchived, setConversationPreferences, openGroupConversation, markConversationAsRead, markReplyThreadRead,
|
|
386
|
+
openBroadcastConversation, openDirectConversation, sendMessage, sendAttachments, editMessage, removeMessage, toggleReaction, createConversationPoll, castPollVote, closeConversationPoll,
|
|
309
387
|
loadConversationConfig, saveConversationConfig, user, api.getAttachment, api.getAttachmentThumbnail, activeConversationId]);
|
|
310
388
|
return _jsx(MessagingContext.Provider, { value: value, children: children });
|
|
311
389
|
}
|
|
312
390
|
export function useMessaging() {
|
|
313
|
-
const context =
|
|
391
|
+
const context = useOptionalMessaging();
|
|
314
392
|
if (!context)
|
|
315
393
|
throw new Error('useMessaging must be used within a MessagingProvider');
|
|
316
394
|
return context;
|
|
317
395
|
}
|
|
396
|
+
export function useOptionalMessaging() {
|
|
397
|
+
return useContext(MessagingContext);
|
|
398
|
+
}
|
|
@@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
6
|
/** Independently mountable live poll view. Server authorization remains authoritative. */
|
|
7
7
|
export function PollCard({ message, canClose: canCloseProp }) {
|
|
8
|
-
var _a, _b, _c, _d, _e, _f;
|
|
8
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
9
9
|
const { t } = useTranslation();
|
|
10
10
|
const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
|
|
11
11
|
const poll = message === null || message === void 0 ? void 0 : message.poll;
|
|
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 === null || poll === void 0 ? void 0 : poll.voted_option_ids) ? [...poll.voted_option_ids] : []), [poll]);
|
|
16
24
|
if (!poll)
|
|
17
25
|
return null;
|
|
18
26
|
const closed = Boolean(poll.closed_at);
|
|
19
|
-
const creatorId = (
|
|
20
|
-
const canClose = (
|
|
27
|
+
const creatorId = (_c = (_a = poll.created_by_id) !== null && _a !== void 0 ? _a : (_b = poll.created_by) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : poll.created_by;
|
|
28
|
+
const canClose = (_g = (_e = (_d = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _d !== void 0 ? _d : message === null || message === void 0 ? void 0 : message.can_close) !== null && _e !== void 0 ? _e : (_f = message === null || message === void 0 ? void 0 : message.permissions) === null || _f === void 0 ? void 0 : _f.can_close) !== null && _g !== void 0 ? _g : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
|
|
21
29
|
const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
|
|
22
30
|
const vote = async () => {
|
|
23
31
|
if (!selected.length) {
|
|
@@ -49,5 +57,14 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
49
57
|
setBusy(false);
|
|
50
58
|
}
|
|
51
59
|
};
|
|
52
|
-
return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
|
|
60
|
+
return _jsx(Paper, { variant: "outlined", sx: { p: 1 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), (poll.options || []).map((option) => {
|
|
61
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
62
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
63
|
+
// is no user-directory endpoint (same structural limit already
|
|
64
|
+
// established for the DM candidate list). A count is always correct;
|
|
65
|
+
// marking the viewer's own vote is derivable from voted_option_ids.
|
|
66
|
+
const votedByMe = Boolean((_a = poll.voted_option_ids) === null || _a === void 0 ? void 0 : _a.includes(option.id));
|
|
67
|
+
const voterCount = (_e = (_d = (_c = (_b = option.voters) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : option.vote_count) !== null && _d !== void 0 ? _d : option.votes_count) !== null && _e !== void 0 ? _e : 0;
|
|
68
|
+
return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_g = (_f = option.vote_count) !== null && _f !== void 0 ? _f : voterCount) !== null && _g !== void 0 ? _g : 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}` }, option.id);
|
|
69
|
+
}), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { size: "small", onClick: vote, disabled: closed || busy, children: t('MessagingPoll.VOTE') }), canClose && !closed && _jsx(Button, { size: "small", color: "warning", onClick: close, disabled: busy, children: t('MessagingPoll.CLOSE') }), closed && _jsx(Typography, { variant: "caption", children: t('MessagingPoll.CLOSED') })] })] }) });
|
|
53
70
|
}
|
|
@@ -4,7 +4,7 @@ import { Alert, Chip, IconButton, Stack } from '@mui/material';
|
|
|
4
4
|
import { useState } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
7
|
-
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
7
|
+
export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
8
8
|
/** Independently mountable aggregate reaction controls for one message. */
|
|
9
9
|
export function ReactionBar({ message }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { useMessaging } from './MessagingProvider';
|
|
@@ -8,6 +8,7 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
8
8
|
const { t } = useTranslation();
|
|
9
9
|
const { getMessageReadStatus } = useMessaging();
|
|
10
10
|
const [status, setStatus] = useState(null);
|
|
11
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
11
12
|
useEffect(() => {
|
|
12
13
|
let mounted = true;
|
|
13
14
|
getMessageReadStatus(messageId).then((next) => { if (mounted)
|
|
@@ -20,6 +21,8 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
20
21
|
const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
|
|
21
22
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
22
23
|
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : status.recipient_detail;
|
|
23
|
-
const
|
|
24
|
-
|
|
24
|
+
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
25
|
+
if (!recipients.length)
|
|
26
|
+
return _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", "aria-label": label, children: label });
|
|
27
|
+
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { typography: 'caption', color: 'text.secondary', textDecoration: 'underline' }, children: label }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
25
28
|
}
|
package/dist/messaging/Thread.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
3
|
-
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { AuthContext } from '../auth/AuthContext';
|
|
6
6
|
import { MessageBubble } from './MessageBubble';
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
8
8
|
import { ReadTicks } from './ReadTicks';
|
|
9
9
|
function chronological(messages) { return [...messages].sort((left, right) => new Date(left.created_at || 0) - new Date(right.created_at || 0)); }
|
|
10
|
+
function replyToId(message) { var _a; return (_a = message.reply_to_id) !== null && _a !== void 0 ? _a : message.reply_to; }
|
|
10
11
|
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
11
12
|
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
12
13
|
// getMessageReadStatus REST call for an id the server has never heard of.
|
|
@@ -26,18 +27,42 @@ function canShowReadTicks(message, user) {
|
|
|
26
27
|
* `getMessageReadStatus` REST calls a rendered timeline fires — one per own
|
|
27
28
|
* message shown, not one per message in the conversation.
|
|
28
29
|
*/
|
|
29
|
-
export function Thread({ conversationId, onReplyTargetChange }) {
|
|
30
|
+
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
30
31
|
var _a;
|
|
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
|
+
useEffect(() => { if (conversationId != null)
|
|
46
|
+
markConversationRead(conversationId).catch(() => { }); }, [conversationId, markConversationRead]);
|
|
47
|
+
useEffect(() => { wasNearBottomRef.current = true; messageCountRef.current = 0; }, [conversationId]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
var _a;
|
|
50
|
+
if (replyingTo && ((_a = cache.messages[replyingTo.id]) === null || _a === void 0 ? void 0 : _a.deleted_at)) {
|
|
51
|
+
setReplyingTo(null);
|
|
52
|
+
onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null);
|
|
53
|
+
}
|
|
54
|
+
}, [cache.messages, onReplyTargetChange, replyingTo]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const isNewMessage = conversationMessages.length > messageCountRef.current;
|
|
57
|
+
messageCountRef.current = conversationMessages.length;
|
|
58
|
+
if (!isNewMessage || !wasNearBottomRef.current || !scrollRef.current)
|
|
59
|
+
return;
|
|
60
|
+
const timeline = scrollRef.current;
|
|
61
|
+
if (typeof timeline.scrollTo === 'function')
|
|
62
|
+
timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'smooth' });
|
|
63
|
+
else
|
|
64
|
+
timeline.scrollTop = timeline.scrollHeight;
|
|
65
|
+
}, [conversationMessages]);
|
|
41
66
|
const loadOlder = useCallback(async () => {
|
|
42
67
|
setLoadingOlder(true);
|
|
43
68
|
setError(null);
|
|
@@ -51,9 +76,20 @@ export function Thread({ conversationId, onReplyTargetChange }) {
|
|
|
51
76
|
setLoadingOlder(false);
|
|
52
77
|
}
|
|
53
78
|
}, [conversationId, loadMoreMessages, t]);
|
|
54
|
-
const onScroll = () => {
|
|
55
|
-
|
|
79
|
+
const onScroll = () => {
|
|
80
|
+
const timeline = scrollRef.current;
|
|
81
|
+
if (timeline)
|
|
82
|
+
wasNearBottomRef.current = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 80;
|
|
83
|
+
if ((timeline === null || timeline === void 0 ? void 0 : timeline.scrollTop) === 0 && cache.cursors.messages[conversationId] && !loadingOlder)
|
|
84
|
+
loadOlder();
|
|
85
|
+
};
|
|
56
86
|
const reply = (message) => { setReplyingTo(message); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(message); };
|
|
87
|
+
const jumpToMessage = (messageId) => {
|
|
88
|
+
var _a, _b;
|
|
89
|
+
const target = [...(((_a = scrollRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll('[data-message-id]')) || [])]
|
|
90
|
+
.find((element) => String(element.dataset.messageId) === String(messageId));
|
|
91
|
+
(_b = target === null || target === void 0 ? void 0 : target.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(target, { block: 'center', behavior: 'smooth' });
|
|
92
|
+
};
|
|
57
93
|
const toggleReplies = async (root) => {
|
|
58
94
|
if (!openThreads[root.id]) {
|
|
59
95
|
try {
|
|
@@ -63,11 +99,12 @@ export function Thread({ conversationId, onReplyTargetChange }) {
|
|
|
63
99
|
setError(t('MessagingThread.REPLIES_ERROR'));
|
|
64
100
|
return;
|
|
65
101
|
}
|
|
102
|
+
markThreadRead(root.id).catch(() => { });
|
|
66
103
|
}
|
|
67
104
|
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
68
105
|
};
|
|
69
106
|
return (_jsxs(Stack, { spacing: 1, sx: { minWidth: 0 }, children: [error && _jsx(Alert, { severity: "error", role: "alert", children: error }), replyingTo && _jsx(Alert, { severity: "info", onClose: () => { setReplyingTo(null); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null); }, children: t('MessagingThread.REPLYING_TO', { sender: ((_a = replyingTo.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsxs(Box, { ref: scrollRef, onScroll: onScroll, sx: { maxHeight: 560, overflowY: 'auto', px: 0.5 }, "aria-label": t('MessagingThread.TIMELINE'), children: [cache.cursors.messages[conversationId] && _jsx(Box, { textAlign: "center", py: 1, children: _jsx(Button, { onClick: loadOlder, disabled: loadingOlder, children: loadingOlder ? _jsx(CircularProgress, { size: 18 }) : t('MessagingThread.LOAD_OLDER') }) }), !roots.length && _jsx(Typography, { color: "text.secondary", textAlign: "center", py: 4, children: t('MessagingThread.EMPTY') }), _jsx(Stack, { spacing: 1, children: roots.map((message) => {
|
|
70
|
-
const replies = chronological(Object.values(cache.messages).filter((item) => item
|
|
71
|
-
return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, onReply: reply, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, onReply: reply, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
|
|
107
|
+
const replies = chronological(Object.values(cache.messages).filter((item) => String(replyToId(item)) === String(message.id)));
|
|
108
|
+
return _jsxs(Stack, { spacing: 0.75, children: [_jsx(MessageBubble, { message: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(message, user) && _jsx(ReadTicks, { messageId: message.id, conversation: conversation }) }), (message.reply_count || replies.length) > 0 && _jsx(Button, { size: "small", onClick: () => toggleReplies(message), children: openThreads[message.id] ? t('MessagingThread.HIDE_REPLIES') : t('MessagingThread.SHOW_REPLIES', { count: message.reply_count || replies.length }) }), openThreads[message.id] && _jsx(Stack, { spacing: 0.75, sx: { pl: 3, borderLeft: 2, borderColor: 'divider' }, children: replies.map((item) => _jsx(MessageBubble, { message: item, replyTo: message, conversation: conversation, onReply: reply, onJumpToMessage: jumpToMessage, onAnnouncementLink: onAnnouncementLink, canModerateMessages: canModerateMessages, children: canShowReadTicks(item, user) && _jsx(ReadTicks, { messageId: item.id, conversation: conversation }) }, item.id)) }), _jsx(Divider, {})] }, message.id);
|
|
72
109
|
}) })] })] }));
|
|
73
110
|
}
|
package/dist/messaging/api.js
CHANGED
|
@@ -15,8 +15,8 @@ const conversationsUrl = `${BASE_URL}conversations/`;
|
|
|
15
15
|
function data(response) {
|
|
16
16
|
return response.data;
|
|
17
17
|
}
|
|
18
|
-
function requestConfig({ cursor, params, headers } = {}) {
|
|
19
|
-
return Object.assign(Object.assign(Object.assign({}, (params ? { params } : {})), (cursor ? { params: Object.assign(Object.assign({}, (params || {})), { cursor }) } : {})), (headers ? { headers } : {}));
|
|
18
|
+
function requestConfig({ cursor, params, headers, onUploadProgress } = {}) {
|
|
19
|
+
return Object.assign(Object.assign(Object.assign(Object.assign({}, (params ? { params } : {})), (cursor ? { params: Object.assign(Object.assign({}, (params || {})), { cursor }) } : {})), (headers ? { headers } : {})), (onUploadProgress ? { onUploadProgress } : {}));
|
|
20
20
|
}
|
|
21
21
|
export function listConversations(_a = {}) {
|
|
22
22
|
var { cursor } = _a, filters = __rest(_a, ["cursor"]);
|
|
@@ -33,7 +33,7 @@ export function archiveConversation(conversationId, archived) { return apiClient
|
|
|
33
33
|
export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
|
|
34
34
|
export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
|
|
35
35
|
export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
36
|
-
export function uploadAttachments(conversationId, formData, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
36
|
+
export function uploadAttachments(conversationId, formData, { idempotencyKey, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
|
|
37
37
|
export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
38
38
|
export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
39
39
|
export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_IMAGE_EDGE = 2560;
|
|
3
|
+
function imageDimensions(file) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const url = URL.createObjectURL(file);
|
|
6
|
+
const image = new Image();
|
|
7
|
+
image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
|
|
8
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Unable to decode image')); };
|
|
9
|
+
image.src = url;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function canvasBlob(canvas) {
|
|
13
|
+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|
14
|
+
}
|
|
15
|
+
/** Compresses large images opportunistically; callers always receive a sendable file. */
|
|
16
|
+
export async function compressImageForUpload(file) {
|
|
17
|
+
var _a, _b;
|
|
18
|
+
if (!((_a = file === null || file === void 0 ? void 0 : file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/')) || file.size <= MAX_IMAGE_BYTES)
|
|
19
|
+
return file;
|
|
20
|
+
try {
|
|
21
|
+
const image = await imageDimensions(file);
|
|
22
|
+
const largestEdge = Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
|
23
|
+
if (!largestEdge || largestEdge <= MAX_IMAGE_EDGE)
|
|
24
|
+
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
|
+
(_b = canvas.getContext('2d')) === null || _b === void 0 ? void 0 : _b.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
|
+
}
|
|
33
|
+
catch (_c) {
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
}
|