diff --git a/apps/web/package.json b/apps/web/package.json index f877b1cb66..efc2aafeb3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -77,7 +77,6 @@ "html-entities": "^2.0.0", "html-react-parser": "^6.0.0", "is-ip": "^5.0.0", - "js-xxhash": "^5.0.0", "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.17.0", diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 0c2757148f..46be865ad6 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -165,7 +165,6 @@ @import "./views/dialogs/_SpotlightDialog.pcss"; @import "./views/dialogs/_TermsDialog.pcss"; @import "./views/dialogs/_UnpinAllDialog.pcss"; -@import "./views/dialogs/_UntrustedDeviceDialog.pcss"; @import "./views/dialogs/_UploadConfirmDialog.pcss"; @import "./views/dialogs/_UserSettingsDialog.pcss"; @import "./views/dialogs/_VerifyEMailDialog.pcss"; @@ -279,7 +278,6 @@ @import "./views/rooms/_ReplyPreview.pcss"; @import "./views/rooms/_ReplyTile.pcss"; @import "./views/rooms/_RoomBreadcrumbs.pcss"; -@import "./views/rooms/_RoomCallBanner.pcss"; @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; @import "./views/rooms/_RoomKnocksBar.pcss"; diff --git a/apps/web/res/css/views/dialogs/_UntrustedDeviceDialog.pcss b/apps/web/res/css/views/dialogs/_UntrustedDeviceDialog.pcss deleted file mode 100644 index 8fb7431900..0000000000 --- a/apps/web/res/css/views/dialogs/_UntrustedDeviceDialog.pcss +++ /dev/null @@ -1,24 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_UntrustedDeviceDialog { - .mx_Dialog_title { - display: flex; - align-items: center; - - .mx_E2EIcon { - margin-left: 0; - } - } - - .mx_Dialog_buttons { - display: flex; - justify-content: flex-end; - gap: 8px; - } -} diff --git a/apps/web/res/css/views/rooms/_RoomCallBanner.pcss b/apps/web/res/css/views/rooms/_RoomCallBanner.pcss deleted file mode 100644 index 8e49f6c126..0000000000 --- a/apps/web/res/css/views/rooms/_RoomCallBanner.pcss +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_RoomCallBanner { - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - - box-sizing: border-box; - padding: $spacing-12 $spacing-16; - - color: $primary-content; - background-color: $system; - cursor: pointer; -} - -.mx_RoomCallBanner_text { - display: flex; - flex: 1; - align-items: center; -} - -.mx_RoomCallBanner_label { - color: $primary-content; - font-weight: var(--cpd-font-weight-semibold); - padding-right: $spacing-8; - - svg { - display: inline-block; - vertical-align: middle; - color: $secondary-content; - width: 16px; - height: 16px; - margin-right: 8px; - } -} diff --git a/apps/web/src/Keyboard.ts b/apps/web/src/Keyboard.ts index 5a7e7b59f1..de7ab059c6 100644 --- a/apps/web/src/Keyboard.ts +++ b/apps/web/src/Keyboard.ts @@ -79,12 +79,3 @@ export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent) return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; } } - -/** - * Checks if the given keyboard event is a modified key event (i.e., if any modifier keys are active). - * @param ev The keyboard event to check - * @returns True if the event is a modified key event, false otherwise - */ -export function isModifiedKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean { - return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey; -} diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index edee4e73c5..14d6fc8024 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -115,7 +115,10 @@ dis.register((payload) => { */ let sessionLockStolen = false; -// this is exposed solely for unit tests. +/** + * this is exposed solely for unit tests. + * @knipignore + */ export function setSessionLockNotStolen(): void { sessionLockStolen = false; } diff --git a/apps/web/src/accessibility/KeyboardShortcutUtils.ts b/apps/web/src/accessibility/KeyboardShortcutUtils.ts index a261bf361b..2d383d10f6 100644 --- a/apps/web/src/accessibility/KeyboardShortcutUtils.ts +++ b/apps/web/src/accessibility/KeyboardShortcutUtils.ts @@ -103,6 +103,7 @@ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { /** * Gets keyboard shortcuts that should be presented to the user in the UI. + * @knipignore - exported for tests */ export const getKeyboardShortcutsForUI = (): IKeyboardShortcuts => { const entries = [...Object.entries(getUIOnlyShortcuts()), ...Object.entries(getKeyboardShortcuts())] as [ diff --git a/apps/web/src/components/structures/ContextMenu.tsx b/apps/web/src/components/structures/ContextMenu.tsx index c1d4bbe622..c0fde685b3 100644 --- a/apps/web/src/components/structures/ContextMenu.tsx +++ b/apps/web/src/components/structures/ContextMenu.tsx @@ -484,35 +484,6 @@ export const toRightOf = (elementRect: Pick return { left, top, chevronOffset }; }; -export type ToLeftOf = { - chevronOffset: number; - right: number; - top: number; -}; - -// Placement method for to position context menu to left of elementRect with chevronOffset -export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => { - const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3; - let top = elementRect.top + elementRect.height / 2 + window.scrollY; - top -= chevronOffset + 8; // where 8 is half the height of the chevron - return { right, top, chevronOffset }; -}; - -/** - * Placement method for to position context menu of or right of elementRect - * depending on which side has more space. - */ -export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => { - const spaceToTheLeft = elementRect.left; - const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right; - - if (spaceToTheLeft > spaceToTheRight) { - return toLeftOf(elementRect, chevronOffset); - } - - return toRightOf(elementRect, chevronOffset); -}; - // Placement method for to position context menu right-aligned and flowing to the left of elementRect, // and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) export const aboveLeftOf = ( diff --git a/apps/web/src/components/views/beacon/RoomCallBanner.tsx b/apps/web/src/components/views/beacon/RoomCallBanner.tsx deleted file mode 100644 index e8b6895720..0000000000 --- a/apps/web/src/components/views/beacon/RoomCallBanner.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { useCallback } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; -import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../../../dispatcher/actions"; -import { ConnectionState, type ElementCall } from "../../../models/Call"; -import { useCall } from "../../../hooks/useCall"; -import { useEventEmitterState } from "../../../hooks/useEventEmitter"; -import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; -import { SessionDuration } from "../voip/CallDuration"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; - -interface RoomCallBannerProps { - roomId: Room["roomId"]; - call: ElementCall; -} - -const RoomCallBannerInner: React.FC = ({ roomId, call }) => { - const connect = useCallback( - (ev: ButtonEvent) => { - ev.preventDefault(); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: roomId, - view_call: true, - skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined, - metricsTrigger: undefined, - }); - }, - [roomId], - ); - - // TODO matrix rtc - const onClick = useCallback(() => { - logger.log("clicking on the call banner is not supported anymore - there are no timeline events anymore."); - let messageLikeEventId: string | undefined; - if (!messageLikeEventId) { - // Until we have a timeline event for calls this will always be true. - // We will never jump to the non existing timeline event. - logger.error("Couldn't find a group call event to jump to"); - return; - } - - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: roomId, - metricsTrigger: undefined, - event_id: messageLikeEventId, - scroll_into_view: true, - highlighted: true, - }); - }, [roomId]); - - return ( -
-
- - - {_t("voip|video_call")} - - -
- - - {_t("action|join")} - -
- ); -}; - -interface Props { - roomId: Room["roomId"]; -} - -const RoomCallBanner: React.FC = ({ roomId }) => { - const call = useCall(roomId); - const { roomViewStore } = useScopedRoomContext("roomViewStore"); - // this section is to check if we have a live location share. If so, we dont show the call banner - const isMonitoringLiveLocation = useEventEmitterState( - OwnBeaconStore.instance, - OwnBeaconStoreEvent.MonitoringLivePosition, - () => OwnBeaconStore.instance.isMonitoringLiveLocation, - ); - - const liveBeaconIds = useEventEmitterState(OwnBeaconStore.instance, OwnBeaconStoreEvent.LivenessChange, () => - OwnBeaconStore.instance.getLiveBeaconIds(roomId), - ); - - if (isMonitoringLiveLocation && liveBeaconIds.length) { - return null; - } - - // Check if the call is already showing. No banner is needed in this case. - if (roomViewStore.isViewingCall()) { - return null; - } - - // Split into outer/inner to avoid watching various parts if there is no call - // No banner if the call is connected (or connecting/disconnecting) - if (call !== null && call.connectionState === ConnectionState.Disconnected) { - return ; - } - - return null; -}; - -export default RoomCallBanner; diff --git a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx b/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx deleted file mode 100644 index d0a168f691..0000000000 --- a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx +++ /dev/null @@ -1,255 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. - -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 JSX, type ComponentProps, useContext } from "react"; -import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; - -import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; -import { ChevronFace } from "../../structures/ContextMenu"; -import { _t } from "../../../languageHandler"; -import { isAppWidget } from "../../../stores/WidgetStore"; -import WidgetUtils from "../../../utils/WidgetUtils"; -import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; -import dis from "../../../dispatcher/dispatcher"; -import SettingsStore from "../../../settings/SettingsStore"; -import Modal from "../../../Modal"; -import QuestionDialog from "../dialogs/QuestionDialog"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import { WidgetType } from "../../../widgets/WidgetType"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; -import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; -import { ModuleRunner } from "../../../modules/ModuleRunner"; -import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging"; -import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; - -interface IProps extends Omit, "children"> { - app: IWidget; - userWidget?: boolean; - showUnpin?: boolean; - // override delete handler - onDeleteClick?(this: void): void; - // override edit handler - onEditClick?(this: void): void; -} - -const showStreamAudioStreamButton = (app: IWidget): boolean => { - return !!getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type); -}; - -const showEditButton = (app: IWidget, canModify: boolean): boolean => { - return canModify && WidgetUtils.isManagedByManager(app); -}; - -const showRevokeButton = ( - cli: MatrixClient, - roomId: string | undefined, - app: IWidget, - userWidget: boolean | undefined, -): boolean => { - const isAllowedWidget = - (isAppWidget(app) && - app.eventId !== undefined && - (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false)) || - app.creatorUserId === cli?.getUserId(); - - const isLocalWidget = WidgetType.JITSI.matches(app.type); - return !userWidget && !isLocalWidget && isAllowedWidget; -}; - -const showDeleteButton = (canModify: boolean, onDeleteClick: undefined | (() => void)): boolean => { - return !!onDeleteClick || canModify; -}; - -const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boolean => { - return ( - SettingsStore.getValue("enableWidgetScreenshots") && - !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots) - ); -}; - -const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolean | undefined): [boolean, boolean] => { - if (!showUnpin) return [false, false]; - - const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : []; - const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id); - return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1]; -}; - -export const WidgetContextMenu: React.FC = ({ - onFinished, - app, - userWidget, - onDeleteClick, - onEditClick, - showUnpin, - ...props -}) => { - const cli = useContext(MatrixClientContext); - const { room, roomId } = useScopedRoomContext("room", "roomId"); - - const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); - const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, roomId); - - let streamAudioStreamButton: JSX.Element | undefined; - if (roomId && showStreamAudioStreamButton(app)) { - const onStreamAudioClick = async (): Promise => { - try { - await startJitsiAudioLivestream(cli, widgetMessaging!.widgetApi!, roomId); - } catch (err) { - logger.error("Failed to start livestream", err); - // XXX: won't i18n well, but looks like widget api only support 'message'? - const message = - err instanceof Error ? err.message : _t("widget|error_unable_start_audio_stream_description"); - Modal.createDialog(ErrorDialog, { - title: _t("widget|error_unable_start_audio_stream_title"), - description: message, - }); - } - onFinished(); - }; - streamAudioStreamButton = ( - - ); - } - - let editButton: JSX.Element | undefined; - if (showEditButton(app, canModify)) { - const _onEditClick = (): void => { - if (onEditClick) { - onEditClick(); - } else if (room) { - WidgetUtils.editWidget(room, app); - } - onFinished(); - }; - - editButton = ; - } - - let snapshotButton: JSX.Element | undefined; - if (showSnapshotButton(widgetMessaging)) { - const onSnapshotClick = (): void => { - widgetMessaging?.widgetApi - ?.takeScreenshot() - .then((data) => { - dis.dispatch({ - action: "picture_snapshot", - file: data.screenshot, - }); - }) - .catch((err) => { - logger.error("Failed to take screenshot: ", err); - }); - onFinished(); - }; - - snapshotButton = ( - - ); - } - - let deleteButton: JSX.Element | undefined; - if (showDeleteButton(canModify, onDeleteClick)) { - const _onDeleteClick = (): void => { - if (onDeleteClick) { - onDeleteClick(); - } else if (roomId) { - // Show delete confirmation dialog - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("widget|context_menu|delete"), - description: _t("widget|context_menu|delete_warning"), - button: _t("widget|context_menu|delete"), - }); - - finished.then(([confirmed]) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(cli, roomId, app.id); - }); - } - - onFinished(); - }; - - deleteButton = ( - - ); - } - - let revokeButton: JSX.Element | undefined; - if (showRevokeButton(cli, roomId, app, userWidget)) { - const opts: ApprovalOpts = { approved: undefined }; - ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app)); - - if (!opts.approved) { - const onRevokeClick = (): void => { - const eventId = isAppWidget(app) ? app.eventId : undefined; - logger.info("Revoking permission for widget to load: " + eventId); - const current = SettingsStore.getValue("allowedWidgets", roomId); - if (eventId !== undefined) current[eventId] = false; - const level = SettingsStore.firstSupportedLevel("allowedWidgets"); - if (!level) throw new Error("level must be defined"); - SettingsStore.setValue("allowedWidgets", roomId ?? null, level, current).catch((err) => { - logger.error(err); - // We don't really need to do anything about this - the user will just hit the button again. - }); - onFinished(); - }; - - revokeButton = ( - - ); - } - } - - const [showMoveLeftButton, showMoveRightButton] = showMoveButtons(app, room, showUnpin); - let moveLeftButton: JSX.Element | undefined; - if (showMoveLeftButton) { - const onClick = (): void => { - if (!room) throw new Error("room must be defined"); - WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, -1); - onFinished(); - }; - - moveLeftButton = ; - } - - let moveRightButton: JSX.Element | undefined; - if (showMoveRightButton) { - const onClick = (): void => { - if (!room) throw new Error("room must be defined"); - WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, 1); - onFinished(); - }; - - moveRightButton = ; - } - - return ( - - - {streamAudioStreamButton} - {editButton} - {revokeButton} - {deleteButton} - {snapshotButton} - {moveLeftButton} - {moveRightButton} - - - ); -}; diff --git a/apps/web/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/apps/web/src/components/views/dialogs/UntrustedDeviceDialog.tsx deleted file mode 100644 index 703dbec85f..0000000000 --- a/apps/web/src/components/views/dialogs/UntrustedDeviceDialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 , 2021 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { type User } from "matrix-js-sdk/src/matrix"; - -import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import E2EIcon from "../rooms/E2EIcon"; -import AccessibleButton from "../elements/AccessibleButton"; -import BaseDialog from "./BaseDialog"; -import { type IDevice } from "../right_panel/UserInfo"; -import { E2EStatus } from "../../../utils/ShieldUtils"; - -interface IProps { - /** - * The user whose device is untrusted. - */ - user: User; - /** - * The device that is untrusted. - */ - device: IDevice; - /** - * Callback for when the dialog is dismissed. - * If mode is "sas", the user wants to verify the device with SAS. Otherwise, the dialog was dismissed normally. - * @param mode The mode of dismissal. - */ - onFinished(this: void, mode?: "sas"): void; -} - -const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) => { - let askToVerifyText: string; - let newSessionText: string; - - if (MatrixClientPeg.safeGet().getUserId() === user.userId) { - newSessionText = _t("encryption|udd|own_new_session_text"); - askToVerifyText = _t("encryption|udd|own_ask_verify_text"); - } else { - newSessionText = _t("encryption|udd|other_new_session_text", { - name: user.displayName, - userId: user.userId, - }); - askToVerifyText = _t("encryption|udd|other_ask_verify_text"); - } - - return ( - - - {_t("encryption|udd|title")} - - } - > -
-

{newSessionText}

-

- {device.displayName} ({device.deviceId}) -

-

{askToVerifyText}

-
-
- onFinished("sas")}> - {_t("encryption|udd|interactive_verification_button")} - - onFinished()}> - {_t("action|done")} - -
-
- ); -}; - -export default UntrustedDeviceDialog; diff --git a/apps/web/src/components/views/right_panel/UserInfo.tsx b/apps/web/src/components/views/right_panel/UserInfo.tsx index 7de3f9be88..2ad8244efc 100644 --- a/apps/web/src/components/views/right_panel/UserInfo.tsx +++ b/apps/web/src/components/views/right_panel/UserInfo.tsx @@ -88,22 +88,6 @@ export interface IPowerLevelsContent { redact?: number; } -export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => { - if (!powerLevelContent || !member) return false; - - const levelToSend = - (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || - powerLevelContent.events_default; - - // levelToSend could be undefined as .events_default is optional. Coercing in this case using - // Number() would always return false, so this preserves behaviour - // FIXME: per the spec, if `events_default` is unset, it defaults to zero. If - // the member has a negative powerlevel, this will give an incorrect result. - if (levelToSend === undefined) return false; - - return member.powerLevel < levelToSend; -}; - export interface IRoomPermissions { modifyLevelMax: number; canEdit: boolean; diff --git a/apps/web/src/events/RelationsHelper.ts b/apps/web/src/events/RelationsHelper.ts deleted file mode 100644 index 83f1eeb264..0000000000 --- a/apps/web/src/events/RelationsHelper.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { - type MatrixClient, - type MatrixEvent, - MatrixEventEvent, - type RelationType, - TypedEventEmitter, - type Relations, - RelationsEvent, -} from "matrix-js-sdk/src/matrix"; - -import { type IDestroyable } from "../utils/IDestroyable"; - -export enum RelationsHelperEvent { - Add = "add", -} - -interface EventMap { - [RelationsHelperEvent.Add]: (event: MatrixEvent) => void; -} - -/** - * Helper class that manages a specific event type relation for an event. - * Just create an instance and listen for new events for that relation. - * Optionally receive the current events by calling emitCurrent(). - * Clean up everything by calling destroy(). - */ -export class RelationsHelper extends TypedEventEmitter implements IDestroyable { - private relations?: Relations; - private eventId: string; - private roomId: string; - - public constructor( - private event: MatrixEvent, - private relationType: RelationType, - private relationEventType: string, - private client: MatrixClient, - ) { - super(); - - const eventId = event.getId(); - - if (!eventId) { - throw new Error("unable to create RelationsHelper: missing event ID"); - } - - const roomId = event.getRoomId(); - - if (!roomId) { - throw new Error("unable to create RelationsHelper: missing room ID"); - } - - this.eventId = eventId; - this.roomId = roomId; - this.setUpRelations(); - } - - private setUpRelations = (): void => { - this.setRelations(); - - if (this.relations) { - this.relations.on(RelationsEvent.Add, this.onRelationsAdd); - } else { - this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - } - }; - - private onRelationsCreated = (): void => { - this.setRelations(); - - if (this.relations) { - this.relations.on(RelationsEvent.Add, this.onRelationsAdd); - this.emitCurrent(); - } else { - this.event.once(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - } - }; - - private setRelations(): void { - const room = this.client.getRoom(this.event.getRoomId()); - this.relations = room - ?.getUnfilteredTimelineSet() - ?.relations?.getChildEventsForEvent(this.eventId, this.relationType, this.relationEventType); - } - - private onRelationsAdd = (event: MatrixEvent): void => { - this.emit(RelationsHelperEvent.Add, event); - }; - - public emitCurrent(): void { - this.relations?.getRelations()?.forEach((e) => this.emit(RelationsHelperEvent.Add, e)); - } - - public getCurrent(): MatrixEvent[] { - return this.relations?.getRelations() || []; - } - - /** - * Fetches all related events from the server and emits them. - */ - public async emitFetchCurrent(): Promise { - let nextBatch: string | undefined = undefined; - - do { - const response = await this.client.relations( - this.roomId, - this.eventId, - this.relationType, - this.relationEventType, - { - from: nextBatch, - limit: 50, - }, - ); - nextBatch = response?.nextBatch ?? undefined; - response?.events.forEach((e) => this.emit(RelationsHelperEvent.Add, e)); - } while (nextBatch); - } - - public destroy(): void { - this.removeAllListeners(); - this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - - if (this.relations) { - this.relations.off(RelationsEvent.Add, this.onRelationsAdd); - } - } -} diff --git a/apps/web/src/hooks/useLocalStorageState.ts b/apps/web/src/hooks/useLocalStorageState.ts index be1b79b49f..a99e810f2d 100644 --- a/apps/web/src/hooks/useLocalStorageState.ts +++ b/apps/web/src/hooks/useLocalStorageState.ts @@ -17,7 +17,9 @@ const getValue = (key: string, initialValue: T): T => { } }; -// Hook behaving like useState but persisting the value to localStorage. Returns same as useState +/** + * Hook behaving like useState but persisting the value to localStorage. Returns same as useState + */ export const useLocalStorageState = (key: string, initialValue: T): [T, Dispatch] => { const lsKey = "mx_" + key; diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index cf48aca1ce..c6f1563967 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1013,14 +1013,6 @@ "set_up_toast_title": "Set up Secure Backup", "turn_on_key_storage": "Turn on key storage", "turn_on_key_storage_description": "This will allow you to view your chat history on any new devices and is required for backup of chats and digital identity.", - "udd": { - "interactive_verification_button": "Interactively verify by emoji", - "other_ask_verify_text": "Ask this user to verify their session, or manually verify it below.", - "other_new_session_text": "%(name)s (%(userId)s) signed in to a new session without verifying it:", - "own_ask_verify_text": "Verify your other session using one of the options below.", - "own_new_session_text": "You signed in to a new session without verifying it:", - "title": "Not Trusted" - }, "unable_to_setup_keys_error": "Unable to set up keys", "verification": { "accepting": "Accepting…", @@ -4077,13 +4069,7 @@ "close_to_view_right_panel": "Close this widget to view it in this panel", "context_menu": { "delete": "Delete widget", - "delete_warning": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", - "move_left": "Move left", - "move_right": "Move right", - "remove": "Remove for everyone", - "revoke": "Revoke permissions", - "screenshot": "Take a picture", - "start_audio_stream": "Start audio stream" + "delete_warning": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?" }, "cookie_warning": "This widget may use cookies.", "error_hangup_description": "You were disconnected from the call. (Error: %(message)s)", diff --git a/apps/web/src/languageHandler.tsx b/apps/web/src/languageHandler.tsx index a315d61298..50ba794f47 100644 --- a/apps/web/src/languageHandler.tsx +++ b/apps/web/src/languageHandler.tsx @@ -96,9 +96,12 @@ export function getUserLanguage(): string { } } -// Allow overriding the text displayed when no translation exists -// Currently only used in unit tests to avoid having to load -// the translations in element-web +/** + * Allow overriding the text displayed when no translation exists + * Currently only used in unit tests to avoid having to load + * the translations in element-web + * @knipignore + */ export function setMissingEntryGenerator(f: (value: string) => void): void { setMissingEntryGeneratorSharedComponents(f); } diff --git a/apps/web/src/utils/PhasedRolloutFeature.ts b/apps/web/src/utils/PhasedRolloutFeature.ts deleted file mode 100644 index dd94ee5c89..0000000000 --- a/apps/web/src/utils/PhasedRolloutFeature.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2024 New Vector 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 { xxHash32 } from "js-xxhash"; - -/** - * The PhasedRolloutFeature class is used to manage the phased rollout of a new feature. - * - * It uses a hash of the user's identifier and the feature name to determine if a feature is enabled for a specific user. - * The rollout percentage determines the probability that a user will be enabled for the feature. - * The feature will be enabled for all users if the rollout percentage is 100, and for no users if the percentage is 0. - * If a user is enabled for a feature at x% rollout, it will also be for any greater than x percent. - * - * The process ensures a uniform distribution of enabled features across users. - * - * @property featureName - The name of the feature to be rolled out. - * @property rolloutPercentage - The int percentage (0..100) of users for whom the feature should be enabled. - */ -export class PhasedRolloutFeature { - public readonly featureName: string; - private readonly rolloutPercentage: number; - private readonly seed: number; - - public constructor(featureName: string, rolloutPercentage: number) { - this.featureName = featureName; - if (!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100) { - throw new Error("Rollout percentage must be an integer between 0 and 100"); - } - this.rolloutPercentage = rolloutPercentage; - // We add the feature name for the seed to ensure that the hash is different for each feature - this.seed = Array.from(featureName).reduce((sum, char) => sum + char.charCodeAt(0), 0); - } - - /** - * Returns true if the feature should be enabled for the given user. - * @param userIdentifier - Some unique identifier for the user, e.g. their user ID or device ID. - */ - public isFeatureEnabled(userIdentifier: string): boolean { - /* - * We use a hash function to convert the unique user ID string into an integer. - * This integer can then be used as a basis for deciding whether the user should have access to the new feature. - * We need some hash with good uniform distribution properties, security is not a concern here. - * We use xxHash32, which is fast and has good distribution properties. - */ - const hash = xxHash32(userIdentifier, this.seed); - // We use the hash modulo 100 to get a number between 0 and 99. - // Modulo is simple and effective and the distribution should be uniform enough for our purposes. - return hash % 100 < this.rolloutPercentage; - } -} diff --git a/apps/web/src/utils/enums.test.ts b/apps/web/src/utils/enums.test.ts deleted file mode 100644 index 81c496f807..0000000000 --- a/apps/web/src/utils/enums.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. - -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 { describe, it, expect } from "vitest"; - -import { getEnumValues, isEnumValue } from "./enums"; - -enum TestStringEnum { - First = "__first__", - Second = "__second__", -} - -enum TestNumberEnum { - FirstKey = 10, - SecondKey = 20, -} - -describe("enums", () => { - describe("getEnumValues", () => { - it("should work on string enums", () => { - const result = getEnumValues(TestStringEnum); - expect(result).toBeDefined(); - expect(result).toHaveLength(2); - expect(result).toEqual(["__first__", "__second__"]); - }); - - it("should work on number enums", () => { - const result = getEnumValues(TestNumberEnum); - expect(result).toBeDefined(); - expect(result).toHaveLength(2); - expect(result).toEqual([10, 20]); - }); - }); - - describe("isEnumValue", () => { - it("should return true on values in a string enum", () => { - const result = isEnumValue(TestStringEnum, "__first__"); - expect(result).toBe(true); - }); - - it("should return false on values not in a string enum", () => { - const result = isEnumValue(TestStringEnum, "not a value"); - expect(result).toBe(false); - }); - - it("should return true on values in a number enum", () => { - const result = isEnumValue(TestNumberEnum, 10); - expect(result).toBe(true); - }); - - it("should return false on values not in a number enum", () => { - const result = isEnumValue(TestStringEnum, 99); - expect(result).toBe(false); - }); - }); -}); diff --git a/apps/web/src/utils/enums.ts b/apps/web/src/utils/enums.ts deleted file mode 100644 index f138ce4cac..0000000000 --- a/apps/web/src/utils/enums.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020, 2021 The Matrix.org Foundation C.I.C. - -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. -*/ - -/** - * Get the values for an enum. - * @param e The enum. - * @returns The enum values. - */ -export function getEnumValues(e: any): (string | number)[] { - // String-based enums will simply be objects ({Key: "value"}), but number-based - // enums will instead map themselves twice: in one direction for {Key: 12} and - // the reverse for easy lookup, presumably ({12: Key}). In the reverse mapping, - // the key is a string, not a number. - // - // For this reason, we try to determine what kind of enum we're dealing with. - - const keys = Object.keys(e); - const values: (string | number)[] = []; - for (const key of keys) { - const value = e[key]; - if (Number.isFinite(value) || e[value.toString()] !== Number(key)) { - values.push(value); - } - } - return values; -} - -/** - * Determines if a given value is a valid value for the provided enum. - * @param e The enum to check against. - * @param val The value to search for. - * @returns True if the enum contains the value. - */ -export function isEnumValue(e: T, val: string | number): boolean { - return getEnumValues(e).includes(val); -} diff --git a/apps/web/test/unit-tests/components/structures/ContextMenu-test.ts b/apps/web/test/unit-tests/components/structures/ContextMenu-test.ts index ae9ff3a1ab..6e57b53a80 100644 --- a/apps/web/test/unit-tests/components/structures/ContextMenu-test.ts +++ b/apps/web/test/unit-tests/components/structures/ContextMenu-test.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { toLeftOf, toLeftOrRightOf, toRightOf } from "../../../../src/components/structures/ContextMenu"; +import { toRightOf } from "../../../../src/components/structures/ContextMenu"; import UIStore from "../../../../src/stores/UIStore"; describe("ContextMenu", () => { @@ -26,16 +26,6 @@ describe("ContextMenu", () => { UIStore.instance.windowWidth = 1280; }); - describe("toLeftOf", () => { - it("should return the correct positioning", () => { - expect(toLeftOf(rect)).toEqual({ - chevronOffset: 12, - right: 1285, // 1280 - 23 + 31 - 3 - top: 303, // 42 + (480 / 2) + 41 - (12 + 8) - }); - }); - }); - describe("toRightOf", () => { it("should return the correct positioning", () => { expect(toRightOf(rect)).toEqual({ @@ -45,35 +35,4 @@ describe("ContextMenu", () => { }); }); }); - - describe("toLeftOrRightOf", () => { - describe("when there is more space to the right", () => { - // default case from test setup - - it("should return a position to the right", () => { - expect(toLeftOrRightOf(rect)).toEqual({ - chevronOffset: 12, - left: 80, // 46 + 31 + 3 - top: 303, // 42 + (480 / 2) + 41 - (12 + 8) - }); - }); - }); - - describe("when there is more space to the left", () => { - beforeEach(() => { - // @ts-ignore - rect.left = 500; - // @ts-ignore - rect.right = 1000; - }); - - it("should return a position to the left", () => { - expect(toLeftOrRightOf(rect)).toEqual({ - chevronOffset: 12, - right: 808, // 1280 - 500 + 31 - 3 - top: 303, // 42 + (480 / 2) + 41 - (12 + 8) - }); - }); - }); - }); }); diff --git a/apps/web/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx b/apps/web/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx index 5ebd537855..48522abb04 100644 --- a/apps/web/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx +++ b/apps/web/test/unit-tests/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel-test.tsx @@ -19,7 +19,6 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg"; import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel"; import { useMuteButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel"; -import { isMuted } from "../../../../../../../src/components/views/right_panel/UserInfo"; import { withClientContextRenderOptions } from "../../../../../../test-utils"; describe("useMuteButtonViewModel", () => { @@ -191,40 +190,4 @@ describe("useMuteButtonViewModel", () => { expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, -1); }); - - it("returns false if either argument is falsy", () => { - // @ts-ignore to let us purposely pass incorrect args - expect(isMuted(defaultMember, null)).toBe(false); - // @ts-ignore to let us purposely pass incorrect args - expect(isMuted(null, {})).toBe(false); - }); - - it("when powerLevelContent.events and .events_default are undefined, returns false", () => { - const powerLevelContents = {}; - expect(isMuted(defaultMember, powerLevelContents)).toBe(false); - }); - - it("when powerLevelContent.events is undefined, uses .events_default", () => { - const higherPowerLevelContents = { events_default: 10 }; - expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true); - - const lowerPowerLevelContents = { events_default: -10 }; - expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false); - }); - - it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => { - const higherPowerLevelContents = { events: {}, events_default: 10 }; - expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true); - - const lowerPowerLevelContents = { events: {}, events_default: -10 }; - expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false); - }); - - it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => { - const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 }; - expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(false); - - const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 }; - expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(true); - }); }); diff --git a/apps/web/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/apps/web/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx deleted file mode 100644 index caa5b23810..0000000000 --- a/apps/web/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { - Room, - PendingEventOrdering, - type MatrixClient, - type RoomMember, - RoomStateEvent, -} from "matrix-js-sdk/src/matrix"; -import { Widget } from "matrix-widget-api"; -import { act, cleanup, render, screen } from "jest-matrix-react"; -import { mocked, type Mocked } from "jest-mock"; - -import { - mkRoomMember, - MockedCall, - setupAsyncStoreWithClient, - stubClient, - useMockedCalls, -} from "../../../../test-utils"; -import RoomCallBanner from "../../../../../src/components/views/beacon/RoomCallBanner"; -import { CallStore } from "../../../../../src/stores/CallStore"; -import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { ConnectionState } from "../../../../../src/models/Call"; -import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; -import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext"; -import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; - -describe("", () => { - let client: Mocked; - let room: Room; - let alice: RoomMember; - useMockedCalls(); - - const defaultProps = { - roomId: "!1:example.org", - }; - - const mockRoomViewStore = { - isViewingCall: jest.fn().mockReturnValue(false), - on: jest.fn(), - off: jest.fn(), - emit: jest.fn(), - }; - - let roomContext: RoomContextType; - - beforeEach(() => { - stubClient(); - - client = mocked(MatrixClientPeg.safeGet()); - - room = new Room("!1:example.org", client, "@alice:example.org", { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - alice = mkRoomMember(room.roomId, "@alice:example.org"); - jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === alice.userId ? alice : null)); - - client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); - client.getRooms.mockReturnValue([room]); - client.reEmitter.reEmit(room, [RoomStateEvent.Events]); - - setupAsyncStoreWithClient(CallStore.instance, client); - setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); - - // Reset the mock RoomViewStore - mockRoomViewStore.isViewingCall.mockReturnValue(false); - - // Create a stable room context for this test - roomContext = { - ...RoomContext, - roomId: room.roomId, - roomViewStore: mockRoomViewStore, - } as unknown as RoomContextType; - }); - - afterEach(async () => { - client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); - }); - - const renderBanner = async (props = {}): Promise => { - render( - - - , - ); - await act(() => Promise.resolve()); // Let effects settle - }; - - it("renders nothing when there is no call", async () => { - await renderBanner(); - const banner = await screen.queryByText("Video call"); - expect(banner).toBeFalsy(); - }); - - describe("call started", () => { - let call: MockedCall; - let widget: Widget; - - beforeEach(() => { - MockedCall.create(room, "1"); - const maybeCall = CallStore.instance.getCall(room.roomId); - if (!(maybeCall instanceof MockedCall)) { - throw new Error("Failed to create call"); - } - call = maybeCall; - - widget = new Widget(call.widget); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - stop: () => {}, - } as unknown as WidgetMessaging); - }); - afterEach(() => { - cleanup(); // Unmount before we do any cleanup that might update the component - call.destroy(); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - }); - - it("renders if there is a call", async () => { - await renderBanner(); - await screen.findByText("Video call"); - }); - - it("shows Join button if the user has not joined", async () => { - await renderBanner(); - await screen.findByText("Join"); - }); - - it("doesn't show banner if the call is connected", async () => { - call.setConnectionState(ConnectionState.Connected); - await renderBanner(); - const banner = await screen.queryByText("Video call"); - expect(banner).toBeFalsy(); - }); - - it("doesn't show banner if the call is shown", async () => { - mockRoomViewStore.isViewingCall.mockReturnValue(true); - await renderBanner(); - const banner = await screen.queryByText("Video call"); - expect(banner).toBeFalsy(); - }); - }); - - // TODO: test clicking buttons - // TODO: add live location share warning test (should not render if there is an active live location share) -}); diff --git a/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx b/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx deleted file mode 100644 index 9fd96c5cfe..0000000000 --- a/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 Mikhail Aheichyk -Copyright 2023 Nordeck IT + Consulting GmbH. - -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 JSX, type ComponentProps } from "react"; -import { screen, render } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; -import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixWidgetType } from "matrix-widget-api"; -import { - type ApprovalOpts, - type WidgetInfo, - WidgetLifecycle, -} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; - -import { WidgetContextMenu } from "../../../../../src/components/views/context_menus/WidgetContextMenu"; -import { type IApp } from "../../../../../src/stores/WidgetStore"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import WidgetUtils from "../../../../../src/utils/WidgetUtils"; -import { ModuleRunner } from "../../../../../src/modules/ModuleRunner"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore"; -import { mkStubRoom } from "../../../../test-utils/test-utils.ts"; -import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; -import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; - -describe("", () => { - const widgetId = "w1"; - const eventId = "e1"; - const roomId = "r1"; - const userId = "@user-id:server"; - - const app: IApp = { - id: widgetId, - eventId, - roomId, - type: MatrixWidgetType.Custom, - url: "https://example.com", - name: "Example 1", - creatorUserId: userId, - avatar_url: undefined, - }; - - let mockClient: MatrixClient; - - let room: Room; - - let onFinished: () => void; - - let roomContext: RoomContextType; - - beforeEach(() => { - onFinished = jest.fn(); - jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); - - mockClient = { - getUserId: jest.fn().mockReturnValue(userId), - } as unknown as MatrixClient; - - room = mkStubRoom(roomId, "Test Room", mockClient); - - roomContext = { - room, - roomId, - } as unknown as RoomContextType; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - function getComponent(props: Partial> = {}): JSX.Element { - return ( - - - - - - ); - } - - it("renders revoke button", async () => { - const { rerender } = render(getComponent()); - - const revokeButton = screen.getByLabelText("Revoke permissions"); - expect(revokeButton).toBeInTheDocument(); - - jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { - if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === widgetId) { - (opts as ApprovalOpts).approved = true; - } - }); - - rerender(getComponent()); - expect(revokeButton).not.toBeInTheDocument(); - }); - - it("revokes permissions", async () => { - render(getComponent()); - await userEvent.click(screen.getByLabelText("Revoke permissions")); - expect(onFinished).toHaveBeenCalled(); - expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false); - }); - - it("shows the move left button when the widget can be moved left", () => { - // Place our widget second so it can move left but not right. - jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([ - { id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" }, - { id: widgetId, type: "m.custom", creatorUserId: userId, url: "" }, - ]); - - render(getComponent({ showUnpin: true })); - - expect(screen.getByLabelText("Move left")).toBeInTheDocument(); - expect(screen.queryByLabelText("Move right")).not.toBeInTheDocument(); - }); - - it("shows the move right button when the widget can be moved right", () => { - // Place our widget first so it can move right but not left. - jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([ - { id: widgetId, type: "m.custom", creatorUserId: userId, url: "" }, - { id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" }, - ]); - - render(getComponent({ showUnpin: true })); - - expect(screen.getByLabelText("Move right")).toBeInTheDocument(); - expect(screen.queryByLabelText("Move left")).not.toBeInTheDocument(); - }); - - it("moves widget left when move left button is clicked", async () => { - // Place our widget second so move left is visible. - jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([ - { id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" }, - { id: widgetId, type: "m.custom", creatorUserId: userId, url: "" }, - ]); - - // Mock moveWithinContainer to verify it's called with the correct arguments. - const moveWithinContainerSpy = jest - .spyOn(WidgetLayoutStore.instance, "moveWithinContainer") - .mockImplementation(); - - render(getComponent({ showUnpin: true })); - - await userEvent.click(screen.getByLabelText("Move left")); - - expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, -1); - expect(onFinished).toHaveBeenCalled(); - }); - - it("moves widget right when move right button is clicked", async () => { - // Place our widget first so move right is visible. - jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([ - { id: widgetId, type: "m.custom", creatorUserId: userId, url: "" }, - { id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" }, - ]); - - // Mock moveWithinContainer to verify it's called with the correct arguments. - const moveWithinContainerSpy = jest - .spyOn(WidgetLayoutStore.instance, "moveWithinContainer") - .mockImplementation(); - - render(getComponent({ showUnpin: true })); - await userEvent.click(screen.getByLabelText("Move right")); - - expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, 1); - expect(onFinished).toHaveBeenCalled(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/UntrustedDeviceDialog-test.tsx b/apps/web/test/unit-tests/components/views/dialogs/UntrustedDeviceDialog-test.tsx deleted file mode 100644 index a9486ca35e..0000000000 --- a/apps/web/test/unit-tests/components/views/dialogs/UntrustedDeviceDialog-test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2024 New Vector 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 from "react"; -import { Device, type MatrixClient, User } from "matrix-js-sdk/src/matrix"; -import { render, screen } from "jest-matrix-react"; - -import { stubClient } from "../../../../test-utils"; -import UntrustedDeviceDialog from "../../../../../src/components/views/dialogs/UntrustedDeviceDialog.tsx"; - -describe("", () => { - let client: MatrixClient; - let user: User; - let device: Device; - const onFinished = jest.fn(); - - beforeEach(() => { - client = stubClient(); - user = User.createUser("@alice:example.org", client); - user.setDisplayName("Alice"); - device = new Device({ deviceId: "device_id", userId: user.userId, algorithms: [], keys: new Map() }); - }); - - afterEach(() => { - onFinished.mockReset(); - }); - - function renderComponent() { - return render(); - } - - it("should display the dialog for the device of another user", () => { - const { asFragment } = renderComponent(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should display the dialog for the device of the current user", () => { - jest.spyOn(client, "getUserId").mockReturnValue(user.userId); - - const { asFragment } = renderComponent(); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should call onFinished without parameter when Done is clicked", () => { - renderComponent(); - screen.getByRole("button", { name: "Done" }).click(); - expect(onFinished).toHaveBeenCalledWith(); - }); - - it("should call onFinished with sas when Interactively verify by emoji is clicked", () => { - renderComponent(); - screen.getByRole("button", { name: "Interactively verify by emoji" }).click(); - expect(onFinished).toHaveBeenCalledWith("sas"); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap deleted file mode 100644 index d2d69ebf26..0000000000 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap +++ /dev/null @@ -1,203 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[` should display the dialog for the device of another user 1`] = ` - -
- -
- -`; - -exports[` should display the dialog for the device of the current user 1`] = ` - -
- -
- -`; diff --git a/apps/web/test/unit-tests/events/RelationsHelper-test.ts b/apps/web/test/unit-tests/events/RelationsHelper-test.ts deleted file mode 100644 index c8992fc922..0000000000 --- a/apps/web/test/unit-tests/events/RelationsHelper-test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { mocked } from "jest-mock"; -import { - EventType, - type MatrixClient, - type MatrixEvent, - MatrixEventEvent, - RelationType, - Room, -} from "matrix-js-sdk/src/matrix"; - -import { RelationsHelper, RelationsHelperEvent } from "../../../src/events/RelationsHelper"; -import { mkEvent, stubClient } from "../../test-utils"; - -describe("RelationsHelper", () => { - const roomId = "!room:example.com"; - let event: MatrixEvent; - let relatedEvent1: MatrixEvent; - let relatedEvent2: MatrixEvent; - let relatedEvent3: MatrixEvent; - let room: Room; - let client: MatrixClient; - let relationsHelper: RelationsHelper; - let onAdd: (event: MatrixEvent) => void; - - beforeEach(() => { - client = stubClient(); - mocked(client.relations).mockClear(); - room = new Room(roomId, client, client.getSafeUserId()); - mocked(client.getRoom).mockImplementation((getRoomId?: string): Room | null => { - if (getRoomId === roomId) return room; - return null; - }); - event = mkEvent({ - event: true, - type: EventType.RoomMessage, - room: roomId, - user: client.getSafeUserId(), - content: {}, - }); - relatedEvent1 = mkEvent({ - event: true, - type: EventType.RoomMessage, - room: roomId, - user: client.getSafeUserId(), - content: { - ["m.relates_to"]: { - rel_type: RelationType.Reference, - event_id: event.getId(), - }, - }, - }); - relatedEvent2 = mkEvent({ - event: true, - type: EventType.RoomMessage, - room: roomId, - user: client.getSafeUserId(), - content: { - ["m.relates_to"]: { - rel_type: RelationType.Reference, - event_id: event.getId(), - }, - }, - }); - relatedEvent3 = mkEvent({ - event: true, - type: EventType.RoomMessage, - room: roomId, - user: client.getSafeUserId(), - content: { - ["m.relates_to"]: { - rel_type: RelationType.Reference, - event_id: event.getId(), - }, - }, - }); - onAdd = jest.fn(); - }); - - afterEach(() => { - relationsHelper?.destroy(); - }); - - describe("when there is an event without ID", () => { - it("should raise an error", () => { - jest.spyOn(event, "getId").mockReturnValue(undefined); - - expect(() => { - new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); - }).toThrow("unable to create RelationsHelper: missing event ID"); - }); - }); - - describe("when there is an event without room ID", () => { - it("should raise an error", () => { - jest.spyOn(event, "getRoomId").mockReturnValue(undefined); - - expect(() => { - new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); - }).toThrow("unable to create RelationsHelper: missing room ID"); - }); - }); - - describe("when there is an event without relations", () => { - beforeEach(() => { - relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); - relationsHelper.on(RelationsHelperEvent.Add, onAdd); - }); - - describe("emitCurrent", () => { - beforeEach(() => { - relationsHelper.emitCurrent(); - }); - - it("should not emit any event", () => { - expect(onAdd).not.toHaveBeenCalled(); - }); - }); - - describe("and a new event appears", () => { - beforeEach(() => { - room.relations.aggregateChildEvent(relatedEvent2); - event.emit(MatrixEventEvent.RelationsCreated, RelationType.Reference, EventType.RoomMessage); - }); - - it("should emit the new event", () => { - expect(onAdd).toHaveBeenCalledWith(relatedEvent2); - }); - }); - }); - - describe("when there is an event with two pages server side relations", () => { - beforeEach(() => { - mocked(client.relations) - .mockResolvedValueOnce({ - events: [relatedEvent1, relatedEvent2], - nextBatch: "next", - }) - .mockResolvedValueOnce({ - events: [relatedEvent3], - nextBatch: null, - }); - relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); - relationsHelper.on(RelationsHelperEvent.Add, onAdd); - }); - - describe("emitFetchCurrent", () => { - beforeEach(async () => { - await relationsHelper.emitFetchCurrent(); - }); - - it("should emit the server side events", () => { - expect(onAdd).toHaveBeenCalledWith(relatedEvent1); - expect(onAdd).toHaveBeenCalledWith(relatedEvent2); - expect(onAdd).toHaveBeenCalledWith(relatedEvent3); - }); - }); - }); - - describe("when there is an event with relations", () => { - beforeEach(() => { - room.relations.aggregateChildEvent(relatedEvent1); - relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); - relationsHelper.on(RelationsHelperEvent.Add, onAdd); - }); - - describe("emitCurrent", () => { - beforeEach(() => { - relationsHelper.emitCurrent(); - }); - - it("should emit the related event", () => { - expect(onAdd).toHaveBeenCalledWith(relatedEvent1); - }); - }); - - describe("and a new event appears", () => { - beforeEach(() => { - room.relations.aggregateChildEvent(relatedEvent2); - event.emit(MatrixEventEvent.RelationsCreated, RelationType.Reference, EventType.RoomMessage); - }); - - it("should emit the new event", () => { - expect(onAdd).toHaveBeenCalledWith(relatedEvent2); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/utils/PhasedRolloutFeature-test.ts b/apps/web/test/unit-tests/utils/PhasedRolloutFeature-test.ts deleted file mode 100644 index 3e4a75a1b2..0000000000 --- a/apps/web/test/unit-tests/utils/PhasedRolloutFeature-test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -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 { PhasedRolloutFeature } from "../../../src/utils/PhasedRolloutFeature"; - -describe("Test PhasedRolloutFeature", () => { - function randomUserId() { - const characters = "abcdefghijklmnopqrstuvwxyz0123456789.=_-/+"; - let result = ""; - const charactersLength = characters.length; - const idLength = Math.floor(Math.random() * 15) + 6; // Random number between 6 and 20 - for (let i = 0; i < idLength; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return "@" + result + ":matrix.org"; - } - - function randomDeviceId() { - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let result = ""; - const charactersLength = characters.length; - for (let i = 0; i < 10; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; - } - - it("should only accept valid percentage", () => { - expect(() => new PhasedRolloutFeature("test", 0.8)).toThrow(); - expect(() => new PhasedRolloutFeature("test", -1)).toThrow(); - expect(() => new PhasedRolloutFeature("test", 123)).toThrow(); - }); - - it("should enable for all if percentage is 100", () => { - const phasedRolloutFeature = new PhasedRolloutFeature("test", 100); - - for (let i = 0; i < 1000; i++) { - expect(phasedRolloutFeature.isFeatureEnabled(randomUserId())).toBeTruthy(); - } - }); - - it("should not enable for anyone if percentage is 0", () => { - const phasedRolloutFeature = new PhasedRolloutFeature("test", 0); - - for (let i = 0; i < 1000; i++) { - expect(phasedRolloutFeature.isFeatureEnabled(randomUserId())).toBeFalsy(); - } - }); - - it("should enable for more users if percentage grows", () => { - let rolloutPercentage = 0; - let previousBatch: string[] = []; - const allUsers = new Array(1000).fill(0).map(() => randomDeviceId()); - - while (rolloutPercentage <= 90) { - rolloutPercentage += 10; - const nextRollout = new PhasedRolloutFeature("test", rolloutPercentage); - const nextBatch = allUsers.filter((userId) => nextRollout.isFeatureEnabled(userId)); - expect(previousBatch.length).toBeLessThan(nextBatch.length); - expect(previousBatch.every((user) => nextBatch.includes(user))).toBeTruthy(); - previousBatch = nextBatch; - } - }); - - it("should distribute differently depending on the feature name", () => { - const allUsers = new Array(1000).fill(0).map(() => randomUserId()); - - const featureARollout = new PhasedRolloutFeature("FeatureA", 50); - const featureBRollout = new PhasedRolloutFeature("FeatureB", 50); - - const featureAUsers = allUsers.filter((userId) => featureARollout.isFeatureEnabled(userId)); - const featureBUsers = allUsers.filter((userId) => featureBRollout.isFeatureEnabled(userId)); - - expect(featureAUsers).not.toEqual(featureBUsers); - }); -}); diff --git a/knip.ts b/knip.ts index 78d4f717f3..d2053ace5c 100644 --- a/knip.ts +++ b/knip.ts @@ -5,9 +5,23 @@ process.env.GITHUB_ACTIONS = "1"; export default { workspaces: { - "packages/shared-components": {}, + "packages/shared-components": { + entry: ["src/index.ts!", "scripts/**"], + project: [ + "**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!", + "!scripts/**!", + "!src/test/**!", + "!src/**/test-*!", + "!src/**/*-{mock,mocks,snapshot,actions}.*!", + ], + }, "packages/playwright-common": { - entry: ["src/fixtures/index.ts", "src/testcontainers/index.ts"], + entry: ["src/fixtures/index.ts!", "src/testcontainers/index.ts!"], + project: [ + "**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!", + "!src/flaky-reporter.ts!", + "!src/stale-screenshot-reporter.ts!", + ], ignoreDependencies: [ // Used in playwright-screenshots.sh "wait-on", @@ -17,20 +31,32 @@ export default { "packages/module-api": {}, "apps/web": { entry: [ - "src/serviceworker/index.ts", - "src/workers/*.worker.ts", - "src/utils/exportUtils/exportJS.js", - "src/vector/localstorage-fix.ts", + "src/serviceworker/index.ts!", + "src/workers/*.worker.ts!", + "src/utils/exportUtils/exportJS.js!", + "src/vector/localstorage-fix.ts!", "scripts/**", "playwright/**", "test/**", "res/decoder-ring/**", "res/jitsi_external_api.min.js", "res/themes/*/css/*.pcss", - ], - ignore: [ + "I18nWebpackPlugin.ts!", + "module_system/**!", // Keep for now - "src/hooks/useLocalStorageState.ts", + "src/hooks/useLocalStorageState.ts!", + "src/hooks/useIsReleaseAnnouncementOpen.ts!", + "src/components/structures/ReleaseAnnouncement.tsx!", + "src/utils/arrays.ts!", + "src/utils/EventPresentationContextProvider.tsx!", + // This is just an awful side-effect import + "src/stores/LifecycleStore.ts!", + ], + project: [ + "**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!", + "!scripts/**!", + "!src/test/**!", + "!recorder-worklet-loader.cjs!", ], ignoreDependencies: [ // False positive @@ -49,7 +75,7 @@ export default { ], }, "apps/desktop": { - entry: ["src/preload.cts", "electron-builder.ts", "scripts/**", "hak/**"], + entry: ["src/preload.cts!", "electron-builder.ts!", "scripts/**", "hak/**"], project: ["**/*.{js,ts}"], ignoreDependencies: [ // Brought in via hak scripts @@ -65,9 +91,12 @@ export default { "lipo", ], }, - "modules": {}, + "modules": { + project: ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!", "!playwright/**!"], + }, "modules/*": { - entry: "src/index.ts{x,}", + entry: ["src/index.ts{x,}!"], + project: ["**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!", "!src/tests/**!", "!e2e/**!"], }, ".": { entry: ["scripts/**", "docs/**"], @@ -95,4 +124,6 @@ export default { config: ["playwright.config.ts", "playwright-merge.config.ts"], }, tags: ["-knipignore"], + treatConfigHintsAsErrors: true, + treatTagHintsAsErrors: true, } satisfies KnipConfig; diff --git a/modules/widget-lifecycle/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts index eff66406c6..28ab258a2a 100644 --- a/modules/widget-lifecycle/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts @@ -11,7 +11,10 @@ import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; import { matchPattern } from "./utils/matchPattern"; import { name as ModuleName } from "../package.json"; -/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ +/** + * Subset of {@link WidgetLifecycleApi} used by the module for registration only. + * @internal + */ export type WidgetLifecycleApiAdapter = Pick< WidgetLifecycleApi, "registerPreloadApprover" | "registerIdentityApprover" | "registerCapabilitiesApprover" diff --git a/package.json b/package.json index 6fc8c93ac5..c0b748b5ae 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint:fmt": "oxfmt --check", "lint:fmt-fix": "oxfmt", "lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) -print -exec action-validator {} ';'", - "lint:knip": "knip", + "lint:knip": "knip --no-tag-hints && knip --strict --exclude unlisted,dependencies,binaries", "install:git-hooks": "husky", "postinstall": "node scripts/pnpm-link.ts && pnpm run -r sane-postinstall", "docs:dev": "vitepress dev docs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38550fe993..ba0ecd37aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -615,9 +615,6 @@ importers: is-ip: specifier: ^5.0.0 version: 5.0.1 - js-xxhash: - specifier: ^5.0.0 - version: 5.0.1 jsrsasign: specifier: ^11.0.0 version: 11.1.3 @@ -10157,10 +10154,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-xxhash@5.0.1: - resolution: {integrity: sha512-OGuTCR9wqYqF2FrhjtGb6fx8Rm1jzhdpUEsY2sLkXbNYLzOS+XWcYT4KTUL7vnaRTmBuGuVpW1AK2zzxjgZXGg==} - engines: {node: '>=20.0.0'} - js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -23889,8 +23882,6 @@ snapshots: js-tokens@4.0.0: {} - js-xxhash@5.0.1: {} - js-yaml@3.14.2: dependencies: argparse: 1.0.10