@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.
@@ -1,17 +1,40 @@
1
- import { Box, Button, Stack } from '@mui/material';
2
- import { useEffect, useState } from 'react';
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
- /** Download-only attachment renderer; all bytes remain behind the authenticated REST endpoints. */
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
- return <Stack direction="row" spacing={1} flexWrap="wrap" aria-label={t('MessagingAttachments.LABEL')}>
34
- {attachments.map((attachment) => <Button key={attachment.id} type="button" size="small" onClick={() => download(attachment)}>
35
- {previews[attachment.id] && <Box component="img" src={previews[attachment.id]} alt="" width={36} height={36} sx={{ objectFit: 'cover', mr: 0.75 }} />}{t('MessagingAttachments.DOWNLOAD', { name: nameOf(attachment) })}
36
- </Button>)}
37
- </Stack>;
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
- {!deleted && (reactions.length > 0 || reactionPickerVisible) && <ReactionBar message={message} expanded={reactionPickerVisible} onExpandedChange={setReactionPickerVisible} sx={{ mt: -0.5, ml: 0.75, position: 'relative', zIndex: 1 }} />}
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
- const result = await api.votePoll(poll.id, optionIds);
425
- const nextPoll = { ...poll, ...(result?.poll || result) };
426
- patchMessage({ ...(cache.messages[messageId] || { id: messageId }), poll: nextPoll });
427
- return result;
428
- }, [api, cache.messages, patchMessage]);
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 { Alert, Button, Checkbox, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
2
- import { useEffect, useState } from 'react';
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
- /** Independently mountable live poll view. Server authorization remains authoritative. */
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 changeSelection = (id) => setSelected((current) => poll.allow_multiple ? (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) : (current.includes(id) ? [] : [id]));
29
- const vote = async () => {
30
- if (!selected.length) { setError(t('MessagingPoll.SELECT_OPTION')); return; }
31
- setBusy(true); setError(null);
32
- try { await castPollVote(message.id, poll, selected); } catch (voteError) { setError(t('MessagingPoll.VOTE_ERROR', { message: extractApiErrorMessage(voteError) })); } finally { setBusy(false); }
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
- return <Paper variant="outlined" sx={{ p: 1 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={0.5}>
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
- {(poll.options || []).map((option) => {
41
- // dcm's poll projection gives voter user ids only, never names — there
42
- // is no user-directory endpoint (same structural limit already
43
- // established for the DM candidate list). A count is always correct;
44
- // marking the viewer's own vote is derivable from voted_option_ids.
45
- const votedByMe = Boolean(poll.voted_option_ids?.includes(option.id));
46
- const voterCount = option.voters?.length ?? option.vote_count ?? option.votes_count ?? 0;
47
- return <FormControlLabel key={option.id} disabled={closed || busy} control={<Checkbox checked={selected.includes(option.id)} onChange={() => changeSelection(option.id)} />} label={`${option.text || option.label} (${option.vote_count ?? voterCount ?? 0})${votedByMe ? ` — ${t('MessagingPoll.YOU_VOTED')}` : ''}`} />;
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}><Button size="small" onClick={vote} disabled={closed || busy}>{t('MessagingPoll.VOTE')}</Button>{canClose && !closed && <Button size="small" color="warning" onClick={close} disabled={busy}>{t('MessagingPoll.CLOSE')}</Button>}{closed && <Typography variant="caption">{t('MessagingPoll.CLOSED')}</Typography>}</Stack>
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
- await waitFor(() => expect(screen.getByText(/MessagingAttachments\.DOWNLOAD/)).toBeTruthy());
60
- expect(container.querySelector('.MuiPaper-root').contains(screen.getByText(/MessagingAttachments\.DOWNLOAD/))).toBe(true);
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('uses single selection for a single poll and preserves selections for a multi poll', async () => {
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 choices = screen.getAllByRole('checkbox'); fireEvent.click(choices[0]); fireEvent.click(choices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(single.votePoll).toHaveBeenCalledWith(1, [2])); unmount();
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'); fireEvent.click(multiChoices[0]); fireEvent.click(multiChoices[1]); fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.VOTE' })); await waitFor(() => expect(multiple.votePoll).toHaveBeenCalledWith(2, [1, 2]));
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
+ });