Files
cinny/src/app/components/message/content/AudioContent.tsx
T
jared 668bdaad7d fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
  logout too (token expiry / remote sign-out) — only manual logout did before.
  F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
  serialized at MODULE scope (single queue + latestRef per client, echo-driven),
  fixing the cross-instance lost-update clobber (useBookmarks mounts per message
  row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
  correct date-range early-break (raw paginated ts). M4: image compression
  skips PNG (was flattening transparency to black), bakes EXIF orientation via
  createImageBitmap, .jpg-renames, and falls back to the original on decode
  failure instead of dropping the file. M5: MediaGallery lightbox opens the
  right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
  the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
  updater has a terminal state.

Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:56:27 -04:00

261 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Chip, 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 { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
useMediaLoading,
useMediaPlay,
useMediaPlayTimeCallback,
useMediaSeek,
useMediaVolume,
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
immediate: true,
};
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = {
mimeType: string;
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({
mimeType,
url,
info,
encInfo,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo]),
);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(
() => () => {
if (
srcState.status === AsyncStatus.Success &&
typeof srcState.data === 'string' &&
srcState.data.startsWith('blob:')
) {
URL.revokeObjectURL(srcState.data);
}
},
[srcState],
);
const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0;
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
const getAudioRef = useCallback(() => audioRef.current, []);
const { loading } = useMediaLoading(getAudioRef);
const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
setDuration(d);
setCurrentTime(ct);
}, []);
useMediaPlayTimeCallback(
getAudioRef,
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
);
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return undefined;
const applyRate = () => {
audio.playbackRate = playbackSpeed;
};
// 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
// resets playbackRate to 1 on load, discarding the user's speed choice.
applyRate();
audio.addEventListener('loadedmetadata', applyRate);
audio.addEventListener('play', applyRate);
return () => {
audio.removeEventListener('loadedmetadata', applyRate);
audio.removeEventListener('play', applyRate);
};
}, [playbackSpeed]);
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
const handleSpeedClick = () => {
const currentIndex = SPEED_STEPS.indexOf(playbackSpeed);
const nextIndex = (currentIndex + 1) % SPEED_STEPS.length;
setPlaybackSpeed(SPEED_STEPS[nextIndex]);
};
const handlePlay = () => {
if (srcState.status === AsyncStatus.Success) {
setPlaying(!playing);
} else if (srcState.status !== AsyncStatus.Loading) {
loadSrc();
}
};
return renderMediaControl({
after: (
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
),
leftControl: (
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime,
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
<Chip
onClick={handleSpeedClick}
variant="Secondary"
radii="300"
aria-label={`Playback speed: ${playbackSpeed}×`}
>
<Text size="B300">{`${playbackSpeed}×`}</Text>
</Chip>
</>
),
rightControl: (
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-label={mute ? 'Unmute' : 'Mute'}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
),
children: (
<audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
),
});
}