@micha.bigler/ui-core-micha 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +5 -1
  3. package/dev/entries.jsx +147 -0
  4. package/dev/fixtures.js +26 -0
  5. package/dev/main.jsx +56 -0
  6. package/dev/mockTransport.jsx +128 -0
  7. package/dist/i18n/messagingTranslations.js +81 -0
  8. package/dist/index.js +14 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +120 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +75 -0
  13. package/dist/messaging/MessageBubble.js +17 -0
  14. package/dist/messaging/MessagingProvider.js +317 -0
  15. package/dist/messaging/MessagingScopeConfig.js +28 -0
  16. package/dist/messaging/PollCard.js +53 -0
  17. package/dist/messaging/ReactionBar.js +44 -0
  18. package/dist/messaging/ReadTicks.js +25 -0
  19. package/dist/messaging/Thread.js +73 -0
  20. package/dist/messaging/api.js +51 -0
  21. package/dist/notifications/NotificationsProvider.js +2 -2
  22. package/dist/notifications/realtime.js +21 -1
  23. package/docs/messaging-deviations.md +36 -0
  24. package/index.html +12 -0
  25. package/package.json +4 -1
  26. package/src/i18n/messagingTranslations.ts +81 -0
  27. package/src/index.js +15 -0
  28. package/src/messaging/AttachmentList.jsx +38 -0
  29. package/src/messaging/Composer.jsx +110 -0
  30. package/src/messaging/ConversationLaunchers.jsx +51 -0
  31. package/src/messaging/ConversationList.jsx +78 -0
  32. package/src/messaging/MessageBubble.jsx +37 -0
  33. package/src/messaging/MessagingProvider.jsx +323 -0
  34. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  35. package/src/messaging/PollCard.jsx +36 -0
  36. package/src/messaging/ReactionBar.jsx +46 -0
  37. package/src/messaging/ReadTicks.jsx +23 -0
  38. package/src/messaging/Thread.jsx +71 -0
  39. package/src/messaging/api.js +47 -0
  40. package/src/notifications/NotificationsProvider.jsx +2 -2
  41. package/src/notifications/realtime.jsx +21 -1
  42. package/tests/ConversationList.test.jsx +66 -0
  43. package/tests/MessagingProvider.test.jsx +50 -0
  44. package/tests/Thread.test.jsx +100 -0
  45. package/tests/messagingApi.test.js +32 -0
  46. package/tests/messagingComposer.test.jsx +111 -0
  47. package/tests/messagingExports.test.js +20 -0
  48. package/tests/messagingInteractions.test.jsx +112 -0
  49. package/tests/realtime.test.jsx +27 -0
  50. package/vite.config.mjs +6 -0
  51. package/work-orders/DX-1.md +166 -0
  52. package/work-orders/MSG-3.md +375 -0
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
6
+ /** Independently mountable live poll view. Server authorization remains authoritative. */
7
+ export function PollCard({ message, canClose: canCloseProp }) {
8
+ var _a, _b, _c, _d, _e, _f;
9
+ const { t } = useTranslation();
10
+ const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
11
+ const poll = message === null || message === void 0 ? void 0 : message.poll;
12
+ const [selected, setSelected] = useState([]);
13
+ const [error, setError] = useState(null);
14
+ const [busy, setBusy] = useState(false);
15
+ useEffect(() => setSelected(((poll === null || poll === void 0 ? void 0 : poll.options) || []).filter((option) => option.selected || option.voted).map((option) => option.id)), [poll]);
16
+ if (!poll)
17
+ return null;
18
+ const closed = Boolean(poll.closed_at);
19
+ const creatorId = (_b = (_a = poll.created_by) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : poll.created_by;
20
+ const canClose = (_f = (_d = (_c = canCloseProp !== null && canCloseProp !== void 0 ? canCloseProp : poll.can_close) !== null && _c !== void 0 ? _c : message === null || message === void 0 ? void 0 : message.can_close) !== null && _d !== void 0 ? _d : (_e = message === null || message === void 0 ? void 0 : message.permissions) === null || _e === void 0 ? void 0 : _e.can_close) !== null && _f !== void 0 ? _f : (creatorId != null && creatorId === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id));
21
+ const changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
22
+ const vote = async () => {
23
+ if (!selected.length) {
24
+ setError(t('MessagingPoll.SELECT_OPTION'));
25
+ return;
26
+ }
27
+ setBusy(true);
28
+ setError(null);
29
+ try {
30
+ await castPollVote(message.id, poll, selected);
31
+ }
32
+ catch (voteError) {
33
+ setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
34
+ }
35
+ finally {
36
+ setBusy(false);
37
+ }
38
+ };
39
+ const close = async () => {
40
+ setBusy(true);
41
+ setError(null);
42
+ try {
43
+ await closeConversationPoll(message.id, poll);
44
+ }
45
+ catch (closeError) {
46
+ setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) }));
47
+ }
48
+ finally {
49
+ setBusy(false);
50
+ }
51
+ };
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) => { var _a, _b; return _jsx(FormControlLabel, { disabled: closed || busy, control: _jsx(Checkbox, { checked: selected.includes(option.id), onChange: () => changeSelection(option.id) }), label: `${option.text || option.label} (${(_b = (_a = option.vote_count) !== null && _a !== void 0 ? _a : option.votes_count) !== null && _b !== void 0 ? _b : 0})` }, option.id); }), 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
+ }
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
3
+ import { Alert, Chip, IconButton, Stack } from '@mui/material';
4
+ import { useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
+ const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
8
+ /** Independently mountable aggregate reaction controls for one message. */
9
+ export function ReactionBar({ message }) {
10
+ const { t } = useTranslation();
11
+ const { toggleReaction } = useMessaging();
12
+ const [expanded, setExpanded] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ // Tracks in-flight emoji toggles for this message so a rapid double-click
15
+ // can't fire two concurrent requests for the same emoji — toggleReaction's
16
+ // rollback-on-failure reverts to a pre-optimistic snapshot, which would
17
+ // silently discard a second, already-succeeded change if both were in
18
+ // flight at once. Disabling the chip while its own request is pending
19
+ // removes the race instead of trying to make concurrent toggles safe.
20
+ const [pending, setPending] = useState(() => new Set());
21
+ const reactions = (message === null || message === void 0 ? void 0 : message.reactions) || [];
22
+ const react = async (emoji, active = false) => {
23
+ if (pending.has(emoji))
24
+ return;
25
+ setError(null);
26
+ setPending((current) => new Set(current).add(emoji));
27
+ try {
28
+ await toggleReaction(message.id, emoji, active);
29
+ }
30
+ catch (reactionError) {
31
+ setError(t('MessagingReactions.ERROR', { message: extractApiErrorMessage(reactionError) }));
32
+ }
33
+ finally {
34
+ setPending((current) => { const next = new Set(current); next.delete(emoji); return next; });
35
+ }
36
+ };
37
+ return _jsxs(Stack, { spacing: 0.5, "aria-label": t('MessagingReactions.LABEL'), children: [_jsxs(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", children: [reactions.map((reaction) => _jsx(Chip, { size: "small", disabled: pending.has(reaction.emoji), color: reaction.reacted ? 'primary' : 'default', label: `${reaction.emoji} ${reaction.count || 0}`, onClick: () => react(reaction.emoji, Boolean(reaction.reacted)), "aria-label": t('MessagingReactions.TOGGLE', { emoji: reaction.emoji, count: reaction.count || 0 }) }, reaction.emoji)), _jsx(IconButton, { size: "small", "aria-label": t('MessagingReactions.ADD'), onClick: () => setExpanded((value) => !value), children: _jsx(AddReactionOutlinedIcon, { fontSize: "small" }) })] }), expanded && _jsx(Stack, { direction: "row", spacing: 0.25, flexWrap: "wrap", children: QUICK_EMOJIS.map((emoji) => {
38
+ // A quick-emoji already reflected in the aggregate row must toggle it
39
+ // off, not re-add it — otherwise clicking an already-reacted emoji here
40
+ // optimistically double-increments the count before the API call.
41
+ const existing = reactions.find((reaction) => reaction.emoji === emoji);
42
+ return _jsx(Chip, { size: "small", disabled: pending.has(emoji), color: (existing === null || existing === void 0 ? void 0 : existing.reacted) ? 'primary' : 'default', label: emoji, onClick: () => { react(emoji, Boolean(existing === null || existing === void 0 ? void 0 : existing.reacted)); setExpanded(false); } }, emoji);
43
+ }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error })] });
44
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tooltip, Typography } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useMessaging } from './MessagingProvider';
6
+ /** Renders only the aggregate receipt unless the server explicitly supplies permitted detail. */
7
+ export function ReadTicks({ messageId, conversation }) {
8
+ const { t } = useTranslation();
9
+ const { getMessageReadStatus } = useMessaging();
10
+ const [status, setStatus] = useState(null);
11
+ useEffect(() => {
12
+ let mounted = true;
13
+ getMessageReadStatus(messageId).then((next) => { if (mounted)
14
+ setStatus(next); }).catch(() => { if (mounted)
15
+ setStatus(null); });
16
+ return () => { mounted = false; };
17
+ }, [getMessageReadStatus, messageId]);
18
+ if (!status)
19
+ return null;
20
+ const label = status.all_read ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.DELIVERED', { count: status.delivered_count || 0 });
21
+ // The server never returns DM detail; enforce the same carve-out defensively.
22
+ const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : status.recipient_detail;
23
+ const detailText = detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean).join(', ');
24
+ return _jsx(Tooltip, { title: detailText || label, children: _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", "aria-label": label, children: label }) });
25
+ }
@@ -0,0 +1,73 @@
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, 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
+ // A still-pending optimistic row (chunk 4) has a fake `local-<requestId>` id
11
+ // and no durable server row yet — mounting ReadTicks against it would fire a
12
+ // getMessageReadStatus REST call for an id the server has never heard of.
13
+ // Wait for reconciliation (status !== 'pending') before showing read state.
14
+ function canShowReadTicks(message, user) {
15
+ var _a;
16
+ 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';
17
+ }
18
+ /**
19
+ * A standalone timeline. Compose/reaction/poll/attachment mutations remain
20
+ * separate chunk collaborators.
21
+ *
22
+ * ReadTicks is only mounted for the current user's own messages (matches
23
+ * jg's `shouldShowReadTick(msg, isOwn)`): a read receipt tells the sender
24
+ * whether their own message was seen, not the recipient anything about a
25
+ * message they're currently reading. Gating here also bounds the number of
26
+ * `getMessageReadStatus` REST calls a rendered timeline fires — one per own
27
+ * message shown, not one per message in the conversation.
28
+ */
29
+ export function Thread({ conversationId, onReplyTargetChange }) {
30
+ var _a;
31
+ const { t } = useTranslation();
32
+ const { user } = useContext(AuthContext) || {};
33
+ const { cache, loadMoreMessages, loadThreadReplies } = useMessaging();
34
+ const scrollRef = useRef(null);
35
+ const [loadingOlder, setLoadingOlder] = useState(false);
36
+ const [error, setError] = useState(null);
37
+ const [replyingTo, setReplyingTo] = useState(null);
38
+ const [openThreads, setOpenThreads] = useState({});
39
+ const conversation = cache.conversations[conversationId];
40
+ const roots = useMemo(() => chronological(Object.values(cache.messages).filter((message) => message.conversation_id === conversationId && !message.reply_to)), [cache.messages, conversationId]);
41
+ const loadOlder = useCallback(async () => {
42
+ setLoadingOlder(true);
43
+ setError(null);
44
+ try {
45
+ await loadMoreMessages(conversationId);
46
+ }
47
+ catch (_a) {
48
+ setError(t('MessagingThread.LOAD_ERROR'));
49
+ }
50
+ finally {
51
+ setLoadingOlder(false);
52
+ }
53
+ }, [conversationId, loadMoreMessages, t]);
54
+ const onScroll = () => { var _a; if (((_a = scrollRef.current) === null || _a === void 0 ? void 0 : _a.scrollTop) === 0 && cache.cursors.messages[conversationId] && !loadingOlder)
55
+ loadOlder(); };
56
+ const reply = (message) => { setReplyingTo(message); onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(message); };
57
+ const toggleReplies = async (root) => {
58
+ if (!openThreads[root.id]) {
59
+ try {
60
+ await loadThreadReplies(root.id);
61
+ }
62
+ catch (_a) {
63
+ setError(t('MessagingThread.REPLIES_ERROR'));
64
+ return;
65
+ }
66
+ }
67
+ setOpenThreads((current) => (Object.assign(Object.assign({}, current), { [root.id]: !current[root.id] })));
68
+ };
69
+ 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.reply_to === message.id));
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);
72
+ }) })] })] }));
73
+ }
@@ -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 } = {}) {
19
+ return Object.assign(Object.assign(Object.assign({}, (params ? { params } : {})), (cursor ? { params: Object.assign(Object.assign({}, (params || {})), { cursor }) } : {})), (headers ? { headers } : {}));
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 } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).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); }
@@ -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,36 @@
1
+ # Messaging deviations from jg
2
+
3
+ ## Chunk 5 — reactions, polls and scope configuration
4
+
5
+ - Reactions use a compact set of common emoji choices plus the aggregate chips, rather than jg's full third-party emoji palette. Adding and toggling every reaction remains available through the same REST mutations; the shared package avoids a new picker dependency.
6
+ - Poll creation is a Composer dialog instead of jg's Thread-coupled dialog. It retains question, two-or-more options and multiple-choice selection, while keeping composition independently mountable.
7
+ - Poll results are rendered inline as an accessible option list with live aggregate counts. Closing is only offered when the serialized creator/capability allows it; a server-side 403 remains visible as a validation error.
8
+ - Scope messaging configuration is an independently mountable component addressed by conversation id, rather than jg's event-id-specific settings panel. The host supplies placement and capability gating; it retains dm policy, group-chat enablement and everyone-can-post controls.
9
+ - **Announcement composing (jg's `AnnouncementDialog.jsx`) was missing from the first chunk-5 pass — flagged by independent review and corrected before commit, not silently accepted as a gap.** `Composer` now offers an announcement mode (title + body + a host-supplied `link_target`) behind an `allowAnnouncement` prop, since whether to offer it at all is a host/capability decision the client cannot make on its own; only the target-link *value* is host-specific navigation context ucm cannot compute (jg pre-fills it from the currently open event-info section — same reasoning as `linkTarget`'s framing here). Uses the same `sendMessage`/`kind` plumbing chunk 1 already built, with `kind: 'announcement'`.
10
+
11
+ ## Final parity confirmation
12
+
13
+ - The provider/cache, conversation list and launchers, timeline/replies/read ticks, composer/attachments/announcements, reactions, polls and scope configuration are all represented by exported ucm surfaces. Host routing and scope selection remain host responsibilities by design.
14
+ - The known broadcast-list pre-open unread/preview gap is retained as the accepted Chunk 2 deviation.
15
+ - Every jg messaging file named in the work order's parity inventory (`Thread.jsx`, `ConversationList.jsx`, `MessagingContext.jsx`, `AnnouncementDialog.jsx`, `MessagingConfig.jsx`, `NewDirectMessageDialog.jsx` — unblocked by MSG-2b, `messagingApi.js`, `EmojiPickerButton.jsx`, `conversationHelpers.js`, `MessagesPage.jsx`'s launcher behavior) is represented by an exported ucm surface or an explicitly named deviation above — this line was corrected during chunk 5's independent review, which is exactly the check it exists to catch.
16
+
17
+ ## Chunk 4 — composer and attachments
18
+
19
+ - The composer is an independently mountable provider collaborator and accepts the timeline's reply target as a small placement prop, instead of being coupled into jg's monolithic `Thread`. It preserves reply selection and clears that selection after a successful send.
20
+ - Attachment previews are compact authenticated thumbnail fetches with explicit download actions rather than jg's gallery/lightbox. Preview and download retain viewer-gated byte access; the licensed interaction redesign avoids a second media-viewer state machine in this shared surface.
21
+
22
+ ## Chunk 3 — timeline, replies and receipts
23
+
24
+ - The timeline is split into `Thread`, `MessageBubble` and `ReadTicks`, with a simple expandable reply view rather than jg's coupled full-screen/thread-pane state. This preserves one-level reply viewing while allowing each surface to mount independently.
25
+ - Reply selection is exposed through `onReplyTargetChange` rather than embedding a temporary composer. The next Composer surface consumes that context, avoiding a second compose state machine.
26
+ - Older history automatically loads when the timeline reaches the top and also provides an explicit accessible button. Both use the server-issued opaque cursor; jg's timeline interaction is redesigned, not removed.
27
+ - This chunk intentionally leaves reactions, polls and attachments as non-interactive count/type chips. Their existing jg capabilities are scheduled for chunks 4/5, rather than reproduced as disposable interaction implementations here.
28
+ - Read ticks reproduce jg's `shouldShowReadTick(msg, isOwn)` semantics exactly: a `ReadTicks` collaborator is only mounted for the current user's own messages, matching jg's meaning of a read receipt (whether the sender's own message was seen) and bounding the number of `getMessageReadStatus` REST calls a rendered timeline fires to one per own message shown, not one per message in the conversation.
29
+
30
+ ## Chunk 2 — conversation list and launchers
31
+
32
+ - The list is a standalone, provider-backed component rather than receiving a conversation array and selection state from a page. This removes the page-owned messaging state machine while preserving selection through the host's `onOpen` placement callback.
33
+ - Launchable group metadata is an explicit host-supplied `groupLaunchers` input, rather than jg's event-specific `listGroupsAvailable` request. The published dcm REST contract has no generic group-discovery endpoint; apps own scope pickers while ucm owns opening and surfacing the conversation.
34
+ - Broadcast opening is exposed as `broadcastLauncher` plus `autoOpenBroadcast`, rather than inspecting jg's `?tab=broadcast` query itself. Routing/query parsing stays host-app scope; auto-*open* is reproduced once the host maps its route to that boolean.
35
+ - **Accepted deviation, not full parity:** jg's `MessagesPage.jsx` unconditionally fetches the broadcast conversation on every event load and pins it, with live unread count and message preview, at the top of the same recency-sorted unified list. ucm instead renders a static launcher button (no unread badge, no preview) until it is opened for the first time, at which point it becomes a normal list row with real unread/preview state and the launcher hides itself. Before that first open, a host relying on the broadcast conversation's unread state being visible without a click does not get it from ucm.
36
+ - The layout and actions are redesigned as a compact MUI list with per-conversation overflow actions and explicit cursor “Load more,” replacing jg's page sidebar composition. Features retained here are unread indication, opening conversations, unopened-group launches, broadcast launch (see above for the surfacing gap), mute and archive. Per-kind row icon (group/broadcast/DM) is reproduced (jg's `getConversationAvatar`), using generic MUI icons rather than jg's per-kind avatar treatment (incl. the DM partner's actual `UserAvatar`) — the identification capability is kept, the specific visual is redesigned.
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.15.0",
3
+ "version": "2.16.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,81 @@
1
+ export const messagingTranslations = {
2
+ 'MessagingList.LABEL': { de: 'Unterhaltungen', en: 'Conversations', fr: 'Conversations' },
3
+ 'MessagingList.EMPTY': { de: 'Noch keine Unterhaltungen.', en: 'No conversations yet.', fr: 'Aucune conversation pour le moment.' },
4
+ 'MessagingList.NO_MESSAGES': { de: 'Noch keine Nachrichten', en: 'No messages yet', fr: 'Aucun message pour le moment' },
5
+ 'MessagingList.UNTITLED': { de: 'Unbenannte Unterhaltung', en: 'Untitled conversation', fr: 'Conversation sans titre' },
6
+ 'MessagingList.ACTIONS': { de: 'Aktionen für Unterhaltung', en: 'Conversation actions', fr: 'Actions de la conversation' },
7
+ 'MessagingList.LOAD_MORE': { de: 'Mehr laden', en: 'Load more', fr: 'Charger plus' },
8
+ 'MessagingList.LOAD_ERROR': { de: 'Unterhaltungen konnten nicht geladen werden.', en: 'Conversations could not be loaded.', fr: 'Impossible de charger les conversations.' },
9
+ 'MessagingList.ARCHIVE': { de: 'Archivieren', en: 'Archive', fr: 'Archiver' },
10
+ 'MessagingList.ARCHIVE_ERROR': { de: 'Unterhaltung konnte nicht archiviert werden.', en: 'Conversation could not be archived.', fr: 'Impossible d’archiver la conversation.' },
11
+ 'MessagingList.MUTE': { de: 'Stummschalten', en: 'Mute', fr: 'Mettre en sourdine' },
12
+ 'MessagingList.UNMUTE': { de: 'Stummschaltung aufheben', en: 'Unmute', fr: 'Réactiver le son' },
13
+ 'MessagingList.PREFERENCES_ERROR': { de: 'Einstellungen konnten nicht gespeichert werden.', en: 'Preferences could not be saved.', fr: 'Impossible d’enregistrer les préférences.' },
14
+ 'MessagingLaunchers.OPEN_GROUP': { de: 'Gruppenunterhaltung öffnen', en: 'Open group conversation', fr: 'Ouvrir la conversation de groupe' },
15
+ 'MessagingLaunchers.OPEN_BROADCAST': { de: 'Ankündigungen öffnen', en: 'Open announcements', fr: 'Ouvrir les annonces' },
16
+ 'MessagingLaunchers.OPEN_ERROR': { de: 'Unterhaltung konnte nicht geöffnet werden.', en: 'Conversation could not be opened.', fr: 'Impossible d’ouvrir la conversation.' },
17
+ 'MessagingThread.TIMELINE': { de: 'Nachrichtenverlauf', en: 'Message timeline', fr: 'Chronologie des messages' },
18
+ 'MessagingThread.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
19
+ 'MessagingThread.EMPTY': { de: 'Noch keine Nachrichten.', en: 'No messages yet.', fr: 'Aucun message pour le moment.' },
20
+ 'MessagingThread.LOAD_OLDER': { de: 'Ältere Nachrichten laden', en: 'Load older messages', fr: 'Charger les messages plus anciens' },
21
+ 'MessagingThread.LOAD_ERROR': { de: 'Nachrichten konnten nicht geladen werden.', en: 'Messages could not be loaded.', fr: 'Impossible de charger les messages.' },
22
+ 'MessagingThread.REPLIES_ERROR': { de: 'Antworten konnten nicht geladen werden.', en: 'Replies could not be loaded.', fr: 'Impossible de charger les réponses.' },
23
+ 'MessagingThread.DELETED': { de: 'Diese Nachricht wurde gelöscht.', en: 'This message was deleted.', fr: 'Ce message a été supprimé.' },
24
+ 'MessagingThread.EDITED': { de: 'bearbeitet', en: 'edited', fr: 'modifié' },
25
+ 'MessagingThread.REPLY': { de: 'Antworten', en: 'Reply', fr: 'Répondre' },
26
+ 'MessagingThread.REPLY_TO': { de: 'Antwort auf {{sender}}', en: 'Reply to {{sender}}', fr: 'Réponse à {{sender}}' },
27
+ 'MessagingThread.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
28
+ 'MessagingThread.SHOW_REPLIES': { de: '{{count}} Antworten anzeigen', en: 'Show {{count}} replies', fr: 'Afficher {{count}} réponses' },
29
+ 'MessagingThread.HIDE_REPLIES': { de: 'Antworten ausblenden', en: 'Hide replies', fr: 'Masquer les réponses' },
30
+ 'MessagingThread.UNKNOWN_SENDER': { de: 'Unbekannte Person', en: 'Unknown person', fr: 'Personne inconnue' },
31
+ 'MessagingReadTicks.ALL_READ': { de: 'Von allen gelesen', en: 'Read by everyone', fr: 'Lu par tout le monde' },
32
+ 'MessagingReadTicks.DELIVERED': { de: 'Zugestellt an {{count}}', en: 'Delivered to {{count}}', fr: 'Distribué à {{count}}' },
33
+ 'MessagingComposer.LABEL': { de: 'Nachricht verfassen', en: 'Compose message', fr: 'Rédiger un message' },
34
+ 'MessagingComposer.MESSAGE': { de: 'Nachricht', en: 'Message', fr: 'Message' },
35
+ 'MessagingComposer.SEND': { de: 'Senden', en: 'Send', fr: 'Envoyer' },
36
+ 'MessagingComposer.RETRY': { de: 'Erneut versuchen', en: 'Retry', fr: 'Réessayer' },
37
+ 'MessagingComposer.ADD_ATTACHMENT': { de: 'Anhang hinzufügen', en: 'Add attachment', fr: 'Ajouter une pièce jointe' },
38
+ 'MessagingComposer.FILES_SELECTED': { de: '{{count}} Anhänge ausgewählt', en: '{{count}} attachments selected', fr: '{{count}} pièces jointes sélectionnées' },
39
+ 'MessagingComposer.REPLYING_TO': { de: 'Antwort an {{sender}}', en: 'Replying to {{sender}}', fr: 'Réponse à {{sender}}' },
40
+ 'MessagingComposer.SEND_ERROR': { de: 'Nachricht konnte nicht gesendet werden: {{message}}', en: 'Message could not be sent: {{message}}', fr: 'Impossible d’envoyer le message : {{message}}' },
41
+ 'MessagingComposer.UPLOAD_ERROR': { de: 'Anhang wurde abgelehnt: {{message}}', en: 'Attachment was rejected: {{message}}', fr: 'La pièce jointe a été rejetée : {{message}}' },
42
+ 'MessagingAttachments.LABEL': { de: 'Anhänge', en: 'Attachments', fr: 'Pièces jointes' },
43
+ 'MessagingAttachments.DOWNLOAD': { de: '{{name}} herunterladen', en: 'Download {{name}}', fr: 'Télécharger {{name}}' },
44
+ 'MessagingReactions.LABEL': { de: 'Reaktionen', en: 'Reactions', fr: 'Réactions' },
45
+ 'MessagingReactions.ADD': { de: 'Reaktion hinzufügen', en: 'Add reaction', fr: 'Ajouter une réaction' },
46
+ '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' },
47
+ 'MessagingReactions.ERROR': { de: 'Reaktion konnte nicht gespeichert werden: {{message}}', en: 'Reaction could not be saved: {{message}}', fr: 'Impossible d’enregistrer la réaction : {{message}}' },
48
+ 'MessagingPoll.LABEL': { de: 'Umfrage', en: 'Poll', fr: 'Sondage' },
49
+ 'MessagingPoll.VOTE': { de: 'Abstimmen', en: 'Vote', fr: 'Voter' },
50
+ 'MessagingPoll.CLOSE': { de: 'Umfrage schliessen', en: 'Close poll', fr: 'Clore le sondage' },
51
+ 'MessagingPoll.CLOSED': { de: 'Geschlossen', en: 'Closed', fr: 'Clos' },
52
+ 'MessagingPoll.SELECT_OPTION': { de: 'Wähle mindestens eine Option.', en: 'Select at least one option.', fr: 'Sélectionnez au moins une option.' },
53
+ 'MessagingPoll.VOTE_ERROR': { de: 'Stimme konnte nicht gespeichert werden: {{message}}', en: 'Vote could not be saved: {{message}}', fr: 'Impossible d’enregistrer le vote : {{message}}' },
54
+ 'MessagingPoll.CLOSE_ERROR': { de: 'Umfrage konnte nicht geschlossen werden: {{message}}', en: 'Poll could not be closed: {{message}}', fr: 'Impossible de clore le sondage : {{message}}' },
55
+ 'MessagingPoll.CREATE': { de: 'Umfrage erstellen', en: 'Create poll', fr: 'Créer un sondage' },
56
+ '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}}' },
57
+ '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.' },
58
+ 'MessagingPoll.QUESTION': { de: 'Frage', en: 'Question', fr: 'Question' },
59
+ 'MessagingPoll.OPTION': { de: 'Option {{count}}', en: 'Option {{count}}', fr: 'Option {{count}}' },
60
+ 'MessagingPoll.ADD_OPTION': { de: 'Option hinzufügen', en: 'Add option', fr: 'Ajouter une option' },
61
+ 'MessagingPoll.ALLOW_MULTIPLE': { de: 'Mehrfachauswahl erlauben', en: 'Allow multiple choices', fr: 'Autoriser plusieurs choix' },
62
+ 'MessagingPoll.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
63
+ 'MessagingConfig.LABEL': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
64
+ 'MessagingConfig.TITLE': { de: 'Nachrichten-Einstellungen', en: 'Messaging settings', fr: 'Paramètres de messagerie' },
65
+ 'MessagingConfig.LOADING': { de: 'Einstellungen werden geladen…', en: 'Loading settings…', fr: 'Chargement des paramètres…' },
66
+ '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}}' },
67
+ '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}}' },
68
+ 'MessagingConfig.DM_POLICY': { de: 'Direktnachrichten-Regel', en: 'Direct-message policy', fr: 'Règle des messages directs' },
69
+ 'MessagingConfig.DM_ALL': { de: 'Alle', en: 'Everyone', fr: 'Tout le monde' },
70
+ 'MessagingConfig.DM_SHARED': { de: 'Nur gemeinsame Gruppen', en: 'Shared groups only', fr: 'Groupes communs uniquement' },
71
+ 'MessagingConfig.DM_TEAM': { de: 'Nur Team', en: 'Team only', fr: 'Équipe uniquement' },
72
+ 'MessagingConfig.GROUP_ENABLED': { de: 'Gruppenchat aktivieren', en: 'Enable group chat', fr: 'Activer le chat de groupe' },
73
+ 'MessagingConfig.EVERYONE_POSTS': { de: 'Alle dürfen schreiben', en: 'Everyone can post', fr: 'Tout le monde peut publier' },
74
+ 'MessagingAnnouncement.CREATE': { de: 'Ankündigung erstellen', en: 'Create announcement', fr: 'Créer une annonce' },
75
+ 'MessagingAnnouncement.TITLE': { de: 'Titel', en: 'Title', fr: 'Titre' },
76
+ 'MessagingAnnouncement.BODY': { de: 'Text', en: 'Body', fr: 'Texte' },
77
+ 'MessagingAnnouncement.SEND': { de: 'Ankündigung senden', en: 'Send announcement', fr: 'Envoyer l’annonce' },
78
+ 'MessagingAnnouncement.CANCEL': { de: 'Abbrechen', en: 'Cancel', fr: 'Annuler' },
79
+ 'MessagingAnnouncement.VALIDATION': { de: 'Ein Titel ist erforderlich.', en: 'A title is required.', fr: 'Un titre est requis.' },
80
+ 'MessagingAnnouncement.ERROR': { de: 'Ankündigung konnte nicht gesendet werden: {{message}}', en: 'Announcement could not be sent: {{message}}', fr: 'Impossible d’envoyer l’annonce : {{message}}' },
81
+ };
package/src/index.js CHANGED
@@ -64,6 +64,20 @@ 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 { Thread } from './messaging/Thread';
73
+ export { MessageBubble } from './messaging/MessageBubble';
74
+ export { ReadTicks } from './messaging/ReadTicks';
75
+ export { Composer } from './messaging/Composer';
76
+ export { AttachmentList } from './messaging/AttachmentList';
77
+ export { ReactionBar } from './messaging/ReactionBar';
78
+ export { PollCard } from './messaging/PollCard';
79
+ export { MessagingScopeConfig } from './messaging/MessagingScopeConfig';
80
+
67
81
  // --- 9. Onboarding ---
68
82
  export * from './onboarding/api';
69
83
  export { selectActiveSteps } from './onboarding/stepSelection';
@@ -83,3 +97,4 @@ export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
83
97
  export { notificationsTranslations } from './i18n/notificationsTranslations';
84
98
  export { onboardingTranslations } from './i18n/onboardingTranslations';
85
99
  export { chartsTranslations } from './i18n/chartsTranslations';
100
+ 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
+ }