From 05ba83754921557ec86ac5dbb07e9350ae24d49a Mon Sep 17 00:00:00 2001
From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:30:16 +0300
Subject: [PATCH 1/2] Default the Ctrl/Cmd+F search shortcut to in-room search
on desktop
---
apps/web/src/i18n/strings/en_EN.json | 1 +
apps/web/src/settings/Settings.tsx | 8 +++--
.../unit-tests/KeyBindingsDefaults-test.ts | 36 +++++++++++++++++++
.../PreferencesUserSettingsTab-test.tsx.snap | 26 ++++++++------
4 files changed, 59 insertions(+), 12 deletions(-)
create mode 100644 apps/web/test/unit-tests/KeyBindingsDefaults-test.ts
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json
index c6f15639676..f5fd2ff0f0c 100644
--- a/apps/web/src/i18n/strings/en_EN.json
+++ b/apps/web/src/i18n/strings/en_EN.json
@@ -3030,6 +3030,7 @@
"use_12_hour_format": "Show timestamps in 12 hour format (e.g. 2:30pm)",
"use_command_enter_send_message": "Use Command + Enter to send a message",
"use_command_f_search": "Use Command + F to search timeline",
+ "use_command_f_search_description": "When enabled, the search shortcut opens in-room message search instead of your browser's find-on-page. Searching encrypted rooms requires the desktop app with message search enabled.",
"use_control_enter_send_message": "Use Ctrl + Enter to send a message",
"use_control_f_search": "Use Ctrl + F to search timeline",
"voip": {
diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx
index e666950daf1..648074ff6dd 100644
--- a/apps/web/src/settings/Settings.tsx
+++ b/apps/web/src/settings/Settings.tsx
@@ -25,7 +25,7 @@ import FontSizeController from "./controllers/FontSizeController";
import SystemFontController from "./controllers/SystemFontController";
import { SettingLevel } from "./SettingLevel";
import type SettingController from "./controllers/SettingController";
-import { IS_MAC } from "../Keyboard";
+import { IS_MAC, IS_ELECTRON } from "../Keyboard";
import UIFeatureController from "./controllers/UIFeatureController";
import { UIFeature } from "./UIFeature";
import { Layout } from "./enums/Layout";
@@ -952,7 +952,11 @@ export const SETTINGS: Settings = {
"ctrlFForSearch": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: IS_MAC ? _td("settings|use_command_f_search") : _td("settings|use_control_f_search"),
- default: false,
+ description: _td("settings|use_command_f_search_description"),
+ // Default the in-room search shortcut on for the desktop app, where Seshat makes
+ // encrypted-room search work and there is no browser "find on page" to override. Web
+ // stays opt-in so the browser's native find bar is preserved (see element-web #24359/#33360).
+ default: !!IS_ELECTRON,
},
"MessageComposerInput.ctrlEnterToSend": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
diff --git a/apps/web/test/unit-tests/KeyBindingsDefaults-test.ts b/apps/web/test/unit-tests/KeyBindingsDefaults-test.ts
new file mode 100644
index 00000000000..5d5ae076607
--- /dev/null
+++ b/apps/web/test/unit-tests/KeyBindingsDefaults-test.ts
@@ -0,0 +1,36 @@
+/*
+Copyright 2026 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { defaultBindingsProvider } from "../../src/KeyBindingsDefaults";
+import SettingsStore from "../../src/settings/SettingsStore";
+import { KeyBindingAction } from "../../src/accessibility/KeyboardShortcuts";
+import { type KeyBinding } from "../../src/KeyBindingsManager";
+import { Key } from "../../src/Keyboard";
+
+const searchBinding = (bindings: KeyBinding[]): KeyBinding | undefined =>
+ bindings.find((b) => b.action === KeyBindingAction.SearchInRoom);
+
+describe("defaultBindingsProvider.getRoomBindings", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("registers the Ctrl/Cmd+F room-search binding when ctrlFForSearch is enabled", () => {
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => name === "ctrlFForSearch");
+
+ const binding = searchBinding(defaultBindingsProvider.getRoomBindings());
+
+ expect(binding).toBeDefined();
+ expect(binding!.keyCombo).toEqual({ key: Key.F, ctrlOrCmdKey: true });
+ });
+
+ it("omits the room-search binding when ctrlFForSearch is disabled", () => {
+ jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
+
+ expect(searchBinding(defaultBindingsProvider.getRoomBindings())).toBeUndefined();
+ });
+});
diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap
index 6e7aecbe86a..9556301a6de 100644
--- a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap
+++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap
@@ -246,6 +246,12 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
Use Ctrl + F to search timeline
+
+ When enabled, the search shortcut opens in-room message search instead of your browser's find-on-page. Searching encrypted rooms requires the desktop app with message search enabled.
+
@@ -403,7 +409,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
Requires your server to support MSC4133
@@ -472,7 +478,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
Your server doesn't support disabling sending read receipts.
@@ -597,7 +603,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
Start messages with
@@ -1484,7 +1490,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
@@ -1498,7 +1504,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
@@ -1512,13 +1518,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
A hidden media can always be shown by tapping on it
@@ -1631,7 +1637,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
checked=""
class="_input_udcm8_24"
disabled=""
- id="react-use-id-8"
+ id="react-use-id-9"
role="switch"
type="checkbox"
/>
@@ -1645,13 +1651,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
Your server does not implement this feature.
From ccb3b5ff5c527ab414bc4efcb9918573271e9a64 Mon Sep 17 00:00:00 2001
From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:36:05 +0300
Subject: [PATCH 2/2] Show a one-time nudge towards in-room search when
pressing Ctrl/Cmd+F on web
---
.../components/structures/LoggedInView.tsx | 5 ++
apps/web/src/i18n/strings/en_EN.json | 2 +
apps/web/src/settings/Settings.tsx | 7 ++
apps/web/src/toasts/InRoomSearchNudgeToast.ts | 70 ++++++++++++++++
.../structures/LoggedInView-test.tsx | 22 +++++
.../toasts/InRoomSearchNudgeToast-test.ts | 84 +++++++++++++++++++
6 files changed, 190 insertions(+)
create mode 100644 apps/web/src/toasts/InRoomSearchNudgeToast.ts
create mode 100644 apps/web/test/unit-tests/toasts/InRoomSearchNudgeToast-test.ts
diff --git a/apps/web/src/components/structures/LoggedInView.tsx b/apps/web/src/components/structures/LoggedInView.tsx
index 09a8d975156..f5026cb6903 100644
--- a/apps/web/src/components/structures/LoggedInView.tsx
+++ b/apps/web/src/components/structures/LoggedInView.tsx
@@ -36,6 +36,7 @@ import { CollapseDistributor, Resizer } from "../../resizer";
import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
+import { showInRoomSearchNudgeIfNeeded } from "../../toasts/InRoomSearchNudgeToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel";
import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
@@ -518,6 +519,10 @@ class LoggedInView extends React.Component {
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this.onKeyDown(ev);
+ // On the web build the in-room search shortcut is opt-in (to preserve the browser's
+ // find-on-page). Surface a one-time nudge towards it here, on the nothing-focused path,
+ // without preventing the browser find bar (see ctrlFForSearch / element-web #33360).
+ showInRoomSearchNudgeIfNeeded(ev);
}
};
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json
index f5fd2ff0f0c..dfc406d3695 100644
--- a/apps/web/src/i18n/strings/en_EN.json
+++ b/apps/web/src/i18n/strings/en_EN.json
@@ -2113,6 +2113,8 @@
"room_is_low_priority": "This is a low priority room",
"search": {
"all_rooms_button": "Search all rooms",
+ "nudge_description": "Enable the in-room search shortcut to search this conversation instead of your browser's find-on-page.",
+ "nudge_title": "Search this room",
"placeholder": "Search messages…",
"summary": {
"one": "1 result found for “”",
diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx
index 648074ff6dd..fc91ad579fc 100644
--- a/apps/web/src/settings/Settings.tsx
+++ b/apps/web/src/settings/Settings.tsx
@@ -275,6 +275,7 @@ export interface Settings {
"sendTypingNotifications": IBaseSetting;
"showTypingNotifications": IBaseSetting;
"ctrlFForSearch": IBaseSetting;
+ "ctrlFForSearchNudgeShown": IBaseSetting;
"MessageComposerInput.ctrlEnterToSend": IBaseSetting;
"MessageComposerInput.surroundWith": IBaseSetting;
"MessageComposerInput.autoReplaceEmoji": IBaseSetting;
@@ -958,6 +959,12 @@ export const SETTINGS: Settings = {
// stays opt-in so the browser's native find bar is preserved (see element-web #24359/#33360).
default: !!IS_ELECTRON,
},
+ // Device-local flag tracking whether the one-time web nudge towards the opt-in in-room search
+ // shortcut has been shown, so it never nags the user more than once per browser.
+ "ctrlFForSearchNudgeShown": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: false,
+ },
"MessageComposerInput.ctrlEnterToSend": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: IS_MAC
diff --git a/apps/web/src/toasts/InRoomSearchNudgeToast.ts b/apps/web/src/toasts/InRoomSearchNudgeToast.ts
new file mode 100644
index 00000000000..41a54d99403
--- /dev/null
+++ b/apps/web/src/toasts/InRoomSearchNudgeToast.ts
@@ -0,0 +1,70 @@
+/*
+Copyright 2026 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { _t } from "../languageHandler";
+import { IS_ELECTRON, IS_MAC, Key } from "../Keyboard";
+import { isKeyComboMatch } from "../KeyBindingsManager";
+import SettingsStore from "../settings/SettingsStore";
+import { SettingLevel } from "../settings/SettingLevel";
+import ToastStore from "../stores/ToastStore";
+import GenericToast from "../components/views/toasts/GenericToast";
+
+const TOAST_KEY = "in-room-search-nudge";
+
+const dismiss = (): void => {
+ ToastStore.sharedInstance().dismissToast(TOAST_KEY);
+};
+
+const markShown = (): void => {
+ // Device-local: the nudge is about this browser's opt-in web shortcut, so it never needs the account.
+ void SettingsStore.setValue("ctrlFForSearchNudgeShown", null, SettingLevel.DEVICE, true);
+};
+
+/**
+ * Show the one-time toast that points the user at the opt-in in-room search shortcut.
+ * Exported for testing; prefer {@link showInRoomSearchNudgeIfNeeded} from the keydown handler.
+ */
+export function showInRoomSearchNudgeToast(): void {
+ markShown();
+ ToastStore.sharedInstance().addOrReplaceToast({
+ key: TOAST_KEY,
+ title: _t("room|search|nudge_title"),
+ props: {
+ description: _t("room|search|nudge_description"),
+ primaryLabel: _t("action|enable"),
+ onPrimaryClick: () => {
+ void SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.ACCOUNT, true);
+ dismiss();
+ },
+ secondaryLabel: _t("action|dismiss"),
+ onSecondaryClick: dismiss,
+ },
+ component: GenericToast,
+ // Low priority: this is a passive discoverability hint, not an urgent action.
+ priority: 30,
+ });
+}
+
+/**
+ * On the web build the in-room search shortcut (Ctrl/Cmd+F) is opt-in so that the browser's native
+ * find-on-page keeps working. The first time the user presses it while it is disabled, surface a
+ * one-time toast pointing them at the setting.
+ *
+ * The caller MUST NOT preventDefault the event, so the browser find bar still opens (element-web
+ * #33360), and should only call this on the "nothing focused" path so a focused composer never
+ * competes with the shortcut.
+ */
+export function showInRoomSearchNudgeIfNeeded(ev: KeyboardEvent): void {
+ // Desktop enables the shortcut by default and has no browser find bar to preserve — nothing to nudge.
+ if (IS_ELECTRON) return;
+ // Already enabled, or we have already shown the nudge once on this device.
+ if (SettingsStore.getValue("ctrlFForSearch")) return;
+ if (SettingsStore.getValue("ctrlFForSearchNudgeShown")) return;
+ if (!isKeyComboMatch(ev, { key: Key.F, ctrlOrCmdKey: true }, IS_MAC)) return;
+
+ showInRoomSearchNudgeToast();
+}
diff --git a/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx b/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx
index 68f8976c257..180d18b306c 100644
--- a/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx
+++ b/apps/web/test/unit-tests/components/structures/LoggedInView-test.tsx
@@ -436,6 +436,28 @@ describe("", () => {
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusMessageSearch);
});
+ it("shows the one-time in-room search nudge on Ctrl+F when the shortcut is disabled (web)", async () => {
+ const addOrReplaceToast = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
+ await SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.DEVICE, false);
+ await SettingsStore.setValue("ctrlFForSearchNudgeShown", null, SettingLevel.DEVICE, false);
+
+ getComponent();
+ await userEvent.keyboard("{Control>}f{/Control}");
+
+ expect(addOrReplaceToast).toHaveBeenCalledWith(expect.objectContaining({ key: "in-room-search-nudge" }));
+ });
+
+ it("does not show the in-room search nudge when the shortcut is enabled", async () => {
+ const addOrReplaceToast = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
+ await SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.DEVICE, true);
+ await SettingsStore.setValue("ctrlFForSearchNudgeShown", null, SettingLevel.DEVICE, false);
+
+ getComponent();
+ await userEvent.keyboard("{Control>}f{/Control}");
+
+ expect(addOrReplaceToast).not.toHaveBeenCalledWith(expect.objectContaining({ key: "in-room-search-nudge" }));
+ });
+
it("should go home on home shortcut", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
diff --git a/apps/web/test/unit-tests/toasts/InRoomSearchNudgeToast-test.ts b/apps/web/test/unit-tests/toasts/InRoomSearchNudgeToast-test.ts
new file mode 100644
index 00000000000..757e0c0d1a3
--- /dev/null
+++ b/apps/web/test/unit-tests/toasts/InRoomSearchNudgeToast-test.ts
@@ -0,0 +1,84 @@
+/*
+Copyright 2026 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { showInRoomSearchNudgeIfNeeded } from "../../../src/toasts/InRoomSearchNudgeToast";
+import SettingsStore from "../../../src/settings/SettingsStore";
+import { SettingLevel } from "../../../src/settings/SettingLevel";
+import ToastStore from "../../../src/stores/ToastStore";
+
+const TOAST_KEY = "in-room-search-nudge";
+
+// In the jsdom test environment IS_ELECTRON (window.electron) is undefined and IS_MAC is false,
+// so these emulate the web build pressing Ctrl+F.
+const ctrlF = (): KeyboardEvent => new KeyboardEvent("keydown", { key: "f", ctrlKey: true });
+
+describe("showInRoomSearchNudgeIfNeeded", () => {
+ let addOrReplaceToast: jest.SpyInstance;
+ let setValue: jest.SpyInstance;
+
+ const mockSettings = (values: Record): void => {
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => !!values[name as string]);
+ };
+
+ beforeEach(() => {
+ addOrReplaceToast = jest
+ .spyOn(ToastStore.sharedInstance(), "addOrReplaceToast")
+ .mockImplementation(() => undefined);
+ setValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("shows the one-time toast on Ctrl/Cmd+F when in-room search is disabled and not yet shown", () => {
+ mockSettings({ ctrlFForSearch: false, ctrlFForSearchNudgeShown: false });
+
+ showInRoomSearchNudgeIfNeeded(ctrlF());
+
+ expect(addOrReplaceToast).toHaveBeenCalledTimes(1);
+ expect(addOrReplaceToast).toHaveBeenCalledWith(expect.objectContaining({ key: TOAST_KEY }));
+ // It marks itself as shown so it never nags again on this device.
+ expect(setValue).toHaveBeenCalledWith("ctrlFForSearchNudgeShown", null, SettingLevel.DEVICE, true);
+ });
+
+ it("does not show when in-room search is already enabled", () => {
+ mockSettings({ ctrlFForSearch: true, ctrlFForSearchNudgeShown: false });
+
+ showInRoomSearchNudgeIfNeeded(ctrlF());
+
+ expect(addOrReplaceToast).not.toHaveBeenCalled();
+ });
+
+ it("does not show when it has already been shown once", () => {
+ mockSettings({ ctrlFForSearch: false, ctrlFForSearchNudgeShown: true });
+
+ showInRoomSearchNudgeIfNeeded(ctrlF());
+
+ expect(addOrReplaceToast).not.toHaveBeenCalled();
+ });
+
+ it("ignores key presses that are not Ctrl/Cmd+F", () => {
+ mockSettings({ ctrlFForSearch: false, ctrlFForSearchNudgeShown: false });
+
+ showInRoomSearchNudgeIfNeeded(new KeyboardEvent("keydown", { key: "f" })); // no modifier
+ showInRoomSearchNudgeIfNeeded(new KeyboardEvent("keydown", { key: "g", ctrlKey: true })); // wrong key
+
+ expect(addOrReplaceToast).not.toHaveBeenCalled();
+ });
+
+ it("enables in-room search when the toast's primary action is clicked", () => {
+ mockSettings({ ctrlFForSearch: false, ctrlFForSearchNudgeShown: false });
+
+ showInRoomSearchNudgeIfNeeded(ctrlF());
+
+ const toast = addOrReplaceToast.mock.calls[0][0];
+ toast.props.onPrimaryClick();
+
+ expect(setValue).toHaveBeenCalledWith("ctrlFForSearch", null, SettingLevel.ACCOUNT, true);
+ });
+});