diff --git a/package-lock.json b/package-lock.json index 8aa6a34c..63c613ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "matrix-js-sdk": "41.4.0", "matrix-widget-api": "1.17.0", "millify": "6.1.0", + "opus-recorder": "8.0.5", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -13256,6 +13257,12 @@ "node": ">= 0.8.0" } }, + "node_modules/opus-recorder": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/opus-recorder/-/opus-recorder-8.0.5.tgz", + "integrity": "sha512-tBRXc9Btds7i3bVfA7d5rekAlyOcfsivt5vSIXHxRV1Oa+s6iXFW8omZ0Lm3ABWotVcEyKt96iIIUcgbV07YOw==", + "license": "MIT" + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", diff --git a/package.json b/package.json index 7d7d3552..98efb92a 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "matrix-js-sdk": "41.4.0", "matrix-widget-api": "1.17.0", "millify": "6.1.0", + "opus-recorder": "8.0.5", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", diff --git a/public/locales/en.json b/public/locales/en.json index ee4c2967..198fcd53 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -295,6 +295,19 @@ }, "Search": { "search": "Search", + "people": "People", + "by_address": "By address", + "address_hint": "To message someone new, type their address — @name:server", + "dm_rate_limited": "Too many requests. Please try again in a moment.", + "dm_failed": "Couldn't start the chat.", + "start_dm_title": "New chat", + "checking": "Checking…", + "user_found": "User found", + "found_on_server": "Found · {{server}}", + "user_not_found": "Not found on {{server}}", + "user_unreachable": "{{server}} isn't responding — can't verify", + "encrypt_label": "Encrypt messages", + "start_dm_action": "Message", "no_match_found": "No Match Found", "no_rooms": "No Rooms", "no_match_for_query": "No match found for \"{{query}}\".", @@ -540,6 +553,18 @@ "send_message_alt_12": "The placeholder stares back...", "drop_files": "Drop Files in \"{{name}}\"", "drag_drop_desc": "Drag and drop files here or click for selection dialog", + "voice_record": "Record voice message", + "voice_close": "Close recorder", + "voice_delete": "Delete recording", + "voice_play": "Play", + "voice_pause": "Pause", + "voice_stop": "Stop recording", + "voice_send": "Send voice message", + "voice_dismiss_error": "Dismiss", + "voice_mic_error": "Couldn't access the microphone.", + "voice_send_error": "Couldn't send the voice message.", + "voice_disabled": "{{name}} disabled voice messages in this chat.", + "voice_disabled_generic": "Voice messages are disabled in this chat.", "pinned_messages": "Pinned Messages", "no_pinned_messages": "No Pinned Messages", "no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.", @@ -753,6 +778,8 @@ "visibility_after_join": "After Join", "visibility_all_messages": "All Messages", "visibility_all_messages_guests": "All Messages (Guests)", + "voice_messages": "Voice messages", + "voice_messages_desc": "Allow voice messages in this chat. When off, others can't send them here.", "room_encryption": "Room Encryption", "encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.", "encryption_disabled_desc": "Once enabled, encryption cannot be disabled!", diff --git a/public/locales/ru.json b/public/locales/ru.json index d01984e2..096412eb 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -295,6 +295,19 @@ }, "Search": { "search": "Поиск", + "people": "Люди", + "by_address": "По адресу", + "address_hint": "Чтобы написать новому человеку, введите его адрес — @имя:сервер", + "dm_rate_limited": "Слишком часто. Попробуйте чуть позже.", + "dm_failed": "Не удалось создать чат.", + "start_dm_title": "Новый чат", + "checking": "Проверяем…", + "user_found": "Пользователь найден", + "found_on_server": "Найден · {{server}}", + "user_not_found": "Не найден на {{server}}", + "user_unreachable": "{{server}} не отвечает — не удалось проверить", + "encrypt_label": "Шифровать переписку", + "start_dm_action": "Написать", "no_match_found": "Совпадений не найдено", "no_rooms": "Нет комнат", "no_match_for_query": "Совпадений для «{{query}}» не найдено.", @@ -550,6 +563,18 @@ "send_message_alt_12": "Плейсхолдер смотрит на вас...", "drop_files": "Перетащите файлы в \"{{name}}\"", "drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора", + "voice_record": "Записать голосовое сообщение", + "voice_close": "Закрыть запись", + "voice_delete": "Удалить запись", + "voice_play": "Воспроизвести", + "voice_pause": "Пауза", + "voice_stop": "Остановить запись", + "voice_send": "Отправить голосовое сообщение", + "voice_dismiss_error": "Скрыть", + "voice_mic_error": "Не удалось получить доступ к микрофону.", + "voice_send_error": "Не удалось отправить голосовое сообщение.", + "voice_disabled": "{{name}} отключил голосовые сообщения в этом чате.", + "voice_disabled_generic": "Голосовые сообщения отключены в этом чате.", "pinned_messages": "Закреплённые сообщения", "no_pinned_messages": "Нет закреплённых сообщений", "no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.", @@ -771,6 +796,8 @@ "visibility_after_join": "После вступления", "visibility_all_messages": "Все сообщения", "visibility_all_messages_guests": "Все сообщения (гости)", + "voice_messages": "Голосовые сообщения", + "voice_messages_desc": "Разрешить голосовые сообщения в этом чате. Если выключено, другие не смогут их отправлять.", "room_encryption": "Шифрование комнаты", "encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.", "encryption_disabled_desc": "После включения шифрование невозможно отключить!", diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 79a756ef..25debac6 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -17,6 +17,7 @@ import { MNotice, MText, MVideo, + MVoice, ReadPdfFile, ReadTextFile, RenderBody, @@ -28,6 +29,7 @@ import { ThumbnailContent, UnsupportedContent, VideoContent, + VoiceContent, } from './message'; import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; import { Image, MediaControl, Video } from './media'; @@ -35,7 +37,7 @@ import { ImageViewer } from './image-viewer'; import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; import { testMatrixTo } from '../plugins/matrix-to'; -import { IImageContent } from '../../types/matrix/common'; +import { IAudioContent, IImageContent, isVoiceMessageContent } from '../../types/matrix/common'; import { logMedia } from './message/attachment/streamMediaDebug'; // Threads the StreamLayout's mediaMode info from Message.tsx down to the @@ -54,6 +56,13 @@ export const useStreamMediaContext = (): StreamMediaContextValue | null => type RenderMessageContentProps = { displayName: string; + // Voice bubble: the sender's id + resolved avatar URL so VoiceContent can draw + // the avatar. Non-timeline callers (pin-menu, search) may omit them. + senderId?: string; + senderAvatarUrl?: string; + // True when the surrounding layout ALREADY draws a per-message avatar (channel + // layout / thread drawer) — VoiceContent then skips its own to avoid doubling. + hideVoiceAvatar?: boolean; msgType: string; ts: number; edited?: boolean; @@ -73,6 +82,9 @@ type RenderMessageContentProps = { }; export function RenderMessageContent({ displayName, + senderId, + senderAvatarUrl, + hideVoiceAvatar, msgType, ts, edited, @@ -300,6 +312,26 @@ export function RenderMessageContent({ } if (msgType === MsgType.Audio) { + // Voice notes (MSC3245) — both Vojo-native and Telegram-bridged — render as + // the Dawn voice bubble; plain audio files keep the generic player. + const audioContent = getContent>(); + if (isVoiceMessageContent(audioContent)) { + return ( + ( + + )} + /> + ); + } return ( <> ( bottom, before, after, + replaceEditable, maxHeight = '50vh', editor, placeholder, @@ -136,38 +142,40 @@ export const CustomEditor = forwardRef(
{top} - - {before && ( - - {before} - - )} - - - - {after && ( - - {after} - - )} - + {replaceEditable ?? ( + + {before && ( + + {before} + + )} + + + + {after && ( + + {after} + + )} + + )} {bottom}
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 4a0fd570..ed36ee78 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -22,6 +22,7 @@ import { IThumbnailContent, IVideoContent, IVideoInfo, + MATRIX_AUDIO_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_REASON_PROPERTY_NAME, } from '../../../types/matrix/common'; @@ -339,6 +340,45 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: ); } +type RenderVoiceContentProps = { + info: IAudioInfo; + mimeType: string; + url: string; + encInfo?: IEncryptedFile; + waveform?: number[]; +}; +type MVoiceProps = { + content: IAudioContent; + renderAsFile: () => ReactNode; + renderVoiceContent: (props: RenderVoiceContentProps) => ReactNode; +}; +// Voice notes (MSC3245). Unlike `MAudio`, this drops the `FileHeader` so the +// voice bubble is a clean full-width rectangle (no "Voice message.ogg" row) and +// owns its own background/padding via `VoiceContent`. Falls back to a plain file +// card when the audio is unplayable. See docs/plans/voice_messages.md §1 (D4). +export function MVoice({ content, renderAsFile, renderVoiceContent }: MVoiceProps) { + const audioInfo = content?.info; + const mxcUrl = content.file?.url ?? content.url; + const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? ''); + + if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') { + if (mxcUrl) { + return renderAsFile(); + } + return ; + } + + const waveform = content[MATRIX_AUDIO_PROPERTY_NAME]?.waveform; + + return renderVoiceContent({ + info: audioInfo, + mimeType: safeMimeType, + url: mxcUrl, + encInfo: content.file, + waveform, + }); +} + type RenderFileContentProps = { body: string; info: IFileInfo & IThumbnailContent; diff --git a/src/app/components/message/content/VoiceContent.css.ts b/src/app/components/message/content/VoiceContent.css.ts new file mode 100644 index 00000000..617e367a --- /dev/null +++ b/src/app/components/message/content/VoiceContent.css.ts @@ -0,0 +1,154 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Dawn voice-note bubble — identical on native and web. The sender avatar sits +// OUTSIDE the bubble (bare, no background). Both own and other have a filled +// bubble with visible edges; the difference is the FILL TONE: others get a +// light subtle fill (SurfaceVariant.ContainerHover #21232b), own gets the +// darker fill of the composer / input form (Surface.Container #0d0e11 — the +// `${ChatComposer} .${Editor}` override in RoomView.css.ts, NOT the lighter +// SurfaceVariant). Same 1px edge on both. See docs/plans/voice_messages.md §5. + +const fadeIn = keyframes({ + from: { opacity: 0, transform: 'translateY(3px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}); + +export const Row = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + width: '100%', + boxSizing: 'border-box', + animation: `${fadeIn} 180ms ease`, +}); + +// Bare avatar — fixed 40px, no background box of its own (the avatar image / +// fallback fills it). Strip any container fill folds might apply. +export const AvatarSlot = style({ + flexShrink: 0, + width: toRem(40), + height: toRem(40), + backgroundColor: 'transparent', +}); + +export const Bubble = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + flexGrow: 1, + minWidth: 0, + maxWidth: toRem(400), + // Fixed height so own and other are pixel-identical regardless of fill. + height: toRem(56), + boxSizing: 'border-box', + padding: `0 ${config.space.S400}`, + color: color.SurfaceVariant.OnContainer, + borderRadius: config.radii.R400, + // Others (default): a light subtle fill with a visible 1px edge — the + // "Владислав" look. Own overrides the fill to the darker composer tone below. + backgroundColor: color.SurfaceVariant.ContainerHover, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, +}); + +// Own messages — the exact composer / input-form fill (Surface.Container, +// #0d0e11), darker than the peer fill; same edge. +export const BubbleOwn = style({ + backgroundColor: color.Surface.Container, +}); + +const playPulse = keyframes({ + '0%': { boxShadow: `0 0 0 0 ${color.Primary.Main}` }, + '70%': { boxShadow: `0 0 0 ${toRem(6)} rgba(0,0,0,0)` }, + '100%': { boxShadow: `0 0 0 0 rgba(0,0,0,0)` }, +}); + +export const PlayButton = style({ + flexShrink: 0, + width: toRem(40), + height: toRem(40), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: config.radii.Pill, + border: 'none', + padding: 0, + cursor: 'pointer', + backgroundColor: color.Primary.Main, + color: color.Primary.OnMain, + transition: 'transform 120ms ease, background-color 120ms ease', + selectors: { + '&:hover': { backgroundColor: color.Primary.MainHover, transform: 'scale(1.06)' }, + '&:active': { transform: 'scale(0.92)' }, + '&:disabled': { cursor: 'default', opacity: 0.6 }, + }, +}); + +export const PlayButtonPlaying = style({ + animation: `${playPulse} 1.6s ease-out infinite`, +}); + +export const Waveform = style({ + position: 'relative', + flexGrow: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + gap: toRem(2), + height: toRem(32), + cursor: 'pointer', + // Guarantee the react-range thumb can never paint past the track edge toward + // the time readout (it's a 1px transparent overhang at most, but contain it). + overflow: 'hidden', +}); + +const barGrow = keyframes({ + from: { transform: 'scaleY(0.25)' }, + to: { transform: 'scaleY(1)' }, +}); + +export const WaveBar = style({ + flex: '1 1 0', + minWidth: toRem(2), + minHeight: toRem(3), + borderRadius: toRem(2), + transformOrigin: 'center', + animation: `${barGrow} 220ms ease`, + transition: 'background-color 120ms linear, transform 120ms ease', +}); + +export const WaveBarPlayed = style({ backgroundColor: color.Primary.Main }); +export const WaveBarRest = style({ backgroundColor: color.SurfaceVariant.ContainerLine }); + +export const WaveThumb = style({ + width: toRem(2), + height: '100%', + backgroundColor: 'transparent', + outline: 'none', +}); + +export const ProgressTrack = style({ + position: 'relative', + flexGrow: 1, + height: toRem(4), + borderRadius: config.radii.Pill, + backgroundColor: color.SurfaceVariant.ContainerLine, + cursor: 'pointer', +}); + +export const ProgressFill = style({ + position: 'absolute', + left: 0, + top: 0, + height: '100%', + borderRadius: config.radii.Pill, + backgroundColor: color.Primary.Main, + transition: 'width 120ms linear', +}); + +export const Time = style({ + flexShrink: 0, + minWidth: toRem(40), + textAlign: 'right', + fontVariantNumeric: 'tabular-nums', +}); diff --git a/src/app/components/message/content/VoiceContent.tsx b/src/app/components/message/content/VoiceContent.tsx new file mode 100644 index 00000000..cb9afad7 --- /dev/null +++ b/src/app/components/message/content/VoiceContent.tsx @@ -0,0 +1,202 @@ +/* eslint-disable jsx-a11y/media-has-caption */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Avatar, Icon, Icons, Spinner, Text } from 'folds'; +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { Range } from 'react-range'; +import { useTranslation } from 'react-i18next'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { IAudioInfo } from '../../../../types/matrix/common'; +import { + PlayTimeCallback, + useMediaLoading, + useMediaPlay, + useMediaPlayTimeCallback, + useMediaSeek, +} 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'; +import { normalizeWaveform } from '../../../utils/audioWaveform'; +import { UserAvatar } from '../../user-avatar'; +import * as css from './VoiceContent.css'; + +const PLAY_TIME_THROTTLE_OPS = { + wait: 200, + immediate: true, +}; + +const TARGET_BARS = 40; + +export type VoiceContentProps = { + mimeType: string; + url: string; + info: IAudioInfo; + encInfo?: EncryptedAttachmentInfo; + waveform?: number[]; + // The message sender — drives the always-present avatar on the left and the + // own-vs-other frame. Non-timeline callers (pin-menu, search) may omit them, + // in which case the avatar falls back to a placeholder. + senderId?: string; + senderAvatarUrl?: string; + senderName?: string; + // Set when the surrounding layout already draws a per-message avatar (channel + // layout / thread drawer) — skip our own to avoid two avatars on the row. + hideAvatar?: boolean; +}; +export function VoiceContent({ + mimeType, + url, + info, + encInfo, + waveform, + senderId, + senderAvatarUrl, + senderName, + hideAvatar, +}: VoiceContentProps) { + const { t } = useTranslation(); + 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]) + ); + + // Revoke the downloaded object URL when it changes / on unmount. + useEffect(() => { + const objectUrl = srcState.status === AsyncStatus.Success ? srcState.data : undefined; + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [srcState]); + + const audioRef = useRef(null); + + const [currentTime, setCurrentTime] = useState(0); + // duration in seconds. (NOTE: info.duration is in milliseconds) + const infoDuration = info.duration ?? 0; + const [duration, setDuration] = useState( + (Number.isFinite(infoDuration) && infoDuration >= 0 ? infoDuration : 0) / 1000 + ); + + const getAudioRef = useCallback(() => audioRef.current, []); + const { loading } = useMediaLoading(getAudioRef); + const { playing, setPlaying } = useMediaPlay(getAudioRef); + const { seek } = useMediaSeek(getAudioRef); + const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => { + // Keep the info.duration seed when the element reports a non-finite duration + // (ogg/opus frequently reports Infinity until fully buffered) — overwriting + // it with 0 would flatten the waveform and show 0:00. + if (Number.isFinite(d) && d > 0) setDuration(d); + setCurrentTime(ct); + }, []); + useMediaPlayTimeCallback( + getAudioRef, + useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS) + ); + + const isOwn = !!senderId && senderId === mx.getUserId(); + const bars = useMemo(() => normalizeWaveform(waveform, TARGET_BARS), [waveform]); + const max = duration || 1; + const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; + const clampedValue = Math.min(Math.max(currentTime, 0), max); + + const handlePlay = () => { + if (srcState.status === AsyncStatus.Success) { + setPlaying(!playing); + } else if (srcState.status !== AsyncStatus.Loading) { + loadSrc(); + } + }; + + const isLoading = srcState.status === AsyncStatus.Loading || loading; + // Show elapsed once the user is into playback, otherwise the total length. + const displayTime = playing || currentTime > 0 ? currentTime : duration; + + return ( +
+ {!hideAvatar && ( + + } + /> + + )} + +
+ + + seek(values[0])} + renderTrack={({ props, children }) => + bars.length > 0 ? ( +
+ {bars.map((amp, index) => { + const played = duration > 0 && (index + 0.5) / bars.length <= progress; + return ( + + ); + })} + {children} +
+ ) : ( +
+
+ {children} +
+ ) + } + renderThumb={({ props }) =>
} + /> + + + {secondsToMinutesAndSeconds(displayTime)} + + + +
+
+ ); +} diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts index 6a31ed7e..70178b42 100644 --- a/src/app/components/message/content/index.ts +++ b/src/app/components/message/content/index.ts @@ -2,6 +2,7 @@ export * from './ThumbnailContent'; export * from './ImageContent'; export * from './VideoContent'; export * from './AudioContent'; +export * from './VoiceContent'; export * from './FileContent'; export * from './FallbackContent'; export * from './EventContent'; diff --git a/src/app/features/common-settings/general/RoomVoiceMessages.tsx b/src/app/features/common-settings/general/RoomVoiceMessages.tsx new file mode 100644 index 00000000..33a91775 --- /dev/null +++ b/src/app/features/common-settings/general/RoomVoiceMessages.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from 'react'; +import { Box, color, Spinner, Switch, Text } from 'folds'; +import { useTranslation } from 'react-i18next'; +import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; +import { SettingTile } from '../../../components/setting-tile'; +import { useRoom } from '../../../hooks/useRoom'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { StateEvent } from '../../../../types/matrix/room'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; + +type RoomVoiceMessagesProps = { + permissions: RoomPermissionsAPI; +}; +// Per-room "allow voice messages" toggle. Writes the `in.vojo.room.voice_messages` +// state event. In a 1:1 both parties are PL 100 (TrustedPrivateChat) so either +// can flip it; in a group it requires `state_default` (admin). Soft, client-side, +// Vojo↔Vojo. See docs/plans/voice_messages.md §4. +export function RoomVoiceMessages({ permissions }: RoomVoiceMessagesProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const room = useRoom(); + + const canEdit = permissions.stateEvent(StateEvent.VoiceMessages, mx.getSafeUserId()); + const content = useStateEvent(room, StateEvent.VoiceMessages)?.getContent<{ + enabled?: boolean; + disabled_by?: string; + }>(); + // Default-on: absent OR enabled !== false ⇒ voice allowed. + const allowed = content?.enabled !== false; + + const [setState, setAllowed] = useAsyncCallback( + useCallback( + async (nextAllowed: boolean) => { + // Only stamp disabled_by when actually disabling — on re-enable it would + // misleadingly name the re-enabler. The gate reads it only when + // enabled === false anyway. See review notes in docs/plans/voice_messages.md. + await mx.sendStateEvent(room.roomId, StateEvent.VoiceMessages as keyof StateEvents, { + enabled: nextAllowed, + ...(nextAllowed ? {} : { disabled_by: mx.getSafeUserId() }), + }); + }, + [mx, room.roomId] + ) + ); + + const loading = setState.status === AsyncStatus.Loading; + + return ( + + {loading && } + + + } + > + {setState.status === AsyncStatus.Error && ( + + {(setState.error as MatrixError).message} + + )} + + ); +} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index 80804b0b..514a1ffd 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -5,3 +5,4 @@ export * from './RoomJoinRules'; export * from './RoomProfile'; export * from './RoomPublish'; export * from './RoomUpgrade'; +export * from './RoomVoiceMessages'; diff --git a/src/app/features/create-chat/CreateChat.tsx b/src/app/features/create-chat/CreateChat.tsx index 10f4c916..b3f69fed 100644 --- a/src/app/features/create-chat/CreateChat.tsx +++ b/src/app/features/create-chat/CreateChat.tsx @@ -62,6 +62,9 @@ export function CreateChat({ defaultUserId, onCreated, gap = '500' }: CreateChat visibility: Visibility.Private, preset: Preset.TrustedPrivateChat, initial_state: initialState, + // TrustedPrivateChat already gives both parties PL 100, so either can + // toggle the voice-messages state event; no events override needed + // (it would replace Synapse's default events map). See voice_messages.md §4. }); addRoomIdToMDirect(mx, result.room_id, userId); diff --git a/src/app/features/create-chat/useCreateDirect.ts b/src/app/features/create-chat/useCreateDirect.ts index 48095a7e..b222d48b 100644 --- a/src/app/features/create-chat/useCreateDirect.ts +++ b/src/app/features/create-chat/useCreateDirect.ts @@ -34,6 +34,10 @@ export function useCreateDirect(): (userId: string, opts?: CreateDirectOptions) visibility: Visibility.Private, preset: Preset.TrustedPrivateChat, initial_state: initialState, + // No power_level override: TrustedPrivateChat already gives both parties + // PL 100, so either can write the voice-messages state event. (A custom + // `events` override would shallow-REPLACE Synapse's default events map + // and weaken the room's state-event protections.) See voice_messages.md §4. }); addRoomIdToMDirect(mx, result.room_id, userId); diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 9fff9963..52b6cb08 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -15,6 +15,7 @@ import { RoomPublishedAddresses, RoomPublish, RoomUpgrade, + RoomVoiceMessages, } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; @@ -54,6 +55,7 @@ export function General({ requestClose }: GeneralProps) { + diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f03eca51..f1a530f1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -28,6 +28,7 @@ import { PopOut, Scroll, Text, + color, config, toRem, } from 'folds'; @@ -102,7 +103,12 @@ import { getFileMsgContent, getImageMsgContent, getVideoMsgContent, + getVoiceMsgContent, } from './msgContent'; +import { VoiceRecording, VoiceRecordingResult } from '../../utils/voiceRecording'; +import { VoiceRecorder } from './VoiceRecorderForm'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; @@ -188,6 +194,18 @@ const StreamComposerIcons = { /> ), + Mic: () => ( + <> + + + + ), }; interface RoomInputProps { @@ -221,6 +239,24 @@ export const RoomInput = forwardRef( const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const isOneOnOne = useIsOneOnOne(); const commands = useCommands(mx, room); + + // Voice messages — per-room "allowed" preference (default-on) + recorder + // state. `voiceDisabledBy` is set (mxid, possibly empty) when the room has + // `enabled: false`; undefined ⇒ allowed. See docs/plans/voice_messages.md. + const voiceMsgEvent = useStateEvent(room, StateEvent.VoiceMessages); + const voiceMsgContent = voiceMsgEvent?.getContent<{ + enabled?: boolean; + disabled_by?: string; + }>(); + const voiceDisabledBy = + voiceMsgContent?.enabled === false ? voiceMsgContent.disabled_by ?? '' : undefined; + const voiceSupported = useMemo(() => VoiceRecording.isSupported(), []); + const [voiceMode, setVoiceMode] = useState(false); + const [voiceError, setVoiceError] = useState(null); + // Drop the stale "voice disabled" banner once the room re-enables voice. + useEffect(() => { + if (voiceDisabledBy === undefined) setVoiceError(null); + }, [voiceDisabledBy]); const emojiBtnRef = useRef(null); const screenSize = useScreenSizeContext(); // On native / narrow screens the emoji-sticker board is docked inline at the @@ -440,6 +476,84 @@ export const RoomInput = forwardRef( ); }; + const voiceBlockedName = useCallback( + () => + (voiceDisabledBy && + (getMemberDisplayName(room, voiceDisabledBy) ?? getMxIdLocalPart(voiceDisabledBy))) || + undefined, + [voiceDisabledBy, room] + ); + + // Open the voice recorder form (it replaces the text input). Blocked when + // the room has voice disabled — surface the inline error instead. Idempotent + // so pressing the mic (pointerdown to start recording) and the following + // click don't double-open. Starting the recorder here ties getUserMedia / + // AudioContext.resume to the user gesture. + const handleOpenVoice = useCallback(() => { + if (textOnly || voiceMode) return; + if (voiceDisabledBy !== undefined) { + const name = voiceBlockedName(); + setVoiceError(name ? t('Room.voice_disabled', { name }) : t('Room.voice_disabled_generic')); + return; + } + setVoiceError(null); + setVoiceMode(true); + }, [textOnly, voiceMode, voiceDisabledBy, voiceBlockedName, t]); + + const handleCloseVoice = useCallback(() => { + setVoiceMode(false); + }, []); + + // Encrypt (when E2EE) + upload + send a finished recording. Called by the + // recorder's Send action. The recorder unmounts (voiceMode → false) which + // stops the preview + releases the mic. See docs/plans/voice_messages.md. + const handleVoiceSend = useCallback( + async (result: VoiceRecordingResult) => { + setVoiceMode(false); + // Too short — an accidental tap; drop silently. + if (result.durationMs < 500) return; + // A real recording that produced no audio (encode failed / timed out) — + // surface it instead of silently losing the message. + if (result.blob.size === 0) { + setVoiceError(t('Room.voice_send_error')); + return; + } + // Re-check the gate at send time — state may have changed mid-recording. + if (voiceDisabledBy !== undefined) { + const name = voiceBlockedName(); + setVoiceError( + name ? t('Room.voice_disabled', { name }) : t('Room.voice_disabled_generic') + ); + return; + } + try { + const file = safeFile( + new File([result.blob], 'Voice message.ogg', { type: 'audio/ogg' }) + ); + let item: TUploadItem; + if (room.hasEncryptionStateEvent()) { + const enc = await encryptFile(file); + item = { ...enc, metadata: { markedAsSpoiler: false } }; + } else { + item = { + file, + originalFile: file, + encInfo: undefined, + metadata: { markedAsSpoiler: false }, + }; + } + const data = await mx.uploadContent(item.file); + const mxc = data?.content_uri; + if (!mxc) throw new Error('Failed to upload voice message'); + const content = getVoiceMsgContent(item, mxc, result.durationMs, result.waveform); + await mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); + } catch { + setVoiceError(t('Room.voice_send_error')); + } + }, + [voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t] + ); + const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); @@ -620,6 +734,22 @@ export const RoomInput = forwardRef( ); + // Voice-record trigger — sits left of the emoji button. A tap opens the + // recorder (which morphs the input into the audio form). Hidden when the + // engine can't record or voice is disabled for the room. + const micButton = ( + + + + ); + // The native dock renders the board in the composer's top slot, so its // open/close state lives here (only read on native). Desktop keeps its // state isolated inside the UseStateProvider below so opening the pop-out @@ -833,9 +963,34 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} + replaceEditable={ + voiceMode ? ( + + ) : undefined + } top={ <> {dockedEmojiBoard} + {voiceError && ( + + setVoiceError(null)} + variant="SurfaceVariant" + size="300" + radii="300" + aria-label={t('Room.voice_dismiss_error')} + > + + + + {voiceError} + + + )} {replyDraft && (
( } bottom={ - - {!textOnly && plusButton} - - {!textOnly && emojiButton} - {sendButton} - + voiceMode ? null : ( + + {!textOnly && plusButton} + + {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton} + {!textOnly && emojiButton} + {sendButton} + + ) } />
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a3458ab9..eb939ec5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -32,7 +32,7 @@ import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useAlive } from '../../hooks/useAlive'; @@ -66,6 +66,7 @@ import { getEditedEvent, getEventReactions, getLatestEditableEvt, + getMemberAvatarMxc, getMemberDisplayName, isBridgedRoom, isMembershipChanged, @@ -1537,6 +1538,10 @@ export function RoomTimeline({ const senderId = mEvent.getSender() ?? ''; const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const senderAvatarUrl = senderAvatarMxc + ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; return ( { @@ -1020,6 +1025,9 @@ export function ThreadDrawer({ return ( . +export const RecIcons = { + Close: () => ( + + ), + Trash: () => ( + <> + + + + + ), + Stop: () => ( + + ), + Play: () => ( + + ), + Pause: () => ( + + ), + Send: () => ( + + ), +}; + +export type VoiceRecorderProps = { + // Fired with the finished recording when the user taps Send. + onSend: (result: VoiceRecordingResult) => void; + // Fired when the user closes the recorder (discard + back to text input). + onClose: () => void; +}; + +// Self-contained recorder that renders into the composer's `replaceEditable` +// slot (so the input morphs in place; the composer card / Slate context / drafts +// stay mounted). It owns ALL recorder state internally — high-frequency updates +// (timer, live levels, preview position) re-render ONLY this component, never the +// big RoomInput. The hidden