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:
2026-06-03 11:23:44 -04:00
parent 9232e1ec8e
commit a3f776134f
5 changed files with 294 additions and 214 deletions
+123 -107
View File
@@ -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<GalleryTab, MsgType> = {
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<ThumbState>(thumbUrl ? 'loading' : 'error');
return (
<a
href={isEncrypted ? undefined : fullUrl}
target={isEncrypted ? undefined : '_blank'}
rel="noreferrer"
title={body}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
cursor: isEncrypted ? 'default' : 'pointer',
textDecoration: 'none',
position: 'relative',
}}
>
{thumbUrl && thumbState !== 'error' && (
<img
src={thumbUrl}
alt={body}
onLoad={() => setThumbState('ok')}
onError={() => setThumbState('error')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
opacity: thumbState === 'ok' ? 1 : 0,
}}
/>
)}
{(thumbState === 'error' || !thumbUrl) && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
padding: config.space.S100,
position: 'absolute',
inset: 0,
justifyContent: 'center',
}}
>
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
>
{body || (isEncrypted ? 'Encrypted' : 'Image')}
</Text>
</Box>
)}
</a>
);
}
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 */}
<Header
variant="Background"
variant="Surface"
size="600"
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H5" truncate>
Media
Media Gallery
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close media gallery"
onClick={onClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close media gallery"
onClick={onClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Header>
{/* Tab bar */}
<Box
shrink="No"
gap="100"
style={{
padding: config.space.S200,
}}
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
))}
</Box>
{/* Content */}
<Box
grow="Yes"
style={{
position: 'relative',
overflow: 'hidden',
}}
>
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
{loading && events.length === 0 && (
@@ -195,12 +259,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
{!loading && events.length === 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Text size="T300" priority="300" align="Center">
{`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`}
{`No ${TAB_LABELS[tab].toLowerCase()} in loaded history. Use Load More to search further back.`}
</Text>
</Box>
)}
{/* Image/Video grid */}
{(tab === 'image' || tab === 'video') && events.length > 0 && (
<div
style={{
@@ -215,68 +278,23 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mxcUrl: string | undefined = content.url ?? content.file?.url;
if (!mxcUrl) return null;
const body: string = content.body ?? '';
// Use unauthenticated thumbnail URL — the v1 authenticated endpoint adds
// allow_redirect=true which Synapse rejects with 400.
const thumbUrl = isEncrypted
? null
: (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null);
: (mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? null);
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
return (
<a
<ImageTile
key={mEvent.getId()}
href={isEncrypted ? '#' : fullUrl}
target={isEncrypted ? undefined : '_blank'}
rel="noreferrer"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: 'var(--bg-surface-low)',
cursor: isEncrypted ? 'default' : 'pointer',
}}
title={body}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={body}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
>
{body || (isEncrypted ? 'Encrypted' : 'Image')}
</Text>
</Box>
)}
</a>
thumbUrl={thumbUrl}
fullUrl={fullUrl}
body={body}
isEncrypted={isEncrypted}
/>
);
})}
</div>
)}
{/* File list */}
{tab === 'file' && events.length > 0 && (
<Box direction="Column" gap="100">
{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,
}}
>
<Icon size="200" src={Icons.File} />
@@ -326,9 +344,8 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Box>
)}
{/* Load more */}
{canLoadMore && !loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
{canLoadMore && !loading && (
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
<Button
size="300"
variant="Secondary"
@@ -336,12 +353,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load more</Text>
<Text size="B300">Load More History</Text>
</Button>
</Box>
)}
{/* Loading more spinner */}
{loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner />