Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -518,6 +519,10 @@ class LoggedInView extends React.Component<IProps, IState> {
// 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);
}
};

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 “<query/>”",
Expand Down Expand Up @@ -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": {
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -275,6 +275,7 @@ export interface Settings {
"sendTypingNotifications": IBaseSetting<boolean>;
"showTypingNotifications": IBaseSetting<boolean>;
"ctrlFForSearch": IBaseSetting<boolean>;
"ctrlFForSearchNudgeShown": IBaseSetting<boolean>;
"MessageComposerInput.ctrlEnterToSend": IBaseSetting<boolean>;
"MessageComposerInput.surroundWith": IBaseSetting<boolean>;
"MessageComposerInput.autoReplaceEmoji": IBaseSetting<boolean>;
Expand Down Expand Up @@ -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": {
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/toasts/InRoomSearchNudgeToast.ts
Original file line number Diff line number Diff line change
@@ -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();
}
36 changes: 36 additions & 0 deletions apps/web/test/unit-tests/KeyBindingsDefaults-test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,28 @@ describe("<LoggedInView />", () => {
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");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
Use Ctrl + F to search timeline
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-react-use-id-2"
>
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.
</span>
</div>
</div>
</div>
Expand Down Expand Up @@ -403,7 +409,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-react-use-id-2"
id="radix-react-use-id-3"
>
Requires your server to support MSC4133
</span>
Expand Down Expand Up @@ -472,7 +478,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-react-use-id-3"
id="radix-react-use-id-4"
>
Your server doesn't support disabling sending read receipts.
</span>
Expand Down Expand Up @@ -597,7 +603,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-react-use-id-4"
id="radix-react-use-id-5"
>
<span>
Start messages with
Expand Down Expand Up @@ -1484,7 +1490,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
<input
class="_input_udcm8_24"
id="react-use-id-5"
id="react-use-id-6"
role="switch"
type="checkbox"
/>
Expand All @@ -1498,7 +1504,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
<label
class="_label_1o4d9_60"
for="react-use-id-5"
for="react-use-id-6"
>
Hide avatars of room and inviter
</label>
Expand All @@ -1512,13 +1518,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-react-use-id-6"
for="radix-react-use-id-7"
>
Show media in timeline
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92 mx_MediaPreviewAccountSetting_RadioHelp"
id="radix-react-use-id-7"
id="radix-react-use-id-8"
>
A hidden media can always be shown by tapping on it
</span>
Expand Down Expand Up @@ -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"
/>
Expand All @@ -1645,13 +1651,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
>
<label
class="_label_1o4d9_60"
for="react-use-id-8"
for="react-use-id-9"
>
Allow users to invite you to rooms
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-react-use-id-9"
id="radix-react-use-id-10"
>
Your server does not implement this feature.
</span>
Expand Down
84 changes: 84 additions & 0 deletions apps/web/test/unit-tests/toasts/InRoomSearchNudgeToast-test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>): 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);
});
});
Loading