fix: ctrl+p print dialog, gallery 400 error, poll multi-choice UX
- Suppress Ctrl+P browser print dialog via SuppressPrintShortcut in ClientNonUIFeatures (no UI opened, just preventDefault) - mxcUrlToHttp: build URL manually instead of delegating to SDK. The SDK forces allow_redirect=true when useAuthentication=true; Synapse's /_matrix/client/v1/media/thumbnail rejects that with 400. Manual construction omits allow_redirect entirely. - Gallery: redesign using folds color tokens (color.Surface.*) instead of non-existent CSS custom properties; add ThumbState so broken images show an icon placeholder; use useAuthentication for thumbnails now that the URL builder is fixed; "Load More" always visible. - PollCreator: replace raw <button> with folds Button components so the Single/Multiple choice toggle renders with actual visual difference. - PollContent: support multiple-choice polls end-to-end — myVote:string → myVotes:Set<string>; computeVotes collects all m.selections (not just [0]); toggle-select for multi, radio for single; checkbox/radio indicator icons next to each option; "◉ Poll · Multiple choice" / "Single choice" label in header; sends full selections array on every vote event. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,12 @@ type PollAnswer = {
|
||||
type PollData = {
|
||||
question?: { body?: string; 'm.text'?: PollTextValue };
|
||||
answers?: PollAnswer[];
|
||||
max_selections?: number;
|
||||
};
|
||||
|
||||
type VoteState = {
|
||||
counts: Map<string, number>;
|
||||
myVote: string | null;
|
||||
myVotes: Set<string>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ function computeVotes(
|
||||
eventId: string,
|
||||
_isStable: boolean,
|
||||
): VoteState {
|
||||
const empty: VoteState = { counts: new Map(), myVote: null, total: 0 };
|
||||
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return empty;
|
||||
|
||||
@@ -53,8 +54,8 @@ function computeVotes(
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Collect all response events; per-sender keep only latest
|
||||
const latestBySender = new Map<string, { ts: number; answerId: string }>();
|
||||
// Per-sender keep only the latest response (which may include multiple selections)
|
||||
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
|
||||
const myUserId = mx.getSafeUserId();
|
||||
|
||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||
@@ -64,19 +65,20 @@ function computeVotes(
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const content = ev.getContent();
|
||||
let answerId: string | undefined;
|
||||
let answerIds: string[] = [];
|
||||
if (stable) {
|
||||
answerId = (content['m.selections'] as string[] | undefined)?.[0];
|
||||
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
||||
} else {
|
||||
answerId = (
|
||||
(content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined
|
||||
)?.[0];
|
||||
answerIds =
|
||||
((content['org.matrix.msc3381.poll.response'] as any)?.answers as
|
||||
| string[]
|
||||
| undefined) ?? [];
|
||||
}
|
||||
if (!answerId) continue;
|
||||
if (answerIds.length === 0) continue;
|
||||
const ts = ev.getTs();
|
||||
const existing = latestBySender.get(sender);
|
||||
if (!existing || ts > existing.ts) {
|
||||
latestBySender.set(sender, { ts, answerId });
|
||||
latestBySender.set(sender, { ts, answerIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -85,13 +87,15 @@ function computeVotes(
|
||||
processRelations(unstableRels, false);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
let myVote: string | null = null;
|
||||
for (const [sender, { answerId }] of latestBySender) {
|
||||
counts.set(answerId, (counts.get(answerId) ?? 0) + 1);
|
||||
if (sender === myUserId) myVote = answerId;
|
||||
const myVotes = new Set<string>();
|
||||
for (const [sender, { answerIds }] of latestBySender) {
|
||||
for (const id of answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVote, total: latestBySender.size };
|
||||
return { counts, myVotes, total: latestBySender.size };
|
||||
}
|
||||
|
||||
export function PollContent({
|
||||
@@ -111,7 +115,7 @@ export function PollContent({
|
||||
| undefined;
|
||||
|
||||
const [votes, setVotes] = useState<VoteState>(() => {
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 };
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
return computeVotes(mx, roomId, eventId, _isStable);
|
||||
});
|
||||
|
||||
@@ -184,30 +188,50 @@ export function PollContent({
|
||||
'Untitled poll';
|
||||
|
||||
const canVote = !!roomId && !!eventId;
|
||||
const { counts, myVote, total } = votes;
|
||||
const maxSelections = (poll as any).max_selections ?? 1;
|
||||
const isMultiple = maxSelections > 1;
|
||||
const { counts, myVotes, total } = votes;
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId) return;
|
||||
|
||||
const newVotes = new Set(myVotes);
|
||||
if (newVotes.has(answerId)) {
|
||||
newVotes.delete(answerId);
|
||||
} else {
|
||||
if (!isMultiple) newVotes.clear();
|
||||
newVotes.add(answerId);
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
setVotes((prev) => {
|
||||
const next = new Map(prev.counts);
|
||||
if (prev.myVote) {
|
||||
const prevCount = next.get(prev.myVote) ?? 1;
|
||||
if (prevCount <= 1) next.delete(prev.myVote);
|
||||
else next.set(prev.myVote, prevCount - 1);
|
||||
// Remove all old vote counts for this user
|
||||
for (const id of prev.myVotes) {
|
||||
const c = next.get(id) ?? 1;
|
||||
if (c <= 1) next.delete(id);
|
||||
else next.set(id, c - 1);
|
||||
}
|
||||
next.set(answerId, (next.get(answerId) ?? 0) + 1);
|
||||
return { counts: next, myVote: answerId, total: prev.myVote ? prev.total : prev.total + 1 };
|
||||
// Add new vote counts
|
||||
for (const id of newVotes) {
|
||||
next.set(id, (next.get(id) ?? 0) + 1);
|
||||
}
|
||||
const hadVotes = prev.myVotes.size > 0;
|
||||
const hasVotes = newVotes.size > 0;
|
||||
const newTotal = prev.total + (hasVotes && !hadVotes ? 1 : !hasVotes && hadVotes ? -1 : 0);
|
||||
return { counts: next, myVotes: newVotes, total: newTotal };
|
||||
});
|
||||
|
||||
const selectionsArr = Array.from(newVotes);
|
||||
if (_isStable) {
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': [answerId],
|
||||
'm.selections': selectionsArr,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'org.matrix.msc3381.poll.response': { answers: [answerId] },
|
||||
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
@@ -234,7 +258,7 @@ export function PollContent({
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
◉ Poll
|
||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||
</Box>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{questionText}
|
||||
@@ -246,7 +270,7 @@ export function PollContent({
|
||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
||||
`Option ${i + 1}`;
|
||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
||||
const selected = myVote === id;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
return (
|
||||
@@ -259,46 +283,79 @@ export function PollContent({
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: '8px',
|
||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
||||
border: `1px solid ${
|
||||
selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'
|
||||
}`,
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
|
||||
: 'rgba(255,255,255,0.04)',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.7)' : 'rgba(255,255,255,0.12)'}`,
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'var(--text-primary)',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* vote progress bar */}
|
||||
{total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
||||
borderRadius: '8px',
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
|
||||
: 'rgba(255,255,255,0.03)',
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
||||
>
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
{selected && (
|
||||
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}>✓</span>
|
||||
{isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
borderRadius: '3px',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: '#fff',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{selected ? '✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
{!isMultiple && (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
borderRadius: '50%',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||
{total > 0 && (
|
||||
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -307,12 +364,14 @@ export function PollContent({
|
||||
</Box>
|
||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''}
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||
{canVote
|
||||
? myVote
|
||||
? 'click another to change'
|
||||
: 'click an option to vote'
|
||||
: 'voting not available'}
|
||||
? isMultiple
|
||||
? 'Select all that apply'
|
||||
: myVotes.size > 0
|
||||
? 'Click to change'
|
||||
: 'Click to vote'
|
||||
: 'Voting not available'}
|
||||
</i>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user