Files
hearth/package/element-web/patches/0001-add-video-message-recorder-ui.patch
T

482 lines
19 KiB
Diff

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!",