Compare commits

..

3 Commits

Author SHA1 Message Date
jared 4af07109c3 feat: QR code in invite modal; fix CSP to allow api.qrserver.com
CI / Build & Quality Checks (push) Successful in 10m30s
InviteUserPrompt: add QR code toggle button (Icons.BlockCode) in header.
When toggled, shows a 180x180 QR code image (api.qrserver.com) and the
raw invite URL below it, between the header and the search form.
inviteUrl computed once and shared between Copy Link and QR display.

Server: added https://api.qrserver.com to img-src in CSP header on
LXC 106 (/etc/nginx/sites-available/cinny) — nginx reloaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:10:25 -04:00
jared 69249d1746 fix: media gallery thumbnails — skip auth URL, handle encrypted media
- Use useAuthentication=false for thumbnail requests: the v1 authenticated
  URL adds allow_redirect=true which Synapse rejects with 400
- Encrypted events (content.file set) show a lock+filename placeholder
  since server can't thumbnail encrypted blobs
- Unencrypted thumbnails add onError handler to hide broken images gracefully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:13:18 -04:00
jared c72cd7fef3 revert: remove redundant QuickSwitcher (Ctrl+K already does this better)
The existing SearchModalRenderer (Ctrl+K) is already a polished room/DM
switcher with avatars, unread badges, fuzzy search, and keyboard nav.
Our QuickSwitcher was an inferior duplicate. Removing it entirely:
- Delete QuickSwitcher.tsx
- Remove QuickSwitcherFeature from ClientNonUIFeatures
- Remove quickSwitcherKey from settingsAtom
- Remove Keyboard Shortcuts section from General settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:03:50 -04:00
9 changed files with 91 additions and 287 deletions
Submodule .claude/worktrees/agent-a4cb7be95d74ab7e7 added at f7c39e20a9
Submodule .claude/worktrees/agent-aaee4049508b9f175 added at f7c39e20a9
Submodule .claude/worktrees/agent-ac5c7e09bae2b0939 added at f7c39e20a9
-206
View File
@@ -1,206 +0,0 @@
import React, {
ChangeEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useAtomValue } from 'jotai';
import { Avatar, Box, Icon, Icons, Input, Text, config } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mDirectAtom } from '../state/mDirectList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { nameInitials } from '../utils/common';
const MAX_RESULTS = 10;
function RoomFallback({ room }: { room: Room }) {
return (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
);
}
type QuickSwitcherProps = {
onClose: () => void;
};
export function QuickSwitcher({ onClose }: QuickSwitcherProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const allRoomIds = useAtomValue(allRoomsAtom);
const mDirects = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [query, setQuery] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const filteredRooms = query.trim()
? allRoomIds
.map((id) => ({ id, room: mx.getRoom(id) }))
.filter(({ room }) => room && room.name.toLowerCase().includes(query.trim().toLowerCase()))
.slice(0, MAX_RESULTS)
: allRoomIds
.map((id) => ({ id, room: mx.getRoom(id) }))
.filter(({ room }) => !!room)
.slice(0, MAX_RESULTS);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setQuery(evt.currentTarget.value);
setSelectedIdx(0);
};
const navigateToRoom = useCallback(
(roomId: string) => {
navigateRoom(roomId);
onClose();
},
[navigateRoom, onClose],
);
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
(evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setSelectedIdx((i) => Math.min(i + 1, filteredRooms.length - 1));
} else if (evt.key === 'ArrowUp') {
evt.preventDefault();
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (evt.key === 'Enter') {
const item = filteredRooms[selectedIdx];
if (item) navigateToRoom(item.id);
} else if (evt.key === 'Escape') {
onClose();
}
},
[filteredRooms, selectedIdx, navigateToRoom, onClose],
);
return (
<>
{/* Backdrop */}
<div
role="presentation"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 9899,
}}
onClick={onClose}
/>
{/* Modal */}
<div
role="dialog"
aria-label="Quick Room Switcher"
aria-modal="true"
style={{
position: 'fixed',
top: '20%',
left: '50%',
transform: 'translateX(-50%)',
width: 'min(560px, 90vw)',
zIndex: 9900,
background: 'var(--bg-surface)',
borderRadius: config.radii.R400,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
overflow: 'hidden',
}}
>
{/* Search input */}
<Box style={{ padding: config.space.S300 }}>
<Input
ref={inputRef}
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="500"
variant="Background"
outlined
/>
</Box>
{/* Results list */}
{filteredRooms.length > 0 && (
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
{filteredRooms.map(({ id, room }, idx) => {
if (!room) return null;
const dm = mDirects.has(id);
const avatarUrl = dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication);
const isSelected = idx === selectedIdx;
return (
<button
key={id}
type="button"
aria-selected={isSelected}
onClick={() => navigateToRoom(id)}
onMouseEnter={() => setSelectedIdx(idx)}
style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
padding: `${config.space.S200} ${config.space.S300}`,
background: isSelected
? 'var(--bg-surface-hover, rgba(255,255,255,0.08))'
: 'transparent',
border: 'none',
cursor: 'pointer',
width: '100%',
textAlign: 'left',
color: 'inherit',
}}
>
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={id}
src={avatarUrl}
alt={room.name}
renderFallback={() => <RoomFallback room={room} />}
/>
) : (
<RoomIcon
size="200"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
<Text truncate size="T400">
{room.name}
</Text>
</button>
);
})}
</Box>
)}
{filteredRooms.length === 0 && (
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S500 }}>
<Text size="T300" align="Center">
{query.trim() ? `No rooms matching "${query}"` : 'No rooms'}
</Text>
</Box>
)}
</div>
</>
);
}
@@ -68,11 +68,16 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [linkCopied, setLinkCopied] = useState(false);
const [showQr, setShowQr] = useState(false);
const handleCopyLink = () => {
const inviteUrl = (() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
return getMatrixToRoom(roomIdOrAlias, viaServers);
})();
const handleCopyLink = () => {
copyToClipboard(inviteUrl);
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
};
@@ -202,11 +207,47 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
>
<Text size="B300">{linkCopied ? 'Copied!' : 'Copy Link'}</Text>
</Button>
<IconButton
size="300"
radii="300"
variant={showQr ? 'Primary' : 'Surface'}
fill={showQr ? 'Soft' : 'None'}
aria-label="Show QR code"
aria-pressed={showQr}
onClick={() => setShowQr((v) => !v)}
>
<Icon src={Icons.BlockCode} size="100" />
</IconButton>
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{showQr && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{
padding: config.space.S300,
borderBottom: `1px solid var(--bg-surface-border)`,
}}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
alt="QR code for room invite link"
width={180}
height={180}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
<Text
size="T200"
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
>
{inviteUrl}
</Text>
</Box>
)}
<Box
as="form"
onSubmit={handleSubmit}
+35 -6
View File
@@ -211,29 +211,41 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
>
{events.map((mEvent) => {
const content = mEvent.getContent();
const isEncrypted = !!content.file;
const mxcUrl: string | undefined = content.url ?? content.file?.url;
if (!mxcUrl) return null;
const thumbUrl =
mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? '';
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);
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
return (
<a
key={mEvent.getId()}
href={mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'}
target="_blank"
href={isEncrypted ? '#' : fullUrl}
target={isEncrypted ? undefined : '_blank'}
rel="noreferrer"
style={{
display: 'block',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: 'var(--bg-surface)',
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%',
@@ -241,6 +253,23 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
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>
);
})}
@@ -1316,43 +1316,6 @@ function Messages() {
);
}
function KeyboardShortcuts() {
const [quickSwitcherKey, setQuickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
const qsBind = useKeyBind(setQuickSwitcherKey);
return (
<Box direction="Column" gap="100">
<Text size="L400">Keyboard Shortcuts</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Quick Room Switcher"
description="Open the quick room switcher overlay. Ctrl/Cmd + this key."
after={
<Button
size="300"
variant={qsBind.listening ? 'Warning' : 'Secondary'}
fill={qsBind.listening ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={qsBind.startListening}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{qsBind.listening ? 'Press a key…' : `Ctrl+${keyLabel(quickSwitcherKey)}`}
</Text>
</Button>
}
/>
</SequenceCard>
</Box>
);
}
type GeneralProps = {
requestClose: () => void;
};
@@ -1383,7 +1346,6 @@ export function General({ requestClose }: GeneralProps) {
<Messages />
<Privacy />
<Calls />
<KeyboardShortcuts />
</Box>
</PageContent>
</Scroll>
@@ -27,7 +27,6 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { QuickSwitcher } from '../../components/QuickSwitcher';
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@@ -262,27 +261,6 @@ function MessageNotifications() {
);
}
function QuickSwitcherFeature() {
const [open, setOpen] = useState(false);
const [quickSwitcherKey] = useSetting(settingsAtom, 'quickSwitcherKey');
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === quickSwitcherKey) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [quickSwitcherKey]);
if (!open) return null;
return <QuickSwitcher onClose={() => setOpen(false)} />;
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -296,7 +274,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PresenceUpdater />
<InviteNotifications />
<MessageNotifications />
<QuickSwitcherFeature />
{children}
</>
);
-2
View File
@@ -82,7 +82,6 @@ export interface Settings {
nightLightOpacity: number;
deafenKey: string;
quickSwitcherKey: string;
}
const defaultSettings: Settings = {
@@ -134,7 +133,6 @@ const defaultSettings: Settings = {
nightLightOpacity: 30,
deafenKey: 'KeyM',
quickSwitcherKey: 'KeyP',
};
export const getSettings = (): Settings => {