diff --git a/.github/ISSUE_TEMPLATE/bug-desktop.yml b/.github/ISSUE_TEMPLATE/bug-desktop.yml index 529c0a0ebc..9f031238e8 100644 --- a/.github/ISSUE_TEMPLATE/bug-desktop.yml +++ b/.github/ISSUE_TEMPLATE/bug-desktop.yml @@ -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: diff --git a/packages/shared-components/.gitignore b/packages/shared-components/.gitignore index 1ec1623885..aea5132513 100644 --- a/packages/shared-components/.gitignore +++ b/packages/shared-components/.gitignore @@ -10,3 +10,5 @@ /coverage/ # Ignore generated docs typedoc +# Build storybook +storybook-static diff --git a/packages/shared-components/.storybook/ElementTheme.ts b/packages/shared-components/.storybook/ElementTheme.ts index 0967697621..ae73dc4070 100644 --- a/packages/shared-components/.storybook/ElementTheme.ts +++ b/packages/shared-components/.storybook/ElementTheme.ts @@ -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", }); diff --git a/packages/shared-components/README.md b/packages/shared-components/README.md index e93a81cdf2..993450a65f 100644 --- a/packages/shared-components/README.md +++ b/packages/shared-components/README.md @@ -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 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png deleted file mode 100644 index 571b86c600..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png deleted file mode 100644 index 0c2c4bbe5a..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png deleted file mode 100644 index 7c5fa14a48..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/bold-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png deleted file mode 100644 index e045a22515..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/default-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png deleted file mode 100644 index b9613435e8..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/invitation-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png deleted file mode 100644 index c1caeadc05..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/no-message-preview-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png deleted file mode 100644 index c44d733d2f..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/selected-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png deleted file mode 100644 index 30ad387147..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/unsent-message-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png deleted file mode 100644 index e045a22515..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-hover-menu-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png deleted file mode 100644 index 345a8775f8..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-mention-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png deleted file mode 100644 index d6ff5c8493..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/with-notification-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png deleted file mode 100644 index e045a22515..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/RoomListItem.stories.tsx/without-hover-menu-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/activity-indicator-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/invited-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/mention-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png new file mode 100644 index 0000000000..de18d302eb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/mention-with-count-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/muted-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/muted-without-activity-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/no-notification-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png new file mode 100644 index 0000000000..06aff02b98 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/notification-with-count-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/unsent-message-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/video-call-without-activity-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx/voice-call-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/bold-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/bold-auto.png new file mode 100644 index 0000000000..aea65dfd2e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/bold-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..c91eaae678 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/invitation-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/invitation-auto.png new file mode 100644 index 0000000000..33a58c0186 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/invitation-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/no-message-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/no-message-preview-auto.png new file mode 100644 index 0000000000..f550ca61ae Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/no-message-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/selected-auto.png new file mode 100644 index 0000000000..aa8b1f52fb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/selected-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/unsent-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/unsent-message-auto.png new file mode 100644 index 0000000000..4cd7639e8d Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/unsent-message-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-hover-menu-auto.png new file mode 100644 index 0000000000..c91eaae678 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-hover-menu-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-mention-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-mention-auto.png new file mode 100644 index 0000000000..d59c818760 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-mention-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-notification-auto.png new file mode 100644 index 0000000000..c680323a32 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-notification-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/without-hover-menu-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/without-hover-menu-auto.png new file mode 100644 index 0000000000..c91eaae678 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/without-hover-menu-auto.png differ diff --git a/packages/shared-components/scripts/storybook-build-i18n.ts b/packages/shared-components/scripts/storybook-build-i18n.ts index e0f6dbfb38..9fcdd09ef5 100644 --- a/packages/shared-components/scripts/storybook-build-i18n.ts +++ b/packages/shared-components/scripts/storybook-build-i18n.ts @@ -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]; } /** diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 96216b8ee2..9c51cf74d2 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx b/packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.stories.tsx rename to packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.stories.tsx diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.test.tsx b/packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.test.tsx similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.test.tsx rename to packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.test.tsx diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.tsx b/packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.tsx similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/NotificationDecoration.tsx rename to packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/NotificationDecoration.tsx diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap b/packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap rename to packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/__snapshots__/NotificationDecoration.test.tsx.snap diff --git a/packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/index.tsx b/packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/index.tsx similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/NotificationDecoration/index.tsx rename to packages/shared-components/src/room-list/RoomListItemView/NotificationDecoration/index.tsx diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemContextMenu.tsx similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemContextMenu.tsx diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemHoverMenu.tsx similarity index 96% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemHoverMenu.tsx index 9a453b2014..ea2b2ef9d0 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemHoverMenu.tsx @@ -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 diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx similarity index 99% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx index 40b9917c5b..2885706cd1 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.test.tsx @@ -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("", () => { diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.tsx similarity index 99% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.tsx index d10b5c32ec..10f7df2414 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMoreOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemMoreOptionsMenu.tsx @@ -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 diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.test.tsx similarity index 99% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.test.tsx index 3f88e2f8a1..e2a0036ef3 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.test.tsx @@ -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("", () => { diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.tsx similarity index 99% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.tsx index e4038fae6c..1011bac4ff 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItemNotificationMenu.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemNotificationMenu.tsx @@ -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 diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.module.css similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.module.css diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx similarity index 97% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx index fcc4017fb1..dc2428d823 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx @@ -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: [ diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx similarity index 98% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx index 788c9f317f..691ea07648 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx @@ -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, diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx similarity index 99% rename from packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx rename to packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx index bc16d15cd3..e4da17e3ee 100644 --- a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomNotifs.ts b/packages/shared-components/src/room-list/RoomListItemView/RoomNotifs.ts similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/RoomNotifs.ts rename to packages/shared-components/src/room-list/RoomListItemView/RoomNotifs.ts diff --git a/packages/shared-components/src/room-list/RoomListItem/__snapshots__/RoomListItem.test.tsx.snap b/packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap similarity index 100% rename from packages/shared-components/src/room-list/RoomListItem/__snapshots__/RoomListItem.test.tsx.snap rename to packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap diff --git a/packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts b/packages/shared-components/src/room-list/RoomListItemView/default-snapshot.ts similarity index 94% rename from packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts rename to packages/shared-components/src/room-list/RoomListItemView/default-snapshot.ts index b5e263567f..2a4338795d 100644 --- a/packages/shared-components/src/room-list/RoomListItem/default-snapshot.ts +++ b/packages/shared-components/src/room-list/RoomListItemView/default-snapshot.ts @@ -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" }; diff --git a/packages/shared-components/src/room-list/RoomListItem/index.ts b/packages/shared-components/src/room-list/RoomListItemView/index.ts similarity index 93% rename from packages/shared-components/src/room-list/RoomListItem/index.ts rename to packages/shared-components/src/room-list/RoomListItemView/index.ts index edf17066b8..1a5e5ae45b 100644 --- a/packages/shared-components/src/room-list/RoomListItem/index.ts +++ b/packages/shared-components/src/room-list/RoomListItemView/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx index 206307262e..8e183fff4a 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 491c28d7d1..1a08813f24 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -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 diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx index 3ea908cb55..aa12524a97 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 7b27df0f28..4ef430520a 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -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"; /** diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx index 83a8eb1b94..de2a4262f4 100644 --- a/packages/shared-components/src/room-list/story-mocks.tsx +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -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 diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e9c723b75d..1a04db3269 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -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 { private dispatcherRef?: string; - // device IDs for which the user has dismissed the verify toast ('Later') - private dismissed = new Set(); + + /** 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 | null = null; - // The set of device IDs we're currently displaying toasts for - private displayingToastsForDeviceIds = new Set(); 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): Promise { + public dismissUnverifiedSessions(deviceIds: Iterable): 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 { - 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> { - const cli = this.client; - if (!cli) return new Set(); - return await getUserDeviceIds(cli, cli.getSafeUserId()); - } - - private onDevicesUpdated = async (users: string[], initialFetch?: boolean): Promise => { - 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 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(); - // Unverified devices that have appeared since then - const newUnverifiedDeviceIds = new Set(); - - // 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); } /** diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index 41781c0a68..f93ea6c8ce 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -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"; diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.tsx b/src/components/views/dialogs/SlashCommandHelpDialog.tsx index 0ac1a0de0d..6ceb80a1fd 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.tsx +++ b/src/components/views/dialogs/SlashCommandHelpDialog.tsx @@ -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"; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d15e90e394..3e9d46cbf6 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 51cd5093bf..970d8cffcd 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 122048d85a..0d9a68c45d 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -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"; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index ace513947b..2594ac4841 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -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"; diff --git a/src/device-listener/DeviceListenerOtherDevices.ts b/src/device-listener/DeviceListenerOtherDevices.ts new file mode 100644 index 0000000000..8cf839e8fd --- /dev/null +++ b/src/device-listener/DeviceListenerOtherDevices.ts @@ -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(); + + /** + * 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 | null = null; + + /** + * The set of device IDs we're currently displaying toasts for. + */ + private displayingToastsForDeviceIds = new Set(); + + /** + * 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): 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> { + return await getUserDeviceIds(this.client, this.client.getSafeUserId()); + } + + /** + */ + private async ensureDeviceIdsAtStartPopulated(): Promise { + 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 => { + // 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 { + 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(); + // Unverified devices that have appeared since then + const newUnverifiedDeviceIds = new Set(); + + // 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; + } +} diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx index 6346785755..7d1125341a 100644 --- a/src/editor/commands.tsx +++ b/src/editor/commands.tsx @@ -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"; diff --git a/src/SlashCommands.tsx b/src/slash-commands/SlashCommands.tsx similarity index 87% rename from src/SlashCommands.tsx rename to src/slash-commands/SlashCommands.tsx index 0169513635..a566a5f093 100644 --- a/src/SlashCommands.tsx +++ b/src/slash-commands/SlashCommands.tsx @@ -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: "", - 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: "", - 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: "", - 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: "", - 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: "", @@ -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 => { @@ -910,21 +862,24 @@ Commands.forEach((cmd) => { }); }); +/** + * If the supplied input starts with "/", 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. diff --git a/src/slash-commands/emoticon.ts b/src/slash-commands/emoticon.ts new file mode 100644 index 0000000000..c1d232bca9 --- /dev/null +++ b/src/slash-commands/emoticon.ts @@ -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: "", + description, + runFn: function (_cli, _roomId, _threadId, args) { + if (args) { + message = message + " " + args; + } + return successSync(ContentHelpers.makeTextMessage(message)); + }, + category: CommandCategories.messages, + }); +} diff --git a/src/vector/index.ts b/src/vector/index.ts index bc656c8071..2010c95d75 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -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; } diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1159fa3fb7..a894608b24 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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); diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index f9339cd38b..f2c3a3dc98 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -734,7 +734,7 @@ describe("DeviceListener", () => { new Set([device3.deviceId]), ); - await instance.dismissUnverifiedSessions([device3.deviceId]); + await instance.otherDevices?.dismissUnverifiedSessions([device3.deviceId]); await flushPromises(); expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); diff --git a/test/unit-tests/SlashCommands-test.tsx b/test/unit-tests/SlashCommands-test.tsx deleted file mode 100644 index 76be3fb92f..0000000000 --- a/test/unit-tests/SlashCommands-test.tsx +++ /dev/null @@ -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); - - // 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); - - // 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, ''); - 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"], - }), - ); - }); - }); -}); diff --git a/test/unit-tests/autocomplete/CommandProvider-test.ts b/test/unit-tests/autocomplete/CommandProvider-test.ts index f282063901..ed02dd1adf 100644 --- a/test/unit-tests/autocomplete/CommandProvider-test.ts +++ b/test/unit-tests/autocomplete/CommandProvider-test.ts @@ -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; diff --git a/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx b/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx index 00e4aa96db..c57c0b7503 100644 --- a/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/SlashCommandHelpDialog-test.tsx @@ -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"; diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index c5dbea367d..c53db5de35 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -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"; diff --git a/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap b/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap new file mode 100644 index 0000000000..dc52a779b3 --- /dev/null +++ b/test/unit-tests/slash-commands/__snapshots__/emoticons-test.ts.snap @@ -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", +} +`; diff --git a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap b/test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap similarity index 55% rename from test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap rename to test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap index 472feb6dcf..0b439253a1 100644 --- a/test/unit-tests/__snapshots__/SlashCommands-test.tsx.snap +++ b/test/unit-tests/slash-commands/__snapshots__/rainbow-test.ts.snap @@ -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", -} -`; diff --git a/test/unit-tests/slash-commands/addwidget-test.ts b/test/unit-tests/slash-commands/addwidget-test.ts new file mode 100644 index 0000000000..90e45a7c81 --- /dev/null +++ b/test/unit-tests/slash-commands/addwidget-test.ts @@ -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, ''); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith( + client, + roomId, + expect.any(String), + WidgetType.CUSTOM, + "https://element.io", + "Custom", + {}, + ), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/ban-test.ts b/test/unit-tests/slash-commands/ban-test.ts new file mode 100644 index 0000000000..c898ebcd9e --- /dev/null +++ b/test/unit-tests/slash-commands/ban-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/slash-commands/disabled-in-local-room-test.ts b/test/unit-tests/slash-commands/disabled-in-local-room-test.ts new file mode 100644 index 0000000000..8a7fb8eb57 --- /dev/null +++ b/test/unit-tests/slash-commands/disabled-in-local-room-test.ts @@ -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); + }); + }); + }); +}); diff --git a/test/unit-tests/slash-commands/emoticons-test.ts b/test/unit-tests/slash-commands/emoticons-test.ts new file mode 100644 index 0000000000..bd30059127 --- /dev/null +++ b/test/unit-tests/slash-commands/emoticons-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/invite-test.ts b/test/unit-tests/slash-commands/invite-test.ts new file mode 100644 index 0000000000..574292a4ae --- /dev/null +++ b/test/unit-tests/slash-commands/invite-test.ts @@ -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); + + 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); + + 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" }); + }); +}); diff --git a/test/unit-tests/slash-commands/join-test.ts b/test/unit-tests/slash-commands/join-test.ts new file mode 100644 index 0000000000..aa1e742788 --- /dev/null +++ b/test/unit-tests/slash-commands/join-test.ts @@ -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"], + }), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/msg-test.ts b/test/unit-tests/slash-commands/msg-test.ts new file mode 100644 index 0000000000..a55cbfc082 --- /dev/null +++ b/test/unit-tests/slash-commands/msg-test.ts @@ -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", + }), + ); + }); +}); diff --git a/test/unit-tests/slash-commands/op-test.ts b/test/unit-tests/slash-commands/op-test.ts new file mode 100644 index 0000000000..e0a843c2b0 --- /dev/null +++ b/test/unit-tests/slash-commands/op-test.ts @@ -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()); + }); +}); diff --git a/test/unit-tests/slash-commands/parse-command-string-test.ts b/test/unit-tests/slash-commands/parse-command-string-test.ts new file mode 100644 index 0000000000..9f2fa1a3dc --- /dev/null +++ b/test/unit-tests/slash-commands/parse-command-string-test.ts @@ -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" }); + }); +}); diff --git a/test/unit-tests/slash-commands/part-test.ts b/test/unit-tests/slash-commands/part-test.ts new file mode 100644 index 0000000000..1253712163 --- /dev/null +++ b/test/unit-tests/slash-commands/part-test.ts @@ -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); + + 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()); + }); +}); diff --git a/test/unit-tests/slash-commands/rainbow-test.ts b/test/unit-tests/slash-commands/rainbow-test.ts new file mode 100644 index 0000000000..d86c307d69 --- /dev/null +++ b/test/unit-tests/slash-commands/rainbow-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/remove-test.ts b/test/unit-tests/slash-commands/remove-test.ts new file mode 100644 index 0000000000..2ba29757fa --- /dev/null +++ b/test/unit-tests/slash-commands/remove-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/slash-commands/split-at-first-space-test.ts b/test/unit-tests/slash-commands/split-at-first-space-test.ts new file mode 100644 index 0000000000..2bbbb87b3b --- /dev/null +++ b/test/unit-tests/slash-commands/split-at-first-space-test.ts @@ -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"]); + }); +}); diff --git a/test/unit-tests/slash-commands/topic-test.ts b/test/unit-tests/slash-commands/topic-test.ts new file mode 100644 index 0000000000..c77a9961b0 --- /dev/null +++ b/test/unit-tests/slash-commands/topic-test.ts @@ -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(); + }); +}); diff --git a/test/unit-tests/slash-commands/upgraderoom-test.tsx b/test/unit-tests/slash-commands/upgraderoom-test.tsx index 88aea8a833..c13c9fc81a 100644 --- a/test/unit-tests/slash-commands/upgraderoom-test.tsx +++ b/test/unit-tests/slash-commands/upgraderoom-test.tsx @@ -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" }); diff --git a/test/unit-tests/slash-commands/utils.ts b/test/unit-tests/slash-commands/utils.ts new file mode 100644 index 0000000000..5654128100 --- /dev/null +++ b/test/unit-tests/slash-commands/utils.ts @@ -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 }; +} diff --git a/test/unit-tests/slash-commands/verify-test.ts b/test/unit-tests/slash-commands/verify-test.ts new file mode 100644 index 0000000000..e5e768f4a0 --- /dev/null +++ b/test/unit-tests/slash-commands/verify-test.ts @@ -0,0 +1,64 @@ +/* + * 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 { act, waitFor } from "jest-matrix-react"; + +import Modal, { type ComponentType, type IHandle } from "../../../src/Modal"; +import { setUpCommandTest } from "./utils"; +import QuestionDialog from "../../../src/components/views/dialogs/QuestionDialog"; +import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog"; + +describe("/verify", () => { + const roomId = "!room:example.com"; + + it("should return usage if no args", () => { + const { client, command } = setUpCommandTest(roomId, `/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); + + // When we run the command + const { client, command } = setUpCommandTest(roomId, `/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); + + // When we run the command + const { client, command } = setUpCommandTest(roomId, `/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()); + }); +});