@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,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
|
+
}
|