Merge remote-tracking branch 'origin/t3chguy/pnpm' into t3chguy/pnpm
2
.github/ISSUE_TEMPLATE/bug-desktop.yml
vendored
@ -1,6 +1,6 @@
|
||||
name: Bug report for the Element desktop app (not in a browser)
|
||||
description: File a bug report if you are using the desktop Element application.
|
||||
labels: [T-Defect]
|
||||
labels: [T-Defect, A-Electron]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
2
packages/shared-components/.gitignore
vendored
@ -10,3 +10,5 @@
|
||||
/coverage/
|
||||
# Ignore generated docs
|
||||
typedoc
|
||||
# Build storybook
|
||||
storybook-static
|
||||
|
||||
@ -21,8 +21,7 @@ export default create({
|
||||
// Toolbar
|
||||
barBg: "#ffffff",
|
||||
|
||||
brandTitle: "Element Web",
|
||||
brandUrl: "https://github.com/element-hq/element-web",
|
||||
brandImage: "https://element.io/images/logo-ele-secondary.svg",
|
||||
brandTitle: "Web Shared Components",
|
||||
brandUrl: "https://github.com/element-hq/element-web/tree/develop/packages/shared-components",
|
||||
brandTarget: "_self",
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# @element-hq/web-shared-components
|
||||
|
||||
[Online storybook](https://shared-components-storybook.element.dev)
|
||||
|
||||
Shared React components library for Element Web, Aurora, Element
|
||||
modules... This package provides opinionated UI components built on top of the
|
||||
[Compound Design System](https://compound.element.io) and [Compound
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@ -55,12 +55,12 @@ function prepareLangFiles(): LangFileMap {
|
||||
function createHashFromFile(lang: string): [filename: string, json: string] {
|
||||
const translationsPath = `${I18N_BASE_PATH}${lang}.json`;
|
||||
|
||||
const json = JSON.stringify(fs.readFileSync(translationsPath).toString(), null, 4);
|
||||
const jsonBuffer = Buffer.from(json);
|
||||
const jsonStr = fs.readFileSync(translationsPath).toString();
|
||||
const jsonBuffer = Buffer.from(jsonStr);
|
||||
const digest = createHash("sha256").update(jsonBuffer).digest("hex").slice(0, 7);
|
||||
const filename = `${lang}.${digest}.json`;
|
||||
|
||||
return [filename, json];
|
||||
return [filename, jsonStr];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -28,7 +28,7 @@ export * from "./rich-list/RichList";
|
||||
export * from "./room-list/RoomListHeaderView";
|
||||
export * from "./room-list/RoomListSearchView";
|
||||
export * from "./room-list/RoomListView";
|
||||
export * from "./room-list/RoomListItem";
|
||||
export * from "./room-list/RoomListItemView";
|
||||
export * from "./room-list/RoomListPrimaryFilters";
|
||||
export * from "./room-list/VirtualizedRoomListView";
|
||||
export * from "./utils/Box";
|
||||
|
||||
@ -10,7 +10,7 @@ import React, { type JSX } from "react";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { RoomListItemMoreOptionsMenu, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu";
|
||||
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
import styles from "./RoomListItem.module.css";
|
||||
import styles from "./RoomListItemView.module.css";
|
||||
|
||||
/**
|
||||
* Props for RoomListItemHoverMenu component
|
||||
@ -12,7 +12,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItem";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItemView";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
|
||||
describe("<RoomListItemMoreOptionsMenu />", () => {
|
||||
@ -20,7 +20,7 @@ import {
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItemView";
|
||||
|
||||
/**
|
||||
* View model type for room list item
|
||||
@ -13,7 +13,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItem";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItemView";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
|
||||
describe("<RoomListItemNotificationMenu />", () => {
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItemView";
|
||||
|
||||
/**
|
||||
* View model type for room list item
|
||||
@ -9,8 +9,8 @@ import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Room } from "./RoomListItem";
|
||||
import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItem";
|
||||
import type { Room } from "./RoomListItemView";
|
||||
import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItemView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
import { renderAvatar } from "../story-mocks";
|
||||
@ -69,7 +69,7 @@ const RoomListItemWrapper = ({
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListItem",
|
||||
title: "Room List/RoomListItemView",
|
||||
component: RoomListItemWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
@ -11,7 +11,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListItem.stories";
|
||||
import * as stories from "./RoomListItemView.stories";
|
||||
|
||||
const {
|
||||
Default,
|
||||
@ -13,7 +13,7 @@ import { NotificationDecoration, type NotificationDecorationData } from "./Notif
|
||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||
import { type RoomNotifState } from "./RoomNotifs";
|
||||
import styles from "./RoomListItem.module.css";
|
||||
import styles from "./RoomListItemView.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RoomListItemSnapshot } from "./RoomListItem";
|
||||
import { type RoomListItemSnapshot } from "./RoomListItemView";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
|
||||
export const mockRoom = { name: "General" };
|
||||
@ -5,14 +5,14 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListItemView } from "./RoomListItem";
|
||||
export { RoomListItemView } from "./RoomListItemView";
|
||||
export type {
|
||||
Room,
|
||||
RoomListItemSnapshot,
|
||||
RoomItemViewModel,
|
||||
RoomListItemActions,
|
||||
RoomListItemViewProps,
|
||||
} from "./RoomListItem";
|
||||
} from "./RoomListItemView";
|
||||
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
||||
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
||||
@ -9,7 +9,7 @@ import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Room } from "../RoomListItem/RoomListItem";
|
||||
import type { Room } from "../RoomListItemView";
|
||||
import type { FilterId } from "../RoomListPrimaryFilters";
|
||||
import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
|
||||
@ -12,7 +12,7 @@ import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters
|
||||
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
||||
import { RoomListEmptyStateView } from "./RoomListEmptyStateView";
|
||||
import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView";
|
||||
import { type Room } from "../RoomListItem";
|
||||
import { type Room } from "../RoomListItemView";
|
||||
|
||||
/**
|
||||
* Snapshot for the room list view
|
||||
|
||||
@ -9,7 +9,7 @@ import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Room } from "../RoomListItem/RoomListItem";
|
||||
import type { Room } from "../RoomListItemView";
|
||||
import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView";
|
||||
import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
|
||||
@ -9,11 +9,10 @@ import React, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "r
|
||||
import { type ScrollIntoViewLocation } from "react-virtuoso";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import type { Room } from "../RoomListItem/RoomListItem";
|
||||
import { RoomListItemView, type Room } from "../RoomListItemView";
|
||||
import { useViewModel } from "../../viewmodel";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
|
||||
import { RoomListItemView } from "../RoomListItem";
|
||||
import type { RoomListViewModel } from "../RoomListView";
|
||||
|
||||
/**
|
||||
|
||||
@ -8,9 +8,7 @@
|
||||
import React from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Room } from "./RoomListItem/RoomListItem";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItem";
|
||||
import { RoomNotifState } from "./RoomListItem/RoomNotifs";
|
||||
import { type Room, type RoomListItemSnapshot, RoomNotifState } from "./RoomListItemView";
|
||||
|
||||
/**
|
||||
* Mock avatar component for stories
|
||||
|
||||
@ -24,18 +24,10 @@ import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import {
|
||||
hideToast as hideBulkUnverifiedSessionsToast,
|
||||
showToast as showBulkUnverifiedSessionsToast,
|
||||
} from "./toasts/BulkUnverifiedSessionsToast";
|
||||
import {
|
||||
hideToast as hideSetupEncryptionToast,
|
||||
showToast as showSetupEncryptionToast,
|
||||
} from "./toasts/SetupEncryptionToast";
|
||||
import {
|
||||
hideToast as hideUnverifiedSessionsToast,
|
||||
showToast as showUnverifiedSessionsToast,
|
||||
} from "./toasts/UnverifiedSessionToast";
|
||||
import { isSecretStorageBeingAccessed } from "./SecurityManager";
|
||||
import { type ActionPayload } from "./dispatcher/payloads";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
@ -43,9 +35,8 @@ import SdkConfig from "./SdkConfig";
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
|
||||
import SettingsStore, { type CallbackFn } from "./settings/SettingsStore";
|
||||
import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder";
|
||||
import { getUserDeviceIds } from "./utils/crypto/deviceInfo";
|
||||
import { asyncSomeParallel } from "./utils/arrays.ts";
|
||||
import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
@ -106,20 +97,16 @@ type EventHandlerMap = {
|
||||
|
||||
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
|
||||
private dispatcherRef?: string;
|
||||
// device IDs for which the user has dismissed the verify toast ('Later')
|
||||
private dismissed = new Set<string>();
|
||||
|
||||
/** All the information about whether other devices are verified. */
|
||||
public otherDevices?: DeviceListenerOtherDevices;
|
||||
|
||||
// has the user dismissed any of the various nag toasts to setup encryption on this device?
|
||||
private dismissedThisDeviceToast = false;
|
||||
/** Cache of the info about the current key backup on the server. */
|
||||
private keyBackupInfo: KeyBackupInfo | null = null;
|
||||
/** When `keyBackupInfo` was last updated */
|
||||
private keyBackupFetchedAt: number | null = null;
|
||||
// We keep a list of our own device IDs so we can batch ones that were already
|
||||
// there the last time the app launched into a single toast, but display new
|
||||
// ones in their own toasts.
|
||||
private ourDeviceIdsAtStart: Set<string> | null = null;
|
||||
// The set of device IDs we're currently displaying toasts for
|
||||
private displayingToastsForDeviceIds = new Set<string>();
|
||||
private running = false;
|
||||
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
|
||||
private client?: MatrixClient;
|
||||
@ -138,8 +125,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
|
||||
public start(matrixClient: MatrixClient): void {
|
||||
this.running = true;
|
||||
|
||||
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
|
||||
|
||||
this.client = matrixClient;
|
||||
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
@ -147,6 +136,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
|
||||
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
|
||||
// only configurable in config, so we don't need to watch the value
|
||||
this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting(
|
||||
@ -162,7 +152,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
public stop(): void {
|
||||
this.running = false;
|
||||
if (this.client) {
|
||||
this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
@ -173,13 +162,11 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.dispatcherRef = undefined;
|
||||
this.dismissed.clear();
|
||||
this.otherDevices?.stop();
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.ourDeviceIdsAtStart = null;
|
||||
this.displayingToastsForDeviceIds = new Set();
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
@ -207,13 +194,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
*
|
||||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
public async dismissUnverifiedSessions(deviceIds: Iterable<string>): Promise<void> {
|
||||
public dismissUnverifiedSessions(deviceIds: Iterable<string>): void {
|
||||
logger.debug("Dismissing unverified sessions: " + Array.from(deviceIds).join(","));
|
||||
for (const d of deviceIds) {
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
|
||||
this.recheck();
|
||||
this.otherDevices?.dismissUnverifiedSessions(deviceIds);
|
||||
}
|
||||
|
||||
public dismissEncryptionSetup(): void {
|
||||
@ -295,35 +279,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the device list for the current user
|
||||
*
|
||||
* @returns the set of device IDs
|
||||
*/
|
||||
private async getDeviceIds(): Promise<Set<string>> {
|
||||
const cli = this.client;
|
||||
if (!cli) return new Set();
|
||||
return await getUserDeviceIds(cli, cli.getSafeUserId());
|
||||
}
|
||||
|
||||
private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise<void> => {
|
||||
if (!this.client) return;
|
||||
// If we didn't know about *any* devices before (ie. it's fresh login),
|
||||
// then they are all pre-existing devices, so ignore this and set the
|
||||
// devicesAtStart list to the devices that we see after the fetch.
|
||||
if (initialFetch) return;
|
||||
|
||||
const myUserId = this.client.getSafeUserId();
|
||||
if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onUserTrustStatusChanged = (userId: string): void => {
|
||||
if (!this.client) return;
|
||||
if (userId !== this.client.getUserId()) return;
|
||||
@ -419,7 +374,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId));
|
||||
}
|
||||
|
||||
private recheck(): void {
|
||||
public recheck(): void {
|
||||
this.doRecheck().catch((e) => {
|
||||
if (e instanceof ClientStoppedError) {
|
||||
// the client was stopped while recheck() was running. Nothing left to do.
|
||||
@ -546,64 +501,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
}
|
||||
}
|
||||
|
||||
// This needs to be done after awaiting on getUserDeviceInfo() above, so
|
||||
// we make sure we get the devices after the fetch is done.
|
||||
await this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
// Unverified devices that were there last time the app ran
|
||||
// (technically could just be a boolean: we don't actually
|
||||
// need to remember the device IDs, but for the sake of
|
||||
// symmetry...).
|
||||
const oldUnverifiedDeviceIds = new Set<string>();
|
||||
// Unverified devices that have appeared since then
|
||||
const newUnverifiedDeviceIds = new Set<string>();
|
||||
|
||||
// as long as cross-signing isn't ready,
|
||||
// you can't see or dismiss any device toasts
|
||||
if (crossSigningReady) {
|
||||
const devices = await this.getDeviceIds();
|
||||
for (const deviceId of devices) {
|
||||
if (deviceId === cli.deviceId) continue;
|
||||
|
||||
const deviceTrust = await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), deviceId);
|
||||
if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) {
|
||||
if (this.ourDeviceIdsAtStart?.has(deviceId)) {
|
||||
oldUnverifiedDeviceIds.add(deviceId);
|
||||
} else {
|
||||
newUnverifiedDeviceIds.add(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
|
||||
logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
|
||||
logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
|
||||
|
||||
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
|
||||
|
||||
// Display or hide the batch toast for old unverified sessions
|
||||
// don't show the toast if the current device is unverified
|
||||
if (oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && !isBulkUnverifiedSessionsReminderSnoozed) {
|
||||
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
|
||||
} else {
|
||||
hideBulkUnverifiedSessionsToast();
|
||||
}
|
||||
|
||||
// Show toasts for new unverified devices if they aren't already there
|
||||
for (const deviceId of newUnverifiedDeviceIds) {
|
||||
showUnverifiedSessionsToast(deviceId);
|
||||
}
|
||||
|
||||
// ...and hide any we don't need any more
|
||||
for (const deviceId of this.displayingToastsForDeviceIds) {
|
||||
if (!newUnverifiedDeviceIds.has(deviceId)) {
|
||||
logSpan.debug("Hiding unverified session toast for " + deviceId);
|
||||
hideUnverifiedSessionsToast(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
await this.otherDevices?.recheck(logSpan);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -17,7 +17,7 @@ import AutocompleteProvider from "./AutocompleteProvider";
|
||||
import QueryMatcher from "./QueryMatcher";
|
||||
import { TextualCompletion } from "./Components";
|
||||
import { type ICompletion, type ISelectionRange } from "./Autocompleter";
|
||||
import { type Command, Commands, CommandMap } from "../SlashCommands";
|
||||
import { type Command, Commands, CommandMap } from "../slash-commands/SlashCommands";
|
||||
import { type TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type Command, CommandCategories, Commands } from "../../../SlashCommands";
|
||||
import { type Command, CommandCategories, Commands } from "../../../slash-commands/SlashCommands";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ import { parseEvent, parsePlainTextMessage } from "../../../editor/deserialize";
|
||||
import { renderModel } from "../../../editor/render";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { IS_MAC, Key } from "../../../Keyboard";
|
||||
import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands";
|
||||
import { CommandCategories, CommandMap, parseCommandString } from "../../../slash-commands/SlashCommands";
|
||||
import Range from "../../../editor/range";
|
||||
import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar";
|
||||
import type DocumentOffset from "../../../editor/offset";
|
||||
|
||||
@ -27,7 +27,7 @@ import { parseEvent } from "../../../editor/deserialize";
|
||||
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
|
||||
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
||||
import { CommandCategories } from "../../../SlashCommands";
|
||||
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import SendHistoryManager from "../../../SendHistoryManager";
|
||||
|
||||
@ -36,7 +36,7 @@ import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
|
||||
import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
|
||||
import { findEditableEvent } from "../../../utils/EventUtils";
|
||||
import SendHistoryManager from "../../../SendHistoryManager";
|
||||
import { CommandCategories } from "../../../SlashCommands";
|
||||
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
|
||||
@ -29,7 +29,7 @@ import { endEditing, cancelPreviousPendingEdit } from "./editing";
|
||||
import type EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
|
||||
import { createMessageContent, EMOTE_PREFIX } from "./createMessageContent";
|
||||
import { isContentModified } from "./isContentModified";
|
||||
import { CommandCategories, getCommand } from "../../../../../SlashCommands";
|
||||
import { CommandCategories, getCommand } from "../../../../../slash-commands/SlashCommands";
|
||||
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
|
||||
import { Action } from "../../../../../dispatcher/actions";
|
||||
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||
|
||||
203
src/device-listener/DeviceListenerOtherDevices.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Creations Ltd.
|
||||
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 { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { type LogSpan } from "matrix-js-sdk/src/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type DeviceListener from "../DeviceListener";
|
||||
import { getUserDeviceIds } from "../utils/crypto/deviceInfo";
|
||||
import { isBulkUnverifiedDeviceReminderSnoozed } from "../utils/device/snoozeBulkUnverifiedDeviceReminder";
|
||||
import {
|
||||
hideToast as hideBulkUnverifiedSessionsToast,
|
||||
showToast as showBulkUnverifiedSessionsToast,
|
||||
} from "../toasts/BulkUnverifiedSessionsToast";
|
||||
import {
|
||||
hideToast as hideUnverifiedSessionToast,
|
||||
showToast as showUnverifiedSessionToast,
|
||||
} from "../toasts/UnverifiedSessionToast";
|
||||
|
||||
export default class DeviceListenerOtherDevices {
|
||||
/**
|
||||
* The DeviceListener launching this instance.
|
||||
*/
|
||||
private deviceListener: DeviceListener;
|
||||
|
||||
/**
|
||||
* The Matrix client in use by the current user.
|
||||
*/
|
||||
private client: MatrixClient;
|
||||
|
||||
/**
|
||||
* Device IDs for which the user has dismissed the verify toast ('Later').
|
||||
*/
|
||||
private dismissed = new Set<string>();
|
||||
|
||||
/**
|
||||
* A list of our own device IDs so we can batch ones that were already
|
||||
* there the last time the app launched into a single toast, but display new
|
||||
* ones in their own toasts.
|
||||
*/
|
||||
private ourDeviceIdsAtStart: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* The set of device IDs we're currently displaying toasts for.
|
||||
*/
|
||||
private displayingToastsForDeviceIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Start tracking other devices and call `recheck()` on the supplied
|
||||
* DeviceListener when something changes.
|
||||
*/
|
||||
public constructor(deviceListener: DeviceListener, client: MatrixClient) {
|
||||
this.deviceListener = deviceListener;
|
||||
this.client = client;
|
||||
|
||||
this.client.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking other devices and clear our stored information.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.dismissed.clear();
|
||||
this.ourDeviceIdsAtStart = null;
|
||||
this.displayingToastsForDeviceIds = new Set();
|
||||
|
||||
this.client.removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss notifications about our own unverified devices.
|
||||
*
|
||||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
public dismissUnverifiedSessions(deviceIds: Iterable<string>): void {
|
||||
for (const d of deviceIds) {
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
|
||||
// TODO: maybe we don't need a full DeviceListener check? (Maybe we only
|
||||
// need to call this.recheck().)
|
||||
this.deviceListener.recheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device list for the current user.
|
||||
*
|
||||
* @returns the set of device IDs
|
||||
*/
|
||||
private async getDeviceIds(): Promise<Set<string>> {
|
||||
return await getUserDeviceIds(this.client, this.client.getSafeUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private async ensureDeviceIdsAtStartPopulated(): Promise<void> {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
this.ourDeviceIdsAtStart = await this.getDeviceIds();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user's devices are updated. Refreshes the device
|
||||
* information and then rechecks whether we need to display any toasts.
|
||||
*/
|
||||
private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise<void> => {
|
||||
// If we didn't know about *any* devices before (ie. it's fresh login),
|
||||
// then they are all pre-existing devices, so ignore this and set the
|
||||
// devicesAtStart list to the devices that we see after the fetch.
|
||||
if (initialFetch) return;
|
||||
|
||||
const myUserId = this.client.getSafeUserId();
|
||||
if (users.includes(myUserId)) await this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
// TODO: maybe we don't need a full DeviceListener check? (Maybe we only
|
||||
// need to call this.recheck().)
|
||||
this.deviceListener.recheck();
|
||||
};
|
||||
|
||||
/**
|
||||
* Display a toast if some new other device is unverified, or if we started
|
||||
* up and some unverified devices have appeared.
|
||||
*/
|
||||
public async recheck(logSpan: LogSpan): Promise<void> {
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = this.client.getSafeUserId();
|
||||
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
|
||||
const isCurrentDeviceTrusted = Boolean(
|
||||
(await crypto.getDeviceVerificationStatus(userId, this.client.deviceId!))?.crossSigningVerified,
|
||||
);
|
||||
|
||||
// This needs to be done after awaiting on getUserDeviceInfo() above, so
|
||||
// we make sure we get the devices after the fetch is done.
|
||||
await this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
// Unverified devices that were there last time the app ran
|
||||
// (technically could just be a boolean: we don't actually
|
||||
// need to remember the device IDs, but for the sake of
|
||||
// symmetry...).
|
||||
const oldUnverifiedDeviceIds = new Set<string>();
|
||||
// Unverified devices that have appeared since then
|
||||
const newUnverifiedDeviceIds = new Set<string>();
|
||||
|
||||
// as long as cross-signing isn't ready,
|
||||
// you can't see or dismiss any device toasts
|
||||
if (crossSigningReady) {
|
||||
const devices = await this.getDeviceIds();
|
||||
for (const deviceId of devices) {
|
||||
if (deviceId === this.client.deviceId) continue;
|
||||
|
||||
const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId);
|
||||
if (!deviceTrust?.crossSigningVerified && !this.dismissed.has(deviceId)) {
|
||||
if (this.ourDeviceIdsAtStart?.has(deviceId)) {
|
||||
oldUnverifiedDeviceIds.add(deviceId);
|
||||
} else {
|
||||
newUnverifiedDeviceIds.add(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logSpan.debug("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(","));
|
||||
logSpan.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(","));
|
||||
logSpan.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(","));
|
||||
|
||||
const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed();
|
||||
|
||||
// Display or hide the batch toast for old unverified sessions
|
||||
// don't show the toast if the current device is unverified
|
||||
if (oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && !isBulkUnverifiedSessionsReminderSnoozed) {
|
||||
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
|
||||
} else {
|
||||
hideBulkUnverifiedSessionsToast();
|
||||
}
|
||||
|
||||
// Show toasts for new unverified devices if they aren't already there
|
||||
for (const deviceId of newUnverifiedDeviceIds) {
|
||||
showUnverifiedSessionToast(deviceId);
|
||||
}
|
||||
|
||||
// ...and hide any we don't need any more
|
||||
for (const deviceId of this.displayingToastsForDeviceIds) {
|
||||
if (!newUnverifiedDeviceIds.has(deviceId)) {
|
||||
logSpan.debug("Hiding unverified session toast for " + deviceId);
|
||||
hideUnverifiedSessionToast(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
this.displayingToastsForDeviceIds = newUnverifiedDeviceIds;
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,7 @@ import { type RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import type EditorModel from "./model";
|
||||
import { Type } from "./parts";
|
||||
import { type Command, CommandCategories, getCommand } from "../SlashCommands";
|
||||
import { type Command, CommandCategories, getCommand } from "../slash-commands/SlashCommands";
|
||||
import { UserFriendlyError, _t, _td } from "../languageHandler";
|
||||
import Modal from "../Modal";
|
||||
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
||||
|
||||
@ -21,45 +21,46 @@ import {
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { KnownMembership, type RoomMemberEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import { _t, _td, UserFriendlyError } from "./languageHandler";
|
||||
import Modal from "./Modal";
|
||||
import MultiInviter from "./utils/MultiInviter";
|
||||
import { Linkify, topicToHtml } from "./HtmlUtils";
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import { textToHtmlRainbow } from "./utils/colour";
|
||||
import { AddressType, getAddressType } from "./UserAddress";
|
||||
import { abbreviateUrl } from "./utils/UrlUtils";
|
||||
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "./utils/IdentityServerUtils";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import BugReportDialog from "./components/views/dialogs/BugReportDialog";
|
||||
import { ensureDMExists } from "./createRoom";
|
||||
import { type ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { UIComponent, UIFeature } from "./settings/UIFeature";
|
||||
import { CHAT_EFFECTS } from "./effects";
|
||||
import LegacyCallHandler from "./LegacyCallHandler";
|
||||
import { guessAndSetDMRoom } from "./Rooms";
|
||||
import DevtoolsDialog from "./components/views/dialogs/DevtoolsDialog";
|
||||
import InfoDialog from "./components/views/dialogs/InfoDialog";
|
||||
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
|
||||
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
|
||||
import { TimelineRenderingType } from "./contexts/RoomContext";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
|
||||
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./slash-commands/utils";
|
||||
import { deop, op } from "./slash-commands/op";
|
||||
import { CommandCategories } from "./slash-commands/interface";
|
||||
import { Command } from "./slash-commands/command";
|
||||
import { goto, join } from "./slash-commands/join";
|
||||
import { manuallyVerifyDevice } from "./components/views/dialogs/ManualDeviceKeyVerificationDialog";
|
||||
import upgraderoom from "./slash-commands/upgraderoom/upgraderoom";
|
||||
import dis from "../dispatcher/dispatcher";
|
||||
import { _t, _td, UserFriendlyError } from "../languageHandler";
|
||||
import Modal from "../Modal";
|
||||
import MultiInviter from "../utils/MultiInviter";
|
||||
import { Linkify, topicToHtml } from "../HtmlUtils";
|
||||
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
|
||||
import WidgetUtils from "../utils/WidgetUtils";
|
||||
import { textToHtmlRainbow } from "../utils/colour";
|
||||
import { AddressType, getAddressType } from "../UserAddress";
|
||||
import { abbreviateUrl } from "../utils/UrlUtils";
|
||||
import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../utils/IdentityServerUtils";
|
||||
import { WidgetType } from "../widgets/WidgetType";
|
||||
import { Jitsi } from "../widgets/Jitsi";
|
||||
import BugReportDialog from "../components/views/dialogs/BugReportDialog";
|
||||
import { ensureDMExists } from "../createRoom";
|
||||
import { type ViewUserPayload } from "../dispatcher/payloads/ViewUserPayload";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { UIComponent, UIFeature } from "../settings/UIFeature";
|
||||
import { CHAT_EFFECTS } from "../effects";
|
||||
import LegacyCallHandler from "../LegacyCallHandler";
|
||||
import { guessAndSetDMRoom } from "../Rooms";
|
||||
import DevtoolsDialog from "../components/views/dialogs/DevtoolsDialog";
|
||||
import InfoDialog from "../components/views/dialogs/InfoDialog";
|
||||
import SlashCommandHelpDialog from "../components/views/dialogs/SlashCommandHelpDialog";
|
||||
import { shouldShowComponent } from "../customisations/helpers/UIComponents";
|
||||
import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
||||
import { htmlSerializeFromMdIfNeeded } from "../editor/serialize";
|
||||
import { leaveRoomBehaviour } from "../utils/leave-behaviour";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { isCurrentLocalRoom, reject, singleMxcUpload, success, successSync } from "./utils";
|
||||
import { deop, op } from "./op";
|
||||
import { CommandCategories } from "./interface";
|
||||
import { Command } from "./command";
|
||||
import { goto, join } from "./join";
|
||||
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
|
||||
import upgraderoom from "./upgraderoom/upgraderoom";
|
||||
import { emoticon } from "./emoticon";
|
||||
|
||||
export { CommandCategories, Command };
|
||||
|
||||
@ -73,58 +74,10 @@ export const Commands = [
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: "shrug",
|
||||
args: "<message>",
|
||||
description: _td("slash_command|shrug"),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
let message = "¯\\_(ツ)_/¯";
|
||||
if (args) {
|
||||
message = message + " " + args;
|
||||
}
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: "tableflip",
|
||||
args: "<message>",
|
||||
description: _td("slash_command|tableflip"),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
let message = "(╯°□°)╯︵ ┻━┻";
|
||||
if (args) {
|
||||
message = message + " " + args;
|
||||
}
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: "unflip",
|
||||
args: "<message>",
|
||||
description: _td("slash_command|unflip"),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
let message = "┬──┬ ノ( ゜-゜ノ)";
|
||||
if (args) {
|
||||
message = message + " " + args;
|
||||
}
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: "lenny",
|
||||
args: "<message>",
|
||||
description: _td("slash_command|lenny"),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
let message = "( ͡° ͜ʖ ͡°)";
|
||||
if (args) {
|
||||
message = message + " " + args;
|
||||
}
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
emoticon("shrug", _td("slash_command|shrug"), "¯\\_(ツ)_/¯"),
|
||||
emoticon("tableflip", _td("slash_command|tableflip"), "(╯°□°)╯︵ ┻━┻"),
|
||||
emoticon("unflip", _td("slash_command|unflip"), "┬──┬ ノ( ゜-゜ノ)"),
|
||||
emoticon("lenny", _td("slash_command|lenny"), "( ͡° ͜ʖ ͡°)"),
|
||||
new Command({
|
||||
command: "plain",
|
||||
args: "<message>",
|
||||
@ -348,7 +301,7 @@ export const Commands = [
|
||||
isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
if (args) {
|
||||
const [address, reason] = args.split(/\s+(.+)/);
|
||||
const [address, reason] = splitAtFirstSpace(args);
|
||||
if (address) {
|
||||
// We use a MultiInviter to re-use the invite logic, even though
|
||||
// we're only inviting one user.
|
||||
@ -460,9 +413,9 @@ export const Commands = [
|
||||
isEnabled: (cli) => !isCurrentLocalRoom(cli),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
if (args) {
|
||||
const matches = args.match(/^(\S+?)( +(.*))?$/);
|
||||
if (matches) {
|
||||
return success(cli.kick(roomId, matches[1], matches[3]));
|
||||
const [userId, reason] = splitAtFirstSpace(args);
|
||||
if (userId) {
|
||||
return success(cli.kick(roomId, userId, reason));
|
||||
}
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
@ -477,9 +430,9 @@ export const Commands = [
|
||||
isEnabled: (cli) => !isCurrentLocalRoom(cli),
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
if (args) {
|
||||
const matches = args.match(/^(\S+?)( +(.*))?$/);
|
||||
if (matches) {
|
||||
return success(cli.ban(roomId, matches[1], matches[3]));
|
||||
const [userId, reason] = splitAtFirstSpace(args);
|
||||
if (userId) {
|
||||
return success(cli.ban(roomId, userId, reason));
|
||||
}
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
@ -784,9 +737,8 @@ export const Commands = [
|
||||
runFn: function (cli, roomId, threadId, args) {
|
||||
if (args) {
|
||||
// matches the first whitespace delimited group and then the rest of the string
|
||||
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
|
||||
if (matches) {
|
||||
const [userId, msg] = matches.slice(1);
|
||||
const [userId, msg] = splitAtFirstSpace(args);
|
||||
if (userId !== "") {
|
||||
if (userId && userId.startsWith("@") && userId.includes(":")) {
|
||||
return success(
|
||||
(async (): Promise<void> => {
|
||||
@ -910,21 +862,24 @@ Commands.forEach((cmd) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* If the supplied input starts with "/<command>", returns an object with these
|
||||
* properties:
|
||||
*
|
||||
* cmd - the string following the / up to some whitespace
|
||||
* args - the string (if any) after first whitespace
|
||||
*
|
||||
* If not, returns {}
|
||||
*/
|
||||
export function parseCommandString(input: string): { cmd?: string; args?: string } {
|
||||
// trim any trailing whitespace, as it can confuse the parser for IRC-style commands
|
||||
input = input.trimEnd();
|
||||
if (!input.startsWith("/")) return {}; // not a command
|
||||
|
||||
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
|
||||
let cmd: string;
|
||||
let args: string | undefined;
|
||||
if (bits) {
|
||||
cmd = bits[1].substring(1).toLowerCase();
|
||||
args = bits[2];
|
||||
} else {
|
||||
cmd = input;
|
||||
const trimmedInput = input.trimStart();
|
||||
if (trimmedInput.charAt(0) !== "/") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const withoutSlash = trimmedInput.slice(1);
|
||||
const [cmd, args] = splitAtFirstSpace(withoutSlash);
|
||||
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
@ -933,6 +888,26 @@ interface ICmd {
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the supplied string into one or two strings separated by the first
|
||||
* region of white space we can find.
|
||||
*/
|
||||
export function splitAtFirstSpace(args: string): [string, string?] {
|
||||
const trimmedArgs = args.trim();
|
||||
const i = trimmedArgs.search(/\s+/);
|
||||
if (i === -1) {
|
||||
return [trimmedArgs];
|
||||
} else {
|
||||
const first = trimmedArgs.slice(0, i);
|
||||
const second = trimmedArgs.slice(i + 1).trimStart();
|
||||
if (second === "") {
|
||||
return [first];
|
||||
} else {
|
||||
return [first, second];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the given text for /commands and returns a parsed command that can be used for running the operation.
|
||||
* @param {string} roomId The room ID where the command was issued.
|
||||
27
src/slash-commands/emoticon.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 { ContentHelpers } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { Command } from "./command";
|
||||
import { successSync } from "./utils";
|
||||
import { CommandCategories } from "./interface";
|
||||
|
||||
export function emoticon(command: string, description: TranslationKey, message: string): Command {
|
||||
return new Command({
|
||||
command,
|
||||
args: "<message>",
|
||||
description,
|
||||
runFn: function (_cli, _roomId, _threadId, args) {
|
||||
if (args) {
|
||||
message = message + " " + args;
|
||||
}
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
});
|
||||
}
|
||||
@ -82,12 +82,13 @@ function checkBrowserFeatures(): boolean {
|
||||
for (const feature of featureList) {
|
||||
if (window.Modernizr[feature] === undefined) {
|
||||
logger.error(
|
||||
`Looked for feature '${feature}' but Modernizr has no results for this. Has it been configured correctly?`,
|
||||
"Looked for feature '%s' but Modernizr has no results for this. " + "Has it been configured correctly?",
|
||||
feature,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (window.Modernizr[feature] === false) {
|
||||
logger.error(`Browser missing feature: '${feature}'`);
|
||||
logger.error("Browser missing feature: '%s'", feature);
|
||||
// toggle flag rather than return early so we log all missing features rather than just the first.
|
||||
featureComplete = false;
|
||||
}
|
||||
|
||||
@ -351,6 +351,10 @@ export function createTestClient(): MatrixClient {
|
||||
},
|
||||
search: jest.fn().mockResolvedValue({}),
|
||||
processRoomEventsSearch: jest.fn().mockResolvedValue({ highlights: [], results: [] }),
|
||||
invite: jest.fn(),
|
||||
kick: jest.fn(),
|
||||
ban: jest.fn(),
|
||||
sendTextMessage: jest.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
client.reEmitter = new ReEmitter(client);
|
||||
|
||||
@ -734,7 +734,7 @@ describe("DeviceListener", () => {
|
||||
new Set<string>([device3.deviceId]),
|
||||
);
|
||||
|
||||
await instance.dismissUnverifiedSessions([device3.deviceId]);
|
||||
await instance.otherDevices?.dismissUnverifiedSessions([device3.deviceId]);
|
||||
await flushPromises();
|
||||
|
||||
expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled();
|
||||
|
||||
@ -1,362 +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, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { mocked } from "jest-mock";
|
||||
import { act, waitFor } from "jest-matrix-react";
|
||||
|
||||
import { type Command, Commands, getCommand } from "../../src/SlashCommands";
|
||||
import { createTestClient } from "../test-utils";
|
||||
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom";
|
||||
import { SdkContextClass } from "../../src/contexts/SDKContext";
|
||||
import Modal, { type ComponentType, type IHandle } from "../../src/Modal";
|
||||
import WidgetUtils from "../../src/utils/WidgetUtils";
|
||||
import { WidgetType } from "../../src/widgets/WidgetType";
|
||||
import { warnSelfDemote } from "../../src/components/views/right_panel/UserInfo";
|
||||
import dispatcher from "../../src/dispatcher/dispatcher";
|
||||
import QuestionDialog from "../../src/components/views/dialogs/QuestionDialog";
|
||||
import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog";
|
||||
|
||||
jest.mock("../../src/components/views/right_panel/UserInfo");
|
||||
|
||||
describe("SlashCommands", () => {
|
||||
let client: MatrixClient;
|
||||
const roomId = "!room:example.com";
|
||||
let room: Room;
|
||||
const localRoomId = LOCAL_ROOM_ID_PREFIX + "test";
|
||||
let localRoom: LocalRoom;
|
||||
let command: Command;
|
||||
|
||||
const findCommand = (cmd: string): Command | undefined => {
|
||||
return Commands.find((command: Command) => command.command === cmd);
|
||||
};
|
||||
|
||||
const setCurrentRoom = (): void => {
|
||||
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
|
||||
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
|
||||
if (rId === roomId) return room;
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const setCurrentLocalRoom = (): void => {
|
||||
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId);
|
||||
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
|
||||
if (rId === localRoomId) return localRoom;
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
client = createTestClient();
|
||||
|
||||
room = new Room(roomId, client, client.getSafeUserId());
|
||||
localRoom = new LocalRoom(localRoomId, client, client.getSafeUserId());
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
|
||||
});
|
||||
|
||||
describe("/topic", () => {
|
||||
it("sets topic", async () => {
|
||||
const command = getCommand(roomId, "/topic pizza");
|
||||
expect(command.cmd).toBeDefined();
|
||||
expect(command.args).toBeDefined();
|
||||
await command.cmd!.run(client, "room-id", null, command.args);
|
||||
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
|
||||
});
|
||||
|
||||
it("should show topic modal if no args passed", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
const command = getCommand(roomId, "/topic")!;
|
||||
await command.cmd!.run(client, roomId, null);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["myroomnick"],
|
||||
["roomavatar"],
|
||||
["myroomavatar"],
|
||||
["topic"],
|
||||
["roomname"],
|
||||
["invite"],
|
||||
["part"],
|
||||
["remove"],
|
||||
["ban"],
|
||||
["unban"],
|
||||
["op"],
|
||||
["deop"],
|
||||
["addwidget"],
|
||||
["discardsession"],
|
||||
["whois"],
|
||||
["holdcall"],
|
||||
["unholdcall"],
|
||||
["converttodm"],
|
||||
["converttoroom"],
|
||||
])("/%s", (commandName: string) => {
|
||||
beforeEach(() => {
|
||||
command = findCommand(commandName)!;
|
||||
});
|
||||
|
||||
describe("isEnabled", () => {
|
||||
it("should return true for Room", () => {
|
||||
setCurrentRoom();
|
||||
expect(command.isEnabled(client, roomId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for LocalRoom", () => {
|
||||
setCurrentLocalRoom();
|
||||
expect(command.isEnabled(client, roomId)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/op", () => {
|
||||
beforeEach(() => {
|
||||
command = findCommand("op")!;
|
||||
});
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should reject with usage if given an invalid power level value", () => {
|
||||
expect(command.run(client, roomId, null, "@bob:server Admin").error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should reject with usage for invalid input", () => {
|
||||
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should warn about self demotion", async () => {
|
||||
setCurrentRoom();
|
||||
const member = new RoomMember(roomId, client.getSafeUserId());
|
||||
member.membership = KnownMembership.Join;
|
||||
member.powerLevel = 100;
|
||||
room.getMember = () => member;
|
||||
command.run(client, roomId, null, `${client.getUserId()} 0`);
|
||||
expect(warnSelfDemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to 50 if no powerlevel specified", async () => {
|
||||
setCurrentRoom();
|
||||
const member = new RoomMember(roomId, "@user:server");
|
||||
member.membership = KnownMembership.Join;
|
||||
room.getMember = () => member;
|
||||
command.run(client, roomId, null, member.userId);
|
||||
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/deop", () => {
|
||||
beforeEach(() => {
|
||||
command = findCommand("deop")!;
|
||||
});
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should warn about self demotion", async () => {
|
||||
setCurrentRoom();
|
||||
const member = new RoomMember(roomId, client.getSafeUserId());
|
||||
member.membership = KnownMembership.Join;
|
||||
member.powerLevel = 100;
|
||||
room.getMember = () => member;
|
||||
command.run(client, roomId, null, client.getSafeUserId());
|
||||
expect(warnSelfDemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject with usage for invalid input", () => {
|
||||
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
|
||||
});
|
||||
});
|
||||
|
||||
describe("/part", () => {
|
||||
it("should part room matching alias if found", async () => {
|
||||
const room1 = new Room("room-id", client, client.getSafeUserId());
|
||||
room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar");
|
||||
const room2 = new Room("other-room", client, client.getSafeUserId());
|
||||
room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar");
|
||||
mocked(client.getRooms).mockReturnValue([room1, room2]);
|
||||
|
||||
const command = getCommand(room1.roomId, "/part #foo:bar");
|
||||
expect(command.cmd).toBeDefined();
|
||||
expect(command.args).toBeDefined();
|
||||
await command.cmd!.run(client, room1.roomId, null, command.args);
|
||||
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
|
||||
});
|
||||
|
||||
it("should part room matching alt alias if found", async () => {
|
||||
const room1 = new Room("room-id", client, client.getSafeUserId());
|
||||
room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]);
|
||||
const room2 = new Room("other-room", client, client.getSafeUserId());
|
||||
room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]);
|
||||
mocked(client.getRooms).mockReturnValue([room1, room2]);
|
||||
|
||||
const command = getCommand(room1.roomId, "/part #foo:bar");
|
||||
expect(command.cmd).toBeDefined();
|
||||
expect(command.args).toBeDefined();
|
||||
await command.cmd!.run(client, room1.roomId, null, command.args!);
|
||||
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["rainbow", "rainbowme"])("/%s", (commandName: string) => {
|
||||
const command = findCommand(commandName)!;
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should make things rainbowy", () => {
|
||||
return expect(
|
||||
command.run(client, roomId, null, "this is a test message").promise,
|
||||
).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => {
|
||||
const command = findCommand(commandName)!;
|
||||
|
||||
it("should match snapshot with no args", () => {
|
||||
return expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match snapshot with args", () => {
|
||||
return expect(
|
||||
command.run(client, roomId, null, "this is a test message").promise,
|
||||
).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/verify", () => {
|
||||
it("should return usage if no args", () => {
|
||||
const command = findCommand("verify")!;
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should attempt manual verification after confirmation", async () => {
|
||||
// Given we say yes to prompt
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
spy.mockReturnValue({ finished: Promise.resolve([true]) } as unknown as IHandle<ComponentType>);
|
||||
|
||||
// When we run the command
|
||||
const command = findCommand("verify")!;
|
||||
await act(() => command.run(client, roomId, null, "mydeviceid myfingerprint"));
|
||||
|
||||
// Then the prompt is displayed
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
QuestionDialog,
|
||||
expect.objectContaining({ title: "Caution: manual device verification" }),
|
||||
);
|
||||
|
||||
// And then we attempt the verification
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
ErrorDialog,
|
||||
expect.objectContaining({ title: "Verification failed" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not do manual verification if cancelled", async () => {
|
||||
// Given we say no to prompt
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
spy.mockReturnValue({ finished: Promise.resolve([false]) } as unknown as IHandle<ComponentType>);
|
||||
|
||||
// When we run the command
|
||||
const command = findCommand("verify")!;
|
||||
command.run(client, roomId, null, "mydeviceid myfingerprint");
|
||||
|
||||
// Then the prompt is displayed
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
QuestionDialog,
|
||||
expect.objectContaining({ title: "Caution: manual device verification" }),
|
||||
);
|
||||
|
||||
// But nothing else happens
|
||||
expect(spy).not.toHaveBeenCalledWith(ErrorDialog, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("/addwidget", () => {
|
||||
it("should parse html iframe snippets", async () => {
|
||||
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||
const spy = jest.spyOn(WidgetUtils, "setRoomWidget");
|
||||
const command = findCommand("addwidget")!;
|
||||
await command.run(client, roomId, null, '<iframe src="https://element.io"></iframe>');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
client,
|
||||
roomId,
|
||||
expect.any(String),
|
||||
WidgetType.CUSTOM,
|
||||
"https://element.io",
|
||||
"Custom",
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/join", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
command = findCommand(KnownMembership.Join)!;
|
||||
});
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should handle matrix.org permalinks", () => {
|
||||
command.run(client, roomId, null, "https://matrix.to/#/!roomId:server/$eventId");
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_id: "!roomId:server",
|
||||
event_id: "$eventId",
|
||||
highlighted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room aliases", () => {
|
||||
command.run(client, roomId, null, "#test:server");
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_alias: "#test:server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room aliases with no server component", () => {
|
||||
command.run(client, roomId, null, "#test");
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_alias: `#test:${client.getDomain()}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room IDs and via servers", () => {
|
||||
command.run(client, roomId, null, "!foo:bar serv1.com serv2.com");
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_id: "!foo:bar",
|
||||
via_servers: ["serv1.com", "serv2.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -12,7 +12,7 @@ import { stubClient } from "../../test-utils";
|
||||
import { Command } from "../../../src/slash-commands/command";
|
||||
import { CommandCategories } from "../../../src/slash-commands/interface";
|
||||
import { _td } from "../../../src/languageHandler";
|
||||
import * as SlashCommands from "../../../src/SlashCommands";
|
||||
import * as SlashCommands from "../../../src/slash-commands/SlashCommands";
|
||||
|
||||
describe("CommandProvider", () => {
|
||||
let room: Room;
|
||||
|
||||
@ -13,7 +13,7 @@ import { stubClient } from "../../../../test-utils";
|
||||
import { Command } from "../../../../../src/slash-commands/command";
|
||||
import { CommandCategories } from "../../../../../src/slash-commands/interface";
|
||||
import { _t, _td } from "../../../../../src/languageHandler";
|
||||
import * as SlashCommands from "../../../../../src/SlashCommands";
|
||||
import * as SlashCommands from "../../../../../src/slash-commands/SlashCommands";
|
||||
|
||||
describe("SlashCommandHelpDialog", () => {
|
||||
const roomId = "!room:server";
|
||||
|
||||
@ -19,7 +19,7 @@ import SettingsStore from "../../../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../../../src/settings/SettingLevel";
|
||||
import EditorStateTransfer from "../../../../../../../src/utils/EditorStateTransfer";
|
||||
import * as ConfirmRedactDialog from "../../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
||||
import * as SlashCommands from "../../../../../../../src/SlashCommands";
|
||||
import * as SlashCommands from "../../../../../../../src/slash-commands/SlashCommands";
|
||||
import * as Commands from "../../../../../../../src/editor/commands";
|
||||
import * as Reply from "../../../../../../../src/utils/Reply";
|
||||
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`/lenny should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "( ͡° ͜ʖ ͡°) this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/lenny should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "( ͡° ͜ʖ ͡°)",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/shrug should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "¯\\_(ツ)_/¯ this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/shrug should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "¯\\_(ツ)_/¯",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/tableflip should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "(╯°□°)╯︵ ┻━┻ this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/tableflip should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "(╯°□°)╯︵ ┻━┻",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/unflip should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "┬──┬ ノ( ゜-゜ノ) this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`/unflip should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "┬──┬ ノ( ゜-゜ノ)",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
@ -1,20 +1,6 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`SlashCommands /lenny should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "( ͡° ͜ʖ ͡°) this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /lenny should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "( ͡° ͜ʖ ͡°)",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /rainbow should make things rainbowy 1`] = `
|
||||
exports[`/rainbow should make things rainbowy 1`] = `
|
||||
{
|
||||
"body": "this is a test message",
|
||||
"format": "org.matrix.custom.html",
|
||||
@ -23,7 +9,7 @@ exports[`SlashCommands /rainbow should make things rainbowy 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /rainbowme should make things rainbowy 1`] = `
|
||||
exports[`/rainbowme should make things rainbowy 1`] = `
|
||||
{
|
||||
"body": "this is a test message",
|
||||
"format": "org.matrix.custom.html",
|
||||
@ -31,45 +17,3 @@ exports[`SlashCommands /rainbowme should make things rainbowy 1`] = `
|
||||
"msgtype": "m.emote",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /shrug should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "¯\\_(ツ)_/¯ this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /shrug should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "¯\\_(ツ)_/¯",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /tableflip should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "(╯°□°)╯︵ ┻━┻ this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /tableflip should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "(╯°□°)╯︵ ┻━┻",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /unflip should match snapshot with args 1`] = `
|
||||
{
|
||||
"body": "┬──┬ ノ( ゜-゜ノ) this is a test message",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`SlashCommands /unflip should match snapshot with no args 1`] = `
|
||||
{
|
||||
"body": "┬──┬ ノ( ゜-゜ノ)",
|
||||
"msgtype": "m.text",
|
||||
}
|
||||
`;
|
||||
39
test/unit-tests/slash-commands/addwidget-test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { waitFor } from "jest-matrix-react";
|
||||
|
||||
import WidgetUtils from "../../../src/utils/WidgetUtils";
|
||||
import { setUpCommandTest } from "./utils";
|
||||
import { WidgetType } from "../../../src/widgets/WidgetType";
|
||||
|
||||
describe("/addwidget", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should parse html iframe snippets", async () => {
|
||||
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||
const spy = jest.spyOn(WidgetUtils, "setRoomWidget");
|
||||
|
||||
const { client, command } = setUpCommandTest(roomId, `/addwidget`);
|
||||
|
||||
command.run(client, roomId, null, '<iframe src="https://element.io"></iframe>');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
client,
|
||||
roomId,
|
||||
expect.any(String),
|
||||
WidgetType.CUSTOM,
|
||||
"https://element.io",
|
||||
"Custom",
|
||||
{},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
33
test/unit-tests/slash-commands/ban-test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("/ban", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/ban`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should ban the user we specify from this room", async () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/ban @u:s.co`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.ban).toHaveBeenCalledWith(roomId, "@u:s.co", undefined);
|
||||
});
|
||||
|
||||
it("should provide the ban reason if we supply it", async () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/ban @u:s.co They were quite nasty`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.ban).toHaveBeenCalledWith(roomId, "@u:s.co", "They were quite nasty");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("SlashCommands", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
describe.each([
|
||||
["myroomnick"],
|
||||
["roomavatar"],
|
||||
["myroomavatar"],
|
||||
["topic"],
|
||||
["roomname"],
|
||||
["invite"],
|
||||
["part"],
|
||||
["remove"],
|
||||
["ban"],
|
||||
["unban"],
|
||||
["op"],
|
||||
["deop"],
|
||||
["addwidget"],
|
||||
["discardsession"],
|
||||
["whois"],
|
||||
["holdcall"],
|
||||
["unholdcall"],
|
||||
["converttodm"],
|
||||
["converttoroom"],
|
||||
])("/%s", (commandName: string) => {
|
||||
describe("isEnabled", () => {
|
||||
it("should return true for Room", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
|
||||
expect(command.isEnabled(client, roomId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for LocalRoom", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`, true);
|
||||
expect(command.isEnabled(client, roomId)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
25
test/unit-tests/slash-commands/emoticons-test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
|
||||
describe.each(["shrug", "tableflip", "unflip", "lenny"])("/%s", (commandName: string) => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should match snapshot with no args", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
|
||||
await expect(command.run(client, roomId, null).promise).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match snapshot with args", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
|
||||
|
||||
await expect(command.run(client, roomId, null, "this is a test message").promise).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
40
test/unit-tests/slash-commands/invite-test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 Modal, { type ComponentType, type IHandle } from "../../../src/Modal";
|
||||
import { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("/invite", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/invite`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should invite the user we specify to this room", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
|
||||
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/invite @u:s.co`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.invite).toHaveBeenCalledWith(roomId, "@u:s.co", {});
|
||||
});
|
||||
|
||||
it("should provide the invite reason if we supply it", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
|
||||
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/invite @u:s.co They are a very nice person`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.invite).toHaveBeenCalledWith(roomId, "@u:s.co", { reason: "They are a very nice person" });
|
||||
});
|
||||
});
|
||||
79
test/unit-tests/slash-commands/join-test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
|
||||
describe("/join", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/join`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should handle matrix.org permalinks", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/join`);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
await command.run(client, roomId, null, "https://matrix.to/#/!roomId:server/$eventId").promise;
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_id: "!roomId:server",
|
||||
event_id: "$eventId",
|
||||
highlighted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room aliases", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/join`);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
await command.run(client, roomId, null, "#test:server").promise;
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_alias: "#test:server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room aliases with no server component", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/join`);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
await command.run(client, roomId, null, "#test").promise;
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_alias: `#test:${client.getDomain()}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle room IDs and via servers", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/join`);
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
await command.run(client, roomId, null, "!foo:bar serv1.com serv2.com").promise;
|
||||
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
room_id: "!foo:bar",
|
||||
via_servers: ["serv1.com", "serv2.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
46
test/unit-tests/slash-commands/msg-test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
|
||||
describe("/msg", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/msg`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should message the user and switch to the relevant DM", async () => {
|
||||
// Given there is no DM room with the user
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
|
||||
getDMRoomsForUserId: jest.fn().mockReturnValue([]),
|
||||
getRoomIds: jest.fn().mockReturnValue([roomId]),
|
||||
} as unknown as DMRoomMap);
|
||||
|
||||
jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
// When we send a message to that user
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/msg @u:s.co Hello there`);
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
// Then we create a room and send the message in there
|
||||
expect(client.sendTextMessage).toHaveBeenCalledWith("!1:example.org", "Hello there");
|
||||
|
||||
// And tell the UI to switch to that room
|
||||
expect(dispatcher.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "view_room",
|
||||
metricsTrigger: "SlashCommand",
|
||||
metricsViaKeyboard: true,
|
||||
room_id: "!1:example.org",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
77
test/unit-tests/slash-commands/op-test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { KnownMembership, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { setUpCommandTest } from "./utils";
|
||||
import { warnSelfDemote } from "../../../src/components/views/right_panel/UserInfo";
|
||||
|
||||
jest.mock("../../../src/components/views/right_panel/UserInfo");
|
||||
|
||||
describe("/op", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, "/op");
|
||||
expect(command.run(client, roomId, null, args).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should reject with usage if given an invalid power level value", () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, "/op @bob:server Admin");
|
||||
expect(command.run(client, roomId, null, args).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should reject with usage for invalid input", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, "/op");
|
||||
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should warn about self demotion", async () => {
|
||||
const { client, command, room } = setUpCommandTest(roomId, "/op");
|
||||
const member = new RoomMember(roomId, client.getSafeUserId());
|
||||
member.membership = KnownMembership.Join;
|
||||
member.powerLevel = 100;
|
||||
room.getMember = () => member;
|
||||
command.run(client, roomId, null, `${client.getUserId()} 0`);
|
||||
expect(warnSelfDemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to 50 if no powerlevel specified", async () => {
|
||||
const { client, command, room } = setUpCommandTest(roomId, "/op");
|
||||
const member = new RoomMember(roomId, "@user:server");
|
||||
member.membership = KnownMembership.Join;
|
||||
room.getMember = () => member;
|
||||
command.run(client, roomId, null, member.userId);
|
||||
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, member.userId, 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/deop", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, "/deop");
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should warn about self demotion", async () => {
|
||||
const { client, command, room } = setUpCommandTest(roomId, "/deop");
|
||||
const member = new RoomMember(roomId, client.getSafeUserId());
|
||||
member.membership = KnownMembership.Join;
|
||||
member.powerLevel = 100;
|
||||
room.getMember = () => member;
|
||||
await command.run(client, roomId, null, client.getSafeUserId()).promise;
|
||||
expect(warnSelfDemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject with usage for invalid input", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, "/deop");
|
||||
expect(command.run(client, roomId, null, " ").error).toBe(command.getUsage());
|
||||
});
|
||||
});
|
||||
22
test/unit-tests/slash-commands/parse-command-string-test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { parseCommandString } from "../../../src/slash-commands/SlashCommands";
|
||||
|
||||
describe("parseCommandString", () => {
|
||||
it("should be able to split arguments at the first whitespace", () => {
|
||||
expect(parseCommandString("/a b")).toEqual({ cmd: "a", args: "b" });
|
||||
expect(parseCommandString("/cmd And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
|
||||
expect(parseCommandString("/cmd And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
|
||||
expect(parseCommandString("/cmd And more\nstuff")).toEqual({ cmd: "cmd", args: "And more\nstuff" });
|
||||
expect(parseCommandString("/cmd \t\n And more stuff")).toEqual({ cmd: "cmd", args: "And more stuff" });
|
||||
expect(parseCommandString("/a")).toEqual({ cmd: "a" });
|
||||
expect(parseCommandString("/cmd")).toEqual({ cmd: "cmd" });
|
||||
expect(parseCommandString("/cmd ")).toEqual({ cmd: "cmd" });
|
||||
expect(parseCommandString(" /cmd ")).toEqual({ cmd: "cmd" });
|
||||
});
|
||||
});
|
||||
72
test/unit-tests/slash-commands/part-test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import Modal, { type ComponentType, type IHandle } from "../../../src/Modal";
|
||||
import { setUpCommandTest } from "./utils";
|
||||
import { type Command } from "../../../src/slash-commands/command";
|
||||
|
||||
describe("/part", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
function setUp(): {
|
||||
client: MatrixClient;
|
||||
command: Command;
|
||||
args?: string;
|
||||
room1: Room;
|
||||
room2: Room;
|
||||
} {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
spy.mockReturnValue({ close: () => {} } as unknown as IHandle<ComponentType>);
|
||||
|
||||
const { client, command, args } = setUpCommandTest(roomId, "/part #foo:bar");
|
||||
expect(args).toBeDefined();
|
||||
|
||||
const room1 = new Room("!room-id", client, client.getSafeUserId(), {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
const room2 = new Room("!other-room", client, client.getSafeUserId());
|
||||
|
||||
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
|
||||
if (rId === room1.roomId) {
|
||||
return room1;
|
||||
} else if (rId === room2.roomId) {
|
||||
return room2;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
mocked(client.getRooms).mockReturnValue([room1, room2]);
|
||||
|
||||
return { client, command, args, room1, room2 };
|
||||
}
|
||||
|
||||
it("should part room matching alias if found", async () => {
|
||||
const { client, command, args, room1, room2 } = setUp();
|
||||
room1.getCanonicalAlias = jest.fn().mockReturnValue("#foo:bar");
|
||||
room2.getCanonicalAlias = jest.fn().mockReturnValue("#baz:bar");
|
||||
|
||||
await command.run(client, room1.roomId, null, args).promise;
|
||||
|
||||
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
|
||||
});
|
||||
|
||||
it("should part room matching alt alias if found", async () => {
|
||||
const { client, command, args, room1, room2 } = setUp();
|
||||
room1.getAltAliases = jest.fn().mockReturnValue(["#foo:bar"]);
|
||||
room2.getAltAliases = jest.fn().mockReturnValue(["#baz:bar"]);
|
||||
|
||||
await command.run(client, room1.roomId, null, args).promise;
|
||||
|
||||
expect(client.leaveRoomChain).toHaveBeenCalledWith(room1.roomId, expect.anything());
|
||||
});
|
||||
});
|
||||
25
test/unit-tests/slash-commands/rainbow-test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
|
||||
describe.each(["rainbow", "rainbowme"])("/%s", (commandName: string) => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should make things rainbowy", async () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/${commandName}`);
|
||||
|
||||
await expect(command.run(client, roomId, null, "this is a test message").promise).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
33
test/unit-tests/slash-commands/remove-test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("/remove", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("should return usage if no args", () => {
|
||||
const { client, command } = setUpCommandTest(roomId, `/remove`);
|
||||
expect(command.run(client, roomId, null, undefined).error).toBe(command.getUsage());
|
||||
});
|
||||
|
||||
it("should kick the user we specify from this room", async () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/remove @u:s.co`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.kick).toHaveBeenCalledWith(roomId, "@u:s.co", undefined);
|
||||
});
|
||||
|
||||
it("should provide the kick reason if we supply it", async () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, `/remove @u:s.co They were not very nice`);
|
||||
|
||||
await command.run(client, roomId, null, args).promise;
|
||||
|
||||
expect(client.kick).toHaveBeenCalledWith(roomId, "@u:s.co", "They were not very nice");
|
||||
});
|
||||
});
|
||||
22
test/unit-tests/slash-commands/split-at-first-space-test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { splitAtFirstSpace } from "../../../src/slash-commands/SlashCommands";
|
||||
|
||||
describe("splitAtFirstSpace", () => {
|
||||
it("should be able to split arguments at the first whitespace", () => {
|
||||
expect(splitAtFirstSpace("a b")).toEqual(["a", "b"]);
|
||||
expect(splitAtFirstSpace("arg1 Followed by more stuff")).toEqual(["arg1", "Followed by more stuff"]);
|
||||
expect(splitAtFirstSpace("arg1 Followed by more\nstuff")).toEqual(["arg1", "Followed by more\nstuff"]);
|
||||
expect(splitAtFirstSpace(" arg1 Followed by more stuff ")).toEqual(["arg1", "Followed by more stuff"]);
|
||||
expect(splitAtFirstSpace("arg1 \t\n Followed by more stuff")).toEqual(["arg1", "Followed by more stuff"]);
|
||||
expect(splitAtFirstSpace("a")).toEqual(["a"]);
|
||||
expect(splitAtFirstSpace("arg1")).toEqual(["arg1"]);
|
||||
expect(splitAtFirstSpace("arg1 ")).toEqual(["arg1"]);
|
||||
expect(splitAtFirstSpace(" arg1 ")).toEqual(["arg1"]);
|
||||
});
|
||||
});
|
||||
31
test/unit-tests/slash-commands/topic-test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 Modal from "../../../src/Modal";
|
||||
import { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("/topic", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
it("sets topic", async () => {
|
||||
const { client, command, args } = setUpCommandTest(roomId, "/topic pizza");
|
||||
expect(args).toBeDefined();
|
||||
|
||||
command.run(client, "room-id", null, args);
|
||||
|
||||
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
|
||||
});
|
||||
|
||||
it("should show topic modal if no args passed", async () => {
|
||||
const spy = jest.spyOn(Modal, "createDialog");
|
||||
const { client, command } = setUpCommandTest(roomId, "/topic");
|
||||
await command.run(client, roomId, null).promise;
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,29 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
* 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 { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import RoomUpgradeWarningDialog, {
|
||||
type IFinishedOpts,
|
||||
} from "../../../src/components/views/dialogs/RoomUpgradeWarningDialog";
|
||||
import { type Command, Commands } from "../../../src/SlashCommands";
|
||||
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||
import { createTestClient } from "../../test-utils";
|
||||
import { type Command } from "../../../src/slash-commands/SlashCommands";
|
||||
import { parseUpgradeRoomArgs } from "../../../src/slash-commands/upgraderoom/parseUpgradeRoomArgs";
|
||||
import Modal from "../../../src/Modal";
|
||||
import { setUpCommandTest } from "./utils";
|
||||
|
||||
describe("/upgraderoom", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
function findCommand(cmd: string): Command | undefined {
|
||||
return Commands.find((command: Command) => command.command === cmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up an upgraderoom test.
|
||||
*
|
||||
@ -39,15 +35,7 @@ describe("/upgraderoom", () => {
|
||||
} {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const command = findCommand("upgraderoom")!;
|
||||
const client = createTestClient();
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
|
||||
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
|
||||
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
|
||||
if (rId === roomId) return new Room(roomId, client, client.getSafeUserId());
|
||||
return null;
|
||||
});
|
||||
const { command, client } = setUpCommandTest(roomId, "/upgraderoom");
|
||||
|
||||
const createDialog = jest.spyOn(Modal, "createDialog");
|
||||
const upgradeRoom = jest.fn().mockResolvedValue({ replacement_room: "!newroom" });
|
||||
|
||||
54
test/unit-tests/slash-commands/utils.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 { Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { type Command } from "../../../src/slash-commands/command";
|
||||
import { getCommand } from "../../../src/slash-commands/SlashCommands";
|
||||
import { stubClient } from "../../test-utils";
|
||||
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||
import { LocalRoom } from "../../../src/models/LocalRoom";
|
||||
|
||||
export function setUpCommandTest(
|
||||
roomId: string,
|
||||
input: string,
|
||||
roomIsLocal?: boolean,
|
||||
): {
|
||||
command: Command;
|
||||
args?: string;
|
||||
client: MatrixClient;
|
||||
room: Room;
|
||||
} {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// TODO: if getCommand took a MatrixClient argument, we could use
|
||||
// createTestClient here instead of stubClient (i.e. avoid setting
|
||||
// MatrixClientPeg.)
|
||||
const client = stubClient();
|
||||
const { cmd: command, args } = getCommand(roomId, input);
|
||||
|
||||
let room: Room;
|
||||
|
||||
if (roomIsLocal) {
|
||||
room = new LocalRoom(roomId, client, client.getSafeUserId());
|
||||
} else {
|
||||
room = new Room(roomId, client, client.getSafeUserId());
|
||||
}
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(roomId);
|
||||
|
||||
mocked(client.getRoom).mockImplementation((rId: string): Room | null => {
|
||||
if (rId === roomId) {
|
||||
return room;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return { command: command!, args, client, room };
|
||||
}
|
||||