diff --git a/src/app/components/VoiceMessageRecorder.tsx b/src/app/components/VoiceMessageRecorder.tsx new file mode 100644 index 000000000..9b5157d1b --- /dev/null +++ b/src/app/components/VoiceMessageRecorder.tsx @@ -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('idle'); + const [durationMs, setDurationMs] = useState(0); + const [waveformBars, setWaveformBars] = useState(Array(WAVEFORM_BARS).fill(0)); + const [previewBlob, setPreviewBlob] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const analyserRef = useRef(null); + const audioCtxRef = useRef(null); + const rawSamplesRef = useRef([]); + const startTimeRef = useRef(0); + const animFrameRef = useRef(0); + const timerRef = useRef | 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 ( + + + + ); + } + + if (state === 'recording') { + return ( + + + + {formatDuration(durationMs)} + + + {waveformBars.map((h, i) => ( +
+ ))} + + + + + + + + + ); + } + + return ( + + {previewUrl && ( + // eslint-disable-next-line jsx-a11y/media-has-caption + + ); +} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 1497c269b..5946ec628 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -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; 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 ( + + {label} + + } + > + {(ref) => ( + + + + )} + + ); +} + 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({ } after={ - typing && ( - - - - ) + <> + {showEncryption && } + {typing && ( + + + + )} + } > @@ -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(null) as React.RefObject; const searchInputRef = useRef(null) as React.RefObject; const scrollTopAnchorRef = useRef(null) as React.RefObject; @@ -252,6 +291,26 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { direction="Column" > + {isEncrypted && ( + + + + {crossSigningActive ? 'E2EE ยท Shield = verified identity' : 'End-to-end encrypted room'} + + + )} @@ -423,6 +482,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { typing={typingMembers.some( (receipt) => receipt.userId === tagOrMember.userId, )} + showEncryption={showEncryption} />
); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e9e5fce38..f22ffde44 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( ); }; + 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>(); @@ -849,6 +882,7 @@ export const RoomInput = forwardRef( )} + ('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; +}