diff --git a/package/element-web/default.nix b/package/element-web/default.nix index 585b7abd..c8828a24 100644 --- a/package/element-web/default.nix +++ b/package/element-web/default.nix @@ -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; diff --git a/package/element-web/patches/0001-add-video-message-recorder-ui.patch b/package/element-web/patches/0001-add-video-message-recorder-ui.patch new file mode 100644 index 00000000..7be4e235 --- /dev/null +++ b/package/element-web/patches/0001-add-video-message-recorder-ui.patch @@ -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; // + ++ 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 = { + 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 { + private dispatcherRef?: string; + private messageComposerInput = createRef(); + private voiceRecordingButton = createRef(); ++ private videoRecordingButton = createRef(); + private ref = createRef(); + private instanceId: number; + +@@ -144,6 +147,7 @@ export class MessageComposer extends React.Component { + 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 { + } + }; + ++ 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 { + 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 = ( + { + 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 { + replyToEvent={this.props.replyToEvent} + /> + , ++ , + ); + } else if (this.context.tombstone) { + const replacementRoomId = this.context.tombstone.getContent()["replacement_room"]; +@@ -688,12 +710,13 @@ export class MessageComposer extends React.Component { + {canSendMessages && ( + 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 = (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 = (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 ( ++ ++ ++ ++ ); ++} ++ + function pollButton(room: Room, relation?: IEventRelation): ReactElement { + return ; + } +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 { ++ 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 => { ++ if (this.state.isRecording) { ++ this.stopRecording(); ++ return; ++ } ++ ++ await this.startRecording(); ++ }; ++ ++ private startRecording = async (): Promise => { ++ 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 ? ( ++ ++ ++ ++ ) : null; ++ ++ const sendButton = this.state.previewUrl ? ( ++ ++ ++ ++ ) : null; ++ ++ return ( ++ <> ++ {this.state.error && ( ++ ++ {this.state.error} ++ ++ )} ++ ++ ++ ++ {stopButton} ++ {sendButton} ++ {this.state.previewUrl && ( ++