@micha.bigler/ui-core-micha 2.15.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/WORK_ORDERS.md +6 -1
- package/dev/entries.jsx +151 -0
- package/dev/fixtures.js +26 -0
- package/dev/main.jsx +56 -0
- package/dev/mockTransport.jsx +128 -0
- package/dist/i18n/messagingTranslations.js +107 -0
- package/dist/index.js +15 -0
- package/dist/messaging/AttachmentList.js +43 -0
- package/dist/messaging/Composer.js +156 -0
- package/dist/messaging/ConversationLaunchers.js +39 -0
- package/dist/messaging/ConversationList.js +82 -0
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +97 -0
- package/dist/messaging/MessagingProvider.js +363 -0
- package/dist/messaging/MessagingScopeConfig.js +28 -0
- package/dist/messaging/PollCard.js +53 -0
- package/dist/messaging/ReactionBar.js +44 -0
- package/dist/messaging/ReadTicks.js +28 -0
- package/dist/messaging/Thread.js +110 -0
- package/dist/messaging/api.js +51 -0
- package/dist/messaging/composerImageCompression.js +36 -0
- package/dist/notifications/NotificationsProvider.js +2 -2
- package/dist/notifications/realtime.js +21 -1
- package/docs/messaging-deviations.md +101 -0
- package/index.html +12 -0
- package/package.json +4 -1
- package/src/i18n/messagingTranslations.ts +107 -0
- package/src/index.js +16 -0
- package/src/messaging/AttachmentList.jsx +38 -0
- package/src/messaging/Composer.jsx +156 -0
- package/src/messaging/ConversationLaunchers.jsx +51 -0
- package/src/messaging/ConversationList.jsx +84 -0
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +104 -0
- package/src/messaging/MessagingProvider.jsx +395 -0
- package/src/messaging/MessagingScopeConfig.jsx +24 -0
- package/src/messaging/PollCard.jsx +36 -0
- package/src/messaging/ReactionBar.jsx +46 -0
- package/src/messaging/ReadTicks.jsx +25 -0
- package/src/messaging/Thread.jsx +102 -0
- package/src/messaging/api.js +48 -0
- package/src/messaging/composerImageCompression.js +35 -0
- package/src/notifications/NotificationsProvider.jsx +2 -2
- package/src/notifications/realtime.jsx +21 -1
- package/tests/ConversationList.test.jsx +66 -0
- package/tests/MessagingProvider.test.jsx +50 -0
- package/tests/Thread.test.jsx +100 -0
- package/tests/messagingApi.test.js +32 -0
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposer.test.jsx +111 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +42 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +21 -0
- package/tests/messagingInteractions.test.jsx +112 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/tests/realtime.test.jsx +27 -0
- package/vite.config.mjs +6 -0
- package/work-orders/DX-1.md +166 -0
- package/work-orders/MSG-3.md +375 -0
- package/work-orders/MSG-3b.md +1002 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
3
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
6
|
+
import { MessageBubble } from './MessageBubble';
|
|
7
|
+
import { useMessaging } from './MessagingProvider';
|
|
8
|
+
import { ReadTicks } from './ReadTicks';
|
|
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; }
|
|
11
|
+
// A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
|
|
12
|
+
// and no durable server row yet — mounting ReadTicks against it would fire a
|
|
13
|
+
// getMessageReadStatus REST call for an id the server has never heard of.
|
|
14
|
+
// Wait for reconciliation (status !== 'pending') before showing read state.
|
|
15
|
+
function canShowReadTicks(message, user) {
|
|
16
|
+
var _a;
|
|
17
|
+
return Boolean(user === null || user === void 0 ? void 0 : user.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === user.id && message.status !== 'pending';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A standalone timeline. Compose/reaction/poll/attachment mutations remain
|
|
21
|
+
* separate chunk collaborators.
|
|
22
|
+
*
|
|
23
|
+
* ReadTicks is only mounted for the current user's own messages (matches
|
|
24
|
+
* jg's `shouldShowReadTick(msg, isOwn)`): a read receipt tells the sender
|
|
25
|
+
* whether their own message was seen, not the recipient anything about a
|
|
26
|
+
* message they're currently reading. Gating here also bounds the number of
|
|
27
|
+
* `getMessageReadStatus` REST calls a rendered timeline fires — one per own
|
|
28
|
+
* message shown, not one per message in the conversation.
|
|
29
|
+
*/
|
|
30
|
+
export function Thread({ conversationId, onReplyTargetChange, canModerateMessages = false, onAnnouncementLink }) {
|
|
31
|
+
var _a;
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const { user } = useContext(AuthContext) || {};
|
|
34
|
+
const { cache, loadMoreMessages, loadThreadReplies, markConversationRead, markThreadRead } = useMessaging();
|
|
35
|
+
const scrollRef = useRef(null);
|
|
36
|
+
const wasNearBottomRef = useRef(true);
|
|
37
|
+
const messageCountRef = useRef(0);
|
|
38
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
const [replyingTo, setReplyingTo] = useState(null);
|
|
41
|
+
const [openThreads, setOpenThreads] = useState({});
|
|
42
|
+
const conversation = cache.conversations[conversationId];
|
|
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]);
|
|
66
|
+
const loadOlder = useCallback(async () => {
|
|
67
|
+
setLoadingOlder(true);
|
|
68
|
+
setError(null);
|
|
69
|
+
try {
|
|
70
|
+
await loadMoreMessages(conversationId);
|
|
71
|
+
}
|
|
72
|
+
catch (_a) {
|
|
73
|
+
setError(t('MessagingThread.LOAD_ERROR'));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
setLoadingOlder(false);
|
|
77
|
+
}
|
|
78
|
+
}, [conversationId, loadMoreMessages, t]);
|
|
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
|
+
};
|
|
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
|
+
};
|
|
93
|
+
const toggleReplies = async (root) => {
|
|
94
|
+
if (!openThreads[root.id]) {
|
|
95
|
+
try {
|
|
96
|
+
await loadThreadReplies(root.id);
|
|
97
|
+
}
|
|
98
|
+
catch (_a) {
|
|
99
|
+
setError(t('MessagingThread.REPLIES_ERROR'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
markThreadRead(root.id).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
|
|
105
|
+
};
|
|
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) => {
|
|
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);
|
|
109
|
+
}) })] })] }));
|
|
110
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
2
|
+
var t = {};
|
|
3
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
4
|
+
t[p] = s[p];
|
|
5
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
6
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
7
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
8
|
+
t[p[i]] = s[p[i]];
|
|
9
|
+
}
|
|
10
|
+
return t;
|
|
11
|
+
};
|
|
12
|
+
import apiClient from '../auth/apiClient';
|
|
13
|
+
const BASE_URL = '/api/messaging/';
|
|
14
|
+
const conversationsUrl = `${BASE_URL}conversations/`;
|
|
15
|
+
function data(response) {
|
|
16
|
+
return response.data;
|
|
17
|
+
}
|
|
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
|
+
}
|
|
21
|
+
export function listConversations(_a = {}) {
|
|
22
|
+
var { cursor } = _a, filters = __rest(_a, ["cursor"]);
|
|
23
|
+
return apiClient.get(conversationsUrl, requestConfig({ cursor, params: filters })).then(data);
|
|
24
|
+
}
|
|
25
|
+
export function createDirectConversation(payload) { return apiClient.post(`${conversationsUrl}direct/`, payload).then(data); }
|
|
26
|
+
export function createGroupConversation(payload) { return apiClient.post(`${conversationsUrl}group/`, payload).then(data); }
|
|
27
|
+
export function createBroadcastConversation(payload) { return apiClient.post(`${conversationsUrl}broadcast/`, payload).then(data); }
|
|
28
|
+
export function createManagedConversation(payload) { return apiClient.post(`${conversationsUrl}managed/`, payload).then(data); }
|
|
29
|
+
export function createObjectThreadConversation(payload) { return apiClient.post(`${conversationsUrl}object-thread/`, payload).then(data); }
|
|
30
|
+
export function getConversationConfig(conversationId) { return apiClient.get(`${conversationsUrl}${conversationId}/config/`).then(data); }
|
|
31
|
+
export function patchConversationConfig(conversationId, patch) { return apiClient.patch(`${conversationsUrl}${conversationId}/config/`, patch).then(data); }
|
|
32
|
+
export function archiveConversation(conversationId, archived) { return apiClient.post(`${conversationsUrl}${conversationId}/archive/`, { archived }).then(data); }
|
|
33
|
+
export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
|
|
34
|
+
export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
|
|
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, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
|
|
37
|
+
export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
38
|
+
export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
39
|
+
export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
|
|
40
|
+
export function deleteMessage(messageId) { return apiClient.delete(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
41
|
+
export function addReaction(messageId, emoji) { return apiClient.post(`${BASE_URL}messages/${messageId}/reactions/`, { emoji }).then(data); }
|
|
42
|
+
export function removeReaction(messageId, emoji) { return apiClient.delete(`${BASE_URL}messages/${messageId}/reactions/${encodeURIComponent(emoji)}/`).then(data); }
|
|
43
|
+
export function listThread(rootId, { cursor } = {}) { return apiClient.get(`${BASE_URL}messages/${rootId}/thread/`, requestConfig({ cursor })).then(data); }
|
|
44
|
+
export function markThreadRead(rootId, readAt) { return apiClient.post(`${BASE_URL}messages/${rootId}/thread/read/`, readAt ? { read_at: readAt } : {}).then(data); }
|
|
45
|
+
export function markConversationRead(conversationId, readAt) { return apiClient.post(`${conversationsUrl}${conversationId}/read/`, readAt ? { read_at: readAt } : {}).then(data); }
|
|
46
|
+
export function getReadStatus(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/read-status/`).then(data); }
|
|
47
|
+
export function votePoll(pollId, optionIds) { return apiClient.post(`${BASE_URL}polls/${pollId}/vote/`, { option_ids: optionIds }).then(data); }
|
|
48
|
+
export function closePoll(pollId) { return apiClient.post(`${BASE_URL}polls/${pollId}/close/`).then(data); }
|
|
49
|
+
export function getAttachment(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/`, { responseType: 'blob' }).then(data); }
|
|
50
|
+
export function getAttachmentThumbnail(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/thumbnail/`, { responseType: 'blob' }).then(data); }
|
|
51
|
+
export function getUnreadCount() { return apiClient.get(`${BASE_URL}unread-count/`).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
|
+
}
|
|
@@ -66,7 +66,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
66
66
|
// `replaceNotifications(...)` is unconditional and would silently drop a message that
|
|
67
67
|
// arrived and patched state before the initial REST response landed.
|
|
68
68
|
const [seeded, setSeeded] = useState(false);
|
|
69
|
-
const { subscribe } = useRealtimeCore({
|
|
69
|
+
const { subscribe, onReconnect } = useRealtimeCore({
|
|
70
70
|
active: authenticated && seeded,
|
|
71
71
|
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
72
72
|
});
|
|
@@ -159,7 +159,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
159
159
|
const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
|
|
160
160
|
const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
|
|
161
161
|
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
162
|
-
return (_jsx(RealtimeContext.Provider, { value: { subscribe }, children: _jsx(NotificationsContext.Provider, { value: {
|
|
162
|
+
return (_jsx(RealtimeContext.Provider, { value: { subscribe, onReconnect }, children: _jsx(NotificationsContext.Provider, { value: {
|
|
163
163
|
notifications,
|
|
164
164
|
unreadCount,
|
|
165
165
|
markSeen,
|
|
@@ -27,6 +27,7 @@ export { RealtimeContext };
|
|
|
27
27
|
export function useRealtimeCore({ active, wsUrl }) {
|
|
28
28
|
const socketRef = useRef(null);
|
|
29
29
|
const subscribersRef = useRef(new Map());
|
|
30
|
+
const reconnectSubscribersRef = useRef(new Set());
|
|
30
31
|
const subscribe = useCallback((envelope, handler) => {
|
|
31
32
|
const subscribers = subscribersRef.current;
|
|
32
33
|
if (!subscribers.has(envelope)) {
|
|
@@ -43,12 +44,20 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
}, []);
|
|
47
|
+
// Additive lifecycle signal for domains whose REST projections must be
|
|
48
|
+
// refreshed after the one shared transport reconnects. It deliberately does
|
|
49
|
+
// not alter the socket/backoff ownership or the envelope subscriber API.
|
|
50
|
+
const onReconnect = useCallback((handler) => {
|
|
51
|
+
reconnectSubscribersRef.current.add(handler);
|
|
52
|
+
return () => reconnectSubscribersRef.current.delete(handler);
|
|
53
|
+
}, []);
|
|
46
54
|
useEffect(() => {
|
|
47
55
|
if (!active)
|
|
48
56
|
return undefined;
|
|
49
57
|
let isActive = true;
|
|
50
58
|
let reconnectTimer = null;
|
|
51
59
|
let backoffMs = INITIAL_BACKOFF_MS;
|
|
60
|
+
let hasConnected = false;
|
|
52
61
|
const handleMessage = (event) => {
|
|
53
62
|
var _a;
|
|
54
63
|
let data;
|
|
@@ -78,6 +87,17 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
78
87
|
socketRef.current = socket;
|
|
79
88
|
socket.onopen = () => {
|
|
80
89
|
backoffMs = INITIAL_BACKOFF_MS;
|
|
90
|
+
if (hasConnected) {
|
|
91
|
+
reconnectSubscribersRef.current.forEach((handler) => {
|
|
92
|
+
try {
|
|
93
|
+
handler();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error('Realtime reconnect subscriber error', error);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
hasConnected = true;
|
|
81
101
|
};
|
|
82
102
|
socket.onmessage = handleMessage;
|
|
83
103
|
socket.onerror = () => {
|
|
@@ -106,5 +126,5 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
106
126
|
socket.close();
|
|
107
127
|
};
|
|
108
128
|
}, [active, wsUrl]);
|
|
109
|
-
return { subscribe };
|
|
129
|
+
return { subscribe, onReconnect };
|
|
110
130
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Messaging parity outcomes (MSG-3b)
|
|
2
|
+
|
|
3
|
+
This is the authoritative MSG-3b parity record. It replaces the former
|
|
4
|
+
file-level “final parity confirmation,” which was not sufficient evidence of
|
|
5
|
+
feature parity. Each entry maps directly to the numbered checklist in Part A
|
|
6
|
+
of `work-orders/MSG-3b.md`. `OK` means the capability is present; `DEV` means
|
|
7
|
+
an intentional, documented redesign or non-parity; `BLOCKED` means dcm 2.36.1
|
|
8
|
+
does not expose the data or realtime contract needed to implement it truthfully.
|
|
9
|
+
|
|
10
|
+
## Unread lifecycle
|
|
11
|
+
|
|
12
|
+
1. **OK** — Selecting a conversation calls the existing mark-read endpoint and clears its local unread count.
|
|
13
|
+
2. **OK** — Opening a `Thread` also marks its conversation read; the duplicate call is idempotent.
|
|
14
|
+
3. **OK** — Marking a conversation read decrements the global count by that conversation’s prior unread value, clamped at zero.
|
|
15
|
+
4. **OK** — Expanding a reply thread calls the existing thread-read endpoint for its root message.
|
|
16
|
+
5. **OK** — Realtime incoming messages increment unread only outside the active conversation and never double-count optimistic-send reconciliation.
|
|
17
|
+
|
|
18
|
+
## Message actions
|
|
19
|
+
|
|
20
|
+
6. **DEV** — Desktop hover and right-click open the MUI action menu. jg’s dedicated mobile long-press gesture is not reproduced; the same actions remain available through the regular MUI affordance.
|
|
21
|
+
7. **OK** — Authors can edit their own messages inline. Moderator edit affordance is host-supplied because dcm exposes no per-viewer capability field; server authorization remains authoritative.
|
|
22
|
+
8. **OK** — An active editor closes when the message is deleted by another update.
|
|
23
|
+
9. **OK** — Authors and host-designated moderators can delete; the host boolean is UX gating only and dcm enforces the actual permission.
|
|
24
|
+
10. **OK** — Deletion is confirmed in a MUI dialog before the request is sent.
|
|
25
|
+
11. **OK** — A deleted reply/edit target clears dependent composer or editor state.
|
|
26
|
+
12. **OK** — Message text can be copied through the action menu.
|
|
27
|
+
13. **OK** — Reply targeting is present.
|
|
28
|
+
14. **DEV** — Reactions use five compact presets rather than jg’s six-preset presentation.
|
|
29
|
+
15. **DEV** — No full third-party emoji picker is added; the compact preset menu keeps the package dependency-free.
|
|
30
|
+
16. **OK** — Deleted and poll messages do not render the generic action menu.
|
|
31
|
+
|
|
32
|
+
## Direct conversations
|
|
33
|
+
|
|
34
|
+
17. **OK** — `DirectMessageLauncher` starts a direct conversation.
|
|
35
|
+
18. **OK** — The launcher displays the host-supplied candidate list and selection state.
|
|
36
|
+
19. **OK** — It has distinct empty and in-flight creation states; candidate discovery remains host-owned because dcm has no directory endpoint.
|
|
37
|
+
20. **OK** — Start is disabled until a candidate is selected and during creation.
|
|
38
|
+
21. **OK** — Policy rejections render an API-derived readable error in the dialog.
|
|
39
|
+
22. **OK** — The picker uses a fullscreen MUI dialog at the small breakpoint.
|
|
40
|
+
|
|
41
|
+
## Threading and quoting
|
|
42
|
+
|
|
43
|
+
23. **OK** — Both dcm’s `reply_to_id` and optimistic rows’ `reply_to` are read, so REST and realtime replies group under their root.
|
|
44
|
+
24. **OK** — Replies show a sender and short quoted snippet.
|
|
45
|
+
25. **OK** — Clicking a quote scrolls its source message into view.
|
|
46
|
+
26. **OK** — Quotes of deleted messages render the deleted placeholder rather than stale content.
|
|
47
|
+
27. **BLOCKED** — dcm writes a thread receipt but exposes no prior thread receipt state, unread flag, or unread count to read. A truthful cross-device unread-reply dot cannot be rendered without a dcm contract addition.
|
|
48
|
+
28. **OK** — Reply threads load lazily on expand and collapse locally.
|
|
49
|
+
29. **DEV** — Reply composition is supplied through the independently mountable `Composer` target prop rather than jg’s per-thread embedded composer.
|
|
50
|
+
|
|
51
|
+
## Composer
|
|
52
|
+
|
|
53
|
+
30. **OK** — Enter sends and Shift+Enter preserves a newline.
|
|
54
|
+
31. **OK** — Staged files show image thumbnails or file labels, with independent removal.
|
|
55
|
+
32. **OK** — Attachment uploads show real byte-percentage progress when the browser reports an upload total; otherwise an indeterminate indicator is shown while the upload is in flight.
|
|
56
|
+
33. **OK** — Images over 2 MB are compressed client-side to a maximum 2560 px JPEG at quality 0.85, with silent original-file fallback.
|
|
57
|
+
34. **DEV** — Composer emoji insertion uses the existing compact preset set instead of jg’s full picker, while preserving insertion at the cursor.
|
|
58
|
+
35. **OK** — Sending is optimistic and exposes retry plus inline error state.
|
|
59
|
+
36. **OK** — The poll button is disabled while an image is staged as a UI convenience matching jg’s composer behavior; no dcm-side constraint requires it. Poll creation and attachment sending are separate API calls and separate message rows.
|
|
60
|
+
37. **DEV** — Drafts are deliberately reset on conversation changes. jg’s cross-conversation draft leak is not reproduced.
|
|
61
|
+
|
|
62
|
+
## Conversation list
|
|
63
|
+
|
|
64
|
+
38. **BLOCKED** — dcm does not serialize `last_message`, so a last-message preview cannot be made correct against the real backend.
|
|
65
|
+
39. **OK** — `last_message_at` renders through browser-native, locale-aware `Intl.RelativeTimeFormat` labels.
|
|
66
|
+
40. **OK** — The existing MUI `selected={conversation.id === activeConversationId}` wiring produces the `Mui-selected` active-row highlight; it was verified and needs no redundant styling.
|
|
67
|
+
41. **OK** — Unread badges and bold unread titles are present.
|
|
68
|
+
42. **BLOCKED** — dcm stores the managed all/team distinction in `external_key` but omits it from conversation serialization. The UI must not guess from titles or other unrelated fields.
|
|
69
|
+
43. **DEV** — Generic MUI kind icons replace jg’s richer per-kind/person avatar treatment while retaining type identification.
|
|
70
|
+
44. **OK** — Mute and archive actions are present.
|
|
71
|
+
|
|
72
|
+
## Timeline
|
|
73
|
+
|
|
74
|
+
45. **OK** — New active-conversation messages auto-scroll when the reader was already near the bottom. Scrolled-up readers are not pulled away from history; this near-bottom gate is an intentional usability refinement.
|
|
75
|
+
46. **OK** — Announcement messages render their deep-link action only when a host supplies `onAnnouncementLink`; routing remains a host-app responsibility.
|
|
76
|
+
47. **OK** — Sender names remain visible for group and managed conversations and are suppressed in direct conversations. No sender-side bubble alignment scheme was added because it is not required for this capability.
|
|
77
|
+
48. **OK** — Deleted tombstones, edited marker, and timestamps are rendered.
|
|
78
|
+
|
|
79
|
+
## Read receipts
|
|
80
|
+
|
|
81
|
+
49. **OK** — Read ticks mount only for the current user’s own persisted messages.
|
|
82
|
+
50. **OK** — Non-direct recipient detail supplied by dcm is shown through a click/keyboard-openable popover list rather than hover-only tooltip. Direct conversations always suppress the detail control; dcm’s recipient-detail payload remains the moderator authorization boundary because no generic client moderator field exists.
|
|
83
|
+
|
|
84
|
+
## Polls
|
|
85
|
+
|
|
86
|
+
51. **BLOCKED** — dcm has no poll read serialization for question, option, or count data; client-side fabrication would hide the backend contract gap.
|
|
87
|
+
52. **BLOCKED** — Per-option voter names are likewise absent from dcm’s readable poll contract.
|
|
88
|
+
53. **BLOCKED** — Closed state and disabled-voting rendering depend on that absent poll read contract.
|
|
89
|
+
54. **OK** — Composer starts at two options and disables adding options once it reaches ten; the server’s two-option minimum remains enforced.
|
|
90
|
+
55. **OK** — Vote and close write paths are wired; their full read rendering remains subject to rows 51–53.
|
|
91
|
+
|
|
92
|
+
## Live synchronization
|
|
93
|
+
|
|
94
|
+
56. **BLOCKED** — Reaction frame handling remains intentionally ready, but dcm 2.36.1 does not emit `reaction` frames.
|
|
95
|
+
57. **BLOCKED** — Poll-update frame handling remains intentionally ready, but dcm 2.36.1 does not emit `poll_updated` frames.
|
|
96
|
+
58. **BLOCKED** — Conversation reorder and preview refresh require dcm’s missing `last_message` projection.
|
|
97
|
+
59. **OK** — dcm’s emitted message, message-edited, and message-deleted frames are handled live.
|
|
98
|
+
|
|
99
|
+
## Required backend follow-up
|
|
100
|
+
|
|
101
|
+
Rows 27, 38, 42, 51–53, and 56–58 require a future dcm work order: expose readable thread receipt state, serialize `external_key` where managed all/team labeling is required, define and serialize the poll read contract, add `last_message`, and emit the designed realtime frames. Until then, these are explicit BLOCKED outcomes rather than simulated client behavior.
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>ui-core-micha development harness</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/dev/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -24,16 +24,19 @@
|
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"dev": "vite",
|
|
27
28
|
"test": "vitest run"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@mui/icons-material": "^7.3.11",
|
|
31
32
|
"@mui/x-charts": "^8.0.0",
|
|
32
33
|
"@testing-library/react": "^16.3.2",
|
|
34
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
33
35
|
"i18next": "^26.2.0",
|
|
34
36
|
"jsdom": "^29.1.1",
|
|
35
37
|
"react-i18next": "^17.0.8",
|
|
36
38
|
"typescript": "^6.0.3",
|
|
39
|
+
"vite": "^8.2.0",
|
|
37
40
|
"vitest": "^4.1.10"
|
|
38
41
|
}
|
|
39
42
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const messagingTranslations = {
|
|
2
|
+
'MessagingComposer.ADD_EMOJI': { de: 'Emoji hinzufügen', en: 'Add emoji', fr: 'Ajouter un emoji' },
|
|
3
|
+
'MessagingComposer.STAGED_FILES': { de: 'Vorgemerkte Anhänge', en: 'Staged attachments', fr: 'Pièces jointes préparées' },
|
|
4
|
+
'MessagingComposer.REMOVE_ATTACHMENT': { de: '{{name}} entfernen', en: 'Remove {{name}}', fr: 'Supprimer {{name}}' },
|
|
5
|
+
'MessagingComposer.UPLOADING': { de: 'Anhänge werden hochgeladen…', en: 'Uploading attachments…', fr: 'Téléversement des pièces jointes…' },
|
|
6
|
+
'MessagingList.LABEL': { de: 'Unterhaltungen', en: 'Conversations', fr: 'Conversations' },
|
|
7
|
+
'MessagingList.EMPTY': { de: 'Noch keine Unterhaltungen.', en: 'No conversations yet.', fr: 'Aucune conversation pour le moment.' },
|
|
8
|
+
'MessagingList.NO_MESSAGES': { de: 'Noch keine Nachrichten', en: 'No messages yet', fr: 'Aucun message pour le moment' },
|
|
9
|
+
'MessagingList.UNTITLED': { de: 'Unbenannte Unterhaltung', en: 'Untitled conversation', fr: 'Conversation sans titre' },
|
|
10
|
+
'MessagingList.ACTIONS': { de: 'Aktionen für Unterhaltung', en: 'Conversation actions', fr: 'Actions de la conversation' },
|
|
11
|
+
'MessagingList.LOAD_MORE': { de: 'Mehr laden', en: 'Load more', fr: 'Charger plus' },
|
|
12
|
+
'MessagingList.LOAD_ERROR': { de: 'Unterhaltungen konnten nicht geladen werden.', en: 'Conversations could not be loaded.', fr: 'Impossible de charger les conversations.' },
|
|
13
|
+
'MessagingList.ARCHIVE': { de: 'Archivieren', en: 'Archive', fr: 'Archiver' },
|
|
14
|
+
'MessagingList.ARCHIVE_ERROR': { de: 'Unterhaltung konnte nicht archiviert werden.', en: 'Conversation could not be archived.', fr: 'Impossible d’archiver la conversation.' },
|
|
15
|
+
'MessagingList.MUTE': { de: 'Stummschalten', en: 'Mute', fr: 'Mettre en sourdine' },
|
|
16
|
+
'MessagingList.UNMUTE': { de: 'Stummschaltung aufheben', en: 'Unmute', fr: 'Réactiver le son' },
|
|
17
|
+
'MessagingList.PREFERENCES_ERROR': { de: 'Einstellungen konnten nicht gespeichert werden.', en: 'Preferences could not be saved.', fr: 'Impossible d’enregistrer les préférences.' },
|
|
18
|
+
'MessagingLaunchers.OPEN_GROUP': { de: 'Gruppenunterhaltung öffnen', en: 'Open group conversation', fr: 'Ouvrir la conversation de groupe' },
|
|
19
|
+
'MessagingLaunchers.OPEN_BROADCAST': { de: 'Ankündigungen öffnen', en: 'Open announcements', fr: 'Ouvrir les annonces' },
|
|
20
|
+
'MessagingLaunchers.OPEN_ERROR': { de: 'Unterhaltung konnte nicht geöffnet werden.', en: 'Conversation could not be opened.', fr: 'Impossible d’ouvrir la conversation.' },
|
|
21
|
+
'MessagingThread.TIMELINE': { de: 'Nachrichtenverlauf', en: 'Message timeline', fr: 'Chronologie des messages' },
|
|
22
|
+
'MessagingThread.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
23
|
+
'MessagingThread.EMPTY': { de: 'Noch keine Nachrichten.', en: 'No messages yet.', fr: 'Aucun message pour le moment.' },
|
|
24
|
+
'MessagingThread.LOAD_OLDER': { de: 'Ältere Nachrichten laden', en: 'Load older messages', fr: 'Charger les messages plus anciens' },
|
|
25
|
+
'MessagingThread.LOAD_ERROR': { de: 'Nachrichten konnten nicht geladen werden.', en: 'Messages could not be loaded.', fr: 'Impossible de charger les messages.' },
|
|
26
|
+
'MessagingThread.REPLIES_ERROR': { de: 'Antworten konnten nicht geladen werden.', en: 'Replies could not be loaded.', fr: 'Impossible de charger les réponses.' },
|
|
27
|
+
'MessagingThread.DELETED': { de: 'Diese Nachricht wurde gelöscht.', en: 'This message was deleted.', fr: 'Ce message a été supprimé.' },
|
|
28
|
+
'MessagingThread.EDITED': { de: 'bearbeitet', en: 'edited', fr: 'modifié' },
|
|
29
|
+
'MessagingThread.REPLY': { de: 'Antworten', en: 'Reply', fr: 'Répondre' },
|
|
30
|
+
'MessagingThread.REPLY_TO': { de: 'Antwort auf {{sender}}', en: 'Reply to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
31
|
+
'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
32
|
+
'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
|
|
33
|
+
'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
|
|
34
|
+
'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
|
|
35
|
+
'MessagingThread.QUOTE_ATTACHMENT': { de: 'Anhang', en: 'Attachment', fr: 'Pièce jointe' },
|
|
36
|
+
'MessagingActions.MENU': { de: 'Nachrichtenaktionen', en: 'Message actions', fr: 'Actions du message' },
|
|
37
|
+
'MessagingActions.EDIT': { de: 'Bearbeiten', en: 'Edit', fr: 'Modifier' },
|
|
38
|
+
'MessagingActions.SAVE': { de: 'Speichern', en: 'Save', fr: 'Enregistrer' },
|
|
39
|
+
'MessagingActions.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
40
|
+
'MessagingActions.DELETE': { de: 'Löschen', en: 'Delete', fr: 'Supprimer' },
|
|
41
|
+
'MessagingActions.COPY': { de: 'Kopieren', en: 'Copy', fr: 'Copier' },
|
|
42
|
+
'MessagingActions.DELETE_CONFIRM_TITLE': { de: 'Nachricht löschen?', en: 'Delete message?', fr: 'Supprimer le message ?' },
|
|
43
|
+
'MessagingActions.DELETE_CONFIRM_BODY': { de: 'Diese Nachricht wird für alle entfernt.', en: 'This message will be removed for everyone.', fr: 'Ce message sera supprimé pour tout le monde.' },
|
|
44
|
+
'MessagingActions.EDIT_ERROR': { de: 'Nachricht konnte nicht bearbeitet werden: {{message}}', en: 'Message could not be edited: {{message}}', fr: 'Impossible de modifier le message : {{message}}' },
|
|
45
|
+
'MessagingActions.DELETE_ERROR': { de: 'Nachricht konnte nicht gelöscht werden: {{message}}', en: 'Message could not be deleted: {{message}}', fr: 'Impossible de supprimer le message : {{message}}' },
|
|
46
|
+
'MessagingActions.COPY_ERROR': { de: 'Nachricht konnte nicht kopiert werden: {{message}}', en: 'Message could not be copied: {{message}}', fr: 'Impossible de copier le message : {{message}}' },
|
|
47
|
+
'MessagingDirect.LAUNCH': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
|
|
48
|
+
'MessagingDirect.TITLE': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct' },
|
|
49
|
+
'MessagingDirect.CANDIDATES': { de: 'Empfängerinnen und Empfänger', en: 'Recipients', fr: 'Destinataires' },
|
|
50
|
+
'MessagingDirect.EMPTY': { de: 'Keine Personen verfügbar.', en: 'No people are available.', fr: 'Aucune personne n’est disponible.' },
|
|
51
|
+
'MessagingDirect.START': { de: 'Starten', en: 'Start', fr: 'Démarrer' },
|
|
52
|
+
'MessagingDirect.STARTING': { de: 'Direktnachricht wird gestartet…', en: 'Starting direct message…', fr: 'Démarrage du message direct…' },
|
|
53
|
+
'MessagingDirect.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
54
|
+
'MessagingDirect.START_ERROR': { de: 'Direktnachricht konnte nicht gestartet werden.', en: 'Direct message could not be started.', fr: 'Impossible de démarrer le message direct.' },
|
|
55
|
+
'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
|
|
56
|
+
'MessagingReadTicks.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
|
|
57
|
+
'MessagingReadTicks.RECIPIENTS': { de: 'Lesebestätigungen', en: 'Read recipients', fr: 'Destinataires lus' },
|
|
58
|
+
'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
|
|
59
|
+
'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
|
|
60
|
+
'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
|
|
61
|
+
'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
|
|
62
|
+
'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
|
|
63
|
+
'MessagingComposer.FILES_SELECTED': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
|
|
64
|
+
'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
|
|
65
|
+
'MessagingComposer.SEND_ERROR': { de: 'Nachricht konnte nicht gesendet werden: {{message}}', en: 'Message could not be sent: {{message}}', fr: 'Impossible d’envoyer le message : {{message}}' },
|
|
66
|
+
'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
|
|
67
|
+
'MessagingAttachments.LABEL': { de: 'Anhänge', en: 'Attachments', fr: 'Pièces jointes' },
|
|
68
|
+
'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
|
|
69
|
+
'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
|
|
70
|
+
'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
|
|
71
|
+
'MessagingReactions.TOGGLE': { de: '{{emoji}} Reaktion mit {{count}} Stimmen umschalten', en: 'Toggle {{emoji}} reaction with {{count}} votes', fr: 'Basculer la réaction {{emoji}} avec {{count}} votes' },
|
|
72
|
+
'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
|
|
73
|
+
'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
|
|
74
|
+
'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
|
|
75
|
+
'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
|
|
76
|
+
'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
|
|
77
|
+
'MessagingPoll.SELECT_OPTION': { de: 'Wähle mindestens eine Option.', en: 'Select at least one option.', fr: 'Sélectionnez au moins une option.' },
|
|
78
|
+
'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
|
|
79
|
+
'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
|
|
80
|
+
'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
|
|
81
|
+
'MessagingPoll.CREATE_ERROR': { de: 'Umfrage konnte nicht erstellt werden: {{message}}', en: 'Poll could not be created: {{message}}', fr: 'Impossible de créer le sondage : {{message}}' },
|
|
82
|
+
'MessagingPoll.CREATE_VALIDATION': { de: 'Frage und mindestens zwei Optionen sind erforderlich.', en: 'A question and at least two options are required.', fr: 'Une question et au moins deux options sont requises.' },
|
|
83
|
+
'MessagingPoll.QUESTION': { de: 'Frage', en: 'Question', fr: 'Question' },
|
|
84
|
+
'MessagingPoll.OPTION': { de: 'Option {{count}}', en: 'Option {{count}}', fr: 'Option {{count}}' },
|
|
85
|
+
'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
|
|
86
|
+
'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
|
|
87
|
+
'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
88
|
+
'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
89
|
+
'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
|
|
90
|
+
'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
|
|
91
|
+
'MessagingConfig.LOAD_ERROR': { de: 'Einstellungen konnten nicht geladen werden: {{message}}', en: 'Settings could not be loaded: {{message}}', fr: 'Impossible de charger les paramètres : {{message}}' },
|
|
92
|
+
'MessagingConfig.SAVE_ERROR': { de: 'Einstellungen konnten nicht gespeichert werden: {{message}}', en: 'Settings could not be saved: {{message}}', fr: 'Impossible d’enregistrer les paramètres : {{message}}' },
|
|
93
|
+
'MessagingConfig.DM_POLICY': { de: 'Direktnachrichten-Regel', en: 'Direct-message policy', fr: 'Règle des messages directs' },
|
|
94
|
+
'MessagingConfig.DM_ALL': { de: 'Alle', en: 'Everyone', fr: 'Tout le monde' },
|
|
95
|
+
'MessagingConfig.DM_SHARED': { de: 'Nur gemeinsame Gruppen', en: 'Shared groups only', fr: 'Groupes communs uniquement' },
|
|
96
|
+
'MessagingConfig.DM_TEAM': { de: 'Nur Team', en: 'Team only', fr: 'Équipe uniquement' },
|
|
97
|
+
'MessagingConfig.GROUP_ENABLED': { de: 'Gruppenchat aktivieren', en: 'Enable group chat', fr: 'Activer le chat de groupe' },
|
|
98
|
+
'MessagingConfig.EVERYONE_POSTS': { de: 'Alle dürfen schreiben', en: 'Everyone can post', fr: 'Tout le monde peut publier' },
|
|
99
|
+
'MessagingAnnouncement.CREATE': { de: 'Ankündigung erstellen', en: 'Create announcement', fr: 'Créer une annonce' },
|
|
100
|
+
'MessagingAnnouncement.TITLE': { de: 'Titel', en: 'Title', fr: 'Titre' },
|
|
101
|
+
'MessagingAnnouncement.BODY': { de: 'Text', en: 'Body', fr: 'Texte' },
|
|
102
|
+
'MessagingAnnouncement.SEND': { de: 'Ankündigung senden', en: 'Send announcement', fr: 'Envoyer l’annonce' },
|
|
103
|
+
'MessagingAnnouncement.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
|
|
104
|
+
'MessagingAnnouncement.VALIDATION': { de: 'Ein Titel ist erforderlich.', en: 'A title is required.', fr: 'Un titre est requis.' },
|
|
105
|
+
'MessagingAnnouncement.ERROR': { de: 'Ankündigung konnte nicht gesendet werden: {{message}}', en: 'Announcement could not be sent: {{message}}', fr: 'Impossible d’envoyer l’annonce : {{message}}' },
|
|
106
|
+
'MessagingAnnouncement.OPEN_LINK': { de: 'Link öffnen', en: 'Open link', fr: 'Ouvrir le lien' },
|
|
107
|
+
};
|
package/src/index.js
CHANGED
|
@@ -64,6 +64,21 @@ export { NotificationBell } from './notifications/NotificationBell';
|
|
|
64
64
|
export { PopupSurface } from './notifications/PopupSurface';
|
|
65
65
|
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
66
66
|
|
|
67
|
+
// --- 9. Messaging ---
|
|
68
|
+
export * from './messaging/api';
|
|
69
|
+
export { MessagingProvider, useMessaging } from './messaging/MessagingProvider';
|
|
70
|
+
export { ConversationList } from './messaging/ConversationList';
|
|
71
|
+
export { ConversationLaunchers } from './messaging/ConversationLaunchers';
|
|
72
|
+
export { DirectMessageLauncher } from './messaging/DirectMessageLauncher';
|
|
73
|
+
export { Thread } from './messaging/Thread';
|
|
74
|
+
export { MessageBubble } from './messaging/MessageBubble';
|
|
75
|
+
export { ReadTicks } from './messaging/ReadTicks';
|
|
76
|
+
export { Composer } from './messaging/Composer';
|
|
77
|
+
export { AttachmentList } from './messaging/AttachmentList';
|
|
78
|
+
export { ReactionBar } from './messaging/ReactionBar';
|
|
79
|
+
export { PollCard } from './messaging/PollCard';
|
|
80
|
+
export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
|
|
81
|
+
|
|
67
82
|
// --- 9. Onboarding ---
|
|
68
83
|
export * from './onboarding/api';
|
|
69
84
|
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
@@ -83,3 +98,4 @@ export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
|
|
|
83
98
|
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
84
99
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
85
100
|
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
101
|
+
export { messagingTranslations } from './i18n/messagingTranslations';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Box, Button, Stack } from '@mui/material';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
|
|
5
|
+
import { useMessaging } from './MessagingProvider';
|
|
6
|
+
|
|
7
|
+
function isImage(attachment) { return attachment.content_type?.startsWith('image/'); }
|
|
8
|
+
function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
|
|
9
|
+
|
|
10
|
+
/** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
|
|
11
|
+
export function AttachmentList({ attachments = [] }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const { getAttachment, getAttachmentThumbnail } = useMessaging();
|
|
14
|
+
const [previews, setPreviews] = useState({});
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true; const urls = [];
|
|
17
|
+
attachments.filter(isImage).forEach(async (attachment) => {
|
|
18
|
+
try {
|
|
19
|
+
const blob = await getAttachmentThumbnail(attachment.id);
|
|
20
|
+
const url = blob instanceof Blob ? URL.createObjectURL(blob) : blob?.url;
|
|
21
|
+
if (url && live) { urls.push(url); setPreviews((current) => ({ ...current, [attachment.id]: url })); }
|
|
22
|
+
} catch { /* The image remains available through the download action. */ }
|
|
23
|
+
});
|
|
24
|
+
return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
|
|
25
|
+
}, [attachments, getAttachmentThumbnail]);
|
|
26
|
+
if (!attachments.length) return null;
|
|
27
|
+
const download = async (attachment) => {
|
|
28
|
+
const blob = await getAttachment(attachment.id);
|
|
29
|
+
if (!(blob instanceof Blob)) return;
|
|
30
|
+
const url = URL.createObjectURL(blob); const link = document.createElement('a');
|
|
31
|
+
link.href = url; link.download = nameOf(attachment); link.click(); URL.revokeObjectURL(url);
|
|
32
|
+
};
|
|
33
|
+
return <Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
|
|
34
|
+
{attachments.map((attachment) => <Button key={attachment.id} type="button" size="small" onClick={() => download(attachment)}>
|
|
35
|
+
{previews[attachment.id] && <Box component="img" src={previews[attachment.id]} alt="" width={36} height={36} sx={{ objectFit: 'cover', mr: 0.75 }} />}{t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })}
|
|
36
|
+
</Button>)}
|
|
37
|
+
</Stack>;
|
|
38
|
+
}
|