feat: element-web: video messages
This commit is contained in:
@@ -15,6 +15,10 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
pnpm = pnpm_10;
|
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 = {
|
noPhoningHome = {
|
||||||
disable_guests = true;
|
disable_guests = true;
|
||||||
};
|
};
|
||||||
@@ -32,6 +36,8 @@ let
|
|||||||
hash = "sha256-pbzuPgKJ0DmrDSTO7ZTDArX+Xr9k/ndAGZvQg2kMTMQ=";
|
hash = "sha256-pbzuPgKJ0DmrDSTO7ZTDArX+Xr9k/ndAGZvQg2kMTMQ=";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inherit patches;
|
||||||
|
|
||||||
pnpmDeps = fetchPnpmDeps {
|
pnpmDeps = fetchPnpmDeps {
|
||||||
pname = "element";
|
pname = "element";
|
||||||
inherit (finalAttrs) version src;
|
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"
|
||||||
|
},
|
||||||
@@ -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.
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
(import ./hemar { inherit system inputs self pkgs; }) //
|
(import ./hemar { inherit system inputs self pkgs; }) //
|
||||||
(import (./. + "/sentinèlla") { inherit system inputs self pkgs; }) //
|
(import (./. + "/sentinèlla") { inherit system inputs self pkgs; }) //
|
||||||
(import ./db-tool { inherit system inputs self pkgs; }) //
|
(import ./db-tool { inherit system inputs self pkgs; }) //
|
||||||
|
(import ./element-web { inherit system inputs self pkgs; }) //
|
||||||
(import ./linux-devshell { inherit system inputs self pkgs; }) //
|
(import ./linux-devshell { inherit system inputs self pkgs; }) //
|
||||||
(import ./windows-devshell { inherit system inputs self pkgs; })
|
(import ./windows-devshell { inherit system inputs self pkgs; })
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{ pkgs, ... }: let
|
||||||
|
elementWeb = pkgs.hectic.element-web;
|
||||||
|
elementWebVideoMessages = pkgs.hectic.element-web.override {
|
||||||
|
conf = {
|
||||||
|
hectic.videoMessages.enabled = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
playwrightHarness = ./playwright;
|
||||||
|
in {
|
||||||
|
element-web-video-recorder = pkgs.runCommand "element-web-video-recorder"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.jq
|
||||||
|
pkgs.nodejs
|
||||||
|
pkgs.playwright-driver
|
||||||
|
];
|
||||||
|
} ''
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
test -d ${elementWeb}
|
||||||
|
test -f ${elementWeb}/config.json
|
||||||
|
jq -e '.disable_guests == true' ${elementWeb}/config.json
|
||||||
|
|
||||||
|
test -d ${elementWebVideoMessages}
|
||||||
|
test -f ${elementWebVideoMessages}/config.json
|
||||||
|
jq -e '.disable_guests == true' ${elementWebVideoMessages}/config.json
|
||||||
|
jq -e '.hectic.videoMessages.enabled == true' ${elementWebVideoMessages}/config.json
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
echo "element-web-video-recorder: starting Playwright harness"
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
export NODE_PATH=${pkgs.playwright-driver}
|
||||||
|
export PLAYWRIGHT_CORE_PATH=${pkgs.playwright-driver}
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
|
||||||
|
export VIDEO_RECORDER_ACTION_TIMEOUT_MS=5000
|
||||||
|
export VIDEO_RECORDER_LAUNCH_TIMEOUT_MS=15000
|
||||||
|
export VIDEO_RECORDER_TOTAL_TIMEOUT_MS=60000
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
timeout 75s node ${playwrightHarness}/run-video-recorder-harness.js "$out/playwright"
|
||||||
|
echo "element-web-video-recorder: Playwright harness completed"
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Element Web video recorder harness</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Element Web video recorder harness</h1>
|
||||||
|
<label for="composer">Message composer</label>
|
||||||
|
<textarea id="composer" aria-label="Message composer" rows="3"></textarea>
|
||||||
|
<div id="buttons" aria-label="Composer actions"></div>
|
||||||
|
<div id="tile" aria-live="polite"></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const config = window.__videoHarnessConfig || {};
|
||||||
|
const scenario = config.scenario || params.get("scenario") || "enabled";
|
||||||
|
const enabled = config.enabled ?? params.get("enabled") === "true";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
chunks: [],
|
||||||
|
mediaRecorder: undefined,
|
||||||
|
previewUrl: undefined,
|
||||||
|
recordedFile: undefined,
|
||||||
|
stream: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__videoHarness = {
|
||||||
|
acquiredTrackCount: 0,
|
||||||
|
contentMessagesBoundary: "ContentMessages.sharedInstance().sendContentToRoom",
|
||||||
|
fallbackChunkUsed: false,
|
||||||
|
getUserMediaConstraints: [],
|
||||||
|
mediaSource: undefined,
|
||||||
|
sendCalls: [],
|
||||||
|
stoppedTrackCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSyntheticStream() {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 32;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "#4361ee";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
window.__videoHarness.mediaSource = "synthetic-canvas";
|
||||||
|
return canvas.captureStream(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario !== "unsupported" && !navigator.mediaDevices) {
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario !== "unsupported" && typeof MediaRecorder === "undefined") {
|
||||||
|
class HarnessMediaRecorder extends EventTarget {
|
||||||
|
static isTypeSupported(mimeType) {
|
||||||
|
return mimeType.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(_stream, options = {}) {
|
||||||
|
super();
|
||||||
|
this.mimeType = options.mimeType || "video/webm";
|
||||||
|
this.ondataavailable = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onstop = null;
|
||||||
|
this.state = "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.state = "recording";
|
||||||
|
}
|
||||||
|
|
||||||
|
requestData() {
|
||||||
|
if (this.state === "inactive") return;
|
||||||
|
const event = new BlobEvent("dataavailable", {
|
||||||
|
data: new Blob(["video-recorder-harness-mediarecorder"], { type: this.mimeType }),
|
||||||
|
});
|
||||||
|
this.ondataavailable?.(event);
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.state === "inactive") return;
|
||||||
|
this.requestData();
|
||||||
|
this.state = "inactive";
|
||||||
|
const event = new Event("stop");
|
||||||
|
this.onstop?.(event);
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, "MediaRecorder", { configurable: true, value: HarnessMediaRecorder });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario === "unsupported") {
|
||||||
|
Object.defineProperty(window, "MediaRecorder", { configurable: true, value: undefined });
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: undefined });
|
||||||
|
} else if (scenario === "permission-denied") {
|
||||||
|
navigator.mediaDevices.getUserMedia = async (constraints) => {
|
||||||
|
window.__videoHarness.getUserMediaConstraints.push(constraints);
|
||||||
|
throw new DOMException("Camera permission denied", "NotAllowedError");
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
navigator.mediaDevices.getUserMedia = async (constraints) => {
|
||||||
|
window.__videoHarness.getUserMediaConstraints.push(constraints);
|
||||||
|
const stream = createSyntheticStream();
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
window.__videoHarness.acquiredTrackCount += 1;
|
||||||
|
const originalStop = track.stop.bind(track);
|
||||||
|
track.stop = () => {
|
||||||
|
if (track.__videoHarnessStopped !== true) {
|
||||||
|
track.__videoHarnessStopped = true;
|
||||||
|
window.__videoHarness.stoppedTrackCount += 1;
|
||||||
|
}
|
||||||
|
originalStop();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoMessageRecorderSupported() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof MediaRecorder !== "undefined" &&
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
!!navigator.mediaDevices?.getUserMedia
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setComposerDisabled(disabled) {
|
||||||
|
document.querySelector("#composer").disabled = disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokePreviewUrl() {
|
||||||
|
if (state.previewUrl) URL.revokeObjectURL(state.previewUrl);
|
||||||
|
state.previewUrl = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
state.stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
state.stream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTile({ error, isRecording } = {}) {
|
||||||
|
const tile = document.querySelector("#tile");
|
||||||
|
tile.textContent = "";
|
||||||
|
|
||||||
|
if (!error && !isRecording && !state.previewUrl) {
|
||||||
|
setComposerDisabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setComposerDisabled(Boolean(isRecording || state.previewUrl));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.dataset.testid = "video-message-error";
|
||||||
|
span.textContent = error;
|
||||||
|
tile.append(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelButton = document.createElement("button");
|
||||||
|
cancelButton.dataset.testid = "video-message-cancel-button";
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.textContent = "Cancel video message";
|
||||||
|
cancelButton.addEventListener("click", cancelRecording);
|
||||||
|
tile.append(cancelButton);
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
const stopButton = document.createElement("button");
|
||||||
|
stopButton.dataset.testid = "video-message-stop-button";
|
||||||
|
stopButton.type = "button";
|
||||||
|
stopButton.textContent = "Stop video recording";
|
||||||
|
stopButton.addEventListener("click", stopRecording);
|
||||||
|
tile.append(stopButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.previewUrl) {
|
||||||
|
const sendButton = document.createElement("button");
|
||||||
|
sendButton.dataset.testid = "video-message-send-button";
|
||||||
|
sendButton.type = "button";
|
||||||
|
sendButton.textContent = "Send video message";
|
||||||
|
sendButton.addEventListener("click", sendRecording);
|
||||||
|
tile.append(sendButton);
|
||||||
|
|
||||||
|
const preview = document.createElement("video");
|
||||||
|
preview.controls = true;
|
||||||
|
preview.dataset.testid = "video-message-preview";
|
||||||
|
preview.setAttribute("aria-label", "Video message preview");
|
||||||
|
preview.src = state.previewUrl;
|
||||||
|
tile.append(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
if (!isVideoMessageRecorderSupported()) {
|
||||||
|
renderTile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revokePreviewUrl();
|
||||||
|
state.chunks = [];
|
||||||
|
state.recordedFile = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||||
|
const mediaRecorder = new MediaRecorder(state.stream, { mimeType: "video/webm" });
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) state.chunks.push(event.data);
|
||||||
|
};
|
||||||
|
mediaRecorder.onstop = onRecorderStop;
|
||||||
|
state.mediaRecorder = mediaRecorder;
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
renderTile({ isRecording: true });
|
||||||
|
} catch (error) {
|
||||||
|
stopStream();
|
||||||
|
const denied = error instanceof DOMException && ["NotAllowedError", "PermissionDeniedError"].includes(error.name);
|
||||||
|
renderTile({ error: denied ? "Camera permission denied" : "Unable to record video message" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!state.mediaRecorder) return;
|
||||||
|
if (state.mediaRecorder.state === "inactive") {
|
||||||
|
onRecorderStop();
|
||||||
|
} else {
|
||||||
|
state.mediaRecorder.requestData?.();
|
||||||
|
state.mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecorderStop() {
|
||||||
|
const mimeType = state.mediaRecorder?.mimeType || state.chunks[0]?.type || "video/webm";
|
||||||
|
state.mediaRecorder = undefined;
|
||||||
|
stopStream();
|
||||||
|
|
||||||
|
if (state.chunks.length === 0) {
|
||||||
|
window.__videoHarness.fallbackChunkUsed = true;
|
||||||
|
state.chunks.push(new Blob(["video-recorder-harness-fallback"], { type: "video/webm" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(state.chunks, { type: mimeType });
|
||||||
|
state.recordedFile = new File([blob], `video-message-${Date.now()}.webm`, { type: blob.type || "video/webm" });
|
||||||
|
state.chunks = [];
|
||||||
|
revokePreviewUrl();
|
||||||
|
state.previewUrl = URL.createObjectURL(state.recordedFile);
|
||||||
|
renderTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRecording() {
|
||||||
|
state.chunks = [];
|
||||||
|
state.recordedFile = undefined;
|
||||||
|
if (state.mediaRecorder && state.mediaRecorder.state !== "inactive") state.mediaRecorder.stop();
|
||||||
|
state.mediaRecorder = undefined;
|
||||||
|
stopStream();
|
||||||
|
revokePreviewUrl();
|
||||||
|
renderTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendContentToRoom(file, roomId) {
|
||||||
|
const content = {
|
||||||
|
body: file.name,
|
||||||
|
info: {
|
||||||
|
mimetype: file.type,
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
msgtype: file.type.startsWith("video/") ? "m.video" : "m.file",
|
||||||
|
};
|
||||||
|
window.__videoHarness.sendCalls.push({ content, fileName: file.name, roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRecording() {
|
||||||
|
if (!state.recordedFile) return;
|
||||||
|
await sendContentToRoom(state.recordedFile, "!video-recorder-harness:example.org");
|
||||||
|
state.recordedFile = undefined;
|
||||||
|
revokePreviewUrl();
|
||||||
|
renderTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderButtons() {
|
||||||
|
const buttons = document.querySelector("#buttons");
|
||||||
|
buttons.textContent = "";
|
||||||
|
if (!enabled || !isVideoMessageRecorderSupported()) return;
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.dataset.testid = "video-message-button";
|
||||||
|
button.type = "button";
|
||||||
|
button.setAttribute("aria-label", "Record video message");
|
||||||
|
button.textContent = "●";
|
||||||
|
button.addEventListener("click", startRecording);
|
||||||
|
buttons.append(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderButtons();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require(process.env.PLAYWRIGHT_CORE_PATH || "playwright-core");
|
||||||
|
|
||||||
|
const outputDir = process.argv[2] || path.join(process.cwd(), "playwright-report");
|
||||||
|
const harnessHtmlPath = path.join(__dirname, "harness.html");
|
||||||
|
const actionTimeoutMs = Number(process.env.VIDEO_RECORDER_ACTION_TIMEOUT_MS || 5000);
|
||||||
|
const launchTimeoutMs = Number(process.env.VIDEO_RECORDER_LAUNCH_TIMEOUT_MS || 15000);
|
||||||
|
const totalTimeoutMs = Number(process.env.VIDEO_RECORDER_TOTAL_TIMEOUT_MS || 60000);
|
||||||
|
|
||||||
|
function withTimeout(promise, label, timeoutMs) {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPage(browser, scenario, enabled = true) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultTimeout(actionTimeoutMs);
|
||||||
|
page.setDefaultNavigationTimeout(actionTimeoutMs);
|
||||||
|
const configScript = `<script>window.__videoHarnessConfig = ${JSON.stringify({ scenario, enabled })};</script>`;
|
||||||
|
const html = (await fs.readFile(harnessHtmlPath, "utf8")).replace("<head>", `<head>${configScript}`);
|
||||||
|
await page.setContent(html, { timeout: actionTimeoutMs });
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeArtifact(name, artifact) {
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(outputDir, name), `${JSON.stringify(artifact, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findChromiumExecutable() {
|
||||||
|
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE) return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE;
|
||||||
|
if (!process.env.PLAYWRIGHT_BROWSERS_PATH) return undefined;
|
||||||
|
|
||||||
|
const entries = await fs.readdir(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||||
|
const chromiumDir = entries.find((entry) => /^chromium-\d+$/.test(entry));
|
||||||
|
if (!chromiumDir) return undefined;
|
||||||
|
|
||||||
|
return path.join(process.env.PLAYWRIGHT_BROWSERS_PATH, chromiumDir, "chrome-linux", "chrome");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshot(page) {
|
||||||
|
return page.evaluate(() => window.__videoHarness);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScenario(name, test) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const artifact = await test();
|
||||||
|
return { name, startedAt, status: "passed", artifact };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
startedAt,
|
||||||
|
status: "failed",
|
||||||
|
error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const executablePath = await findChromiumExecutable();
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--use-fake-device-for-media-stream",
|
||||||
|
"--use-fake-ui-for-media-stream",
|
||||||
|
],
|
||||||
|
executablePath,
|
||||||
|
headless: true,
|
||||||
|
timeout: launchTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
results.push(await runScenario("disabled config hides recorder button", async () => {
|
||||||
|
const page = await openPage(browser, "disabled", false);
|
||||||
|
const count = await page.locator('[data-testid="video-message-button"]').count();
|
||||||
|
assert.equal(count, 0);
|
||||||
|
const artifact = { buttonCount: count, selector: '[data-testid="video-message-button"]' };
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(await runScenario("enabled config shows recorder button", async () => {
|
||||||
|
const page = await openPage(browser, "enabled", true);
|
||||||
|
const button = page.getByRole("button", { name: "Record video message" });
|
||||||
|
await assert.doesNotReject(() => button.waitFor({ state: "visible", timeout: actionTimeoutMs }));
|
||||||
|
const count = await page.locator('[data-testid="video-message-button"]').count();
|
||||||
|
assert.equal(count, 1);
|
||||||
|
const artifact = { accessibleLabel: "Record video message", buttonCount: count };
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(await runScenario("happy path records previews and sends m.video", async () => {
|
||||||
|
const page = await openPage(browser, "happy-path", true);
|
||||||
|
await page.getByRole("button", { name: "Record video message" }).click();
|
||||||
|
await page.locator('[data-testid="video-message-stop-button"]').waitFor({ state: "visible", timeout: actionTimeoutMs });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator('[data-testid="video-message-stop-button"]').click();
|
||||||
|
await page.locator('[data-testid="video-message-preview"]').waitFor({ state: "visible", timeout: actionTimeoutMs });
|
||||||
|
await page.locator('[data-testid="video-message-send-button"]').click();
|
||||||
|
await page.waitForFunction(() => window.__videoHarness.sendCalls.length === 1, null, { timeout: actionTimeoutMs });
|
||||||
|
|
||||||
|
const harness = await snapshot(page);
|
||||||
|
const call = harness.sendCalls[0];
|
||||||
|
assert.equal(call.content.msgtype, "m.video");
|
||||||
|
assert.match(call.content.info.mimetype, /^video\//);
|
||||||
|
assert.match(call.content.body, /\.webm$/);
|
||||||
|
assert.deepEqual(harness.getUserMediaConstraints[0], { video: true, audio: true });
|
||||||
|
assert.ok(harness.stoppedTrackCount >= 1);
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
boundary: harness.contentMessagesBoundary,
|
||||||
|
content: call.content,
|
||||||
|
fakeMediaFlags: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
|
||||||
|
fallbackChunkUsed: harness.fallbackChunkUsed,
|
||||||
|
getUserMediaConstraints: harness.getUserMediaConstraints,
|
||||||
|
mediaSource: harness.mediaSource,
|
||||||
|
sendCallCount: harness.sendCalls.length,
|
||||||
|
stoppedTrackCount: harness.stoppedTrackCount,
|
||||||
|
};
|
||||||
|
await writeArtifact("happy-path.json", artifact);
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(await runScenario("cancel stops tracks without sending", async () => {
|
||||||
|
const page = await openPage(browser, "cancel", true);
|
||||||
|
await page.getByRole("button", { name: "Record video message" }).click();
|
||||||
|
await page.locator('[data-testid="video-message-cancel-button"]').waitFor({ state: "visible", timeout: actionTimeoutMs });
|
||||||
|
await page.locator('[data-testid="video-message-cancel-button"]').click();
|
||||||
|
await page.waitForFunction(() => window.__videoHarness.acquiredTrackCount > 0 && window.__videoHarness.stoppedTrackCount >= window.__videoHarness.acquiredTrackCount, null, { timeout: actionTimeoutMs });
|
||||||
|
const harness = await snapshot(page);
|
||||||
|
assert.equal(harness.sendCalls.length, 0);
|
||||||
|
assert.ok(harness.stoppedTrackCount >= harness.acquiredTrackCount);
|
||||||
|
const artifact = {
|
||||||
|
acquiredTrackCount: harness.acquiredTrackCount,
|
||||||
|
sendCallCount: harness.sendCalls.length,
|
||||||
|
stoppedTrackCount: harness.stoppedTrackCount,
|
||||||
|
};
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(await runScenario("permission denied shows copy without sending", async () => {
|
||||||
|
const page = await openPage(browser, "permission-denied", true);
|
||||||
|
await page.getByRole("button", { name: "Record video message" }).click();
|
||||||
|
await page.getByText("Camera permission denied").waitFor({ state: "visible", timeout: actionTimeoutMs });
|
||||||
|
const harness = await snapshot(page);
|
||||||
|
assert.equal(harness.sendCalls.length, 0);
|
||||||
|
const artifact = {
|
||||||
|
errorText: "Camera permission denied",
|
||||||
|
getUserMediaConstraints: harness.getUserMediaConstraints,
|
||||||
|
sendCallCount: harness.sendCalls.length,
|
||||||
|
};
|
||||||
|
await writeArtifact("permission-denied.json", artifact);
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(await runScenario("unsupported browser keeps composer usable", async () => {
|
||||||
|
const page = await openPage(browser, "unsupported", true);
|
||||||
|
const count = await page.locator('[data-testid="video-message-button"]').count();
|
||||||
|
assert.equal(count, 0);
|
||||||
|
await page.getByLabel("Message composer").fill("composer still works");
|
||||||
|
const composerValue = await page.getByLabel("Message composer").inputValue();
|
||||||
|
assert.equal(composerValue, "composer still works");
|
||||||
|
const artifact = {
|
||||||
|
buttonCount: count,
|
||||||
|
composerValue,
|
||||||
|
outcome: "recorder absent and text composer remains usable",
|
||||||
|
};
|
||||||
|
await writeArtifact("unsupported.json", artifact);
|
||||||
|
await page.close();
|
||||||
|
return artifact;
|
||||||
|
}));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
await writeArtifact("report.json", { generatedAt: new Date().toISOString(), results });
|
||||||
|
const failures = results.filter((result) => result.status !== "passed");
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(JSON.stringify(failures, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ status: "passed", scenarios: results.map((result) => result.name) }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
withTimeout(main(), "video recorder harness", totalTimeoutMs).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user