From cb848be0b68e7187dc3d3e8994f8f22cf537e43f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 3 Jun 2026 11:23:44 -0400 Subject: [PATCH] fix: ctrl+p print dialog, gallery 400 error, poll multi-choice UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 @@ -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