Compare commits
6
Commits
384a1dd262
...
5d5ae0ee70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d5ae0ee70 | ||
|
|
7d02f4e538 | ||
|
|
56863d2649 | ||
|
|
2d0c804abc | ||
|
|
28cb004e80 | ||
|
|
0b06158477 |
+26
-12
@@ -733,7 +733,8 @@ Redacted events display "This message has been deleted" along with the redaction
|
||||
|
||||
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
|
||||
- `ScheduleMessageModal.tsx` provides the date/time picker UI
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual edit and cancel buttons
|
||||
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
|
||||
- Utilities in `src/app/utils/scheduledMessages.ts`
|
||||
|
||||
### File Upload Compression (opt-in)
|
||||
@@ -829,18 +830,28 @@ player.kick).
|
||||
|
||||
### Poll Creation
|
||||
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events
|
||||
- Supports 2 to 10 answer options
|
||||
- Supports both single-choice and multiple-choice modes
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
|
||||
- Supports 2 to 10 answer options; single-choice or multiple-choice
|
||||
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
|
||||
- Accessible via the `Icons.OrderList` button in the composer toolbar
|
||||
|
||||
### Poll Display
|
||||
### Poll Display & Voting (MSC3381, full lifecycle)
|
||||
|
||||
`PollContent.tsx` renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
|
||||
`PollContent.tsx` is a fully interactive, spec-correct poll card:
|
||||
|
||||
### Voice Message Playback Speed
|
||||
- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
|
||||
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
|
||||
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
|
||||
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
|
||||
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
|
||||
- Pure tally/visibility/winner + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 14 tests incl. the stable/unstable round-trip).
|
||||
|
||||
`AudioContent.tsx` adds a playback speed cycle button to voice message players. Available speeds: `[0.75, 1, 1.5, 2]×`. A `useEffect` sets `audioElement.playbackRate` whenever the speed selection changes.
|
||||
### Voice Message Playback (waveform + speed)
|
||||
|
||||
`AudioContent.tsx` is the shared audio player (timeline + Media Gallery Audio tab):
|
||||
|
||||
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
|
||||
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
|
||||
|
||||
---
|
||||
|
||||
@@ -1075,10 +1086,13 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
|
||||
|
||||
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||
|
||||
- Three tabs: **Images**, **Videos**, **Files**
|
||||
- Reads already-decrypted events from the room timeline
|
||||
- Encrypted images show a lock placeholder rather than an error
|
||||
- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
|
||||
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
|
||||
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next)
|
||||
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
|
||||
- **Files** — name/size/sender rows with download
|
||||
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
|
||||
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
|
||||
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
|
||||
|
||||
### Knock-to-Join
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ type RenderAudioContentProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
waveform?: number[];
|
||||
};
|
||||
type MAudioProps = {
|
||||
content: IAudioContent;
|
||||
@@ -431,6 +432,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
||||
}
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Audio';
|
||||
const waveform = (
|
||||
content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
|
||||
)['org.matrix.msc1767.audio']?.waveform;
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
@@ -454,6 +458,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
||||
mimeType: safeMimeType,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
waveform,
|
||||
})}
|
||||
</AttachmentContent>
|
||||
</AttachmentBox>
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Chip,
|
||||
color,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
ProgressBar,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||
import {
|
||||
@@ -28,6 +41,127 @@ const PLAY_TIME_THROTTLE_OPS = {
|
||||
immediate: true,
|
||||
};
|
||||
|
||||
const WAVEFORM_DISPLAY_BARS = 48;
|
||||
|
||||
/** Reduce a stored MSC1767 waveform (up to ~100 samples) to a fixed display count. */
|
||||
export function downsampleWaveform(waveform: number[], count: number): number[] {
|
||||
if (waveform.length <= count) return waveform;
|
||||
const out: number[] = [];
|
||||
const step = waveform.length / count;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push(waveform[Math.floor(i * step)] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Voice-message waveform that doubles as a seek control: bars fill with the accent
|
||||
// as the clip plays; click/drag/keyboard scrubs. Mirrors the recorder's bar styling
|
||||
// (VoiceMessageRecorder). Falls back to the plain seek Range when there's no waveform.
|
||||
function WaveformSeek({
|
||||
waveform,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
getCurrentTime,
|
||||
}: {
|
||||
waveform: number[];
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
|
||||
getCurrentTime: () => number;
|
||||
}) {
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
|
||||
const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
|
||||
const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0;
|
||||
const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main;
|
||||
const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`;
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el || duration <= 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||
onSeek(Math.min(duration, Math.max(0, ratio * duration)));
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handlePointerDown = (evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId);
|
||||
seekFromClientX(evt.clientX);
|
||||
};
|
||||
const handlePointerMove = (evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX);
|
||||
};
|
||||
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (duration <= 0) return;
|
||||
// Base off the LIVE time so rapid presses accumulate (the prop is throttled).
|
||||
const base = getCurrentTime();
|
||||
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.min(duration, base + 5));
|
||||
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.max(0, base - 5));
|
||||
} else if (evt.key === 'Home') {
|
||||
evt.preventDefault();
|
||||
onSeek(0);
|
||||
} else if (evt.key === 'End') {
|
||||
evt.preventDefault();
|
||||
onSeek(duration);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={Math.round(currentTime)}
|
||||
aria-valuetext={`${secondsToMinutesAndSeconds(currentTime)} of ${secondsToMinutesAndSeconds(
|
||||
duration,
|
||||
)}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(2),
|
||||
width: '100%',
|
||||
height: toRem(24),
|
||||
cursor: 'pointer',
|
||||
touchAction: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bars.map((v, i) => {
|
||||
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: toRem(2),
|
||||
height: toRem(2 + (v / barMax) * 16),
|
||||
borderRadius: toRem(1),
|
||||
background: played ? accent : unplayedColor,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderMediaControlProps = {
|
||||
after: ReactNode;
|
||||
leftControl: ReactNode;
|
||||
@@ -39,6 +173,8 @@ export type AudioContentProps = {
|
||||
url: string;
|
||||
info: IAudioInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
/** MSC1767 voice waveform (0–1024 ints); when present, the seek bar renders it. */
|
||||
waveform?: number[];
|
||||
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
|
||||
};
|
||||
export function AudioContent({
|
||||
@@ -46,6 +182,7 @@ export function AudioContent({
|
||||
url,
|
||||
info,
|
||||
encInfo,
|
||||
waveform,
|
||||
renderMediaControl,
|
||||
}: AudioContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,6 +200,8 @@ export function AudioContent({
|
||||
);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// A seek requested before the media has loaded; applied once metadata arrives.
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -103,6 +242,11 @@ export function AudioContent({
|
||||
if (!audio) return undefined;
|
||||
const applyRate = () => {
|
||||
audio.playbackRate = playbackSpeed;
|
||||
// Apply a seek that was requested before the source loaded.
|
||||
if (pendingSeekRef.current != null && audio.readyState >= 1) {
|
||||
audio.currentTime = pendingSeekRef.current;
|
||||
pendingSeekRef.current = null;
|
||||
}
|
||||
};
|
||||
// Apply immediately, and re-apply whenever the media element (re)loads a new
|
||||
// source — e.g. after async decrypt swaps in the blob URL — since the browser
|
||||
@@ -132,14 +276,38 @@ export function AudioContent({
|
||||
}
|
||||
};
|
||||
|
||||
// Seeking before the media has loaded (e.g. clicking the waveform first) loads it
|
||||
// and applies the position once metadata arrives (the <audio> autoPlays).
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
seek(time);
|
||||
} else if (srcState.status !== AsyncStatus.Loading) {
|
||||
pendingSeekRef.current = time;
|
||||
loadSrc();
|
||||
}
|
||||
},
|
||||
[srcState.status, seek, loadSrc],
|
||||
);
|
||||
|
||||
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
|
||||
|
||||
return renderMediaControl({
|
||||
after: (
|
||||
after: hasWaveform ? (
|
||||
<WaveformSeek
|
||||
waveform={waveform ?? []}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
|
||||
/>
|
||||
) : (
|
||||
<Range
|
||||
step={1}
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
values={[currentTime]}
|
||||
onChange={(values) => seek(values[0])}
|
||||
onChange={(values) => handleSeek(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
|
||||
@@ -1,179 +1,183 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { RoomEvent } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
ParsedPoll,
|
||||
PollResponse,
|
||||
PollTally,
|
||||
parsePollStart,
|
||||
parseResponseAnswerIds,
|
||||
resultsVisible,
|
||||
tallyResponses,
|
||||
validateSelections,
|
||||
winningAnswerIds,
|
||||
} from '../../../utils/poll';
|
||||
|
||||
type PollTextValue = Array<{ body: string }> | string;
|
||||
const EMPTY_TALLY: PollTally = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
|
||||
function extractText(val: PollTextValue | undefined): string {
|
||||
if (!val) return '';
|
||||
if (typeof val === 'string') return val;
|
||||
return val[0]?.body ?? '';
|
||||
type PollState = { tally: PollTally; isEnded: boolean };
|
||||
|
||||
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const x of a) if (!b.has(x)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type PollAnswer = {
|
||||
'm.id'?: string;
|
||||
id?: string;
|
||||
'm.text'?: PollTextValue;
|
||||
'org.matrix.msc3381.poll.answer'?: { body: string };
|
||||
};
|
||||
|
||||
type PollData = {
|
||||
question?: { body?: string; 'm.text'?: PollTextValue };
|
||||
answers?: PollAnswer[];
|
||||
max_selections?: number;
|
||||
};
|
||||
|
||||
type VoteState = {
|
||||
counts: Map<string, number>;
|
||||
myVotes: Set<string>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function computeVotes(
|
||||
function computePollState(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
room: Room,
|
||||
eventId: string,
|
||||
_isStable: boolean,
|
||||
): VoteState {
|
||||
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return empty;
|
||||
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response',
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Per-sender keep only the latest response (which may include multiple selections)
|
||||
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
|
||||
parsed: ParsedPoll
|
||||
): PollState {
|
||||
const relations = room.getUnfilteredTimelineSet().relations;
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const validIds = new Set(parsed.answers.map((a) => a.id));
|
||||
|
||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||
const events = rels?.getRelations() ?? [];
|
||||
for (const ev of events) {
|
||||
const getRels = (type: string): MatrixEvent[] =>
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
|
||||
|
||||
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
|
||||
const respEvents = [
|
||||
...getRels('m.poll.response'),
|
||||
...getRels('org.matrix.msc3381.poll.response'),
|
||||
];
|
||||
|
||||
// End state: prefer the SDK Poll model (validates ender = creator or redact PL,
|
||||
// refilters responses). Fall back to an end from the poll's own creator, which is
|
||||
// always valid, in case the model hasn't processed it yet.
|
||||
const poll = room.polls.get(eventId);
|
||||
let isEnded = poll?.isEnded ?? false;
|
||||
let endTs = poll?.endEventId ? room.findEventById(poll.endEventId)?.getTs() : undefined;
|
||||
if (!isEnded) {
|
||||
const creator = room.findEventById(eventId)?.getSender();
|
||||
for (const ev of endEvents) {
|
||||
if (ev.isRedacted()) continue;
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const content = ev.getContent();
|
||||
let answerIds: string[] = [];
|
||||
if (stable) {
|
||||
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
||||
} else {
|
||||
answerIds =
|
||||
((content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined) ??
|
||||
[];
|
||||
if (creator && ev.getSender() === creator) {
|
||||
isEnded = true;
|
||||
const t = ev.getTs();
|
||||
if (endTs === undefined || t < endTs) endTs = t;
|
||||
}
|
||||
if (answerIds.length === 0) continue;
|
||||
const ts = ev.getTs();
|
||||
const existing = latestBySender.get(sender);
|
||||
if (!existing || ts > existing.ts) {
|
||||
latestBySender.set(sender, { ts, answerIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processRelations(stableRels, true);
|
||||
processRelations(unstableRels, false);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const myVotes = new Set<string>();
|
||||
for (const [sender, { answerIds }] of latestBySender) {
|
||||
for (const id of answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVotes, total: latestBySender.size };
|
||||
const responses: PollResponse[] = [];
|
||||
for (const ev of respEvents) {
|
||||
if (ev.isRedacted()) continue;
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const ts = ev.getTs();
|
||||
// Only responses cast on or before the end event are valid.
|
||||
if (isEnded && endTs !== undefined && ts > endTs) continue;
|
||||
const answerIds = validateSelections(
|
||||
parseResponseAnswerIds(ev.getContent()),
|
||||
validIds,
|
||||
parsed.maxSelections
|
||||
);
|
||||
responses.push({ sender, ts, answerIds });
|
||||
}
|
||||
|
||||
return { tally: tallyResponses(responses, myUserId), isEnded };
|
||||
}
|
||||
|
||||
export function PollContent({
|
||||
content,
|
||||
roomId,
|
||||
eventId,
|
||||
mEvent,
|
||||
room,
|
||||
canRedact,
|
||||
}: {
|
||||
content: Record<string, unknown>;
|
||||
roomId?: string;
|
||||
eventId?: string;
|
||||
mEvent: MatrixEvent;
|
||||
room: Room;
|
||||
/** Whether the current user may redact in this room (gates the End-poll action). */
|
||||
canRedact: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const _isStable = !!content['m.poll'];
|
||||
const roomId = room.roomId;
|
||||
const eventId = mEvent.getId();
|
||||
const senderId = mEvent.getSender();
|
||||
|
||||
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
|
||||
| PollData
|
||||
| undefined;
|
||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
|
||||
const [votes, setVotes] = useState<VoteState>(() => {
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
return computeVotes(mx, roomId, eventId, _isStable);
|
||||
const [state, setState] = useState<PollState>(() => {
|
||||
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
|
||||
return computePollState(mx, room, eventId, parsed);
|
||||
});
|
||||
const [pending, setPending] = useState<Set<string> | null>(null);
|
||||
const [confirmEnd, setConfirmEnd] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
|
||||
// Refresh votes whenever Relations events fire
|
||||
const refresh = useCallback(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
setVotes(computeVotes(mx, roomId, eventId, _isStable));
|
||||
}, [mx, roomId, eventId, _isStable]);
|
||||
if (!eventId || !parsed) return;
|
||||
const next = computePollState(mx, room, eventId, parsed);
|
||||
setState(next);
|
||||
// Drop the optimistic selection only once our own vote is actually reflected,
|
||||
// so an unrelated refresh (another user voting) can't revert our pending click.
|
||||
setPending((prev) => (prev !== null && setsEqual(prev, next.tally.myVotes) ? null : prev));
|
||||
}, [mx, room, eventId, parsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
if (!eventId || !parsed) return undefined;
|
||||
const relations = room.getUnfilteredTimelineSet().relations;
|
||||
const relObjs = [
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response' as any),
|
||||
relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'org.matrix.msc3381.poll.response' as any
|
||||
),
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.end' as any),
|
||||
];
|
||||
relObjs.forEach((r) => {
|
||||
r?.on(RelationsEvent.Add, refresh);
|
||||
r?.on(RelationsEvent.Remove, refresh);
|
||||
r?.on(RelationsEvent.Redaction, refresh);
|
||||
});
|
||||
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response',
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
stableRels?.on(RelationsEvent.Add, refresh);
|
||||
stableRels?.on(RelationsEvent.Remove, refresh);
|
||||
stableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.on(RelationsEvent.Add, refresh);
|
||||
unstableRels?.on(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
// Also listen at room level: if no votes exist yet, the Relations object is null
|
||||
// and the listeners above are no-ops. The room timeline event catches the first vote.
|
||||
const onTimeline = (ev: any) => {
|
||||
const type = ev.getType?.();
|
||||
const relatesTo = ev.getContent?.()?.['m.relates_to'];
|
||||
// The relations object for the first response/end may not exist yet at mount, and
|
||||
// redactions (m.room.redaction) don't flow through the relations listeners for a
|
||||
// post-mount response — the room timeline catches those.
|
||||
const onTimeline = (ev: MatrixEvent) => {
|
||||
const type = ev.getType();
|
||||
if (type === 'm.room.redaction') {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
|
||||
if (
|
||||
(type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') &&
|
||||
(type === 'm.poll.response' ||
|
||||
type === 'org.matrix.msc3381.poll.response' ||
|
||||
type === 'm.poll.end' ||
|
||||
type === 'org.matrix.msc3381.poll.end') &&
|
||||
relatesTo?.event_id === eventId
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
const room2 = mx.getRoom(roomId);
|
||||
room2?.on(RoomEvent.Timeline, onTimeline);
|
||||
room.on(RoomEvent.Timeline, onTimeline);
|
||||
|
||||
const poll = room.polls.get(eventId);
|
||||
poll?.on(PollEvent.Update, refresh);
|
||||
poll?.on(PollEvent.Responses, refresh);
|
||||
poll?.on(PollEvent.End, refresh);
|
||||
room.on(PollEvent.New, refresh);
|
||||
|
||||
return () => {
|
||||
stableRels?.off(RelationsEvent.Add, refresh);
|
||||
stableRels?.off(RelationsEvent.Remove, refresh);
|
||||
stableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.off(RelationsEvent.Add, refresh);
|
||||
unstableRels?.off(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
room2?.off(RoomEvent.Timeline, onTimeline);
|
||||
relObjs.forEach((r) => {
|
||||
r?.off(RelationsEvent.Add, refresh);
|
||||
r?.off(RelationsEvent.Remove, refresh);
|
||||
r?.off(RelationsEvent.Redaction, refresh);
|
||||
});
|
||||
room.off(RoomEvent.Timeline, onTimeline);
|
||||
poll?.off(PollEvent.Update, refresh);
|
||||
poll?.off(PollEvent.Responses, refresh);
|
||||
poll?.off(PollEvent.End, refresh);
|
||||
room.off(PollEvent.New, refresh);
|
||||
};
|
||||
}, [mx, roomId, eventId, refresh]);
|
||||
}, [room, eventId, parsed, refresh]);
|
||||
|
||||
if (!poll) {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<Text priority="300">
|
||||
<i>Poll (unreadable format)</i>
|
||||
@@ -181,61 +185,93 @@ export function PollContent({
|
||||
);
|
||||
}
|
||||
|
||||
const questionText =
|
||||
extractText((poll.question as any)?.['m.text']) ||
|
||||
(poll.question as any)?.body ||
|
||||
'Untitled poll';
|
||||
|
||||
const canVote = !!roomId && !!eventId;
|
||||
const maxSelections = (poll as any).max_selections ?? 1;
|
||||
const { tally, isEnded } = state;
|
||||
const { isUndisclosed, maxSelections, question, answers } = parsed;
|
||||
const isMultiple = maxSelections > 1;
|
||||
const { counts, myVotes, total } = votes;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total } = tally;
|
||||
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId) return;
|
||||
if (!roomId || !eventId || !canVote) return;
|
||||
|
||||
const newVotes = new Set(myVotes);
|
||||
if (newVotes.has(answerId)) {
|
||||
newVotes.delete(answerId);
|
||||
const next = new Set(myVotes);
|
||||
if (next.has(answerId)) {
|
||||
next.delete(answerId);
|
||||
} else if (isMultiple) {
|
||||
if (next.size >= maxSelections) return; // enforce max_selections
|
||||
next.add(answerId);
|
||||
} else {
|
||||
if (!isMultiple) newVotes.clear();
|
||||
newVotes.add(answerId);
|
||||
next.clear();
|
||||
next.add(answerId);
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
setVotes((prev) => {
|
||||
const next = new Map(prev.counts);
|
||||
// Remove all old vote counts for this user
|
||||
for (const id of prev.myVotes) {
|
||||
const c = next.get(id) ?? 1;
|
||||
if (c <= 1) next.delete(id);
|
||||
else next.set(id, c - 1);
|
||||
}
|
||||
// Add new vote counts
|
||||
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) {
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': selectionsArr,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
};
|
||||
|
||||
const answers = poll.answers ?? [];
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
mx.sendEvent(roomId, 'm.poll.end' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
const handleRadioKeyDown = (evt: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMultiple || !canVote) return;
|
||||
const nav = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
|
||||
if (!nav.includes(evt.key)) return;
|
||||
evt.preventDefault();
|
||||
const buttons = Array.from(
|
||||
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]')
|
||||
);
|
||||
if (buttons.length === 0) return;
|
||||
const current = buttons.findIndex((b) => b === document.activeElement);
|
||||
let idx = current < 0 ? 0 : current;
|
||||
if (evt.key === 'ArrowDown' || evt.key === 'ArrowRight') idx = (idx + 1) % buttons.length;
|
||||
else if (evt.key === 'ArrowUp' || evt.key === 'ArrowLeft')
|
||||
idx = (idx - 1 + buttons.length) % buttons.length;
|
||||
else if (evt.key === 'Home') idx = 0;
|
||||
else if (evt.key === 'End') idx = buttons.length - 1;
|
||||
buttons[idx]?.focus();
|
||||
handleVote(answers[idx].id);
|
||||
};
|
||||
|
||||
const headerLabel = isEnded
|
||||
? 'Poll closed · Final results'
|
||||
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
|
||||
|
||||
const footerNote = (() => {
|
||||
if (isUndisclosed && !isEnded) {
|
||||
return canVote
|
||||
? 'Voting open · Results hidden until the poll ends'
|
||||
: 'Results hidden until the poll ends';
|
||||
}
|
||||
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
|
||||
if (isEnded) return `${votesPart} · Poll closed`;
|
||||
if (!canVote) return votesPart;
|
||||
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
|
||||
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -256,34 +292,55 @@ export function PollContent({
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||
<span aria-hidden>◉ </span>
|
||||
{headerLabel}
|
||||
</Text>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{questionText}
|
||||
{question}
|
||||
</Text>
|
||||
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ marginTop: '2px' }}
|
||||
role={isMultiple ? 'group' : 'radiogroup'}
|
||||
aria-label={question}
|
||||
onKeyDown={handleRadioKeyDown}
|
||||
>
|
||||
{answers.map((answer, i) => {
|
||||
const text =
|
||||
extractText((answer as any)['m.text']) ||
|
||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
||||
`Option ${i + 1}`;
|
||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
||||
const id = answer.id;
|
||||
const text = answer.text;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const isWinner = winners.has(id);
|
||||
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
|
||||
const tabIndex = isMultiple
|
||||
? 0
|
||||
: selected || (myVotes.size === 0 && i === 0)
|
||||
? 0
|
||||
: -1;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
role={isMultiple ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-disabled={!canVote}
|
||||
aria-label={isWinner ? `${text}, winning answer` : undefined}
|
||||
tabIndex={tabIndex}
|
||||
onClick={canVote ? () => handleVote(id) : undefined}
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
|
||||
isWinner
|
||||
? color.Success.Main
|
||||
: selected
|
||||
? color.Primary.Main
|
||||
: color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
@@ -298,7 +355,7 @@ export function PollContent({
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{total > 0 && (
|
||||
{showResults && total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
@@ -323,6 +380,7 @@ export function PollContent({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: toRem(14),
|
||||
@@ -339,12 +397,19 @@ export function PollContent({
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
{selected ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
{text}
|
||||
</Text>
|
||||
{total > 0 && (
|
||||
{isWinner && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Check}
|
||||
style={{ flexShrink: 0, color: color.Success.Main }}
|
||||
/>
|
||||
)}
|
||||
{showResults && total > 0 && (
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
@@ -354,18 +419,36 @@ export function PollContent({
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||
{canVote
|
||||
? isMultiple
|
||||
? 'Select all that apply'
|
||||
: myVotes.size > 0
|
||||
? 'Click to change'
|
||||
: 'Click to vote'
|
||||
: 'Voting not available'}
|
||||
</i>
|
||||
</Text>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||
<i>{footerNote}</i>
|
||||
</Text>
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<Box gap="100" shrink="No" alignItems="Center">
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">End poll</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,21 +24,27 @@ import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/ma
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AudioContent, FileDownloadButton } from '../../components/message';
|
||||
import { MediaControl } from '../../components/media';
|
||||
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './MediaGallery.css';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file';
|
||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||
|
||||
const TAB_LABELS: Record<GalleryTab, string> = {
|
||||
image: 'Images',
|
||||
video: 'Videos',
|
||||
audio: 'Audio',
|
||||
file: 'Files',
|
||||
};
|
||||
|
||||
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
image: MsgType.Image,
|
||||
video: MsgType.Video,
|
||||
audio: MsgType.Audio,
|
||||
file: MsgType.File,
|
||||
};
|
||||
|
||||
@@ -155,6 +161,7 @@ type LightboxItem = {
|
||||
body: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
function LightboxMedia({
|
||||
@@ -233,11 +240,13 @@ function Lightbox({
|
||||
initialIndex,
|
||||
useAuthentication,
|
||||
onClose,
|
||||
onJump,
|
||||
}: {
|
||||
items: LightboxItem[];
|
||||
initialIndex: number;
|
||||
useAuthentication: boolean;
|
||||
onClose: () => void;
|
||||
onJump: (eventId: string) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
|
||||
@@ -309,6 +318,29 @@ function Lightbox({
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||
{index + 1} / {items.length}
|
||||
</Text>
|
||||
{item.eventId && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Go to message</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
variant="Surface"
|
||||
aria-label="Go to message"
|
||||
onClick={() => onJump(item.eventId)}
|
||||
>
|
||||
<Icon src={Icons.Message} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
@@ -503,6 +535,16 @@ type MediaGalleryProps = {
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
// Close the drawer and land the timeline on the source message.
|
||||
const jumpToMessage = useCallback(
|
||||
(eventId: string) => {
|
||||
onClose();
|
||||
navigateRoom(room.roomId, eventId);
|
||||
},
|
||||
[onClose, navigateRoom, room.roomId],
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState<GalleryTab>('image');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -613,12 +655,13 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
body: c.body ?? '',
|
||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||
ts: ev.getTs(),
|
||||
eventId: ev.getId() ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
// Per-tab counts for the tab labels (single pass over loaded timeline)
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
|
||||
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -627,6 +670,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mt = ev.getContent().msgtype;
|
||||
if (mt === MsgType.Image) counts.image += 1;
|
||||
else if (mt === MsgType.Video) counts.video += 1;
|
||||
else if (mt === MsgType.Audio) counts.audio += 1;
|
||||
else if (mt === MsgType.File) counts.file += 1;
|
||||
});
|
||||
return counts;
|
||||
@@ -784,9 +828,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const body: string = c.body ?? 'Unnamed file';
|
||||
const size: number | undefined = c.info?.size;
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const downloadUrl = mxcUrl
|
||||
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
|
||||
: '#';
|
||||
return (
|
||||
<Box
|
||||
key={mEvent.getId()}
|
||||
@@ -816,18 +857,111 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={`Download ${body}`}
|
||||
aria-label="Go to message"
|
||||
onClick={() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = body;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noreferrer';
|
||||
a.click();
|
||||
const id = mEvent.getId();
|
||||
if (id) jumpToMessage(id);
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.Download} />
|
||||
<Icon size="200" src={Icons.Message} />
|
||||
</IconButton>
|
||||
{mxcUrl && (
|
||||
<FileDownloadButton
|
||||
filename={body}
|
||||
url={mxcUrl}
|
||||
mimeType={c.info?.mimetype ?? 'application/octet-stream'}
|
||||
encInfo={c.file}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Audio / voice list ── */}
|
||||
{tab === 'audio' && (
|
||||
<>
|
||||
{events.length === 0 && !loading && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Icon src={Icons.VolumeHigh} size="600" />
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box direction="Column" gap="200">
|
||||
{events.map((mEvent) => {
|
||||
const c = mEvent.getContent();
|
||||
const url: string | undefined = c.file?.url ?? c.url;
|
||||
if (!url) return null;
|
||||
const body: string = c.body || 'Voice message';
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const relDate = formatRelativeDate(mEvent.getTs());
|
||||
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
|
||||
// audio/ogg) so the decrypted blob actually plays.
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
|
||||
const filename = body.includes('.') ? body : `${body}.${mimeTypeToExt(mimeType)}`;
|
||||
const waveform = (
|
||||
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
|
||||
)['org.matrix.msc1767.audio']?.waveform;
|
||||
return (
|
||||
<Box
|
||||
key={mEvent.getId()}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={{ overflow: 'hidden', gap: '2px' }}
|
||||
>
|
||||
<Text size="T300" truncate title={body}>
|
||||
{body}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{sender} · {relDate}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Go to message"
|
||||
onClick={() => {
|
||||
const id = mEvent.getId();
|
||||
if (id) jumpToMessage(id);
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.Message} />
|
||||
</IconButton>
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={url}
|
||||
mimeType={mimeType}
|
||||
encInfo={c.file}
|
||||
/>
|
||||
</Box>
|
||||
<AudioContent
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
info={c.info ?? {}}
|
||||
encInfo={c.file}
|
||||
waveform={waveform}
|
||||
renderMediaControl={(p) => <MediaControl {...p} />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
@@ -888,6 +1022,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
initialIndex={lightboxIndex}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onJump={jumpToMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -34,6 +34,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '']);
|
||||
const [isMultiple, setIsMultiple] = useState(false);
|
||||
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
|
||||
// until the poll is ended).
|
||||
const [disclosed, setDisclosed] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,14 +76,20 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Text fallback for clients that don't understand polls: the question + a
|
||||
// numbered list of the options.
|
||||
const fallbackBody = [
|
||||
trimmedQuestion,
|
||||
...filledOptions.map((o, i) => `${i + 1}. ${o}`),
|
||||
].join('\n');
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
max_selections: isMultiple ? filledOptions.length : 1,
|
||||
kind: 'm.poll.undisclosed',
|
||||
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
|
||||
},
|
||||
body: trimmedQuestion,
|
||||
body: fallbackBody,
|
||||
msgtype: 'm.text',
|
||||
});
|
||||
onClose();
|
||||
@@ -216,6 +225,31 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Results visibility */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Results</Text>
|
||||
<Box gap="200">
|
||||
{(['live', 'hidden'] as const).map((mode) => {
|
||||
const active = mode === 'live' ? disclosed : !disclosed;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill={active ? 'Solid' : 'None'}
|
||||
radii="300"
|
||||
onClick={() => setDisclosed(mode === 'live')}
|
||||
>
|
||||
<Text size="B300">
|
||||
{mode === 'live' ? 'Show live results' : 'Hidden until ended'}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
|
||||
@@ -1314,13 +1314,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
mEvent.getType() === 'm.poll.start' ||
|
||||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
|
||||
)
|
||||
return (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
);
|
||||
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
|
||||
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
||||
return (
|
||||
<Text>
|
||||
@@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@ interface ScheduleMessageModalProps {
|
||||
roomId: string;
|
||||
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
|
||||
initialBody?: string;
|
||||
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
|
||||
initialSendAt?: number;
|
||||
/** Header title; defaults to "Schedule Message". */
|
||||
title?: string;
|
||||
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -88,6 +92,8 @@ const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSPropert
|
||||
export function ScheduleMessageModal({
|
||||
roomId,
|
||||
initialBody,
|
||||
initialSendAt,
|
||||
title = 'Schedule Message',
|
||||
onScheduled,
|
||||
onClose,
|
||||
}: ScheduleMessageModalProps) {
|
||||
@@ -105,7 +111,8 @@ export function ScheduleMessageModal({
|
||||
return d;
|
||||
};
|
||||
|
||||
const def = defaultDate();
|
||||
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
||||
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
|
||||
@@ -199,7 +206,7 @@ export function ScheduleMessageModal({
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
<Text id="schedule-message-title" size="H4">
|
||||
Schedule Message
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
@@ -34,6 +36,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<ScheduledMessage | null>(null);
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
@@ -106,16 +109,57 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
// Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has
|
||||
// already scheduled the NEW message by the time this fires; add it, then cancel the
|
||||
// old one — removing the old from state only once the server confirms, so a failed
|
||||
// cancel leaves it visible (and retriable) instead of letting it silently fire.
|
||||
const handleEdit = useCallback(
|
||||
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
|
||||
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
|
||||
return next;
|
||||
});
|
||||
cancelScheduledMessage(mx, oldMsg.delayId)
|
||||
.then(() => {
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const remaining = (next.get(roomId) ?? []).filter((m) => m.delayId !== oldMsg.delayId);
|
||||
if (remaining.length === 0) next.delete(roomId);
|
||||
else next.set(roomId, remaining);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId)));
|
||||
setEditing(null);
|
||||
},
|
||||
[mx, roomId, setScheduledMessages],
|
||||
);
|
||||
|
||||
if (messages.length === 0 && !editing) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{editing && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
||||
initialSendAt={editing.sendAt}
|
||||
title="Edit scheduled message"
|
||||
onScheduled={(newDelayId, sendAt, content) =>
|
||||
handleEdit(editing, newDelayId, sendAt, content)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
{/* Tray header */}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
@@ -166,6 +210,19 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Edit scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="50" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
@@ -192,6 +249,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
tallyResponses,
|
||||
resultsVisible,
|
||||
winningAnswerIds,
|
||||
parsePollStart,
|
||||
parseResponseAnswerIds,
|
||||
validateSelections,
|
||||
PollResponse,
|
||||
} from './poll';
|
||||
|
||||
const me = '@me:server';
|
||||
|
||||
test('tallyResponses counts one vote per selection', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@b:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@c:s', ts: 1, answerIds: ['2'] },
|
||||
];
|
||||
const { counts, total } = tallyResponses(responses, me);
|
||||
assert.equal(counts.get('1'), 2);
|
||||
assert.equal(counts.get('2'), 1);
|
||||
assert.equal(total, 3);
|
||||
});
|
||||
|
||||
test('tallyResponses keeps only the latest response per sender', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote
|
||||
{ sender: '@a:s', ts: 3, answerIds: ['1'] }, // older, ignored
|
||||
];
|
||||
const { counts, total } = tallyResponses(responses, me);
|
||||
assert.equal(counts.get('1'), undefined);
|
||||
assert.equal(counts.get('2'), 1);
|
||||
assert.equal(total, 1);
|
||||
});
|
||||
|
||||
test('tallyResponses handles multi-select and reports myVotes', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: me, ts: 2, answerIds: ['1', '3'] },
|
||||
{ sender: '@b:s', ts: 1, answerIds: ['3'] },
|
||||
];
|
||||
const { counts, myVotes, total } = tallyResponses(responses, me);
|
||||
assert.equal(counts.get('1'), 1);
|
||||
assert.equal(counts.get('3'), 2);
|
||||
assert.deepEqual([...myVotes].sort(), ['1', '3']);
|
||||
assert.equal(total, 2);
|
||||
});
|
||||
|
||||
test('tallyResponses: a cleared (empty) latest response removes the voter', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@a:s', ts: 2, answerIds: [] }, // latest is empty → sender dropped entirely
|
||||
];
|
||||
const { counts, total } = tallyResponses(responses, me);
|
||||
assert.equal(counts.get('1'), undefined);
|
||||
assert.equal(total, 0);
|
||||
});
|
||||
|
||||
test('tallyResponses: re-voting after an empty clear counts the newest non-empty', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@a:s', ts: 2, answerIds: [] }, // cleared
|
||||
{ sender: '@a:s', ts: 3, answerIds: ['2'] }, // re-voted, newest wins
|
||||
];
|
||||
const { counts, total } = tallyResponses(responses, me);
|
||||
assert.equal(counts.get('1'), undefined);
|
||||
assert.equal(counts.get('2'), 1);
|
||||
assert.equal(total, 1);
|
||||
});
|
||||
|
||||
test('resultsVisible: disclosed always, undisclosed only when ended', () => {
|
||||
assert.equal(resultsVisible(false, false), true); // disclosed, open
|
||||
assert.equal(resultsVisible(false, true), true); // disclosed, ended
|
||||
assert.equal(resultsVisible(true, false), false); // undisclosed, open → hidden
|
||||
assert.equal(resultsVisible(true, true), true); // undisclosed, ended → revealed
|
||||
});
|
||||
|
||||
test('winningAnswerIds returns the single top answer', () => {
|
||||
const counts = new Map([
|
||||
['1', 3],
|
||||
['2', 1],
|
||||
]);
|
||||
assert.deepEqual(winningAnswerIds(counts), ['1']);
|
||||
});
|
||||
|
||||
test('winningAnswerIds returns all tied answers', () => {
|
||||
const counts = new Map([
|
||||
['1', 2],
|
||||
['2', 2],
|
||||
['3', 1],
|
||||
]);
|
||||
assert.deepEqual(winningAnswerIds(counts).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
test('winningAnswerIds is empty when there are no votes', () => {
|
||||
assert.deepEqual(winningAnswerIds(new Map()), []);
|
||||
});
|
||||
|
||||
// --- wire-format round-trip: exactly what PollCreator + handleVote send (STABLE) ---
|
||||
|
||||
test('parsePollStart reads the STABLE m.poll format Lotus sends', () => {
|
||||
const content = {
|
||||
'm.poll': {
|
||||
question: { 'm.text': 'Favorite color?' },
|
||||
answers: [
|
||||
{ 'm.id': '0', 'm.text': 'Red' },
|
||||
{ 'm.id': '1', 'm.text': 'Blue' },
|
||||
],
|
||||
max_selections: 1,
|
||||
kind: 'm.poll.disclosed',
|
||||
},
|
||||
body: 'Favorite color?\n1. Red\n2. Blue',
|
||||
msgtype: 'm.text',
|
||||
};
|
||||
const parsed = parsePollStart(content);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed!.question, 'Favorite color?');
|
||||
assert.deepEqual(parsed!.answers, [
|
||||
{ id: '0', text: 'Red' },
|
||||
{ id: '1', text: 'Blue' },
|
||||
]);
|
||||
assert.equal(parsed!.maxSelections, 1);
|
||||
assert.equal(parsed!.isUndisclosed, false);
|
||||
});
|
||||
|
||||
test('parsePollStart reads an UNSTABLE (Element-authored) poll + defaults kind to undisclosed', () => {
|
||||
const content = {
|
||||
'org.matrix.msc3381.poll.start': {
|
||||
question: { 'org.matrix.msc1767.text': 'Lunch?' },
|
||||
answers: [
|
||||
{ id: 'a', 'org.matrix.msc1767.text': 'Pizza' },
|
||||
{ id: 'b', 'org.matrix.msc1767.text': 'Tacos' },
|
||||
],
|
||||
max_selections: 2,
|
||||
// no kind → undisclosed per spec
|
||||
},
|
||||
};
|
||||
const parsed = parsePollStart(content);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed!.question, 'Lunch?');
|
||||
assert.deepEqual(parsed!.answers, [
|
||||
{ id: 'a', text: 'Pizza' },
|
||||
{ id: 'b', text: 'Tacos' },
|
||||
]);
|
||||
assert.equal(parsed!.maxSelections, 2);
|
||||
assert.equal(parsed!.isUndisclosed, true);
|
||||
});
|
||||
|
||||
test('parsePollStart returns null for non-poll content', () => {
|
||||
assert.equal(parsePollStart({ body: 'hi', msgtype: 'm.text' }), null);
|
||||
});
|
||||
|
||||
test('parseResponseAnswerIds reads stable m.selections and unstable nested answers', () => {
|
||||
assert.deepEqual(parseResponseAnswerIds({ 'm.selections': ['0', '1'] }), ['0', '1']);
|
||||
assert.deepEqual(
|
||||
parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }),
|
||||
['a']
|
||||
);
|
||||
assert.deepEqual(parseResponseAnswerIds({}), []);
|
||||
});
|
||||
|
||||
test('validateSelections filters unknown ids, de-dupes, and caps to max_selections', () => {
|
||||
const valid = new Set(['0', '1', '2']);
|
||||
assert.deepEqual(validateSelections(['0', 'x', '1'], valid, 3), ['0', '1']); // drops unknown 'x'
|
||||
assert.deepEqual(validateSelections(['1', '1', '2'], valid, 3), ['1', '2']); // de-dupes
|
||||
assert.deepEqual(validateSelections(['0', '1', '2'], valid, 1), ['0']); // caps to first max_selections
|
||||
assert.deepEqual(validateSelections(['nope'], valid, 3), []); // all invalid → spoiled/empty
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { M_POLL_KIND_DISCLOSED } from 'matrix-js-sdk';
|
||||
|
||||
// Pure helpers for poll display. matrix-js-sdk 41.7.0's PollStartEvent /
|
||||
// PollResponseEvent parsers only understand the UNSTABLE MSC3381 wire format, but
|
||||
// this app (and spec-compliant clients) send the STABLE format (`m.poll`, `m.id`,
|
||||
// `m.text`, `m.selections`, `m.poll.disclosed`). So we parse both namespaces by hand
|
||||
// here rather than delegating to the SDK. Kept in a plain module so the full
|
||||
// wire-format round-trip is unit-testable (see poll.test.ts).
|
||||
|
||||
export type ParsedPoll = {
|
||||
question: string;
|
||||
answers: { id: string; text: string }[];
|
||||
maxSelections: number;
|
||||
isUndisclosed: boolean;
|
||||
};
|
||||
|
||||
function extractText(val: unknown): string {
|
||||
if (typeof val === 'string') return val;
|
||||
if (Array.isArray(val)) {
|
||||
const first = val[0] as { body?: string } | undefined;
|
||||
if (first && typeof first.body === 'string') return first.body;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Parse an `m.poll.start` event's content (stable `m.poll` or unstable
|
||||
* `org.matrix.msc3381.poll.start`). Returns null if it can't be read as a poll. */
|
||||
export function parsePollStart(content: Record<string, any>): ParsedPoll | null {
|
||||
const poll = content['m.poll'] ?? content['org.matrix.msc3381.poll.start'];
|
||||
if (!poll || typeof poll !== 'object') return null;
|
||||
|
||||
const q = poll.question ?? {};
|
||||
const question =
|
||||
extractText(q['m.text']) ||
|
||||
(typeof q['org.matrix.msc1767.text'] === 'string' ? q['org.matrix.msc1767.text'] : '') ||
|
||||
(typeof q.body === 'string' ? q.body : '') ||
|
||||
'Untitled poll';
|
||||
|
||||
const rawAnswers = Array.isArray(poll.answers) ? poll.answers : [];
|
||||
const answers = rawAnswers
|
||||
.map((a: Record<string, any>, i: number) => ({
|
||||
id:
|
||||
(typeof a['m.id'] === 'string' && a['m.id']) ||
|
||||
(typeof a.id === 'string' && a.id) ||
|
||||
String(i),
|
||||
text:
|
||||
extractText(a['m.text']) ||
|
||||
a['org.matrix.msc3381.poll.answer']?.body ||
|
||||
(typeof a['org.matrix.msc1767.text'] === 'string' ? a['org.matrix.msc1767.text'] : '') ||
|
||||
`Option ${i + 1}`,
|
||||
}))
|
||||
.filter((a: { id: string }) => a.id);
|
||||
if (answers.length === 0) return null;
|
||||
|
||||
const maxSelections = Math.max(1, Math.floor(Number(poll.max_selections)) || 1);
|
||||
const kind = typeof poll.kind === 'string' ? poll.kind : '';
|
||||
// Per MSC3381, anything not explicitly disclosed is treated as undisclosed.
|
||||
const isUndisclosed = !M_POLL_KIND_DISCLOSED.matches(kind);
|
||||
|
||||
return { question, answers, maxSelections, isUndisclosed };
|
||||
}
|
||||
|
||||
/** Read the selected answer ids out of an `m.poll.response` content (stable
|
||||
* `m.selections` or unstable nested `…poll.response.answers`). */
|
||||
export function parseResponseAnswerIds(content: Record<string, any>): string[] {
|
||||
const stable = content['m.selections'];
|
||||
if (Array.isArray(stable)) return stable.filter((x) => typeof x === 'string');
|
||||
const nested = content['org.matrix.msc3381.poll.response'] ?? content['m.poll.response'];
|
||||
const answers = nested && typeof nested === 'object' ? nested.answers : undefined;
|
||||
if (Array.isArray(answers)) return answers.filter((x) => typeof x === 'string');
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Restrict a raw selection list to the poll's valid answer ids, de-dupe, and cap to
|
||||
* max_selections (keep the first N) — MSC3381 spoiled-vote handling. */
|
||||
export function validateSelections(
|
||||
rawIds: string[],
|
||||
validIds: Set<string>,
|
||||
maxSelections: number
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of rawIds) {
|
||||
if (validIds.has(id) && !seen.has(id)) {
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
if (out.length >= maxSelections) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A single voter's validated response: non-spoiled, answer ids already restricted
|
||||
* to the poll's valid answers and capped to max_selections. */
|
||||
export type PollResponse = {
|
||||
sender: string;
|
||||
ts: number;
|
||||
answerIds: string[];
|
||||
};
|
||||
|
||||
export type PollTally = {
|
||||
/** answerId → number of voters who selected it. */
|
||||
counts: Map<string, number>;
|
||||
/** answer ids the current user selected (from their latest response). */
|
||||
myVotes: Set<string>;
|
||||
/** number of distinct voters counted. */
|
||||
total: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate responses into a tally. The LATEST response per sender wins (a later
|
||||
* response fully replaces an earlier one). If a sender's latest response is empty
|
||||
* (they cleared their vote, or it was spoiled and reduced to no valid answers),
|
||||
* that sender is NOT counted — so clearing a vote removes you from the total.
|
||||
* Ties in timestamp keep the first seen (stable). Callers pass answer ids that are
|
||||
* already restricted to the poll's valid answers + capped to max_selections.
|
||||
*/
|
||||
export function tallyResponses(responses: PollResponse[], myUserId: string): PollTally {
|
||||
const latestBySender = new Map<string, PollResponse>();
|
||||
for (const r of responses) {
|
||||
const existing = latestBySender.get(r.sender);
|
||||
if (!existing || r.ts > existing.ts) {
|
||||
latestBySender.set(r.sender, r);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const myVotes = new Set<string>();
|
||||
let total = 0;
|
||||
for (const [sender, r] of latestBySender) {
|
||||
if (r.answerIds.length === 0) continue; // cleared / spoiled latest → not a voter
|
||||
total += 1;
|
||||
for (const id of r.answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVotes, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether vote counts may be shown. Disclosed polls always show live results;
|
||||
* undisclosed polls hide them until the poll has ended.
|
||||
*/
|
||||
export function resultsVisible(isUndisclosed: boolean, isEnded: boolean): boolean {
|
||||
return !isUndisclosed || isEnded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer id(s) with the highest vote count. Returns every tied id, or an empty
|
||||
* array when there are no votes.
|
||||
*/
|
||||
export function winningAnswerIds(counts: Map<string, number>): string[] {
|
||||
let max = 0;
|
||||
for (const c of counts.values()) {
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (max === 0) return [];
|
||||
const winners: string[] = [];
|
||||
for (const [id, c] of counts) {
|
||||
if (c === max) winners.push(id);
|
||||
}
|
||||
return winners;
|
||||
}
|
||||
Reference in New Issue
Block a user