Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 5d5ae0ee70 feat(scheduling): edit / reschedule a scheduled message
CI / Trigger Desktop Build (push) Successful in 8s
CI / Build & Quality Checks (push) Successful in 11m43s
The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.

MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).

ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:47:43 -04:00
jaredandClaude Opus 4.8 7d02f4e538 fix(voice): apply waveform review findings
Three review agents (no regressions found). Applied:
- Keyboard arrow-seek now reads the live media currentTime, not the throttled
  ~500ms state, so rapid presses accumulate instead of dropping steps.
- Scrubbing the waveform (or the fallback seek bar) BEFORE first play now loads
  the media and plays from the clicked position (was a silent no-op).
- Unplayed bars use a dimmed accent (color-mix 32%) instead of a faint surface
  token, for consistent contrast across TDS-dark/light + normal themes.
- Fixed first-bar always-lit off-by-one ((i+1)/len), and added overflow:hidden so
  the strip clips rather than overflows in a very narrow drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:01:41 -04:00
jaredandClaude Opus 4.8 56863d2649 feat(voice): render the waveform on playback with click/drag/keyboard scrubbing
Voice messages carry an MSC1767 waveform (org.matrix.msc1767.audio.waveform) and the
recorder draws a live one, but AudioContent playback only showed a plain seek bar.
Now the player renders the waveform as bars that fill with the accent (TDS green under
Lotus Terminal) as the clip plays, and the waveform itself is the seek control —
click, drag, or keyboard (arrows +/-5s, Home/End) with role=slider + ARIA value text.

- AudioContent: new optional "waveform" prop + a WaveformSeek sub-component
  (downsamples to 48 bars, mirrors the recorder's bar styling); falls back to the
  plain Range seek bar when there's no waveform.
- Threaded through MAudio (RenderAudioContentProps) so timeline voice messages get it
  automatically; the Media Gallery Audio tab passes it directly.

Improves both the timeline and the new gallery Audio tab at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:50:08 -04:00
jaredandClaude Opus 4.8 2d0c804abc fix(media-gallery): address review — proper decrypt-download + mimetype sanitize
Review of 28cb004e (3 agents) surfaced two real issues:
- Downloads used a raw mxc→http anchor, so encrypted media (voice messages are
  almost always in E2EE DMs) downloaded ciphertext. Reuse FileDownloadButton for
  both the audio AND file rows — it decrypts before saving, adds loading/success
  state + a toast, and derives a filename extension.
- Audio playback bypassed MAudio's mimetype sanitization; pass the value through
  getBlobSafeMimeType (e.g. application/ogg → audio/ogg) so encrypted/odd-mimetype
  audio actually plays. Give voice messages a real filename+extension.

Also corrected the docs: AudioContent renders a seek bar, not an MSC3245 waveform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:35:35 -04:00
jaredandClaude Opus 4.8 28cb004e80 feat(media-gallery): add Audio/Voice tab + jump-to-message
- New Audio tab (m.audio) listing voice messages + audio files with an inline player
  (reuses AudioContent + MediaControl: play/seek/speed, MSC3245 voice waveform,
  decrypt-on-play), sender/date, and download. Added to the tab counts.
- 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; threaded eventId into LightboxItem.
- Update the (stale) LOTUS_FEATURES.md Media Gallery entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:26:02 -04:00
jaredandClaude Opus 4.8 0b06158477 feat(polls): complete the poll lifecycle — voting, undisclosed, end, spec-correct
Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:

- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
  locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
  (creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
  Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
  roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
  and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
  PollStart/Response parsers only understand the unstable bodies, so delegating to
  them broke every stable poll (caught in agent review). Use the SDK Poll model only
  for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
  incl. a stable/unstable wire-format round-trip.

Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:48:58 -04:00
11 changed files with 1092 additions and 266 deletions
+26 -12
View File
@@ -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 (01024 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}
+290 -207
View File
@@ -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>
);
}
+148 -13
View File
@@ -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}
/>
)}
</>
+36 -2
View File
@@ -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 }}>
+3 -17
View File
@@ -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>
</>
);
}
+170
View File
@@ -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
});
+166
View File
@@ -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;
}