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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user