feat: voice message recording + per-member encryption verification
- Add VoiceMessageRecorder component: mic button in composer toolbar, live waveform + timer, preview before send, MSC3245-compliant content (org.matrix.msc3245.voice, org.matrix.msc1767.audio with waveform), E2EE support via encryptFile before upload - Add useUserVerifiedStatus hook: uses crypto.getUserVerificationStatus, reacts live to CryptoEvent.UserTrustStatusChanged - MembersDrawer: show green/yellow shield badge per member in encrypted rooms (cross-signing verified/unverified), E2EE status banner in header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds';
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'preview';
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
|
||||
onError?: (err: string) => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function normalizeWaveform(samples: number[]): number[] {
|
||||
if (samples.length === 0) return Array(20).fill(0);
|
||||
const max = Math.max(...samples, 1);
|
||||
const count = Math.min(samples.length, 100);
|
||||
const step = samples.length / count;
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const idx = Math.floor(i * step);
|
||||
result.push(Math.round((samples[idx] / max) * 1024));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const WAVEFORM_BARS = 40;
|
||||
|
||||
export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
const [state, setState] = useState<RecorderState>('idle');
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const [waveformBars, setWaveformBars] = useState<number[]>(Array(WAVEFORM_BARS).fill(0));
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rawSamplesRef = useRef<number[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const previewMimeRef = useRef('audio/ogg;codecs=opus');
|
||||
const previewDurationRef = useRef(0);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopAll();
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
||||
? 'audio/ogg;codecs=opus'
|
||||
: 'audio/webm;codecs=opus';
|
||||
previewMimeRef.current = mimeType;
|
||||
|
||||
const mr = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = mr;
|
||||
chunksRef.current = [];
|
||||
rawSamplesRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
rawSamplesRef.current.push(avg);
|
||||
|
||||
setWaveformBars((prev) => {
|
||||
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
|
||||
return next;
|
||||
});
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setDurationMs(Date.now() - startTimeRef.current);
|
||||
}, 100);
|
||||
|
||||
mr.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mr.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
previewDurationRef.current = Date.now() - startTimeRef.current;
|
||||
setPreviewBlob(blob);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
setState('preview');
|
||||
};
|
||||
|
||||
mr.start(250);
|
||||
setState('recording');
|
||||
} catch {
|
||||
onError?.('Microphone access denied');
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
stopAll();
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, [stopAll]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
stopAll();
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state === 'recording') {
|
||||
mr.ondataavailable = null;
|
||||
mr.onstop = null;
|
||||
mr.stop();
|
||||
}
|
||||
setPreviewBlob(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
rawSamplesRef.current = [];
|
||||
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
||||
setDurationMs(0);
|
||||
setState('idle');
|
||||
}, [stopAll]);
|
||||
|
||||
const sendVoice = useCallback(() => {
|
||||
if (!previewBlob) return;
|
||||
const waveform = normalizeWaveform(rawSamplesRef.current);
|
||||
onSend(previewBlob, previewMimeRef.current, previewDurationRef.current, waveform);
|
||||
cancelRecording();
|
||||
}, [previewBlob, onSend, cancelRecording]);
|
||||
|
||||
const barMax = Math.max(...waveformBars, 1);
|
||||
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={startRecording}
|
||||
aria-label="Record voice message"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Record voice message"
|
||||
>
|
||||
<Icon src={Icons.Mic} size="100" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'recording') {
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(8),
|
||||
height: toRem(8),
|
||||
borderRadius: '50%',
|
||||
background: 'var(--tc-danger-normal)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(32), fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDuration(durationMs)}
|
||||
</Text>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="50"
|
||||
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
|
||||
>
|
||||
{waveformBars.map((h, i) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
style={{
|
||||
width: toRem(2),
|
||||
height: toRem(2 + (h / barMax) * 16),
|
||||
borderRadius: toRem(1),
|
||||
background: 'var(--tc-primary-normal)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={stopRecording}
|
||||
aria-label="Stop recording"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Stop recording"
|
||||
>
|
||||
<Icon src={Icons.Pause} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={cancelRecording}
|
||||
aria-label="Cancel recording"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Cancel"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
}}
|
||||
>
|
||||
{previewUrl && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio src={previewUrl} controls style={{ height: toRem(28), maxWidth: toRem(180) }} />
|
||||
)}
|
||||
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
|
||||
{formatDuration(previewDurationRef.current)}
|
||||
</Text>
|
||||
<IconButton
|
||||
onClick={sendVoice}
|
||||
aria-label="Send voice message"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Send voice message"
|
||||
>
|
||||
<Icon src={Icons.Send} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={cancelRecording}
|
||||
aria-label="Discard voice message"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Discard"
|
||||
>
|
||||
<Icon src={Icons.Delete} size="100" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import { useUserVerifiedStatus } from '../../hooks/useUserVerifiedStatus';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
@@ -110,7 +112,37 @@ type MemberItemProps = {
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
pressed?: boolean;
|
||||
typing?: boolean;
|
||||
showEncryption?: boolean;
|
||||
};
|
||||
|
||||
function MemberVerificationBadge({ userId }: { userId: string }) {
|
||||
const vs = useUserVerifiedStatus(userId);
|
||||
if (vs === 'unknown') return null;
|
||||
const color =
|
||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{label}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
title={label}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
<Icon size="50" src={Icons.ShieldUser} style={{ color }} />
|
||||
</span>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberItem({
|
||||
mx,
|
||||
useAuthentication,
|
||||
@@ -119,6 +151,7 @@ function MemberItem({
|
||||
onClick,
|
||||
pressed,
|
||||
typing,
|
||||
showEncryption,
|
||||
}: MemberItemProps) {
|
||||
const name =
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
@@ -146,11 +179,14 @@ function MemberItem({
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
typing && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" />
|
||||
</Badge>
|
||||
)
|
||||
<>
|
||||
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
||||
{typing && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" />
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
@@ -180,6 +216,9 @@ type MembersDrawerProps = {
|
||||
export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const isEncrypted = room.hasEncryptionStateEvent();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const showEncryption = isEncrypted && crossSigningActive;
|
||||
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
@@ -252,6 +291,26 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
direction="Column"
|
||||
>
|
||||
<MemberDrawerHeader room={room} />
|
||||
{isEncrypted && (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
background: 'var(--bg-surface-variant)',
|
||||
borderBottom: '1px solid var(--border-surface-variant)',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Lock}
|
||||
style={{ color: 'var(--tc-positive-normal, #5effc4)', flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
|
||||
{crossSigningActive ? 'E2EE · Shield = verified identity' : 'End-to-end encrypted room'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
|
||||
@@ -423,6 +482,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
typing={typingMembers.some(
|
||||
(receipt) => receipt.userId === tagOrMember.userId,
|
||||
)}
|
||||
showEncryption={showEncryption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,6 +119,7 @@ import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -201,6 +202,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
};
|
||||
|
||||
const handleVoiceSend = useCallback(
|
||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||
const baseContent: IContent = {
|
||||
msgtype: MsgType.Audio,
|
||||
body: 'Voice message',
|
||||
filename: 'voice-message.ogg',
|
||||
'org.matrix.msc3245.voice': {},
|
||||
'org.matrix.msc1767.audio': { duration: durationMs, waveform },
|
||||
info: { mimetype: mimeType, size: blob.size, duration: durationMs },
|
||||
};
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const { encInfo, file: encBlob } = await encryptFile(blob);
|
||||
const uploadResult = await mx.uploadContent(encBlob);
|
||||
mx.sendMessage(roomId, {
|
||||
...baseContent,
|
||||
file: { ...encInfo, url: uploadResult.content_uri },
|
||||
} as any);
|
||||
} else {
|
||||
const uploadResult = await mx.uploadContent(blob, {
|
||||
name: 'voice-message.ogg',
|
||||
type: mimeType,
|
||||
});
|
||||
mx.sendMessage(roomId, {
|
||||
...baseContent,
|
||||
url: uploadResult.content_uri,
|
||||
} as any);
|
||||
}
|
||||
},
|
||||
[mx, room, roomId],
|
||||
);
|
||||
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
@@ -849,6 +882,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
<Icon src={Icons.Pin} size="100" />
|
||||
)}
|
||||
</IconButton>
|
||||
<VoiceMessageRecorder onSend={handleVoiceSend} />
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useUserTrustStatusChange } from './useUserTrustStatusChange';
|
||||
import { useCrossSigningActive } from './useCrossSigning';
|
||||
|
||||
export type UserVerifiedStatus = 'verified' | 'unverified' | 'unknown';
|
||||
|
||||
export function useUserVerifiedStatus(userId: string): UserVerifiedStatus {
|
||||
const mx = useMatrixClient();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const [status, setStatus] = useState<UserVerifiedStatus>('unknown');
|
||||
|
||||
const check = useCallback(async () => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto || !crossSigningActive) {
|
||||
setStatus('unknown');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const vs = await crypto.getUserVerificationStatus(userId);
|
||||
setStatus(vs.isVerified() ? 'verified' : 'unverified');
|
||||
} catch {
|
||||
setStatus('unknown');
|
||||
}
|
||||
}, [mx, userId, crossSigningActive]);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
}, [check]);
|
||||
|
||||
useUserTrustStatusChange(
|
||||
useCallback(
|
||||
(changedUserId: string) => {
|
||||
if (changedUserId === userId) check();
|
||||
},
|
||||
[userId, check],
|
||||
),
|
||||
);
|
||||
|
||||
return status;
|
||||
}
|
||||
Reference in New Issue
Block a user