@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.
Files changed (64) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +6 -1
  3. package/dev/entries.jsx +151 -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 +107 -0
  8. package/dist/index.js +15 -0
  9. package/dist/messaging/AttachmentList.js +43 -0
  10. package/dist/messaging/Composer.js +156 -0
  11. package/dist/messaging/ConversationLaunchers.js +39 -0
  12. package/dist/messaging/ConversationList.js +82 -0
  13. package/dist/messaging/DirectMessageLauncher.js +48 -0
  14. package/dist/messaging/MessageBubble.js +97 -0
  15. package/dist/messaging/MessagingProvider.js +363 -0
  16. package/dist/messaging/MessagingScopeConfig.js +28 -0
  17. package/dist/messaging/PollCard.js +53 -0
  18. package/dist/messaging/ReactionBar.js +44 -0
  19. package/dist/messaging/ReadTicks.js +28 -0
  20. package/dist/messaging/Thread.js +110 -0
  21. package/dist/messaging/api.js +51 -0
  22. package/dist/messaging/composerImageCompression.js +36 -0
  23. package/dist/notifications/NotificationsProvider.js +2 -2
  24. package/dist/notifications/realtime.js +21 -1
  25. package/docs/messaging-deviations.md +101 -0
  26. package/index.html +12 -0
  27. package/package.json +4 -1
  28. package/src/i18n/messagingTranslations.ts +107 -0
  29. package/src/index.js +16 -0
  30. package/src/messaging/AttachmentList.jsx +38 -0
  31. package/src/messaging/Composer.jsx +156 -0
  32. package/src/messaging/ConversationLaunchers.jsx +51 -0
  33. package/src/messaging/ConversationList.jsx +84 -0
  34. package/src/messaging/DirectMessageLauncher.jsx +96 -0
  35. package/src/messaging/MessageBubble.jsx +104 -0
  36. package/src/messaging/MessagingProvider.jsx +395 -0
  37. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  38. package/src/messaging/PollCard.jsx +36 -0
  39. package/src/messaging/ReactionBar.jsx +46 -0
  40. package/src/messaging/ReadTicks.jsx +25 -0
  41. package/src/messaging/Thread.jsx +102 -0
  42. package/src/messaging/api.js +48 -0
  43. package/src/messaging/composerImageCompression.js +35 -0
  44. package/src/notifications/NotificationsProvider.jsx +2 -2
  45. package/src/notifications/realtime.jsx +21 -1
  46. package/tests/ConversationList.test.jsx +66 -0
  47. package/tests/MessagingProvider.test.jsx +50 -0
  48. package/tests/Thread.test.jsx +100 -0
  49. package/tests/messagingApi.test.js +32 -0
  50. package/tests/messagingChunk6.test.jsx +109 -0
  51. package/tests/messagingComposer.test.jsx +111 -0
  52. package/tests/messagingComposerChunk5.test.jsx +110 -0
  53. package/tests/messagingContractConformance.test.js +42 -0
  54. package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
  55. package/tests/messagingExports.test.js +21 -0
  56. package/tests/messagingInteractions.test.jsx +112 -0
  57. package/tests/messagingMessageActions.test.jsx +93 -0
  58. package/tests/messagingThreadingQuoting.test.jsx +86 -0
  59. package/tests/messagingUnreadLifecycle.test.jsx +63 -0
  60. package/tests/realtime.test.jsx +27 -0
  61. package/vite.config.mjs +6 -0
  62. package/work-orders/DX-1.md +166 -0
  63. package/work-orders/MSG-3.md +375 -0
  64. package/work-orders/MSG-3b.md +1002 -0
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Button, Stack } from '@mui/material';
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useMessaging } from './MessagingProvider';
6
+ function isImage(attachment) { var _a; return (_a = attachment.content_type) === null || _a === void 0 ? void 0 : _a.startsWith('image/'); }
7
+ function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
8
+ /** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
9
+ export function AttachmentList({ attachments = [] }) {
10
+ const { t } = useTranslation();
11
+ const { getAttachment, getAttachmentThumbnail } = useMessaging();
12
+ const [previews, setPreviews] = useState({});
13
+ useEffect(() => {
14
+ let live = true;
15
+ const urls = [];
16
+ attachments.filter(isImage).forEach(async (attachment) => {
17
+ try {
18
+ const blob = await getAttachmentThumbnail(attachment.id);
19
+ const url = blob instanceof Blob ? URL.createObjectURL(blob) : blob === null || blob === void 0 ? void 0 : blob.url;
20
+ if (url && live) {
21
+ urls.push(url);
22
+ setPreviews((current) => (Object.assign(Object.assign({}, current), { [attachment.id]: url })));
23
+ }
24
+ }
25
+ catch ( /* The image remains available through the download action. */_a) { /* The image remains available through the download action. */ }
26
+ });
27
+ return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
28
+ }, [attachments, getAttachmentThumbnail]);
29
+ if (!attachments.length)
30
+ return null;
31
+ const download = async (attachment) => {
32
+ const blob = await getAttachment(attachment.id);
33
+ if (!(blob instanceof Blob))
34
+ return;
35
+ const url = URL.createObjectURL(blob);
36
+ const link = document.createElement('a');
37
+ link.href = url;
38
+ link.download = nameOf(attachment);
39
+ link.click();
40
+ URL.revokeObjectURL(url);
41
+ };
42
+ return _jsx(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", "aria-label": t('MessagingAttachments.LABEL'), children: attachments.map((attachment) => _jsxs(Button, { type: "button", size: "small", onClick: () => download(attachment), children: [previews[attachment.id] && _jsx(Box, { component: "img", src: previews[attachment.id], alt: "", width: 36, height: 36, sx: { objectFit: 'cover', mr: 0.75 } }), t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })] }, attachment.id)) });
43
+ }
@@ -0,0 +1,156 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Box, Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress, Menu, MenuItem, Stack, TextField, Typography } from '@mui/material';
3
+ import AttachFileIcon from '@mui/icons-material/AttachFile';
4
+ import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
5
+ import CloseIcon from '@mui/icons-material/Close';
6
+ import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined';
7
+ import PollOutlinedIcon from '@mui/icons-material/PollOutlined';
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { compressImageForUpload } from './composerImageCompression';
11
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
12
+ import { QUICK_EMOJIS } from './ReactionBar';
13
+ function newRequestId() { var _a, _b; return ((_b = (_a = globalThis.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === null || _b === void 0 ? void 0 : _b.call(_a)) || `message-${Date.now()}-${Math.random().toString(36).slice(2)}`; }
14
+ function isImage(file) { var _a; return (_a = file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/'); }
15
+ /**
16
+ * A provider-backed, independently mountable REST composer.
17
+ *
18
+ * `allowAnnouncement` and `linkTarget`: whether announcement composing (jg's
19
+ * `AnnouncementDialog` — title + body + a deep-link target) is offered here at
20
+ * all is a host decision (the server enforces who may actually post one; the
21
+ * client only offers the affordance). `linkTarget`'s VALUE is inherently
22
+ * app-specific navigation context (jg pre-fills it from the currently open
23
+ * event-info section) that ucm cannot compute itself, so the host supplies it.
24
+ */
25
+ export function Composer({ conversationId, replyTarget = null, onReplyTargetChange, allowAnnouncement = false, linkTarget }) {
26
+ var _a;
27
+ const { t } = useTranslation();
28
+ const { sendMessage, sendAttachments, createConversationPoll } = useMessaging();
29
+ const [body, setBody] = useState('');
30
+ const [files, setFiles] = useState([]);
31
+ const [error, setError] = useState(null);
32
+ const [failed, setFailed] = useState(null);
33
+ const [sending, setSending] = useState(false);
34
+ const [pollOpen, setPollOpen] = useState(false);
35
+ const [pollQuestion, setPollQuestion] = useState('');
36
+ const [pollOptions, setPollOptions] = useState(['', '']);
37
+ const [allowMultiple, setAllowMultiple] = useState(false);
38
+ const [announcementOpen, setAnnouncementOpen] = useState(false);
39
+ const [announcementTitle, setAnnouncementTitle] = useState('');
40
+ const [announcementBody, setAnnouncementBody] = useState('');
41
+ const [emojiAnchor, setEmojiAnchor] = useState(null);
42
+ const [previews, setPreviews] = useState([]);
43
+ const [uploadProgress, setUploadProgress] = useState(null);
44
+ const inputRef = useRef(null);
45
+ const messageInputRef = useRef(null);
46
+ useEffect(() => {
47
+ if (replyTarget === null || replyTarget === void 0 ? void 0 : replyTarget.deleted_at)
48
+ onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null);
49
+ }, [replyTarget, onReplyTargetChange]);
50
+ useEffect(() => { setBody(''); setFiles([]); }, [conversationId]);
51
+ useEffect(() => {
52
+ const urls = files.map((file) => isImage(file) ? URL.createObjectURL(file) : null);
53
+ setPreviews(urls);
54
+ return () => urls.filter(Boolean).forEach((url) => URL.revokeObjectURL(url));
55
+ }, [files]);
56
+ const submit = async (requestId = newRequestId(), retry = false) => {
57
+ if (!body.trim() && !files.length)
58
+ return;
59
+ setSending(true);
60
+ setError(null);
61
+ const payload = { kind: 'chat', body: body.trim() || undefined, reply_to: (replyTarget === null || replyTarget === void 0 ? void 0 : replyTarget.id) || undefined, client_request_id: requestId };
62
+ try {
63
+ if (files.length) {
64
+ const formData = new FormData();
65
+ const uploadFiles = await Promise.all(files.map(compressImageForUpload));
66
+ uploadFiles.forEach((file) => formData.append('files[]', file));
67
+ if (payload.body)
68
+ formData.append('body', payload.body);
69
+ if (payload.reply_to)
70
+ formData.append('reply_to', String(payload.reply_to));
71
+ formData.append('client_request_id', requestId);
72
+ setUploadProgress(0);
73
+ const onUploadProgress = (event) => setUploadProgress(event.total ? Math.round((event.loaded / event.total) * 100) : null);
74
+ await sendAttachments(conversationId, formData, { clientRequestId: requestId, retry, onUploadProgress, optimisticMessage: { body: payload.body, reply_to: payload.reply_to, attachments: files.map((file, index) => ({ id: `local-${requestId}-${index}`, filename: file.name, content_type: file.type })) } });
75
+ }
76
+ else
77
+ await sendMessage(conversationId, payload, { clientRequestId: requestId, retry });
78
+ setBody('');
79
+ setFiles([]);
80
+ setFailed(null);
81
+ onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null);
82
+ }
83
+ catch (sendError) {
84
+ const message = extractApiErrorMessage(sendError);
85
+ setError(files.length ? t('MessagingComposer.UPLOAD_ERROR', { message }) : t('MessagingComposer.SEND_ERROR', { message }));
86
+ setFailed({ requestId, retry: true });
87
+ }
88
+ finally {
89
+ setSending(false);
90
+ setUploadProgress(null);
91
+ }
92
+ };
93
+ const removeFile = (index) => setFiles((current) => current.filter((_, fileIndex) => fileIndex !== index));
94
+ const insertEmoji = (emoji) => {
95
+ var _a, _b, _c, _d;
96
+ const input = ((_b = (_a = messageInputRef.current) === null || _a === void 0 ? void 0 : _a.querySelector) === null || _b === void 0 ? void 0 : _b.call(_a, 'textarea')) || messageInputRef.current;
97
+ const start = (_c = input === null || input === void 0 ? void 0 : input.selectionStart) !== null && _c !== void 0 ? _c : body.length;
98
+ const end = (_d = input === null || input === void 0 ? void 0 : input.selectionEnd) !== null && _d !== void 0 ? _d : start;
99
+ setBody(`${body.slice(0, start)}${emoji}${body.slice(end)}`);
100
+ setEmojiAnchor(null);
101
+ requestAnimationFrame(() => { input === null || input === void 0 ? void 0 : input.focus(); input === null || input === void 0 ? void 0 : input.setSelectionRange(start + emoji.length, start + emoji.length); });
102
+ };
103
+ const submitPoll = async () => {
104
+ const options = pollOptions.map((option) => option.trim()).filter(Boolean);
105
+ if (!pollQuestion.trim() || options.length < 2) {
106
+ setError(t('MessagingPoll.CREATE_VALIDATION'));
107
+ return;
108
+ }
109
+ setSending(true);
110
+ setError(null);
111
+ try {
112
+ await createConversationPoll(conversationId, { question: pollQuestion.trim(), options, allow_multiple: allowMultiple }, { clientRequestId: newRequestId() });
113
+ setPollQuestion('');
114
+ setPollOptions(['', '']);
115
+ setAllowMultiple(false);
116
+ setPollOpen(false);
117
+ }
118
+ catch (pollError) {
119
+ setError(t('MessagingPoll.CREATE_ERROR', { message: extractApiErrorMessage(pollError) }));
120
+ }
121
+ finally {
122
+ setSending(false);
123
+ }
124
+ };
125
+ const submitAnnouncement = async () => {
126
+ if (!announcementTitle.trim()) {
127
+ setError(t('MessagingAnnouncement.VALIDATION'));
128
+ return;
129
+ }
130
+ setSending(true);
131
+ setError(null);
132
+ const requestId = newRequestId();
133
+ try {
134
+ await sendMessage(conversationId, {
135
+ kind: 'announcement',
136
+ title: announcementTitle.trim(),
137
+ body: announcementBody.trim() || undefined,
138
+ link_target: linkTarget || undefined,
139
+ client_request_id: requestId,
140
+ }, { clientRequestId: requestId });
141
+ setAnnouncementTitle('');
142
+ setAnnouncementBody('');
143
+ setAnnouncementOpen(false);
144
+ }
145
+ catch (announcementError) {
146
+ setError(t('MessagingAnnouncement.ERROR', { message: extractApiErrorMessage(announcementError) }));
147
+ }
148
+ finally {
149
+ setSending(false);
150
+ }
151
+ };
152
+ return _jsxs(Stack, { component: "form", spacing: 1, onSubmit: (event) => { event.preventDefault(); submit(); }, "aria-label": t('MessagingComposer.LABEL'), children: [replyTarget && _jsx(Alert, { severity: "info", onClose: () => onReplyTargetChange === null || onReplyTargetChange === void 0 ? void 0 : onReplyTargetChange(null), children: t('MessagingComposer.REPLYING_TO', { sender: ((_a = replyTarget.sender) === null || _a === void 0 ? void 0 : _a.display_name) || t('MessagingThread.UNKNOWN_SENDER') }) }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), files.length > 0 && _jsx(Stack, { direction: "row", spacing: 0.5, flexWrap: "wrap", "aria-label": t('MessagingComposer.STAGED_FILES'), children: files.map((file, index) => _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.25, sx: { border: 1, borderColor: 'divider', borderRadius: 1, p: 0.25 }, children: [previews[index] && _jsx(Box, { component: "img", src: previews[index], alt: file.name, width: 36, height: 36, sx: { objectFit: 'cover' } }), _jsx(Typography, { variant: "caption", children: file.name }), _jsx(IconButton, { type: "button", size: "small", "aria-label": t('MessagingComposer.REMOVE_ATTACHMENT', { name: file.name }), onClick: () => removeFile(index), children: _jsx(CloseIcon, { fontSize: "inherit" }) })] }, `${file.name}-${index}`)) }), files.length > 0 && _jsx(Typography, { variant: "caption", children: t('MessagingComposer.FILES_SELECTED', { count: files.length }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "flex-end", children: [_jsx("input", { ref: inputRef, hidden: true, type: "file", multiple: true, onChange: (event) => setFiles(Array.from(event.target.files || [])) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_ATTACHMENT'), onClick: () => { var _a; return (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, children: _jsx(AttachFileIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingComposer.ADD_EMOJI'), onClick: (event) => setEmojiAnchor(event.currentTarget), children: _jsx(EmojiEmotionsOutlinedIcon, {}) }), _jsx(IconButton, { type: "button", "aria-label": t('MessagingPoll.CREATE'), disabled: pollOpen || files.some(isImage), onClick: () => setPollOpen(true), children: _jsx(PollOutlinedIcon, {}) }), allowAnnouncement && _jsx(IconButton, { type: "button", "aria-label": t('MessagingAnnouncement.CREATE'), onClick: () => setAnnouncementOpen(true), children: _jsx(CampaignOutlinedIcon, {}) }), _jsx(TextField, { inputRef: messageInputRef, fullWidth: true, multiline: true, minRows: 2, value: body, onChange: (event) => setBody(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
153
+ event.preventDefault();
154
+ submit();
155
+ } }, label: t('MessagingComposer.MESSAGE'), disabled: sending }), _jsx(Button, { type: "submit", variant: "contained", disabled: sending || (!body.trim() && !files.length), children: t('MessagingComposer.SEND') })] }), sending && files.length > 0 && _jsxs(Stack, { spacing: 0.5, role: "status", children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [uploadProgress == null ? _jsx(CircularProgress, { size: 16 }) : _jsx(CircularProgress, { size: 16, variant: "determinate", value: uploadProgress }), _jsx(Typography, { variant: "caption", children: t('MessagingComposer.UPLOADING') })] }), uploadProgress != null && _jsx(LinearProgress, { variant: "determinate", value: uploadProgress })] }), failed && _jsx(Button, { type: "button", onClick: () => submit(failed.requestId, true), disabled: sending, children: t('MessagingComposer.RETRY') }), _jsx(Menu, { anchorEl: emojiAnchor, open: Boolean(emojiAnchor), onClose: () => setEmojiAnchor(null), children: QUICK_EMOJIS.map((emoji) => _jsx(MenuItem, { onClick: () => insertEmoji(emoji), children: emoji }, emoji)) }), _jsxs(Dialog, { open: pollOpen, onClose: () => !sending && setPollOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingPoll.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingPoll.QUESTION'), value: pollQuestion, onChange: (event) => setPollQuestion(event.target.value), autoFocus: true }), pollOptions.map((option, index) => _jsx(TextField, { label: t('MessagingPoll.OPTION', { count: index + 1 }), value: option, onChange: (event) => setPollOptions((current) => current.map((value, optionIndex) => optionIndex === index ? event.target.value : value)) }, index)), _jsx(Button, { type: "button", disabled: pollOptions.length >= 10, onClick: () => setPollOptions((current) => [...current, '']), children: t('MessagingPoll.ADD_OPTION') }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: allowMultiple, onChange: (event) => setAllowMultiple(event.target.checked) }), label: t('MessagingPoll.ALLOW_MULTIPLE') })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setPollOpen(false), children: t('MessagingPoll.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitPoll, disabled: sending, children: t('MessagingPoll.CREATE') })] })] }), allowAnnouncement && _jsxs(Dialog, { open: announcementOpen, onClose: () => !sending && setAnnouncementOpen(false), fullWidth: true, maxWidth: "sm", children: [_jsx(DialogTitle, { children: t('MessagingAnnouncement.CREATE') }), _jsx(DialogContent, { children: _jsxs(Stack, { spacing: 1, sx: { pt: 1 }, children: [_jsx(TextField, { label: t('MessagingAnnouncement.TITLE'), value: announcementTitle, onChange: (event) => setAnnouncementTitle(event.target.value), autoFocus: true }), _jsx(TextField, { label: t('MessagingAnnouncement.BODY'), value: announcementBody, onChange: (event) => setAnnouncementBody(event.target.value), multiline: true, minRows: 2 })] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setAnnouncementOpen(false), children: t('MessagingAnnouncement.CANCEL') }), _jsx(Button, { variant: "contained", onClick: submitAnnouncement, disabled: sending, children: t('MessagingAnnouncement.SEND') })] })] })] });
156
+ }
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Alert, Button, Stack } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { useMessaging } from './MessagingProvider';
6
+ /**
7
+ * Host apps provide launchable scopes. This component owns the REST transition
8
+ * that turns one into a normalized conversation, but deliberately does not
9
+ * discover groups itself: that is app-specific scope-picker data.
10
+ */
11
+ export function ConversationLaunchers({ groupLaunchers = [], broadcastLauncher, autoOpenBroadcast = false, onOpen }) {
12
+ const { t } = useTranslation();
13
+ const { openGroupConversation, openBroadcastConversation } = useMessaging();
14
+ const [error, setError] = useState(null);
15
+ const autoOpened = useRef(false);
16
+ const open = async (kind, payload) => {
17
+ setError(null);
18
+ try {
19
+ const conversation = kind === 'broadcast'
20
+ ? await openBroadcastConversation(payload)
21
+ : await openGroupConversation(payload);
22
+ onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation);
23
+ }
24
+ catch (_a) {
25
+ setError(t('MessagingLaunchers.OPEN_ERROR'));
26
+ }
27
+ };
28
+ useEffect(() => {
29
+ var _a;
30
+ if (!autoOpenBroadcast || !broadcastLauncher || autoOpened.current)
31
+ return;
32
+ autoOpened.current = true;
33
+ open('broadcast', (_a = broadcastLauncher.payload) !== null && _a !== void 0 ? _a : broadcastLauncher);
34
+ }, [autoOpenBroadcast, broadcastLauncher]); // one intentional auto-open per mounted launcher
35
+ return (_jsxs(Stack, { spacing: 1, children: [broadcastLauncher && !autoOpenBroadcast && (_jsx(Button, { variant: "outlined", onClick: () => { var _a; return open('broadcast', (_a = broadcastLauncher.payload) !== null && _a !== void 0 ? _a : broadcastLauncher); }, children: broadcastLauncher.label || t('MessagingLaunchers.OPEN_BROADCAST') })), groupLaunchers.map((launcher) => {
36
+ var _a;
37
+ return (_jsx(Button, { variant: "outlined", onClick: () => { var _a; return open('group', (_a = launcher.payload) !== null && _a !== void 0 ? _a : launcher); }, children: launcher.label || t('MessagingLaunchers.OPEN_GROUP') }, (_a = launcher.id) !== null && _a !== void 0 ? _a : launcher.label));
38
+ }), error && _jsx(Alert, { severity: "error", children: error })] }));
39
+ }
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { Archive, Campaign, Groups, MoreVert, NotificationsOff, Person } from '@mui/icons-material';
4
+ import { Badge, Box, Button, CircularProgress, IconButton, List, ListItemButton, ListItemText, Menu, MenuItem, Stack, Typography } from '@mui/material';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useMessaging } from './MessagingProvider';
7
+ import { ConversationLaunchers } from './ConversationLaunchers';
8
+ function titleOf(conversation, t) { var _a, _b; return conversation.title || ((_a = conversation.other_user) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = conversation.other_user) === null || _b === void 0 ? void 0 : _b.username) || t('MessagingList.UNTITLED'); }
9
+ function ordered(conversations) {
10
+ return [...conversations].sort((left, right) => new Date(right.last_message_at || 0) - new Date(left.last_message_at || 0));
11
+ }
12
+ function relativeTime(value, locale) {
13
+ if (!value)
14
+ return null;
15
+ const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000));
16
+ const [amount, unit] = seconds < 60 ? [0, 'second'] : seconds < 3600 ? [Math.floor(seconds / 60), 'minute'] : seconds < 86400 ? [Math.floor(seconds / 3600), 'hour'] : [Math.floor(seconds / 86400), 'day'];
17
+ return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' }).format(-amount, unit);
18
+ }
19
+ // jg's ConversationList renders a distinct icon per conversation kind so a
20
+ // user can identify type/person at a glance (getConversationAvatar); that
21
+ // glanceable identification is a feature, not layout, so it's reproduced
22
+ // here rather than left as a generic archive-status glyph.
23
+ function KindIcon({ conversation }) {
24
+ if (conversation.kind === 'broadcast')
25
+ return _jsx(Campaign, { fontSize: "small" });
26
+ if (conversation.kind === 'group' || conversation.kind === 'managed' || conversation.kind === 'object_thread')
27
+ return _jsx(Groups, { fontSize: "small" });
28
+ return _jsx(Person, { fontSize: "small" });
29
+ }
30
+ /** A standalone, provider-backed list; scope picker metadata is supplied as launcher props. */
31
+ export function ConversationList({ onOpen, groupLaunchers, broadcastLauncher, autoOpenBroadcast = false, includeArchived = false }) {
32
+ const { t, i18n } = useTranslation();
33
+ const { cache, activeConversationId, loadMoreConversations, setConversationArchived, setConversationPreferences, markConversationRead } = useMessaging();
34
+ const [menuConversation, setMenuConversation] = useState(null);
35
+ const [menuAnchor, setMenuAnchor] = useState(null);
36
+ const [loadingMore, setLoadingMore] = useState(false);
37
+ const [error, setError] = useState(null);
38
+ const conversations = useMemo(() => ordered(Object.values(cache.conversations).filter((item) => includeArchived || !item.archived_at && !item.archived)), [cache.conversations, includeArchived]);
39
+ const unreadFor = (conversation) => { var _a, _b, _c; return (_c = (_a = conversation.unread_count) !== null && _a !== void 0 ? _a : (_b = cache.unread.by_conversation) === null || _b === void 0 ? void 0 : _b[conversation.id]) !== null && _c !== void 0 ? _c : 0; };
40
+ // Once opened, the broadcast conversation becomes a normal, recency-sorted
41
+ // row with real unread/preview state — hide the static launcher so it isn't
42
+ // shown twice (button + list row) with the button's stale, unread-less copy.
43
+ const broadcastAlreadyOpened = useMemo(() => Object.values(cache.conversations).some((item) => item.kind === 'broadcast'), [cache.conversations]);
44
+ const loadMore = async () => {
45
+ setLoadingMore(true);
46
+ setError(null);
47
+ try {
48
+ await loadMoreConversations();
49
+ }
50
+ catch (_a) {
51
+ setError(t('MessagingList.LOAD_ERROR'));
52
+ }
53
+ finally {
54
+ setLoadingMore(false);
55
+ }
56
+ };
57
+ const closeMenu = () => { setMenuConversation(null); setMenuAnchor(null); };
58
+ const archive = async () => {
59
+ try {
60
+ await setConversationArchived(menuConversation.id, true);
61
+ }
62
+ catch (_a) {
63
+ setError(t('MessagingList.ARCHIVE_ERROR'));
64
+ }
65
+ closeMenu();
66
+ };
67
+ const toggleMute = async () => {
68
+ try {
69
+ await setConversationPreferences(menuConversation.id, { muted: !menuConversation.muted });
70
+ }
71
+ catch (_a) {
72
+ setError(t('MessagingList.PREFERENCES_ERROR'));
73
+ }
74
+ closeMenu();
75
+ };
76
+ return (_jsxs(Stack, { spacing: 1, children: [_jsx(ConversationLaunchers, { groupLaunchers: groupLaunchers, broadcastLauncher: broadcastAlreadyOpened ? null : broadcastLauncher, autoOpenBroadcast: autoOpenBroadcast && !broadcastAlreadyOpened, onOpen: onOpen }), error && _jsx(Typography, { color: "error", role: "alert", children: error }), !conversations.length ? _jsx(Box, { sx: { py: 3, textAlign: 'center' }, children: _jsx(Typography, { color: "text.secondary", children: t('MessagingList.EMPTY') }) }) : (_jsx(List, { disablePadding: true, "aria-label": t('MessagingList.LABEL'), children: conversations.map((conversation) => {
77
+ var _a;
78
+ const unread = unreadFor(conversation);
79
+ const archived = Boolean(conversation.archived || conversation.archived_at);
80
+ return _jsxs(ListItemButton, { selected: conversation.id === activeConversationId, onClick: () => { markConversationRead(conversation.id).catch(() => { }); onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation); }, sx: { minHeight: 64, opacity: archived ? 0.6 : 1 }, children: [_jsx(Badge, { color: "error", badgeContent: unread || null, max: 99, sx: { mr: 2 }, children: archived ? _jsx(Archive, { fontSize: "small", color: "disabled" }) : _jsx(KindIcon, { conversation: conversation }) }), _jsx(ListItemText, { primary: _jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { component: "span", fontWeight: unread ? 700 : 400, noWrap: true, children: titleOf(conversation, t) }), relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) && _jsx(Typography, { component: "span", variant: "caption", color: "text.secondary", noWrap: true, children: relativeTime(conversation.last_message_at, i18n === null || i18n === void 0 ? void 0 : i18n.language) })] }), secondary: ((_a = conversation.last_message) === null || _a === void 0 ? void 0 : _a.body) || t('MessagingList.NO_MESSAGES'), secondaryTypographyProps: { noWrap: true } }), _jsx(IconButton, { "aria-label": t('MessagingList.ACTIONS'), onClick: (event) => { event.stopPropagation(); setMenuAnchor(event.currentTarget); setMenuConversation(conversation); }, children: _jsx(MoreVert, {}) })] }, conversation.id);
81
+ }) })), cache.cursors.conversations && _jsx(Button, { onClick: loadMore, disabled: loadingMore, children: loadingMore ? _jsx(CircularProgress, { size: 18 }) : t('MessagingList.LOAD_MORE') }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuConversation), onClose: closeMenu, children: [_jsxs(MenuItem, { onClick: toggleMute, children: [_jsx(NotificationsOff, { fontSize: "small", sx: { mr: 1 } }), (menuConversation === null || menuConversation === void 0 ? void 0 : menuConversation.muted) ? t('MessagingList.UNMUTE') : t('MessagingList.MUTE')] }), _jsxs(MenuItem, { onClick: archive, children: [_jsx(Archive, { fontSize: "small", sx: { mr: 1 } }), t('MessagingList.ARCHIVE')] })] })] }));
82
+ }
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useId, useState } from 'react';
3
+ import { Alert, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, List, ListItemButton, ListItemText, Typography, useMediaQuery, } from '@mui/material';
4
+ import { useTheme } from '@mui/material/styles';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
7
+ /**
8
+ * Starts direct conversations from host-provided people. Candidate discovery is
9
+ * intentionally outside messaging because memberships are host-domain data.
10
+ */
11
+ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
12
+ const { t } = useTranslation();
13
+ const { openDirectConversation } = useMessaging();
14
+ const theme = useTheme();
15
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
16
+ const [open, setOpen] = useState(false);
17
+ const [selectedId, setSelectedId] = useState(null);
18
+ const [starting, setStarting] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const titleId = useId();
21
+ const showPicker = () => {
22
+ setSelectedId(null);
23
+ setError(null);
24
+ setOpen(true);
25
+ };
26
+ const closePicker = () => {
27
+ if (!starting)
28
+ setOpen(false);
29
+ };
30
+ const start = async () => {
31
+ if (selectedId == null || starting)
32
+ return;
33
+ setStarting(true);
34
+ setError(null);
35
+ try {
36
+ const conversation = await openDirectConversation(Object.assign({ target_user_id: selectedId }, (scope == null ? {} : { scope })));
37
+ setOpen(false);
38
+ onOpen === null || onOpen === void 0 ? void 0 : onOpen(conversation);
39
+ }
40
+ catch (requestError) {
41
+ setError(extractApiErrorMessage(requestError) || t('MessagingDirect.START_ERROR'));
42
+ }
43
+ finally {
44
+ setStarting(false);
45
+ }
46
+ };
47
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outlined", onClick: showPicker, children: t('MessagingDirect.LAUNCH') }), _jsxs(Dialog, { open: open, onClose: closePicker, fullScreen: fullScreen, "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t('MessagingDirect.TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: { mb: 1 }, children: error }), starting && _jsxs(Typography, { role: "status", sx: { mb: 1 }, children: [_jsx(CircularProgress, { size: 16, sx: { mr: 1 } }), t('MessagingDirect.STARTING')] }), candidates.length === 0 ? (_jsx(Typography, { children: t('MessagingDirect.EMPTY') })) : (_jsx(List, { "aria-label": t('MessagingDirect.CANDIDATES'), children: candidates.map((candidate) => (_jsx(ListItemButton, { selected: candidate.id === selectedId, disabled: starting, onClick: () => setSelectedId(candidate.id), children: _jsx(ListItemText, { primary: candidate.display_name || candidate.name || candidate.label || candidate.id }) }, candidate.id))) }))] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closePicker, disabled: starting, children: t('MessagingDirect.CANCEL') }), _jsx(Button, { variant: "contained", onClick: start, disabled: selectedId == null || starting, children: t('MessagingDirect.START') })] })] })] }));
48
+ }
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
3
+ import MoreVertIcon from '@mui/icons-material/MoreVert';
4
+ import { useEffect, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { AttachmentList } from './AttachmentList';
7
+ import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
8
+ import { ReactionBar } from './ReactionBar';
9
+ import { PollCard } from './PollCard';
10
+ function senderName(message) {
11
+ var _a, _b, _c;
12
+ return ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.display_name) || ((_b = message.sender) === null || _b === void 0 ? void 0 : _b.username) || message.sender_name || ((_c = message.sender) === null || _c === void 0 ? void 0 : _c.name);
13
+ }
14
+ function quoteSnippet(message) {
15
+ const content = message.body || message.title;
16
+ if (!content)
17
+ return null;
18
+ return content.length > 120 ? `${content.slice(0, 117)}...` : content;
19
+ }
20
+ // A quoted source can be textless for two different reasons: it was deleted
21
+ // (server blanks body/title/link_target on soft delete), or it always had no
22
+ // text — an attachment-only chat send is a real, reachable case (Composer
23
+ // only requires body OR files). Collapsing both into "This message was
24
+ // deleted" would mislabel the second case, so they're distinguished here.
25
+ function quoteLabel(message, t) {
26
+ var _a;
27
+ if (message.deleted_at)
28
+ return t('MessagingThread.DELETED');
29
+ const snippet = quoteSnippet(message);
30
+ if (snippet)
31
+ return snippet;
32
+ return ((_a = message.attachments) === null || _a === void 0 ? void 0 : _a.length) ? t('MessagingThread.QUOTE_ATTACHMENT') : '';
33
+ }
34
+ /** Provider-backed timeline collaborator, including per-message mutations. */
35
+ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpToMessage, onAnnouncementLink, canModerateMessages = false, children }) {
36
+ var _a;
37
+ const { t } = useTranslation();
38
+ const messaging = useOptionalMessaging();
39
+ const { currentUser, editMessage, removeMessage } = messaging || {};
40
+ const deleted = Boolean(message.deleted_at);
41
+ const attachments = message.attachments || [];
42
+ const canAct = !deleted && message.kind !== 'poll' && ((Boolean(currentUser === null || currentUser === void 0 ? void 0 : currentUser.id) && ((_a = message.sender) === null || _a === void 0 ? void 0 : _a.id) === (currentUser === null || currentUser === void 0 ? void 0 : currentUser.id)) || canModerateMessages);
43
+ const [menuAnchor, setMenuAnchor] = useState(null);
44
+ const [editing, setEditing] = useState(false);
45
+ const [draft, setDraft] = useState(message.body || '');
46
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
47
+ const [saving, setSaving] = useState(false);
48
+ const [error, setError] = useState(null);
49
+ useEffect(() => {
50
+ if (deleted) {
51
+ setEditing(false);
52
+ setConfirmingDelete(false);
53
+ setMenuAnchor(null);
54
+ }
55
+ }, [deleted]);
56
+ const closeMenu = () => setMenuAnchor(null);
57
+ const openMenu = (event) => { event.preventDefault(); setMenuAnchor(event.currentTarget); };
58
+ const startEdit = () => { setDraft(message.body || ''); setEditing(true); closeMenu(); };
59
+ const saveEdit = async () => {
60
+ setSaving(true);
61
+ setError(null);
62
+ try {
63
+ await (editMessage === null || editMessage === void 0 ? void 0 : editMessage(message.id, { body: draft.trim() }));
64
+ setEditing(false);
65
+ }
66
+ catch (saveError) {
67
+ setError(t('MessagingActions.EDIT_ERROR', { message: extractApiErrorMessage(saveError) }));
68
+ }
69
+ finally {
70
+ setSaving(false);
71
+ }
72
+ };
73
+ const confirmDelete = async () => {
74
+ setSaving(true);
75
+ setError(null);
76
+ try {
77
+ await (removeMessage === null || removeMessage === void 0 ? void 0 : removeMessage(message.id));
78
+ setConfirmingDelete(false);
79
+ }
80
+ catch (deleteError) {
81
+ setError(t('MessagingActions.DELETE_ERROR', { message: extractApiErrorMessage(deleteError) }));
82
+ }
83
+ finally {
84
+ setSaving(false);
85
+ }
86
+ };
87
+ const copyMessage = async () => {
88
+ closeMenu();
89
+ try {
90
+ await navigator.clipboard.writeText(message.body || '');
91
+ }
92
+ catch (copyError) {
93
+ setError(t('MessagingActions.COPY_ERROR', { message: extractApiErrorMessage(copyError) }));
94
+ }
95
+ };
96
+ return (_jsxs(Paper, { component: "article", variant: "outlined", sx: { p: 1.25, maxWidth: 'min(100%, 680px)', '& .message-actions': { opacity: 0 }, '&:hover .message-actions': { opacity: 1 } }, "aria-label": t('MessagingThread.MESSAGE'), "data-message-id": message.id, onContextMenu: canAct ? openMenu : undefined, children: [_jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(Button, { type: "button", variant: "text", size: "small", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { justifyContent: 'flex-start', px: 0, textAlign: 'left', textTransform: 'none' }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "baseline", children: [(conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), message.created_at && _jsx(Typography, { variant: "caption", color: "text.secondary", children: new Date(message.created_at).toLocaleString() }), message.edited_at && !deleted && _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.EDITED') })] }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), _jsx(ReactionBar, { message: message }), message.poll && _jsx(PollCard, { message: message })] }), !deleted && onReply && _jsx(Button, { type: "button", size: "small", onClick: () => onReply(message), children: t('MessagingThread.REPLY') }), canAct && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, children: _jsx(MoreVertIcon, {}) }), children] }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
97
+ }