@micha.bigler/ui-core-micha 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -1
- package/dist/i18n/messagingTranslations.js +6 -2
- package/dist/messaging/AttachmentList.js +65 -5
- package/dist/messaging/MessageBubble.js +1 -1
- package/dist/messaging/MessagingProvider.js +63 -5
- package/dist/messaging/PollCard.js +83 -23
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +6 -2
- package/src/messaging/AttachmentList.jsx +106 -8
- package/src/messaging/MessageBubble.jsx +7 -1
- package/src/messaging/MessagingProvider.jsx +60 -5
- package/src/messaging/PollCard.jsx +103 -19
- package/tests/messagingCompactBubble.test.jsx +4 -2
- package/tests/messagingInteractions.test.jsx +10 -3
- package/tests/messagingMsg6g.test.jsx +166 -0
- package/tests/messagingMsg6h.test.jsx +136 -0
- package/tests/messagingPollRendering.test.jsx +24 -14
- package/work-orders/MSG-6g.md +244 -0
- package/work-orders/MSG-6h.md +238 -0
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
2
|
+
import DownloadIcon from '@mui/icons-material/DownloadOutlined';
|
|
3
|
+
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
|
4
|
+
import { Alert, Box, ButtonBase, CircularProgress, Dialog, IconButton, Menu, MenuItem, Stack, Typography } from '@mui/material';
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
6
|
import { useTranslation } from 'react-i18next';
|
|
4
7
|
|
|
5
8
|
import { useMessaging } from './MessagingProvider';
|
|
6
9
|
|
|
7
10
|
function isImage(attachment) { return attachment.content_type?.startsWith('image/'); }
|
|
11
|
+
// `filename` is dcm's real, sanitized upload name (MSG-12) -- `id` is the
|
|
12
|
+
// last-resort fallback for interop with a host still on a pre-MSG-12 pin.
|
|
8
13
|
function nameOf(attachment) { return attachment.filename || attachment.name || attachment.id; }
|
|
9
14
|
|
|
10
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* WhatsApp-style attachment gallery (MSG-6h): a small thumbnail per image
|
|
17
|
+
* attachment; click opens a full-size lightbox, which itself carries its own
|
|
18
|
+
* Download action (reachable by mouse, touch, or keyboard -- right-click is a
|
|
19
|
+
* shortcut, never the only path). A non-image attachment has no preview to
|
|
20
|
+
* open -- click downloads directly. Right-click still opens the same context
|
|
21
|
+
* menu on either kind, for the mouse-user shortcut. Bytes only ever move
|
|
22
|
+
* through the existing authenticated `getAttachment`/`getAttachmentThumbnail`
|
|
23
|
+
* blob endpoints -- no new byte-handling path.
|
|
24
|
+
*/
|
|
11
25
|
export function AttachmentList({ attachments = [] }) {
|
|
12
26
|
const { t } = useTranslation();
|
|
13
27
|
const { getAttachment, getAttachmentThumbnail } = useMessaging();
|
|
14
28
|
const [previews, setPreviews] = useState({});
|
|
29
|
+
// { attachment, url: string|null, status: 'loading'|'ready'|'error' } | null
|
|
30
|
+
const [lightbox, setLightbox] = useState(null);
|
|
31
|
+
const [menu, setMenu] = useState(null); // { attachment, anchorPosition } | null
|
|
32
|
+
// Full-resolution object URLs, fetched once per attachment on first
|
|
33
|
+
// lightbox open and reused for the rest of this mount (a re-open must not
|
|
34
|
+
// re-fetch what's already sitting in memory). Only ever revoked on
|
|
35
|
+
// unmount -- never while the Dialog might still be displaying one.
|
|
36
|
+
const fullUrlCacheRef = useRef({});
|
|
37
|
+
|
|
15
38
|
useEffect(() => {
|
|
16
39
|
let live = true; const urls = [];
|
|
17
40
|
attachments.filter(isImage).forEach(async (attachment) => {
|
|
@@ -23,16 +46,91 @@ export function AttachmentList({ attachments = [] }) {
|
|
|
23
46
|
});
|
|
24
47
|
return () => { live = false; urls.forEach((url) => URL.revokeObjectURL(url)); };
|
|
25
48
|
}, [attachments, getAttachmentThumbnail]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => () => { Object.values(fullUrlCacheRef.current).forEach((url) => URL.revokeObjectURL(url)); }, []);
|
|
51
|
+
|
|
26
52
|
if (!attachments.length) return null;
|
|
53
|
+
|
|
27
54
|
const download = async (attachment) => {
|
|
28
55
|
const blob = await getAttachment(attachment.id);
|
|
29
56
|
if (!(blob instanceof Blob)) return;
|
|
30
57
|
const url = URL.createObjectURL(blob); const link = document.createElement('a');
|
|
31
58
|
link.href = url; link.download = nameOf(attachment); link.click(); URL.revokeObjectURL(url);
|
|
32
59
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
|
|
61
|
+
const openLightbox = async (attachment) => {
|
|
62
|
+
const cachedUrl = fullUrlCacheRef.current[attachment.id];
|
|
63
|
+
if (cachedUrl) { setLightbox({ attachment, url: cachedUrl, status: 'ready' }); return; }
|
|
64
|
+
setLightbox({ attachment, url: null, status: 'loading' });
|
|
65
|
+
try {
|
|
66
|
+
const blob = await getAttachment(attachment.id);
|
|
67
|
+
if (!(blob instanceof Blob)) throw new Error('Attachment response was not a Blob');
|
|
68
|
+
const url = URL.createObjectURL(blob);
|
|
69
|
+
fullUrlCacheRef.current[attachment.id] = url;
|
|
70
|
+
// A newer open (a different attachment) may have superseded this one
|
|
71
|
+
// while the fetch was in flight -- don't stomp on it with a stale result.
|
|
72
|
+
setLightbox((current) => (current?.attachment.id === attachment.id ? { attachment, url, status: 'ready' } : current));
|
|
73
|
+
} catch {
|
|
74
|
+
setLightbox((current) => (current?.attachment.id === attachment.id ? { ...current, status: 'error' } : current));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const openMenu = (event, attachment) => {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
// Attachments live inside a message bubble that has its own right-click
|
|
81
|
+
// menu (MessageBubble.jsx's onContextMenu) -- this must not also open.
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
setMenu({ attachment, anchorPosition: { top: event.clientY, left: event.clientX } });
|
|
84
|
+
};
|
|
85
|
+
const closeMenu = () => setMenu(null);
|
|
86
|
+
|
|
87
|
+
return <>
|
|
88
|
+
<Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
|
|
89
|
+
{attachments.map((attachment) => {
|
|
90
|
+
const image = isImage(attachment);
|
|
91
|
+
const preview = previews[attachment.id];
|
|
92
|
+
const label = image ? t('MessagingAttachments.PREVIEW', { name: nameOf(attachment) }) : t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) });
|
|
93
|
+
return (
|
|
94
|
+
<ButtonBase
|
|
95
|
+
key={attachment.id}
|
|
96
|
+
type="button"
|
|
97
|
+
title={nameOf(attachment)}
|
|
98
|
+
onClick={() => (image ? openLightbox(attachment) : download(attachment))}
|
|
99
|
+
onContextMenu={(event) => openMenu(event, attachment)}
|
|
100
|
+
aria-label={label}
|
|
101
|
+
sx={{ width: 64, height: 64, borderRadius: 1, overflow: 'hidden', border: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover' }}
|
|
102
|
+
>
|
|
103
|
+
{image && preview
|
|
104
|
+
? <Box component="img" src={preview} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
105
|
+
: <Stack alignItems="center" spacing={0.25} sx={{ px: 0.5, maxWidth: '100%' }}>
|
|
106
|
+
<InsertDriveFileOutlinedIcon fontSize="small" />
|
|
107
|
+
<Typography variant="caption" noWrap sx={{ maxWidth: '100%' }}>{nameOf(attachment)}</Typography>
|
|
108
|
+
</Stack>}
|
|
109
|
+
</ButtonBase>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</Stack>
|
|
113
|
+
|
|
114
|
+
<Dialog open={Boolean(lightbox)} onClose={() => setLightbox(null)} maxWidth="lg" aria-label={lightbox ? nameOf(lightbox.attachment) : undefined}>
|
|
115
|
+
{lightbox && <Box sx={{ position: 'relative', minWidth: 240, minHeight: lightbox.status === 'ready' ? undefined : 160, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
116
|
+
<Stack direction="row" spacing={0.5} sx={{ position: 'absolute', top: 4, right: 4 }}>
|
|
117
|
+
{lightbox.status === 'ready' && <IconButton aria-label={t('MessagingAttachments.DOWNLOAD_ACTION')} onClick={() => download(lightbox.attachment)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
|
|
118
|
+
<DownloadIcon />
|
|
119
|
+
</IconButton>}
|
|
120
|
+
<IconButton aria-label={t('MessagingAttachments.CLOSE_PREVIEW')} onClick={() => setLightbox(null)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }}>
|
|
121
|
+
<CloseIcon />
|
|
122
|
+
</IconButton>
|
|
123
|
+
</Stack>
|
|
124
|
+
{lightbox.status === 'loading' && <CircularProgress aria-label={t('MessagingAttachments.LOADING_PREVIEW')} sx={{ m: 4 }} />}
|
|
125
|
+
{lightbox.status === 'error' && <Alert severity="error" role="alert" sx={{ m: 2 }}>{t('MessagingAttachments.PREVIEW_ERROR')}</Alert>}
|
|
126
|
+
{lightbox.status === 'ready' && <Box component="img" src={lightbox.url} alt={nameOf(lightbox.attachment)} sx={{ display: 'block', maxWidth: '90vw', maxHeight: '90vh' }} />}
|
|
127
|
+
</Box>}
|
|
128
|
+
</Dialog>
|
|
129
|
+
|
|
130
|
+
<Menu open={Boolean(menu)} onClose={closeMenu} anchorReference="anchorPosition" anchorPosition={menu?.anchorPosition}>
|
|
131
|
+
<MenuItem onClick={() => { const attachment = menu?.attachment; closeMenu(); if (attachment) download(attachment); }}>
|
|
132
|
+
<DownloadIcon fontSize="small" sx={{ mr: 1 }} />{t('MessagingAttachments.DOWNLOAD_ACTION')}
|
|
133
|
+
</MenuItem>
|
|
134
|
+
</Menu>
|
|
135
|
+
</>;
|
|
38
136
|
}
|
|
@@ -136,7 +136,13 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
136
136
|
</Stack>}
|
|
137
137
|
</Stack>
|
|
138
138
|
</Paper>
|
|
139
|
-
{
|
|
139
|
+
{/* A small positive gap, not the negative-margin "peek from the bottom
|
|
140
|
+
edge" trick this used to have -- that pull was tuned against a
|
|
141
|
+
plain text line and read as an unnatural collision once the
|
|
142
|
+
bubble's actual bottom content became variable (an attachment
|
|
143
|
+
row's bordered Button, or MSG-6g's poll bars), both visually
|
|
144
|
+
boxier than text and closer to the bubble's edge. */}
|
|
145
|
+
{!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: 0.5, ml: 0.75 }} />}
|
|
140
146
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
|
|
141
147
|
<MenuItem onClick={startReply}>{t('MessagingThread.REPLY')}</MenuItem>
|
|
142
148
|
<MenuItem onClick={startReact}>{t('MessagingReactions.ADD')}</MenuItem>
|
|
@@ -47,6 +47,30 @@ function mergeById(slice, item) { const id = idOf(item); return id == null ? sli
|
|
|
47
47
|
function resultsOf(response) { return response?.results || []; }
|
|
48
48
|
function clientRequestIdOf(message) { return message?.client_request_id; }
|
|
49
49
|
|
|
50
|
+
// Optimistic vote-count/voters projection for MSG-6g tap-to-vote -- only options whose
|
|
51
|
+
// membership in the vote actually changed get their count/voters adjusted, so an
|
|
52
|
+
// unrelated option's tally is never nudged by someone else's concurrent vote landing in
|
|
53
|
+
// cache between taps.
|
|
54
|
+
function applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId) {
|
|
55
|
+
const previousSet = new Set(previousSelectedIds || []);
|
|
56
|
+
const nextSet = new Set(nextSelectedIds || []);
|
|
57
|
+
const options = (poll.options || []).map((option) => {
|
|
58
|
+
const wasSelected = previousSet.has(option.id);
|
|
59
|
+
const isSelected = nextSet.has(option.id);
|
|
60
|
+
if (wasSelected === isSelected) return option;
|
|
61
|
+
const delta = isSelected ? 1 : -1;
|
|
62
|
+
// userId can be undefined for a brief window if AuthContext hasn't
|
|
63
|
+
// populated `user` yet -- never push a literal `undefined` into the
|
|
64
|
+
// voters array (the reconcile/revert below overwrites it regardless,
|
|
65
|
+
// but the optimistic window must not carry a bogus entry).
|
|
66
|
+
const voters = Array.isArray(option.voters) && userId != null
|
|
67
|
+
? (isSelected ? [...option.voters, userId] : option.voters.filter((voter) => voter !== userId))
|
|
68
|
+
: option.voters;
|
|
69
|
+
return { ...option, vote_count: Math.max(0, (option.vote_count ?? option.voters?.length ?? 0) + delta), voters };
|
|
70
|
+
});
|
|
71
|
+
return { ...poll, options, voted_option_ids: nextSelectedIds };
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
/**
|
|
51
75
|
* dcm's DRF validation errors aren't always {detail}: ConversationAttachmentView
|
|
52
76
|
* translates a rejected upload into a field-keyed {"files": [...]} body (the
|
|
@@ -245,6 +269,10 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
245
269
|
const activeIdRef = useRef(activeConversationId);
|
|
246
270
|
const seenEventsRef = useRef(new Set());
|
|
247
271
|
const filtersRef = useRef(filters);
|
|
272
|
+
// Per-poll "latest tap wins" token: rapid successive taps race, and a stale
|
|
273
|
+
// response (revert on failure, or reconcile on success) must not clobber a
|
|
274
|
+
// newer optimistic state that has already superseded it.
|
|
275
|
+
const pollVoteTokensRef = useRef({});
|
|
248
276
|
activeIdRef.current = activeConversationId;
|
|
249
277
|
filtersRef.current = filters;
|
|
250
278
|
|
|
@@ -421,11 +449,38 @@ export function MessagingProvider({ children, filters = {}, activeConversationId
|
|
|
421
449
|
return poll;
|
|
422
450
|
}, [api, patchMessage]);
|
|
423
451
|
const castPollVote = useCallback(async (messageId, poll, optionIds) => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
452
|
+
// Optimistic tap-to-vote (MSG-6g), following the same immediate-patch /
|
|
453
|
+
// reconcile-or-revert shape `toggleReaction` already uses. The previous poll
|
|
454
|
+
// (pre-tap) is captured up front so a failure can revert to it exactly, not to
|
|
455
|
+
// whatever `cache.messages` happens to hold when the error arrives.
|
|
456
|
+
const message = cache.messages[messageId] || { id: messageId };
|
|
457
|
+
const previousPoll = message.poll || poll;
|
|
458
|
+
// Project from `previousPoll` (the freshest known state in the shared cache),
|
|
459
|
+
// never from the caller-supplied `poll` param -- `PollCard` is a "dumb",
|
|
460
|
+
// prop-driven component that may still be holding the pre-first-tap `poll`
|
|
461
|
+
// it was originally passed when a second rapid tap fires, and projecting
|
|
462
|
+
// counts from that stale baseline would compute a wrong transient
|
|
463
|
+
// vote_count/voters in the SHARED cache (visible to every consumer, not
|
|
464
|
+
// just this component's own render).
|
|
465
|
+
const optimisticPoll = applyOptimisticVote(previousPoll, previousPoll.voted_option_ids, optionIds, user?.id);
|
|
466
|
+
const token = {};
|
|
467
|
+
pollVoteTokensRef.current[poll.id] = token;
|
|
468
|
+
patchMessage({ ...message, poll: optimisticPoll });
|
|
469
|
+
try {
|
|
470
|
+
const result = await api.votePoll(poll.id, optionIds);
|
|
471
|
+
// A newer tap already started (and will reconcile/revert for itself) --
|
|
472
|
+
// applying this now-stale response would stomp on it.
|
|
473
|
+
if (pollVoteTokensRef.current[poll.id] !== token) return result;
|
|
474
|
+
const nextPoll = { ...poll, ...(result?.poll || result) };
|
|
475
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
|
|
476
|
+
return result;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (pollVoteTokensRef.current[poll.id] === token) {
|
|
479
|
+
patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: previousPoll });
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}, [api, cache.messages, patchMessage, user]);
|
|
429
484
|
const closeConversationPoll = useCallback(async (messageId, poll) => {
|
|
430
485
|
const result = await api.closePoll(poll.id);
|
|
431
486
|
const nextPoll = { ...poll, ...(result?.poll || result), closed_at: result?.poll?.closed_at || result?.closed_at || new Date().toISOString() };
|
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
|
2
|
+
import { Alert, Button, Checkbox, Chip, FormControlLabel, LinearProgress, Paper, Radio, RadioGroup, Stack, Typography } from '@mui/material';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
4
|
import { useTranslation } from 'react-i18next';
|
|
4
5
|
|
|
5
6
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Independently mountable live poll view. Server authorization remains authoritative.
|
|
10
|
+
*
|
|
11
|
+
* Tap-to-vote (MSG-6g): each option is its own control, cast immediately on tap — no
|
|
12
|
+
* separate submit step. Single-choice uses a real `RadioGroup` (native "tap a different
|
|
13
|
+
* option moves the vote, tap the same one is a no-op" semantics, plus the group's own
|
|
14
|
+
* arrow-key navigation and screen-reader group announcement — a bare ungrouped `Radio`
|
|
15
|
+
* per option, tried first, loses both); multi-select uses `Checkbox` (each tap
|
|
16
|
+
* independently toggles that option). `FormControlLabel` wraps each control, matching
|
|
17
|
+
* this repo's existing radio convention (`AuthFactorRequirementCard.jsx`) — clicking
|
|
18
|
+
* anywhere in the label natively activates the control, no manual click-handling needed.
|
|
19
|
+
* Every tap is optimistic (see `castPollVote` in `MessagingProvider`), dims while
|
|
20
|
+
* in flight (matching `ReactionBar`'s `pending` convention) so an optimistic vote is
|
|
21
|
+
* never visually indistinguishable from a confirmed one, and visibly reverts on a
|
|
22
|
+
* rejected vote.
|
|
23
|
+
*/
|
|
8
24
|
export function PollCard({ message, canClose: canCloseProp }) {
|
|
9
25
|
const { t } = useTranslation();
|
|
10
26
|
const { castPollVote, closeConversationPoll, currentUser } = useMessaging();
|
|
@@ -12,6 +28,10 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
12
28
|
const [selected, setSelected] = useState([]);
|
|
13
29
|
const [error, setError] = useState(null);
|
|
14
30
|
const [busy, setBusy] = useState(false);
|
|
31
|
+
const [pending, setPending] = useState(() => new Set());
|
|
32
|
+
// Guards a rejected vote's revert against a newer tap that has since
|
|
33
|
+
// superseded it -- see the matching token in MessagingProvider.castPollVote.
|
|
34
|
+
const voteTokenRef = useRef(null);
|
|
15
35
|
// voted_option_ids is a view-level enrichment dcm adds ONLY to the
|
|
16
36
|
// create/vote/close poll REST responses (serialize_poll itself, every
|
|
17
37
|
// message-list/thread load, and every poll_updated realtime frame never
|
|
@@ -25,28 +45,92 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
25
45
|
const closed = Boolean(poll.closed_at);
|
|
26
46
|
const creatorId = poll.created_by_id ?? poll.created_by?.id ?? poll.created_by;
|
|
27
47
|
const canClose = canCloseProp ?? poll.can_close ?? message?.can_close ?? message?.permissions?.can_close ?? (creatorId != null && creatorId === currentUser?.id);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
const totalVotes = (poll.options || []).reduce((sum, option) => sum + (option.vote_count ?? option.voters?.length ?? 0), 0);
|
|
49
|
+
|
|
50
|
+
const tap = async (id) => {
|
|
51
|
+
if (closed) return;
|
|
52
|
+
const previousSelected = selected;
|
|
53
|
+
const nextSelected = poll.allow_multiple
|
|
54
|
+
? (selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id])
|
|
55
|
+
: [id];
|
|
56
|
+
// Belt-and-suspenders: native radios already don't fire a change event
|
|
57
|
+
// when re-clicking the checked one, but this guards any caller shape.
|
|
58
|
+
if (nextSelected.length === previousSelected.length && nextSelected.every((value) => previousSelected.includes(value))) return;
|
|
59
|
+
const token = {};
|
|
60
|
+
voteTokenRef.current = token;
|
|
61
|
+
setSelected(nextSelected);
|
|
62
|
+
setError(null);
|
|
63
|
+
// Rapid re-taps are allowed and race deliberately (last write wins via
|
|
64
|
+
// the token above) -- `pending` is a visual signal only, never gates
|
|
65
|
+
// further taps, so it must not disable the control.
|
|
66
|
+
setPending((current) => new Set(current).add(id));
|
|
67
|
+
try {
|
|
68
|
+
await castPollVote(message.id, poll, nextSelected);
|
|
69
|
+
} catch (voteError) {
|
|
70
|
+
// A later tap already moved the selection on; a stale failure must not
|
|
71
|
+
// revert past it.
|
|
72
|
+
if (voteTokenRef.current === token) {
|
|
73
|
+
setSelected(previousSelected);
|
|
74
|
+
setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) }));
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
setPending((current) => { const next = new Set(current); next.delete(id); return next; });
|
|
78
|
+
}
|
|
33
79
|
};
|
|
34
80
|
const close = async () => {
|
|
35
81
|
setBusy(true); setError(null);
|
|
36
82
|
try { await closeConversationPoll(message.id, poll); } catch (closeError) { setError(t('MessagingPoll.CLOSE_ERROR', { message: extractApiErrorMessage(closeError) })); } finally { setBusy(false); }
|
|
37
83
|
};
|
|
38
|
-
|
|
84
|
+
|
|
85
|
+
const optionLabel = (option) => {
|
|
86
|
+
// dcm's poll projection gives voter user ids only, never names — there
|
|
87
|
+
// is no user-directory endpoint (same structural limit already
|
|
88
|
+
// established for the DM candidate list).
|
|
89
|
+
const voteCount = option.vote_count ?? option.voters?.length ?? 0;
|
|
90
|
+
const percent = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
|
91
|
+
const votedByMe = selected.includes(option.id);
|
|
92
|
+
const resultText = t('MessagingPoll.OPTION_RESULT', { count: voteCount, percent });
|
|
93
|
+
// The bar is decorative; count/percent live in the accessible name (via
|
|
94
|
+
// aria-label on the control itself) as well as in adjacent visible text —
|
|
95
|
+
// width alone must never be the only signal (WO risk note).
|
|
96
|
+
const accessibleLabel = `${option.text || option.label}, ${resultText}${votedByMe ? `, ${t('MessagingPoll.YOU_VOTED')}` : ''}`;
|
|
97
|
+
const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.25}>
|
|
98
|
+
<Stack direction="row" justifyContent="space-between">
|
|
99
|
+
<Typography variant="body2" noWrap>{option.text || option.label}</Typography>
|
|
100
|
+
<Typography variant="caption" color="text.secondary">{resultText}</Typography>
|
|
101
|
+
</Stack>
|
|
102
|
+
<LinearProgress variant="determinate" value={percent} sx={{ borderRadius: 1, height: 6 }} color={votedByMe ? 'primary' : 'inherit'} />
|
|
103
|
+
</Stack>;
|
|
104
|
+
return { accessibleLabel, richLabel };
|
|
105
|
+
};
|
|
106
|
+
const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
|
|
107
|
+
|
|
108
|
+
return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.75}>
|
|
39
109
|
<Typography variant="subtitle2">{poll.question}</Typography>
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
110
|
+
{poll.allow_multiple
|
|
111
|
+
? (poll.options || []).map((option) => {
|
|
112
|
+
const { accessibleLabel, richLabel } = optionLabel(option);
|
|
113
|
+
return <FormControlLabel key={option.id} disabled={closed} sx={formControlSx} label={richLabel}
|
|
114
|
+
control={<Checkbox checked={selected.includes(option.id)} inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
|
|
115
|
+
})
|
|
116
|
+
// No RadioGroup-level onChange: it would only ever hand back
|
|
117
|
+
// `event.target.value`, a DOM string -- coercing a non-string option id
|
|
118
|
+
// (dcm's are UUID strings today, but the id type isn't a guaranteed
|
|
119
|
+
// contract). `value` alone still drives each Radio's `checked` via the
|
|
120
|
+
// group's own comparison; each Radio's own `onChange` below fires
|
|
121
|
+
// `tap` with the option's real, correctly-typed id, matching the
|
|
122
|
+
// Checkbox branch above exactly.
|
|
123
|
+
: <RadioGroup value={selected[0] ?? ''} name={`poll-${poll.id}-options`}>
|
|
124
|
+
{(poll.options || []).map((option) => {
|
|
125
|
+
const { accessibleLabel, richLabel } = optionLabel(option);
|
|
126
|
+
return <FormControlLabel key={option.id} value={option.id} disabled={closed} sx={formControlSx} label={richLabel}
|
|
127
|
+
control={<Radio inputProps={{ 'aria-label': accessibleLabel }} onChange={() => tap(option.id)} sx={{ pt: 0.25 }} />} />;
|
|
128
|
+
})}
|
|
129
|
+
</RadioGroup>}
|
|
49
130
|
{error && <Alert severity="error" role="alert">{error}</Alert>}
|
|
50
|
-
<Stack direction="row" spacing={1}
|
|
131
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
132
|
+
{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}
|
|
133
|
+
{closed && <Chip size="small" icon={<LockOutlinedIcon fontSize="small" />} label={t('MessagingPoll.CLOSED')} />}
|
|
134
|
+
</Stack>
|
|
51
135
|
</Stack></Paper>;
|
|
52
136
|
}
|
|
@@ -56,7 +56,9 @@ describe('compact message bubbles', () => {
|
|
|
56
56
|
rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 7, kind: 'poll', body: 'Poll', sender: { id: 1 }, poll: { id: 1, question: 'Where?', options: [], allow_multiple: false } }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
|
|
57
57
|
expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('Where?'))).toBe(true);
|
|
58
58
|
rerender(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api()} active={false}><MessageBubble message={{ id: 8, body: 'File', sender: { id: 1 }, attachments: [{ id: 1, filename: 'notes.pdf', content_type: 'application/pdf' }] }} conversation={{ kind: 'group' }} /></MessagingProvider></AuthContext.Provider>);
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// MSG-6h: a non-image attachment's visible content is now its filename +
|
|
60
|
+
// a file icon (the download semantics live in the accessible name).
|
|
61
|
+
await waitFor(() => expect(screen.getByText('notes.pdf')).toBeTruthy());
|
|
62
|
+
expect(container.querySelector('.MuiPaper-root').contains(screen.getByText('notes.pdf'))).toBe(true);
|
|
61
63
|
});
|
|
62
64
|
});
|
|
@@ -28,12 +28,19 @@ describe('messaging reactions and polls', () => {
|
|
|
28
28
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE' })); await waitFor(() => expect(api.removeReaction).toHaveBeenCalledWith(7, '👍'));
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it('
|
|
31
|
+
it('taps vote immediately (no submit step): single-choice uses radios and only ever sends the last-tapped option, multi-select uses checkboxes and accumulates', async () => {
|
|
32
|
+
// MSG-6g: tap-to-vote, no separate "Abstimmen" button.
|
|
32
33
|
const single = baseApi(); single.votePoll.mockResolvedValue({ poll: { id: 1, options: [] } });
|
|
33
34
|
const { unmount } = render(<MessagingProvider api={single} active={false}><PollCard message={{ id: 7, poll: { id: 1, question: 'One', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: false }}} /></MessagingProvider>);
|
|
34
|
-
const
|
|
35
|
+
const radios = screen.getAllByRole('radio');
|
|
36
|
+
fireEvent.click(radios[0]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [1]));
|
|
37
|
+
fireEvent.click(radios[1]); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2]));
|
|
38
|
+
expect(single.votePoll).toHaveBeenCalledTimes(2);
|
|
39
|
+
unmount();
|
|
35
40
|
const multiple = baseApi(); multiple.votePoll.mockResolvedValue({ poll: { id: 2, options: [] } }); render(<MessagingProvider api={multiple} active={false}><PollCard message={{ id: 8, poll: { id: 2, question: 'Many', options: [{ id: 1, text: 'A' }, { id: 2, text: 'B' }], allow_multiple: true }}} /></MessagingProvider>);
|
|
36
|
-
const multiChoices = screen.getAllByRole('checkbox');
|
|
41
|
+
const multiChoices = screen.getAllByRole('checkbox');
|
|
42
|
+
fireEvent.click(multiChoices[0]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1]));
|
|
43
|
+
fireEvent.click(multiChoices[1]); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
|
|
37
44
|
});
|
|
38
45
|
|
|
39
46
|
it('only exposes close to the creator/capability and surfaces a forbidden close', async () => {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
18
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
19
|
+
|
|
20
|
+
function baseApi(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
24
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function serverPoll(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id: 'poll-1', question: 'Q', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
|
|
31
|
+
options: [
|
|
32
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
|
|
33
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
|
|
34
|
+
],
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function ConnectedPollCard({ messageId }) {
|
|
39
|
+
const { cache } = useMessaging();
|
|
40
|
+
return <PollCard message={cache.messages[messageId]} />;
|
|
41
|
+
}
|
|
42
|
+
function CachePollState({ messageId }) {
|
|
43
|
+
const { cache } = useMessaging();
|
|
44
|
+
return <output data-testid="cache-poll">{JSON.stringify(cache.messages[messageId]?.poll)}</output>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
48
|
+
|
|
49
|
+
describe('MSG-6g: tap-to-vote poll UI with result bars', () => {
|
|
50
|
+
it('1. single-choice: tapping an option votes without further interaction; tapping a second option moves the vote', async () => {
|
|
51
|
+
const api = baseApi();
|
|
52
|
+
api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] } });
|
|
53
|
+
api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-b'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['me'] }] } });
|
|
54
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
55
|
+
const radios = screen.getAllByRole('radio');
|
|
56
|
+
|
|
57
|
+
fireEvent.click(radios[0]);
|
|
58
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
|
|
59
|
+
expect(radios[0].checked).toBe(true);
|
|
60
|
+
|
|
61
|
+
fireEvent.click(radios[1]);
|
|
62
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
|
|
63
|
+
await waitFor(() => expect(radios[1].checked).toBe(true));
|
|
64
|
+
expect(radios[0].checked).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('2. multi-select: tapping toggles one option on and off, and each tap sends the complete option-id set', async () => {
|
|
68
|
+
const api = baseApi();
|
|
69
|
+
api.votePoll.mockResolvedValue({ poll: serverPoll({ allow_multiple: true }) });
|
|
70
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ allow_multiple: true }) }} /></MessagingProvider>);
|
|
71
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
72
|
+
|
|
73
|
+
fireEvent.click(checkboxes[0]);
|
|
74
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
|
|
75
|
+
fireEvent.click(checkboxes[1]);
|
|
76
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a', 'opt-b']));
|
|
77
|
+
fireEvent.click(checkboxes[0]);
|
|
78
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
|
|
79
|
+
expect(api.votePoll).toHaveBeenCalledTimes(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('3. a rejected vote reverts the optimistic state and surfaces an error -- the reverted value, not merely an error message', async () => {
|
|
83
|
+
const api = baseApi();
|
|
84
|
+
api.votePoll.mockRejectedValue({ response: { data: { detail: 'Voting disabled' } } });
|
|
85
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
86
|
+
const radios = screen.getAllByRole('radio');
|
|
87
|
+
|
|
88
|
+
fireEvent.click(radios[0]);
|
|
89
|
+
// Optimistic: briefly checked before the rejection resolves.
|
|
90
|
+
await waitFor(() => expect(radios[0].checked).toBe(true));
|
|
91
|
+
await screen.findByRole('alert');
|
|
92
|
+
await waitFor(() => expect(radios[0].checked).toBe(false));
|
|
93
|
+
expect(radios[1].checked).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('4. bars render proportionally, including the zero-vote case, with no NaN and no division error', () => {
|
|
97
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
98
|
+
// vote_count 0/0 for both options -> 0%, never NaN%.
|
|
99
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
100
|
+
expect(results).toHaveLength(2);
|
|
101
|
+
results.forEach((node) => {
|
|
102
|
+
expect(node.textContent).toMatch(/"count":0/);
|
|
103
|
+
expect(node.textContent).toMatch(/"percent":0/);
|
|
104
|
+
expect(node.textContent).not.toMatch(/NaN/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('5. the viewer\'s own selection survives a poll_updated frame that does not carry voted_option_ids (merge-trap guard)', async () => {
|
|
109
|
+
const api = baseApi();
|
|
110
|
+
render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /></MessagingProvider>);
|
|
111
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: serverPoll({ voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }) } });
|
|
112
|
+
await waitFor(() => expect(screen.getAllByRole('radio')[0].checked).toBe(true));
|
|
113
|
+
|
|
114
|
+
// A poll_updated frame never carries voted_option_ids (dcm's serialize_poll
|
|
115
|
+
// is viewer-independent) -- must not wipe the REST-known selection.
|
|
116
|
+
dispatchMessagingFrame({
|
|
117
|
+
envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
|
|
118
|
+
poll: serverPoll({ options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 2, voters: ['me', 'other'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }),
|
|
119
|
+
});
|
|
120
|
+
await waitFor(() => expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2/)).toBeTruthy());
|
|
121
|
+
expect(screen.getAllByRole('radio')[0].checked).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('6. a closed poll is non-interactive: tapping an option does nothing and issues no request', () => {
|
|
125
|
+
const api = baseApi();
|
|
126
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ closed_at: '2026-08-01T00:00:00Z' }) }} /></MessagingProvider>);
|
|
127
|
+
const radios = screen.getAllByRole('radio');
|
|
128
|
+
fireEvent.click(radios[0]);
|
|
129
|
+
expect(api.votePoll).not.toHaveBeenCalled();
|
|
130
|
+
expect(radios[0].checked).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('a rapid switch (A then B, before the first vote confirms) projects counts from the freshest cache state, not a stale prop (regression)', async () => {
|
|
134
|
+
// MessagingProvider.castPollVote must build its optimistic count delta from
|
|
135
|
+
// `previousPoll` (freshly read from the shared cache at call time), never
|
|
136
|
+
// from the caller-supplied `poll` argument. PollCard is deliberately given
|
|
137
|
+
// a STATIC message prop here (never re-rendered with a fresh one between
|
|
138
|
+
// taps, exactly like PollCard's own documented "dumb, prop-driven"
|
|
139
|
+
// contract, and exactly how a host that doesn't re-connect it to live
|
|
140
|
+
// cache updates would behave) -- a separate, genuinely cache-connected
|
|
141
|
+
// reader (CachePollState) is what must show the correct number. Starting
|
|
142
|
+
// counts are non-zero so a wrong baseline isn't masked by the
|
|
143
|
+
// Math.max(0, ...) clamp.
|
|
144
|
+
const api = baseApi();
|
|
145
|
+
api.votePoll.mockReturnValue(new Promise(() => {})); // never resolves -- isolate the optimistic phase
|
|
146
|
+
const seedPoll = serverPoll({
|
|
147
|
+
voted_option_ids: [],
|
|
148
|
+
options: [
|
|
149
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 5, voters: ['u1', 'u2', 'u3', 'u4', 'u5'] },
|
|
150
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 2, voters: ['u6', 'u7'] },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: seedPoll }} /><CachePollState messageId="msg-1" /></MessagingProvider>);
|
|
154
|
+
const radios = screen.getAllByRole('radio');
|
|
155
|
+
|
|
156
|
+
fireEvent.click(radios[0]); // vote A: 5 -> 6
|
|
157
|
+
await waitFor(() => expect(JSON.parse(screen.getByTestId('cache-poll').textContent).options.find((option) => option.id === 'opt-a').vote_count).toBe(6));
|
|
158
|
+
|
|
159
|
+
fireEvent.click(radios[1]); // switch to B: A must go 6 -> 5 in the shared cache (a stale-prop baseline of 5 would wrongly compute 4)
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
const poll = JSON.parse(screen.getByTestId('cache-poll').textContent);
|
|
162
|
+
expect(poll.options.find((option) => option.id === 'opt-a').vote_count).toBe(5);
|
|
163
|
+
expect(poll.options.find((option) => option.id === 'opt-b').vote_count).toBe(3);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|