diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index b0af3b59a..054cb8612 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -23,11 +23,12 @@ type PollAnswer = { type PollData = { question?: { body?: string; 'm.text'?: PollTextValue }; answers?: PollAnswer[]; + max_selections?: number; }; type VoteState = { counts: Map; - myVote: string | null; + myVotes: Set; 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(); + // Per-sender keep only the latest response (which may include multiple selections) + const latestBySender = new Map(); 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(); - 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(); + 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(() => { - 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'}`} {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 && ( )} - {text} - {selected && ( - + {isMultiple && ( + + {selected ? '✓' : ''} + )} + {!isMultiple && ( + + )} + {text} {total > 0 && ( - {pct}% + {pct}% )} @@ -307,12 +364,14 @@ export function PollContent({ - {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'} diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 01b1f7bb6..8959d9c7e 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -11,6 +11,7 @@ import { Text, Tooltip, TooltipProvider, + color, config, } from 'folds'; import { EventType, MsgType, Room } from 'matrix-js-sdk'; @@ -38,6 +39,82 @@ const TAB_MSGTYPES: Record = { file: MsgType.File, }; +type ThumbState = 'loading' | 'error' | 'ok'; + +function ImageTile({ + thumbUrl, + fullUrl, + body, + isEncrypted, +}: { + thumbUrl: string | null; + fullUrl: string; + body: string; + isEncrypted: boolean; +}) { + const [thumbState, setThumbState] = useState(thumbUrl ? 'loading' : 'error'); + + return ( + + {thumbUrl && thumbState !== 'error' && ( + {body} setThumbState('ok')} + onError={() => setThumbState('error')} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', + opacity: thumbState === 'ok' ? 1 : 0, + }} + /> + )} + {(thumbState === 'error' || !thumbUrl) && ( + + + + {body || (isEncrypted ? 'Encrypted' : 'Image')} + + + )} + + ); +} + function TabButton({ label, active, @@ -70,7 +147,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { const msgtype = TAB_MSGTYPES[tab]; - // Read already-decrypted events from the live timeline (works for E2EE rooms) const getFilteredEvents = useCallback(() => { const timeline = room.getLiveTimeline(); return timeline @@ -81,7 +157,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype; }) .slice() - .reverse(); // newest first + .reverse(); }, [room, msgtype]); const [events, setEvents] = useState(() => getFilteredEvents()); @@ -116,74 +192,62 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { bottom: 0, width: '320px', zIndex: 500, - background: 'var(--bg-surface)', + borderLeft: `1px solid ${color.Surface.ContainerLine}`, overflow: 'hidden', }} > - {/* Header */}
- - + + - Media + Media Gallery - - - Close - - } - > - {(triggerRef) => ( - - - - )} - - + + Close + + } + > + {(triggerRef) => ( + + + + )} +
- {/* Tab bar */} {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( setTab(t)} /> ))} - {/* Content */} - + {loading && events.length === 0 && ( @@ -195,12 +259,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { {!loading && events.length === 0 && ( - {`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`} + {`No ${TAB_LABELS[tab].toLowerCase()} in loaded history. Use Load More to search further back.`} )} - {/* Image/Video grid */} {(tab === 'image' || tab === 'video') && events.length > 0 && (
- {thumbUrl ? ( - {body} { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - style={{ - width: '100%', - height: '100%', - objectFit: 'cover', - display: 'block', - }} - /> - ) : ( - - - - {body || (isEncrypted ? 'Encrypted' : 'Image')} - - - )} - + thumbUrl={thumbUrl} + fullUrl={fullUrl} + body={body} + isEncrypted={isEncrypted} + /> ); })}
)} - {/* File list */} {tab === 'file' && events.length > 0 && ( {events.map((mEvent) => { @@ -292,10 +310,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { alignItems="Center" gap="200" style={{ - padding: `${config.space.S100} ${config.space.S200}`, + padding: `${config.space.S200} ${config.space.S200}`, borderRadius: config.radii.R300, - background: 'var(--bg-surface)', - overflow: 'hidden', + border: `1px solid ${color.Surface.ContainerLine}`, + background: color.Surface.Container, }} > @@ -326,9 +344,8 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { )} - {/* Load more */} - {canLoadMore && !loading && events.length > 0 && ( - + {canLoadMore && !loading && ( + )} - {/* Loading more spinner */} {loading && events.length > 0 && ( diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx index d5e7dbcec..b80e38bc8 100644 --- a/src/app/features/room/PollCreator.tsx +++ b/src/app/features/room/PollCreator.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Room } from 'matrix-js-sdk'; -import { Box, Icon, IconButton, Icons, Text, config } from 'folds'; +import { Box, Button, Icon, IconButton, Icons, Text, config } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; interface PollCreatorProps { @@ -193,34 +193,26 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) { )} -
+ Selection Type -
+ {(['single', 'multiple'] as const).map((type) => { const active = type === 'multiple' ? isMultiple : !isMultiple; return ( - + {type === 'single' ? 'Single choice' : 'Multiple choice'} + ); })} -
-
+
+
{error && ( @@ -229,38 +221,27 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) { )} - - + Cancel + + diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 899ca2ad6..731afe9cf 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from 'jotai'; -import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; @@ -265,9 +265,21 @@ type ClientNonUIFeaturesProps = { children: ReactNode; }; +function SuppressPrintShortcut() { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.code === 'KeyP') e.preventDefault(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> + diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index dd15bd4c1..21ddaca8b 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -284,18 +284,30 @@ export const mxcUrlToHttp = ( height?: number, resizeMethod?: string, allowDirectLinks?: boolean, - // Synapse's thumbnail endpoint returns 400 for allow_redirect=true; keep false everywhere. - allowRedirects = false, -): string | null => - mx.mxcUrlToHttp( - mxcUrl, - width, - height, - resizeMethod, - allowDirectLinks, - allowRedirects, - useAuthentication, - ); +): string | null => { + // Build the URL manually so we never add allow_redirect. + // The SDK forces allow_redirect=true when useAuthentication=true, but Synapse's + // /_matrix/client/v1/media/thumbnail endpoint rejects that parameter with 400. + if (!mxcUrl) return null; + if (!mxcUrl.startsWith('mxc://')) { + return allowDirectLinks ? mxcUrl : null; + } + const parts = mxcUrl.slice(6).split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + const [serverName, mediaId] = parts; + + const isThumbnail = !!(width || height || resizeMethod); + const verb = isThumbnail ? 'thumbnail' : 'download'; + const prefix = useAuthentication + ? `/_matrix/client/v1/media/${verb}` + : `/_matrix/media/v3/${verb}`; + + const url = new URL(`${prefix}/${serverName}/${mediaId}`, mx.getHomeserverUrl()); + if (width) url.searchParams.set('width', String(Math.round(width))); + if (height) url.searchParams.set('height', String(Math.round(height))); + if (resizeMethod) url.searchParams.set('method', resizeMethod); + return url.href; +}; export const downloadMedia = async (src: string): Promise => { // this request is authenticated by service worker