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:
@@ -23,11 +23,12 @@ type PollAnswer = {
|
|||||||
type PollData = {
|
type PollData = {
|
||||||
question?: { body?: string; 'm.text'?: PollTextValue };
|
question?: { body?: string; 'm.text'?: PollTextValue };
|
||||||
answers?: PollAnswer[];
|
answers?: PollAnswer[];
|
||||||
|
max_selections?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VoteState = {
|
type VoteState = {
|
||||||
counts: Map<string, number>;
|
counts: Map<string, number>;
|
||||||
myVote: string | null;
|
myVotes: Set<string>;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ function computeVotes(
|
|||||||
eventId: string,
|
eventId: string,
|
||||||
_isStable: boolean,
|
_isStable: boolean,
|
||||||
): VoteState {
|
): 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);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return empty;
|
if (!room) return empty;
|
||||||
|
|
||||||
@@ -53,8 +54,8 @@ function computeVotes(
|
|||||||
'org.matrix.msc3381.poll.response',
|
'org.matrix.msc3381.poll.response',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect all response events; per-sender keep only latest
|
// Per-sender keep only the latest response (which may include multiple selections)
|
||||||
const latestBySender = new Map<string, { ts: number; answerId: string }>();
|
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
|
||||||
const myUserId = mx.getSafeUserId();
|
const myUserId = mx.getSafeUserId();
|
||||||
|
|
||||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||||
@@ -64,19 +65,20 @@ function computeVotes(
|
|||||||
const sender = ev.getSender();
|
const sender = ev.getSender();
|
||||||
if (!sender) continue;
|
if (!sender) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
let answerId: string | undefined;
|
let answerIds: string[] = [];
|
||||||
if (stable) {
|
if (stable) {
|
||||||
answerId = (content['m.selections'] as string[] | undefined)?.[0];
|
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
||||||
} else {
|
} else {
|
||||||
answerId = (
|
answerIds =
|
||||||
(content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined
|
((content['org.matrix.msc3381.poll.response'] as any)?.answers as
|
||||||
)?.[0];
|
| string[]
|
||||||
|
| undefined) ?? [];
|
||||||
}
|
}
|
||||||
if (!answerId) continue;
|
if (answerIds.length === 0) continue;
|
||||||
const ts = ev.getTs();
|
const ts = ev.getTs();
|
||||||
const existing = latestBySender.get(sender);
|
const existing = latestBySender.get(sender);
|
||||||
if (!existing || ts > existing.ts) {
|
if (!existing || ts > existing.ts) {
|
||||||
latestBySender.set(sender, { ts, answerId });
|
latestBySender.set(sender, { ts, answerIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -85,13 +87,15 @@ function computeVotes(
|
|||||||
processRelations(unstableRels, false);
|
processRelations(unstableRels, false);
|
||||||
|
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
let myVote: string | null = null;
|
const myVotes = new Set<string>();
|
||||||
for (const [sender, { answerId }] of latestBySender) {
|
for (const [sender, { answerIds }] of latestBySender) {
|
||||||
counts.set(answerId, (counts.get(answerId) ?? 0) + 1);
|
for (const id of answerIds) {
|
||||||
if (sender === myUserId) myVote = answerId;
|
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({
|
export function PollContent({
|
||||||
@@ -111,7 +115,7 @@ export function PollContent({
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const [votes, setVotes] = useState<VoteState>(() => {
|
const [votes, setVotes] = useState<VoteState>(() => {
|
||||||
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);
|
return computeVotes(mx, roomId, eventId, _isStable);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,30 +188,50 @@ export function PollContent({
|
|||||||
'Untitled poll';
|
'Untitled poll';
|
||||||
|
|
||||||
const canVote = !!roomId && !!eventId;
|
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) => {
|
const handleVote = (answerId: string) => {
|
||||||
if (!roomId || !eventId) return;
|
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
|
// Optimistic local update
|
||||||
setVotes((prev) => {
|
setVotes((prev) => {
|
||||||
const next = new Map(prev.counts);
|
const next = new Map(prev.counts);
|
||||||
if (prev.myVote) {
|
// Remove all old vote counts for this user
|
||||||
const prevCount = next.get(prev.myVote) ?? 1;
|
for (const id of prev.myVotes) {
|
||||||
if (prevCount <= 1) next.delete(prev.myVote);
|
const c = next.get(id) ?? 1;
|
||||||
else next.set(prev.myVote, prevCount - 1);
|
if (c <= 1) next.delete(id);
|
||||||
|
else next.set(id, c - 1);
|
||||||
}
|
}
|
||||||
next.set(answerId, (next.get(answerId) ?? 0) + 1);
|
// Add new vote counts
|
||||||
return { counts: next, myVote: answerId, total: prev.myVote ? prev.total : prev.total + 1 };
|
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) {
|
if (_isStable) {
|
||||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
'm.selections': [answerId],
|
'm.selections': selectionsArr,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
} else {
|
} else {
|
||||||
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
'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);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -234,7 +258,7 @@ export function PollContent({
|
|||||||
marginBottom: '2px',
|
marginBottom: '2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
◉ Poll
|
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||||
{questionText}
|
{questionText}
|
||||||
@@ -246,7 +270,7 @@ export function PollContent({
|
|||||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
||||||
`Option ${i + 1}`;
|
`Option ${i + 1}`;
|
||||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
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 voteCount = counts.get(id) ?? 0;
|
||||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
@@ -259,46 +283,79 @@ export function PollContent({
|
|||||||
style={{
|
style={{
|
||||||
padding: '7px 12px',
|
padding: '7px 12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
background: selected
|
||||||
border: `1px solid ${
|
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
|
||||||
selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'
|
: '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',
|
fontSize: '0.88rem',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
cursor: canVote ? 'pointer' : 'default',
|
cursor: canVote ? 'pointer' : 'default',
|
||||||
color: 'var(--text-primary)',
|
color: 'inherit',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* vote progress bar */}
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
right: 'auto',
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
|
background: selected
|
||||||
borderRadius: '8px',
|
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
|
||||||
|
: 'rgba(255,255,255,0.03)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
||||||
>
|
>
|
||||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
{isMultiple && (
|
||||||
{selected && (
|
<span
|
||||||
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}>✓</span>
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#fff',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!isMultiple && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={{ flexGrow: 1 }}>{text}</span>
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -307,12 +364,14 @@ export function PollContent({
|
|||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
||||||
<i>
|
<i>
|
||||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''}
|
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||||
{canVote
|
{canVote
|
||||||
? myVote
|
? isMultiple
|
||||||
? 'click another to change'
|
? 'Select all that apply'
|
||||||
: 'click an option to vote'
|
: myVotes.size > 0
|
||||||
: 'voting not available'}
|
? 'Click to change'
|
||||||
|
: 'Click to vote'
|
||||||
|
: 'Voting not available'}
|
||||||
</i>
|
</i>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { EventType, MsgType, Room } from 'matrix-js-sdk';
|
import { EventType, MsgType, Room } from 'matrix-js-sdk';
|
||||||
@@ -38,6 +39,82 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
|||||||
file: MsgType.File,
|
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({
|
function TabButton({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
@@ -70,7 +147,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
|
|
||||||
const msgtype = TAB_MSGTYPES[tab];
|
const msgtype = TAB_MSGTYPES[tab];
|
||||||
|
|
||||||
// Read already-decrypted events from the live timeline (works for E2EE rooms)
|
|
||||||
const getFilteredEvents = useCallback(() => {
|
const getFilteredEvents = useCallback(() => {
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
return timeline
|
return timeline
|
||||||
@@ -81,7 +157,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype;
|
return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype;
|
||||||
})
|
})
|
||||||
.slice()
|
.slice()
|
||||||
.reverse(); // newest first
|
.reverse();
|
||||||
}, [room, msgtype]);
|
}, [room, msgtype]);
|
||||||
|
|
||||||
const [events, setEvents] = useState(() => getFilteredEvents());
|
const [events, setEvents] = useState(() => getFilteredEvents());
|
||||||
@@ -116,28 +192,27 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: '320px',
|
width: '320px',
|
||||||
zIndex: 500,
|
zIndex: 500,
|
||||||
background: 'var(--bg-surface)',
|
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<Header
|
<Header
|
||||||
variant="Background"
|
variant="Surface"
|
||||||
size="600"
|
size="600"
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
paddingRight: config.space.S200,
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
paddingLeft: config.space.S300,
|
||||||
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<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>
|
<Text size="H5" truncate>
|
||||||
Media
|
Media Gallery
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center">
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
@@ -160,30 +235,19 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||||
padding: config.space.S200,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||||
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
|
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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>
|
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||||
{loading && events.length === 0 && (
|
{loading && events.length === 0 && (
|
||||||
@@ -195,12 +259,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
{!loading && events.length === 0 && (
|
{!loading && events.length === 0 && (
|
||||||
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
|
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
|
||||||
<Text size="T300" priority="300" align="Center">
|
<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>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image/Video grid */}
|
|
||||||
{(tab === 'image' || tab === 'video') && events.length > 0 && (
|
{(tab === 'image' || tab === 'video') && events.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -215,68 +278,23 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
const mxcUrl: string | undefined = content.url ?? content.file?.url;
|
||||||
if (!mxcUrl) return null;
|
if (!mxcUrl) return null;
|
||||||
const body: string = content.body ?? '';
|
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
|
const thumbUrl = isEncrypted
|
||||||
? null
|
? null
|
||||||
: (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null);
|
: (mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? null);
|
||||||
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
|
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
|
||||||
return (
|
return (
|
||||||
<a
|
<ImageTile
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
href={isEncrypted ? '#' : fullUrl}
|
thumbUrl={thumbUrl}
|
||||||
target={isEncrypted ? undefined : '_blank'}
|
fullUrl={fullUrl}
|
||||||
rel="noreferrer"
|
body={body}
|
||||||
style={{
|
isEncrypted={isEncrypted}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File list */}
|
|
||||||
{tab === 'file' && events.length > 0 && (
|
{tab === 'file' && events.length > 0 && (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
{events.map((mEvent) => {
|
{events.map((mEvent) => {
|
||||||
@@ -292,10 +310,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
padding: `${config.space.S200} ${config.space.S200}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
background: 'var(--bg-surface)',
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
overflow: 'hidden',
|
background: color.Surface.Container,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.File} />
|
<Icon size="200" src={Icons.File} />
|
||||||
@@ -326,9 +344,8 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more */}
|
{canLoadMore && !loading && (
|
||||||
{canLoadMore && !loading && events.length > 0 && (
|
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
|
||||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
@@ -336,12 +353,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
>
|
>
|
||||||
<Text size="B300">Load more</Text>
|
<Text size="B300">Load More History</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading more spinner */}
|
|
||||||
{loading && events.length > 0 && (
|
{loading && events.length > 0 && (
|
||||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
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';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
||||||
interface PollCreatorProps {
|
interface PollCreatorProps {
|
||||||
@@ -193,34 +193,26 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Selection Type</Text>
|
<Text size="L400">Selection Type</Text>
|
||||||
<div style={{ display: 'flex', gap: config.space.S200 }}>
|
<Box gap="200">
|
||||||
{(['single', 'multiple'] as const).map((type) => {
|
{(['single', 'multiple'] as const).map((type) => {
|
||||||
const active = type === 'multiple' ? isMultiple : !isMultiple;
|
const active = type === 'multiple' ? isMultiple : !isMultiple;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill={active ? 'Solid' : 'None'}
|
||||||
|
radii="300"
|
||||||
onClick={() => setIsMultiple(type === 'multiple')}
|
onClick={() => setIsMultiple(type === 'multiple')}
|
||||||
style={{
|
|
||||||
padding: `${config.space.S100} ${config.space.S300}`,
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
border: `1px solid ${active ? 'var(--bg-primary-main)' : 'var(--bg-surface-border)'}`,
|
|
||||||
background: active ? 'var(--bg-primary-main)' : 'transparent',
|
|
||||||
color: active ? 'var(--tc-primary-on-primary)' : 'var(--tc-surface-high)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: active ? 600 : 400,
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{type === 'single' ? 'Single choice' : 'Multiple choice'}
|
<Text size="B300">{type === 'single' ? 'Single choice' : 'Multiple choice'}</Text>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||||
@@ -229,38 +221,27 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box direction="Row" justifyContent="End" gap="200">
|
<Box direction="Row" justifyContent="End" gap="200">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="400"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
|
||||||
background: 'var(--bg-surface-low)',
|
|
||||||
border: '1px solid var(--bg-surface-border)',
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S200} ${config.space.S400}`,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'var(--tc-surface-high)',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting}
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-primary-main)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S200} ${config.space.S400}`,
|
|
||||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
|
||||||
color: 'var(--tc-primary-on-primary)',
|
|
||||||
fontSize: '14px',
|
|
||||||
opacity: submitting ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{submitting ? 'Creating...' : 'Create Poll'}
|
<Text size="B400">Cancel</Text>
|
||||||
</button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="400"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B400">{submitting ? 'Creating…' : 'Create Poll'}</Text>
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
@@ -265,9 +265,21 @@ type ClientNonUIFeaturesProps = {
|
|||||||
children: ReactNode;
|
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) {
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SuppressPrintShortcut />
|
||||||
<SystemEmojiFeature />
|
<SystemEmojiFeature />
|
||||||
<PageZoomFeature />
|
<PageZoomFeature />
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
|
|||||||
+24
-12
@@ -284,18 +284,30 @@ export const mxcUrlToHttp = (
|
|||||||
height?: number,
|
height?: number,
|
||||||
resizeMethod?: string,
|
resizeMethod?: string,
|
||||||
allowDirectLinks?: boolean,
|
allowDirectLinks?: boolean,
|
||||||
// Synapse's thumbnail endpoint returns 400 for allow_redirect=true; keep false everywhere.
|
): string | null => {
|
||||||
allowRedirects = false,
|
// Build the URL manually so we never add allow_redirect.
|
||||||
): string | null =>
|
// The SDK forces allow_redirect=true when useAuthentication=true, but Synapse's
|
||||||
mx.mxcUrlToHttp(
|
// /_matrix/client/v1/media/thumbnail endpoint rejects that parameter with 400.
|
||||||
mxcUrl,
|
if (!mxcUrl) return null;
|
||||||
width,
|
if (!mxcUrl.startsWith('mxc://')) {
|
||||||
height,
|
return allowDirectLinks ? mxcUrl : null;
|
||||||
resizeMethod,
|
}
|
||||||
allowDirectLinks,
|
const parts = mxcUrl.slice(6).split('/');
|
||||||
allowRedirects,
|
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
||||||
useAuthentication,
|
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<Blob> => {
|
export const downloadMedia = async (src: string): Promise<Blob> => {
|
||||||
// this request is authenticated by service worker
|
// this request is authenticated by service worker
|
||||||
|
|||||||
Reference in New Issue
Block a user