feat: element-web: video messages

This commit is contained in:
2026-06-06 05:25:02 +00:00
parent d76c0b0273
commit 592d1f04c5
9 changed files with 1458 additions and 0 deletions
+6
View File
@@ -15,6 +15,10 @@
let
pnpm = pnpm_10;
patchDir = ./patches;
patches = if builtins.pathExists patchDir then map (name: patchDir + "/${name}") (
builtins.filter (name: lib.hasSuffix ".patch" name) (builtins.attrNames (builtins.readDir patchDir))
) else [ ];
noPhoningHome = {
disable_guests = true;
};
@@ -32,6 +36,8 @@ let
hash = "sha256-pbzuPgKJ0DmrDSTO7ZTDArX+Xr9k/ndAGZvQg2kMTMQ=";
};
inherit patches;
pnpmDeps = fetchPnpmDeps {
pname = "element";
inherit (finalAttrs) version src;
@@ -0,0 +1,481 @@
diff --git a/apps/web/src/IConfigOptions.ts b/apps/web/src/IConfigOptions.ts
index a3a6a32..c61c24f 100644
--- a/apps/web/src/IConfigOptions.ts
+++ b/apps/web/src/IConfigOptions.ts
@@ -105,6 +105,15 @@ export interface IConfigOptions {
show_labs_settings: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
+ hectic?: {
+ // This local package config intentionally uses the documented camelCase path
+ // `hectic.videoMessages.enabled`.
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ videoMessages?: {
+ enabled?: boolean;
+ };
+ };
+
/**
* Bug report endpoint URL. "local" means the logs should not be uploaded.
*/
diff --git a/apps/web/src/SdkConfig.ts b/apps/web/src/SdkConfig.ts
index 4be6464..9f48743 100644
--- a/apps/web/src/SdkConfig.ts
+++ b/apps/web/src/SdkConfig.ts
@@ -28,6 +28,12 @@ export const DEFAULTS: DeepReadonly<IConfigOptions> = {
integrations_ui_url: "https://scalar.vector.im/",
integrations_rest_url: "https://scalar.vector.im/api",
show_labs_settings: false,
+ hectic: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ videoMessages: {
+ enabled: false,
+ },
+ },
force_verification: false,
jitsi: {
diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx
index 06c843f..eea76b7 100644
--- a/apps/web/src/components/views/rooms/MessageComposer.tsx
+++ b/apps/web/src/components/views/rooms/MessageComposer.tsx
@@ -33,6 +33,7 @@ import ReplyPreview from "./ReplyPreview";
import { UserIdentityWarning } from "./UserIdentityWarning";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
+import VideoRecordComposerTile from "./VideoRecordComposerTile";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { RecordingState } from "../../../audio/VoiceRecording";
import type ResizeNotifier from "../../../utils/ResizeNotifier";
@@ -92,6 +93,7 @@ interface IState {
composerContent: string;
isComposerEmpty: boolean;
haveRecording: boolean;
+ haveVideoRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
isMenuOpen: boolean;
@@ -113,6 +115,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
+ private videoRecordingButton = createRef<VideoRecordComposerTile>();
private ref = createRef<HTMLDivElement>();
private instanceId: number;
@@ -144,6 +147,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
haveRecording: false,
+ haveVideoRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
isStickerPickerOpen: false,
@@ -526,6 +530,18 @@ export class MessageComposer extends React.Component<IProps, IState> {
}
};
+ private onVideoRecordStartClick = (): void => {
+ this.videoRecordingButton.current?.onRecordStartEndClick();
+
+ if (this.context.narrow) {
+ this.toggleButtonMenu();
+ }
+ };
+
+ private onVideoRecordingStateChange = (haveVideoRecording: boolean): void => {
+ this.setState({ haveVideoRecording });
+ };
+
public render(): React.ReactNode {
let leftIcon: false | JSX.Element = false;
if (!this.state.isWysiwygLabEnabled) {
@@ -561,13 +577,14 @@ export class MessageComposer extends React.Component<IProps, IState> {
const menuPosition = this.getMenuPosition();
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
+ const hasActiveRecording = this.state.haveRecording || this.state.haveVideoRecording;
let composer: ReactNode;
if (canSendMessages) {
if (this.state.isWysiwygLabEnabled && menuPosition) {
composer = (
<SendWysiwygComposer
key="controls_input"
- disabled={this.state.haveRecording}
+ disabled={hasActiveRecording}
onChange={this.onWysiwygChange}
onSend={this.sendMessage}
isRichTextEnabled={this.state.isRichTextEnabled}
@@ -588,7 +605,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
relation={this.props.relation}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
- disabled={this.state.haveRecording}
+ disabled={hasActiveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen}
/>
);
@@ -608,6 +625,11 @@ export class MessageComposer extends React.Component<IProps, IState> {
replyToEvent={this.props.replyToEvent}
/>
</Tooltip>,
+ <VideoRecordComposerTile
+ key="controls_video_record"
+ ref={this.videoRecordingButton}
+ onRecordingStateChange={this.onVideoRecordingStateChange}
+ />,
);
} else if (this.context.tombstone) {
const replacementRoomId = this.context.tombstone.getContent()["replacement_room"];
@@ -688,12 +710,13 @@ export class MessageComposer extends React.Component<IProps, IState> {
{canSendMessages && (
<MessageComposerButtons
addEmoji={this.addEmoji}
- haveRecording={this.state.haveRecording}
+ haveRecording={hasActiveRecording}
isMenuOpen={this.state.isMenuOpen}
isStickerPickerOpen={this.state.isStickerPickerOpen}
menuPosition={menuPosition}
relation={this.props.relation}
onRecordStartEndClick={this.onRecordStartEndClick}
+ onVideoRecordStartClick={this.onVideoRecordStartClick}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={
!window.electron && SettingsStore.getValue(UIFeature.LocationSharing)
diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx
index 6f4f5d1..0c1b7ff 100644
--- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx
+++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx
@@ -44,6 +44,8 @@ import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
+import SdkConfig from "../../../SdkConfig";
+import { isVideoMessageRecorderSupported } from "./VideoRecordComposerTile";
interface IProps {
addEmoji: (emoji: string) => boolean;
@@ -52,6 +54,7 @@ interface IProps {
isStickerPickerOpen: boolean;
menuPosition?: MenuProps;
onRecordStartEndClick: () => void;
+ onVideoRecordStartClick: () => void;
relation?: IEventRelation;
setStickerPickerOpen: (isStickerPickerOpen: boolean) => void;
showLocationButton: boolean;
@@ -103,6 +106,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
)),
showStickersButton(props),
voiceRecordingButton(props, narrow),
+ videoRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
];
@@ -122,6 +126,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
moreButtons = [
showStickersButton(props),
voiceRecordingButton(props, narrow),
+ videoRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
];
@@ -203,6 +208,25 @@ function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | nu
);
}
+function videoRecordingButton(props: IProps, narrow: boolean): ReactElement | null {
+ // The current recorder skeleton follows the voice recorder and stays out of narrow mode.
+ if (narrow || SdkConfig.get("hectic")?.videoMessages?.enabled !== true || !isVideoMessageRecorderSupported()) {
+ return null;
+ }
+
+ return (
+ <CollapsibleButton
+ key="video_message_send"
+ className="mx_MessageComposer_button"
+ data-testid="video-message-button"
+ onClick={props.onVideoRecordStartClick}
+ title={_t("composer|video_message_button")}
+ >
+ <span aria-hidden="true">●</span>
+ </CollapsibleButton>
+ );
+}
+
function pollButton(room: Room, relation?: IEventRelation): ReactElement {
return <PollButton key="polls" room={room} relation={relation} />;
}
diff --git a/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
new file mode 100644
index 0000000..c5f0adc
--- /dev/null
+++ b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
@@ -0,0 +1,249 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React, { type ReactNode } from "react";
+import { logger } from "matrix-js-sdk/src/logger";
+import { DeleteIcon, SendSolidIcon, StopSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
+
+import { _t } from "../../../languageHandler";
+import AccessibleButton from "../elements/AccessibleButton";
+
+interface IProps {
+ onRecordingStateChange: (haveVideoRecording: boolean) => void;
+}
+
+interface IState {
+ error?: string;
+ isRecording: boolean;
+ previewUrl?: string;
+}
+
+export function isVideoMessageRecorderSupported(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof MediaRecorder !== "undefined" &&
+ typeof navigator !== "undefined" &&
+ !!navigator.mediaDevices?.getUserMedia
+ );
+}
+
+/**
+ * Container tile for rendering the config-gated video message recorder skeleton in the composer.
+ */
+export default class VideoRecordComposerTile extends React.PureComponent<IProps, IState> {
+ private chunks: Blob[] = [];
+ private mediaRecorder?: MediaRecorder;
+ private stream?: MediaStream;
+ private isRequestingMedia = false;
+ private isUnmounted = false;
+ private shouldDiscardRecording = false;
+
+ public constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ isRecording: false,
+ };
+ }
+
+ public componentWillUnmount(): void {
+ this.isUnmounted = true;
+ this.disposeRecording();
+ }
+
+ public onRecordStartEndClick = async (): Promise<void> => {
+ if (this.state.isRecording) {
+ this.stopRecording();
+ return;
+ }
+
+ await this.startRecording();
+ };
+
+ private startRecording = async (): Promise<void> => {
+ if (this.isRequestingMedia || this.mediaRecorder) return;
+
+ if (!isVideoMessageRecorderSupported()) {
+ if (!this.isUnmounted) {
+ this.setState({ error: _t("composer|video_message_error") });
+ this.props.onRecordingStateChange(false);
+ }
+ return;
+ }
+
+ this.isRequestingMedia = true;
+ this.shouldDiscardRecording = false;
+ this.revokePreviewUrl();
+ this.chunks = [];
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
+ if (this.isUnmounted) {
+ stream.getTracks().forEach((track) => track.stop());
+ return;
+ }
+
+ this.stream = stream;
+ const mediaRecorder = new MediaRecorder(stream);
+
+ mediaRecorder.ondataavailable = (ev: BlobEvent): void => {
+ if (ev.data.size > 0) {
+ this.chunks.push(ev.data);
+ }
+ };
+ mediaRecorder.onerror = (ev: Event): void => {
+ logger.error("Error recording video message:", ev);
+ this.setState({ error: _t("composer|video_message_error") });
+ };
+ mediaRecorder.onstop = this.onRecorderStop;
+
+ this.mediaRecorder = mediaRecorder;
+ mediaRecorder.start();
+
+ this.setState({ error: undefined, isRecording: true, previewUrl: undefined });
+ this.props.onRecordingStateChange(true);
+ } catch (e) {
+ logger.error("Error starting video message recording:", e);
+ this.stopStream();
+ if (!this.isUnmounted) {
+ this.setState({ error: _t("composer|video_message_error"), isRecording: false, previewUrl: undefined });
+ this.props.onRecordingStateChange(false);
+ }
+ } finally {
+ this.isRequestingMedia = false;
+ }
+ };
+
+ private stopRecording = (): void => {
+ if (!this.mediaRecorder) return;
+
+ if (this.mediaRecorder.state === "inactive") {
+ this.onRecorderStop();
+ } else {
+ this.mediaRecorder.stop();
+ }
+ };
+
+ private onRecorderStop = (): void => {
+ const shouldDiscardRecording = this.shouldDiscardRecording;
+ const hasRecording = this.chunks.length > 0 && !shouldDiscardRecording;
+
+ this.mediaRecorder = undefined;
+ this.stopStream();
+
+ if (this.isUnmounted) {
+ this.chunks = [];
+ return;
+ }
+
+ if (!hasRecording) {
+ this.chunks = [];
+ this.setState({ isRecording: false, previewUrl: undefined });
+ this.props.onRecordingStateChange(false);
+ return;
+ }
+
+ const blob = new Blob(this.chunks, { type: this.chunks[0]?.type || "video/webm" });
+ const previewUrl = URL.createObjectURL(blob);
+
+ this.chunks = [];
+ this.revokePreviewUrl();
+ this.setState({ isRecording: false, previewUrl });
+ this.props.onRecordingStateChange(true);
+ };
+
+ private onCancel = (): void => {
+ this.shouldDiscardRecording = true;
+ this.disposeRecording();
+ this.setState({ error: undefined, isRecording: false, previewUrl: undefined });
+ this.props.onRecordingStateChange(false);
+ };
+
+ private disposeRecording(): void {
+ this.chunks = [];
+ this.stopStream();
+ this.revokePreviewUrl();
+
+ if (this.mediaRecorder) {
+ this.mediaRecorder.ondataavailable = null;
+ this.mediaRecorder.onerror = null;
+ this.mediaRecorder.onstop = null;
+
+ if (this.mediaRecorder.state !== "inactive") {
+ this.mediaRecorder.stop();
+ }
+ this.mediaRecorder = undefined;
+ }
+ }
+
+ private stopStream(): void {
+ this.stream?.getTracks().forEach((track) => track.stop());
+ this.stream = undefined;
+ }
+
+ private revokePreviewUrl(): void {
+ if (this.state.previewUrl) {
+ URL.revokeObjectURL(this.state.previewUrl);
+ }
+ }
+
+ public render(): ReactNode {
+ if (!this.state.isRecording && !this.state.previewUrl && !this.state.error) return null;
+
+ const stopButton = this.state.isRecording ? (
+ <AccessibleButton
+ className="mx_VoiceRecordComposerTile_stop"
+ data-testid="video-message-stop-button"
+ onClick={this.onRecordStartEndClick}
+ title={_t("composer|stop_video_message")}
+ >
+ <StopSolidIcon />
+ </AccessibleButton>
+ ) : null;
+
+ const sendButton = this.state.previewUrl ? (
+ <AccessibleButton
+ className="mx_VoiceRecordComposerTile_stop"
+ data-testid="video-message-send-button"
+ disabled={true}
+ onClick={null}
+ title={_t("composer|send_video_message")}
+ >
+ <SendSolidIcon />
+ </AccessibleButton>
+ ) : null;
+
+ return (
+ <>
+ {this.state.error && (
+ <span className="mx_VoiceRecordComposerTile_failedState" data-testid="video-message-error">
+ {this.state.error}
+ </span>
+ )}
+ <AccessibleButton
+ className="mx_VoiceRecordComposerTile_delete"
+ data-testid="video-message-cancel-button"
+ onClick={this.onCancel}
+ title={_t("composer|video_message_cancel")}
+ >
+ <DeleteIcon />
+ </AccessibleButton>
+ {stopButton}
+ {sendButton}
+ {this.state.previewUrl && (
+ <video
+ aria-label={_t("composer|video_message_preview")}
+ className="mx_VoiceMessagePrimaryContainer"
+ controls={true}
+ data-testid="video-message-preview"
+ src={this.state.previewUrl}
+ />
+ )}
+ </>
+ );
+ }
+}
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json
index 4ed51db..e11fe90 100644
--- a/apps/web/src/i18n/strings/en_EN.json
+++ b/apps/web/src/i18n/strings/en_EN.json
@@ -641,8 +641,14 @@
"room_upgraded_notice": "This room has been replaced and is no longer active.",
"send_button_title": "Send message",
"send_button_voice_message": "Send voice message",
+ "send_video_message": "Send video message",
"send_voice_message": "Send voice message",
+ "stop_video_message": "Stop video recording",
"stop_voice_message": "Stop recording",
+ "video_message_button": "Record video message",
+ "video_message_cancel": "Cancel video message",
+ "video_message_error": "Unable to record video message",
+ "video_message_preview": "Video message preview",
"voice_message_button": "Voice Message"
},
"console_dev_note": "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!",
@@ -0,0 +1,201 @@
diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx
index eea76b7..3483c28 100644
--- a/apps/web/src/components/views/rooms/MessageComposer.tsx
+++ b/apps/web/src/components/views/rooms/MessageComposer.tsx
@@ -629,6 +629,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
key="controls_video_record"
ref={this.videoRecordingButton}
onRecordingStateChange={this.onVideoRecordingStateChange}
+ relation={this.props.relation}
+ replyToEvent={this.props.replyToEvent}
+ room={this.props.room}
/>,
);
} else if (this.context.tombstone) {
diff --git a/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
index c5f0adc..851c64e 100644
--- a/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
+++ b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
@@ -6,22 +6,39 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
+import { type IEventRelation, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { DeleteIcon, SendSolidIcon, StopSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import ContentMessages from "../../../ContentMessages";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
onRecordingStateChange: (haveVideoRecording: boolean) => void;
+ relation?: IEventRelation;
+ replyToEvent?: MatrixEvent;
+ room: Room;
}
interface IState {
error?: string;
isRecording: boolean;
+ isSending: boolean;
previewUrl?: string;
}
+const PREFERRED_VIDEO_MIME_TYPES = ["video/webm;codecs=vp8,opus", "video/webm"];
+
+function preferredVideoMimeType(): string | undefined {
+ for (const mimeType of PREFERRED_VIDEO_MIME_TYPES) {
+ if (MediaRecorder.isTypeSupported(mimeType)) {
+ return mimeType;
+ }
+ }
+}
+
export function isVideoMessageRecorderSupported(): boolean {
return (
typeof window !== "undefined" &&
@@ -37,6 +54,7 @@ export function isVideoMessageRecorderSupported(): boolean {
export default class VideoRecordComposerTile extends React.PureComponent<IProps, IState> {
private chunks: Blob[] = [];
private mediaRecorder?: MediaRecorder;
+ private recordedFile?: File;
private stream?: MediaStream;
private isRequestingMedia = false;
private isUnmounted = false;
@@ -47,6 +65,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.state = {
isRecording: false,
+ isSending: false,
};
}
@@ -78,6 +97,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.isRequestingMedia = true;
this.shouldDiscardRecording = false;
this.revokePreviewUrl();
+ this.recordedFile = undefined;
this.chunks = [];
try {
@@ -88,7 +108,8 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
}
this.stream = stream;
- const mediaRecorder = new MediaRecorder(stream);
+ const mimeType = preferredVideoMimeType();
+ const mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
mediaRecorder.ondataavailable = (ev: BlobEvent): void => {
if (ev.data.size > 0) {
@@ -104,13 +125,18 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.mediaRecorder = mediaRecorder;
mediaRecorder.start();
- this.setState({ error: undefined, isRecording: true, previewUrl: undefined });
+ this.setState({ error: undefined, isRecording: true, isSending: false, previewUrl: undefined });
this.props.onRecordingStateChange(true);
} catch (e) {
logger.error("Error starting video message recording:", e);
this.stopStream();
if (!this.isUnmounted) {
- this.setState({ error: _t("composer|video_message_error"), isRecording: false, previewUrl: undefined });
+ this.setState({
+ error: _t("composer|video_message_error"),
+ isRecording: false,
+ isSending: false,
+ previewUrl: undefined,
+ });
this.props.onRecordingStateChange(false);
}
} finally {
@@ -129,6 +155,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
};
private onRecorderStop = (): void => {
+ const mimeType = this.mediaRecorder?.mimeType;
const shouldDiscardRecording = this.shouldDiscardRecording;
const hasRecording = this.chunks.length > 0 && !shouldDiscardRecording;
@@ -142,29 +169,63 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
if (!hasRecording) {
this.chunks = [];
- this.setState({ isRecording: false, previewUrl: undefined });
+ this.recordedFile = undefined;
+ this.setState({ isRecording: false, isSending: false, previewUrl: undefined });
this.props.onRecordingStateChange(false);
return;
}
- const blob = new Blob(this.chunks, { type: this.chunks[0]?.type || "video/webm" });
- const previewUrl = URL.createObjectURL(blob);
+ const blob = new Blob(this.chunks, { type: mimeType || this.chunks[0]?.type || "video/webm" });
+ const file = new File([blob], `video-message-${Date.now()}.webm`, { type: blob.type || "video/webm" });
+ const previewUrl = URL.createObjectURL(file);
this.chunks = [];
+ this.recordedFile = file;
this.revokePreviewUrl();
- this.setState({ isRecording: false, previewUrl });
+ this.setState({ isRecording: false, isSending: false, previewUrl });
this.props.onRecordingStateChange(true);
};
+ private onSend = async (): Promise<void> => {
+ if (!this.recordedFile || this.state.isSending) return;
+
+ this.setState({ isSending: true });
+
+ try {
+ await ContentMessages.sharedInstance().sendContentToRoom(
+ this.recordedFile,
+ this.props.room.roomId,
+ this.props.relation,
+ MatrixClientPeg.safeGet(),
+ this.props.replyToEvent,
+ );
+ this.clearRecording();
+ this.props.onRecordingStateChange(false);
+ } catch (e) {
+ logger.error("Error sending video message recording:", e);
+ if (!this.isUnmounted) {
+ this.setState({ error: _t("composer|video_message_error"), isSending: false });
+ }
+ }
+ };
+
private onCancel = (): void => {
this.shouldDiscardRecording = true;
this.disposeRecording();
- this.setState({ error: undefined, isRecording: false, previewUrl: undefined });
+ this.setState({ error: undefined, isRecording: false, isSending: false, previewUrl: undefined });
this.props.onRecordingStateChange(false);
};
+ private clearRecording(): void {
+ this.chunks = [];
+ this.recordedFile = undefined;
+ this.revokePreviewUrl();
+ this.setState({ error: undefined, isRecording: false, isSending: false, previewUrl: undefined });
+ }
+
private disposeRecording(): void {
this.chunks = [];
+ this.recordedFile = undefined;
this.stopStream();
this.revokePreviewUrl();
@@ -209,8 +270,8 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
<AccessibleButton
className="mx_VoiceRecordComposerTile_stop"
data-testid="video-message-send-button"
- disabled={true}
- onClick={null}
+ disabled={!this.recordedFile || this.state.isSending}
+ onClick={this.onSend}
title={_t("composer|send_video_message")}
>
<SendSolidIcon />
@@ -0,0 +1,195 @@
diff --git a/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
index 851c64e..4285c72 100644
--- a/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
+++ b/apps/web/src/components/views/rooms/VideoRecordComposerTile.tsx
@@ -30,6 +30,7 @@ interface IState {
}
const PREFERRED_VIDEO_MIME_TYPES = ["video/webm;codecs=vp8,opus", "video/webm"];
+const MAX_VIDEO_RECORDING_MS = 120000;
function preferredVideoMimeType(): string | undefined {
for (const mimeType of PREFERRED_VIDEO_MIME_TYPES) {
@@ -39,6 +40,18 @@ function preferredVideoMimeType(): string | undefined {
}
}
+function isPermissionDeniedError(error: unknown): boolean {
+ if (error instanceof DOMException) {
+ return error.name === "NotAllowedError" || error.name === "PermissionDeniedError";
+ }
+
+ if (error instanceof Error) {
+ return /permission denied|not allowed|denied permission/i.test(error.message);
+ }
+
+ return false;
+}
+
export function isVideoMessageRecorderSupported(): boolean {
return (
typeof window !== "undefined" &&
@@ -56,6 +69,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
private mediaRecorder?: MediaRecorder;
private recordedFile?: File;
private stream?: MediaStream;
+ private durationCapTimer?: number;
private isRequestingMedia = false;
private isUnmounted = false;
private shouldDiscardRecording = false;
@@ -72,6 +86,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
public componentWillUnmount(): void {
this.isUnmounted = true;
this.disposeRecording();
+ this.props.onRecordingStateChange(false);
}
public onRecordStartEndClick = async (): Promise<void> => {
@@ -96,6 +111,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.isRequestingMedia = true;
this.shouldDiscardRecording = false;
+ this.clearDurationCapTimer();
this.revokePreviewUrl();
this.recordedFile = undefined;
this.chunks = [];
@@ -108,6 +124,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
}
this.stream = stream;
+ this.bindStreamEndedHandlers(stream);
const mimeType = preferredVideoMimeType();
const mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
@@ -118,12 +135,13 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
};
mediaRecorder.onerror = (ev: Event): void => {
logger.error("Error recording video message:", ev);
- this.setState({ error: _t("composer|video_message_error") });
+ this.failRecording(_t("composer|video_message_error"));
};
mediaRecorder.onstop = this.onRecorderStop;
this.mediaRecorder = mediaRecorder;
mediaRecorder.start();
+ this.durationCapTimer = window.setTimeout(this.onDurationCap, MAX_VIDEO_RECORDING_MS);
this.setState({ error: undefined, isRecording: true, isSending: false, previewUrl: undefined });
this.props.onRecordingStateChange(true);
@@ -132,7 +150,9 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.stopStream();
if (!this.isUnmounted) {
this.setState({
- error: _t("composer|video_message_error"),
+ error: isPermissionDeniedError(e)
+ ? _t("composer|video_message_permission_denied")
+ : _t("composer|video_message_error"),
isRecording: false,
isSending: false,
previewUrl: undefined,
@@ -144,6 +164,21 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
}
};
+ private onDurationCap = (): void => {
+ this.durationCapTimer = undefined;
+
+ if (!this.mediaRecorder || this.mediaRecorder.state === "inactive") return;
+
+ this.stopRecording();
+ };
+
+ private onStreamTrackEnded = (): void => {
+ if (!this.mediaRecorder || this.mediaRecorder.state === "inactive") return;
+
+ logger.warn("Video message media stream ended before recording stopped");
+ this.failRecording(_t("composer|video_message_error"));
+ };
+
private stopRecording = (): void => {
if (!this.mediaRecorder) return;
@@ -160,6 +195,7 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
const hasRecording = this.chunks.length > 0 && !shouldDiscardRecording;
this.mediaRecorder = undefined;
+ this.clearDurationCapTimer();
this.stopStream();
if (this.isUnmounted) {
@@ -200,7 +236,9 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
this.props.replyToEvent,
);
this.clearRecording();
- this.props.onRecordingStateChange(false);
+ if (!this.isUnmounted) {
+ this.props.onRecordingStateChange(false);
+ }
} catch (e) {
logger.error("Error sending video message recording:", e);
if (!this.isUnmounted) {
@@ -219,13 +257,18 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
private clearRecording(): void {
this.chunks = [];
this.recordedFile = undefined;
+ this.clearDurationCapTimer();
+ this.stopStream();
this.revokePreviewUrl();
+ if (this.isUnmounted) return;
+
this.setState({ error: undefined, isRecording: false, isSending: false, previewUrl: undefined });
}
private disposeRecording(): void {
this.chunks = [];
this.recordedFile = undefined;
+ this.clearDurationCapTimer();
this.stopStream();
this.revokePreviewUrl();
@@ -241,8 +284,32 @@ export default class VideoRecordComposerTile extends React.PureComponent<IProps,
}
}
+ private failRecording(error: string): void {
+ this.shouldDiscardRecording = true;
+ this.disposeRecording();
+
+ if (this.isUnmounted) return;
+
+ this.setState({ error, isRecording: false, isSending: false, previewUrl: undefined });
+ this.props.onRecordingStateChange(false);
+ }
+
+ private clearDurationCapTimer(): void {
+ if (this.durationCapTimer !== undefined) {
+ window.clearTimeout(this.durationCapTimer);
+ this.durationCapTimer = undefined;
+ }
+ }
+
+ private bindStreamEndedHandlers(stream: MediaStream): void {
+ stream.getTracks().forEach((track) => track.addEventListener("ended", this.onStreamTrackEnded));
+ }
+
private stopStream(): void {
- this.stream?.getTracks().forEach((track) => track.stop());
+ this.stream?.getTracks().forEach((track) => {
+ track.removeEventListener("ended", this.onStreamTrackEnded);
+ track.stop();
+ });
this.stream = undefined;
}
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json
index e11fe90..83b7c21 100644
--- a/apps/web/src/i18n/strings/en_EN.json
+++ b/apps/web/src/i18n/strings/en_EN.json
@@ -648,6 +648,7 @@
"video_message_button": "Record video message",
"video_message_cancel": "Cancel video message",
"video_message_error": "Unable to record video message",
+ "video_message_permission_denied": "Camera permission denied",
"video_message_preview": "Video message preview",
"voice_message_button": "Voice Message"
},
+17
View File
@@ -0,0 +1,17 @@
# Element Web patches
Put ordered local patch files in this directory as `*.patch`.
- Files are applied in lexical order.
- Use zero-padded numeric prefixes when patch order matters, e.g. `0001-...patch`.
- Keep non-patch files out of the order by using a different extension; `README.md` is ignored by the Nix filter.
## Regenerating a patch
Create a clean checkout of upstream Element Web, make the change under `apps/web`, then run from the repository root:
```sh
git diff -- apps/web > package/element-web/patches/0001-description.patch
```
Use one patch per logical change so the sequence stays reproducible and reviewable.