1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-08 03:42:14 +03:00

Allow jumping to message search from spotlight (#29850)

* Allow jumping to message search from spotlight

replaces the message search hint which referenced the old UX

Fixes #29831

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update RoomSummaryCard.tsx

* Update actions.ts

* Delete src/hooks/useTransition.ts

* Update RoomSummaryCard.tsx

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2025-04-30 12:23:35 +01:00
committed by GitHub
parent 23597e959b
commit 4bf28f8159
14 changed files with 212 additions and 240 deletions

View File

@@ -412,7 +412,8 @@ Please see LICENSE files in the repository root for full details.
.mx_SpotlightDialog_joinRoomAlias, .mx_SpotlightDialog_joinRoomAlias,
.mx_SpotlightDialog_explorePublicRooms, .mx_SpotlightDialog_explorePublicRooms,
.mx_SpotlightDialog_explorePublicSpaces, .mx_SpotlightDialog_explorePublicSpaces,
.mx_SpotlightDialog_startGroupChat { .mx_SpotlightDialog_startGroupChat,
.mx_SpotlightDialog_searchMessages {
padding-left: $spacing-32; padding-left: $spacing-32;
position: relative; position: relative;
@@ -451,22 +452,14 @@ Please see LICENSE files in the repository root for full details.
mask-image: url("$(res)/img/element-icons/group-members.svg"); mask-image: url("$(res)/img/element-icons/group-members.svg");
} }
.mx_SpotlightDialog_searchMessages::before {
mask-image: url("$(res)/img/element-icons/room/search-inset.svg");
}
.mx_SpotlightDialog_otherSearches_messageSearchText { .mx_SpotlightDialog_otherSearches_messageSearchText {
font-size: $font-15px; font-size: $font-15px;
line-height: $font-24px; line-height: $font-24px;
} }
.mx_SpotlightDialog_otherSearches_messageSearchIcon {
display: inline-block;
width: 24px;
height: 24px;
background-color: $secondary-content;
vertical-align: text-bottom;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url("$(res)/img/element-icons/room/search-inset.svg");
}
} }
.mx_SpotlightDialog_result_details { .mx_SpotlightDialog_result_details {

View File

@@ -163,7 +163,8 @@ $accent-1400: var(--cpd-color-green-1400);
&.mx_SpotlightDialog_startChat::before, &.mx_SpotlightDialog_startChat::before,
&.mx_SpotlightDialog_joinRoomAlias::before, &.mx_SpotlightDialog_joinRoomAlias::before,
&.mx_SpotlightDialog_explorePublicRooms::before, &.mx_SpotlightDialog_explorePublicRooms::before,
&.mx_SpotlightDialog_startGroupChat::before { &.mx_SpotlightDialog_startGroupChat::before,
&.mx_SpotlightDialog_searchMessages::before {
background-color: $background !important; background-color: $background !important;
} }

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ChangeEvent } from "react"; import React from "react";
import { type Room, type RoomState, RoomStateEvent, RoomMember, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type Room, type RoomState, RoomStateEvent, RoomMember, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash"; import { throttle } from "lodash";
@@ -49,8 +49,9 @@ interface RoomlessProps extends BaseProps {
interface RoomProps extends BaseProps { interface RoomProps extends BaseProps {
room: Room; room: Room;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
onSearchChange?: (e: ChangeEvent) => void; onSearchChange?: (term: string) => void;
onSearchCancel?: () => void; onSearchCancel?: () => void;
searchTerm?: string;
} }
type Props = XOR<RoomlessProps, RoomProps>; type Props = XOR<RoomlessProps, RoomProps>;
@@ -260,6 +261,7 @@ export default class RightPanel extends React.Component<Props, IState> {
permalinkCreator={this.props.permalinkCreator!} permalinkCreator={this.props.permalinkCreator!}
onSearchChange={this.props.onSearchChange} onSearchChange={this.props.onSearchChange}
onSearchCancel={this.props.onSearchCancel} onSearchCancel={this.props.onSearchCancel}
searchTerm={this.props.searchTerm}
focusRoomSearch={cardState?.focusRoomSearch} focusRoomSearch={cardState?.focusRoomSearch}
/> />
); );

View File

@@ -10,7 +10,6 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React, { import React, {
type ChangeEvent,
type ComponentProps, type ComponentProps,
createRef, createRef,
type ReactElement, type ReactElement,
@@ -133,6 +132,7 @@ import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext"; import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext";
import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog";
import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts";
const DEBUG = false; const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -1244,6 +1244,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case Action.View3pidInvite: case Action.View3pidInvite:
onView3pidInvite(payload, RightPanelStore.instance); onView3pidInvite(payload, RightPanelStore.instance);
break; break;
case Action.FocusMessageSearch:
if ((payload as FocusMessageSearchPayload).initialText) {
this.onSearch(payload.initialText);
}
break;
} }
}; };
@@ -1806,8 +1811,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
defaultDispatcher.fire(Action.ViewRoomDirectory); defaultDispatcher.fire(Action.ViewRoomDirectory);
}; };
private onSearchChange = debounce((e: ChangeEvent): void => { private onSearchChange = debounce((term: string): void => {
const term = (e.target as HTMLInputElement).value;
this.onSearch(term); this.onSearch(term);
}, 300); }, 300);
@@ -2487,6 +2491,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
onSearchChange={this.onSearchChange} onSearchChange={this.onSearchChange}
onSearchCancel={this.onCancelSearchClick} onSearchCancel={this.onCancelSearchClick}
searchTerm={this.state.search?.term ?? ""}
/> />
) : undefined; ) : undefined;

View File

@@ -608,6 +608,21 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
{filterToLabel(Filter.People)} {filterToLabel(Filter.People)}
</Option> </Option>
)} )}
{filter === null && (
<Option
id="mx_SpotlightDialog_button_searchMessages"
className="mx_SpotlightDialog_searchMessages"
onClick={() => {
defaultDispatcher.dispatch({
action: Action.FocusMessageSearch,
initialText: trimmedQuery,
});
onFinished();
}}
>
{_t("spotlight_dialog|messages_label")}
</Option>
)}
</div> </div>
</div> </div>
); );
@@ -997,28 +1012,6 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
); );
} }
let messageSearchSection: JSX.Element | undefined;
if (filter === null) {
messageSearchSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_messageSearch"
>
<h4 id="mx_SpotlightDialog_section_messageSearch">
{_t("spotlight_dialog|message_search_section_title")}
</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{_t(
"spotlight_dialog|search_messages_hint",
{},
{ icon: () => <div className="mx_SpotlightDialog_otherSearches_messageSearchIcon" /> },
)}
</div>
</div>
);
}
content = ( content = (
<> <>
{peopleSection} {peopleSection}
@@ -1031,7 +1024,6 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
{hiddenResultsSection} {hiddenResultsSection}
{otherSearchesSection} {otherSearchesSection}
{groupChatSection} {groupChatSection}
{messageSearchSection}
</> </>
); );
} else { } else {

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ChangeEvent, useContext, useEffect, useRef, useState } from "react"; import React, { type JSX, useContext, useEffect, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { import {
MenuItem, MenuItem,
@@ -52,7 +52,6 @@ import { ShareDialog } from "../dialogs/ShareDialog";
import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { E2EStatus } from "../../../utils/ShieldUtils"; import { E2EStatus } from "../../../utils/ShieldUtils";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import ExportDialog from "../dialogs/ExportDialog"; import ExportDialog from "../dialogs/ExportDialog";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
@@ -71,7 +70,6 @@ import { Box } from "../../utils/Box";
import { useDispatcher } from "../../../hooks/useDispatcher"; import { useDispatcher } from "../../../hooks/useDispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { useTransition } from "../../../hooks/useTransition";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
@@ -82,9 +80,10 @@ import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryC
interface IProps { interface IProps {
room: Room; room: Room;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
onSearchChange?: (e: ChangeEvent) => void; onSearchChange?: (term: string) => void;
onSearchCancel?: () => void; onSearchCancel?: () => void;
focusRoomSearch?: boolean; focusRoomSearch?: boolean;
searchTerm?: string;
} }
const onRoomMembersClick = (): void => { const onRoomMembersClick = (): void => {
@@ -180,6 +179,7 @@ const RoomSummaryCard: React.FC<IProps> = ({
onSearchChange, onSearchChange,
onSearchCancel, onSearchCancel,
focusRoomSearch, focusRoomSearch,
searchTerm,
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
@@ -244,19 +244,6 @@ const RoomSummaryCard: React.FC<IProps> = ({
searchInputRef.current?.focus(); searchInputRef.current?.focus();
} }
}); });
// Clear the search field when the user leaves the search view
useTransition(
(prevTimelineRenderingType) => {
if (
prevTimelineRenderingType === TimelineRenderingType.Search &&
roomContext.timelineRenderingType !== TimelineRenderingType.Search &&
searchInputRef.current
) {
searchInputRef.current.value = "";
}
},
[roomContext.timelineRenderingType],
);
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || ""; const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
const roomInfo = ( const roomInfo = (
@@ -332,7 +319,10 @@ const RoomSummaryCard: React.FC<IProps> = ({
<Search <Search
placeholder={_t("room|search|placeholder")} placeholder={_t("room|search|placeholder")}
name="room_message_search" name="room_message_search"
onChange={onSearchChange} onChange={(e) => {
onSearchChange(e.currentTarget.value);
}}
value={searchTerm}
className="mx_no_textinput" className="mx_no_textinput"
ref={searchInputRef} ref={searchInputRef}
autoFocus={focusRoomSearch} autoFocus={focusRoomSearch}

View File

@@ -362,7 +362,7 @@ export enum Action {
View3pidInvite = "view_3pid_invite", View3pidInvite = "view_3pid_invite",
/** /**
* Opens right panel room summary and focuses the search input * Opens right panel room summary and focuses the search input. Use with a FocusMessageSearchPayload.
*/ */
FocusMessageSearch = "focus_search", FocusMessageSearch = "focus_search",

View File

@@ -0,0 +1,15 @@
/*
Copyright 2025 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 { type Action } from "../actions";
import { type ActionPayload } from "../payloads";
export interface FocusMessageSearchPayload extends ActionPayload {
action: Action.FocusMessageSearch;
initialText?: string;
}

View File

@@ -1,27 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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.
*/
// Based on https://stackoverflow.com/a/61680184
import { type DependencyList, useEffect, useRef } from "react";
export const useTransition = <D extends DependencyList>(callback: (...params: D) => void, deps: D): void => {
const func = useRef<(...params: D) => void>(callback);
useEffect(() => {
func.current = callback;
}, [callback]);
const args = useRef<D | null>(null);
useEffect(() => {
if (args.current !== null) func.current(...args.current);
args.current = deps;
// eslint-disable-next-line react-compiler/react-compiler,react-hooks/exhaustive-deps
}, deps);
};

View File

@@ -3230,7 +3230,7 @@
"heading_without_query": "Search for", "heading_without_query": "Search for",
"join_button_text": "Join %(roomAddress)s", "join_button_text": "Join %(roomAddress)s",
"keyboard_scroll_hint": "Use <arrows/> to scroll", "keyboard_scroll_hint": "Use <arrows/> to scroll",
"message_search_section_title": "Other searches", "messages_label": "Messages",
"other_rooms_in_space": "Other rooms in %(spaceName)s", "other_rooms_in_space": "Other rooms in %(spaceName)s",
"public_rooms_label": "Public rooms", "public_rooms_label": "Public rooms",
"public_spaces_label": "Public spaces", "public_spaces_label": "Public spaces",
@@ -3240,7 +3240,6 @@
"result_may_be_hidden_privacy_warning": "Some results may be hidden for privacy", "result_may_be_hidden_privacy_warning": "Some results may be hidden for privacy",
"result_may_be_hidden_warning": "Some results may be hidden", "result_may_be_hidden_warning": "Some results may be hidden",
"search_dialog": "Search Dialog", "search_dialog": "Search Dialog",
"search_messages_hint": "To search messages, look for this icon at the top of a room <icon/>",
"spaces_title": "Spaces you're in", "spaces_title": "Spaces you're in",
"start_group_chat_button": "Start a group chat" "start_group_chat_button": "Start a group chat"
}, },

View File

@@ -309,6 +309,8 @@ export function createTestClient(): MatrixClient {
pushProcessor: { pushProcessor: {
getPushRuleById: jest.fn(), getPushRuleById: jest.fn(),
}, },
search: jest.fn().mockResolvedValue({}),
processRoomEventsSearch: jest.fn().mockResolvedValue({ highlights: [], results: [] }),
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);

View File

@@ -608,6 +608,7 @@ describe("RoomView", () => {
}); });
}); });
describe("message search", () => {
it("should close search results when edit is clicked", async () => { it("should close search results when edit is clicked", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
@@ -733,6 +734,24 @@ describe("RoomView", () => {
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
}); });
it("should pre-fill search field on FocusMessageSearch dispatch", async () => {
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);
const roomViewRef = createRef<RoomView>();
const { findByPlaceholderText } = await mountRoomView(roomViewRef);
await waitFor(() => expect(roomViewRef.current).toBeTruthy());
act(() =>
defaultDispatcher.dispatch({
action: Action.FocusMessageSearch,
initialText: "search term",
}),
);
await expect(findByPlaceholderText("Search messages…")).resolves.toHaveValue("search term");
});
});
it("fires Action.RoomLoaded", async () => { it("fires Action.RoomLoaded", async () => {
jest.spyOn(defaultDispatcher, "dispatch"); jest.spyOn(defaultDispatcher, "dispatch");
await mountRoomView(); await mountRoomView();

View File

@@ -353,12 +353,12 @@ describe("Spotlight Dialog", () => {
}); });
it("should find Rooms", () => { it("should find Rooms", () => {
expect(options).toHaveLength(4); expect(options).toHaveLength(5);
expect(options[0]!.innerHTML).toContain(testRoom.name); expect(options[0]!.innerHTML).toContain(testRoom.name);
}); });
it("should not find LocalRooms", () => { it("should not find LocalRooms", () => {
expect(options).toHaveLength(4); expect(options).toHaveLength(5);
expect(options[0]!.innerHTML).not.toContain(testLocalRoom.name); expect(options[0]!.innerHTML).not.toContain(testLocalRoom.name);
}); });
}); });
@@ -648,4 +648,20 @@ describe("Spotlight Dialog", () => {
}); });
}); });
}); });
it("should allow jumping into message search", async () => {
const onFinished = jest.fn();
render(<SpotlightDialog initialText="search term" onFinished={onFinished} />);
jest.advanceTimersByTime(200);
await flushPromisesWithFakeTimers();
fireEvent.click(screen.getByText("Messages"));
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.FocusMessageSearch,
initialText: "search term",
}),
);
});
}); });

View File

@@ -11,7 +11,6 @@ import { render, fireEvent, screen, waitFor } from "jest-matrix-react";
import { EventType, MatrixEvent, Room, type MatrixClient, JoinRule } from "matrix-js-sdk/src/matrix"; import { EventType, MatrixEvent, Room, type MatrixClient, JoinRule } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked, type MockedObject } from "jest-mock"; import { mocked, type MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import RoomSummaryCard from "../../../../../src/components/views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../../../../../src/components/views/right_panel/RoomSummaryCard";
@@ -30,8 +29,6 @@ import { _t } from "../../../../../src/languageHandler";
import { tagRoom } from "../../../../../src/utils/room/tagRoom"; import { tagRoom } from "../../../../../src/utils/room/tagRoom";
import { DefaultTagID } from "../../../../../src/stores/room-list/models"; import { DefaultTagID } from "../../../../../src/stores/room-list/models";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/ReportRoomDialog.tsx"; import { ReportRoomDialog } from "../../../../../src/components/views/dialogs/ReportRoomDialog.tsx";
jest.mock("../../../../../src/utils/room/tagRoom"); jest.mock("../../../../../src/utils/room/tagRoom");
@@ -169,38 +166,6 @@ describe("<RoomSummaryCard />", () => {
fireEvent.keyDown(getByPlaceholderText("Search messages…"), { key: "Escape" }); fireEvent.keyDown(getByPlaceholderText("Search messages…"), { key: "Escape" });
expect(onSearchCancel).toHaveBeenCalled(); expect(onSearchCancel).toHaveBeenCalled();
}); });
it("should empty search field when the timeline rendering type changes away", async () => {
const onSearchChange = jest.fn();
const { rerender } = render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Search } as any)}>
<RoomSummaryCard
room={room}
permalinkCreator={new RoomPermalinkCreator(room)}
onSearchChange={onSearchChange}
focusRoomSearch={true}
/>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
await userEvent.type(screen.getByPlaceholderText("Search messages…"), "test");
expect(screen.getByPlaceholderText("Search messages…")).toHaveValue("test");
rerender(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
<RoomSummaryCard
room={room}
permalinkCreator={new RoomPermalinkCreator(room)}
onSearchChange={onSearchChange}
/>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
expect(screen.getByPlaceholderText("Search messages…")).toHaveValue("");
});
}); });
it("opens room file panel on button click", () => { it("opens room file panel on button click", () => {