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 c6f15639676..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 “”", @@ -3030,6 +3032,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..fc91ad579fc 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"; @@ -275,6 +275,7 @@ export interface Settings { "sendTypingNotifications": IBaseSetting; "showTypingNotifications": IBaseSetting; "ctrlFForSearch": IBaseSetting; + "ctrlFForSearchNudgeShown": IBaseSetting; "MessageComposerInput.ctrlEnterToSend": IBaseSetting; "MessageComposerInput.surroundWith": IBaseSetting; "MessageComposerInput.autoReplaceEmoji": IBaseSetting; @@ -952,6 +953,16 @@ export const SETTINGS: Settings = { "ctrlFForSearch": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: IS_MAC ? _td("settings|use_command_f_search") : _td("settings|use_control_f_search"), + 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, + }, + // 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": { 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/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/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/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. 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); + }); +});