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());
+ });
+});