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: 4 additions & 1 deletion apps/web/res/css/views/rooms/_EventBubbleTile.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ Please see LICENSE files in the repository root for full details.
}

&:hover,
&.mx_EventTile_selected {
&.mx_EventTile_selected,
/* Focused search-stepping match: give the bubble the same subtle band as the
selected/hover state so the active match is distinguishable in bubble layout too. */
&.mx_EventTile_searchHighlightActive {
&::before {
background: $eventbubble-bg-hover;
}
Expand Down
9 changes: 7 additions & 2 deletions apps/web/res/css/views/rooms/_EventTile.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,20 @@ $left-gutter: 64px;
&:focus-visible:focus-within,
&.mx_EventTile_actionBarFocused,
&.mx_EventTile_isEditing,
&.mx_EventTile_selected {
&.mx_EventTile_selected,
/* The live tile currently focused while stepping search matches reuses the
subtle "selected" background + the accent stroke below, so it stands out from a hovered tile
and from the mention/keyword highlight while the match stays focused. */
&.mx_EventTile_searchHighlightActive {
.mx_EventTile_line {
background-color: $event-selected-color;
}
}

/* this is used for the tile for the event which is selected via the URL. */
&.mx_EventTile_isEditing,
&.mx_EventTile_selected {
&.mx_EventTile_selected,
&.mx_EventTile_searchHighlightActive {
> .mx_EventTile_line {
/* TODO: ultimately we probably want some transition on here. */
box-shadow: inset var(--EventTile-box-shadow-offset-x) 0 0 var(--EventTile-box-shadow-spread-radius)
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/Searching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,104 @@ export enum SearchScope {
All = "All",
}

/**
* The location of a single search match, used for stepping through matches in the live timeline.
*/
export interface SearchMatch {
/**
* The room the matched event belongs to.
*/
roomId: string;
/**
* The id of the matched event.
*/
eventId: string;
}

/**
* Build a chronologically ordered list of match locations from a set of search results, for in-timeline
* stepping.
*
* Matches are ordered newest-first by event timestamp so that the up/down arrows mean a consistent
* "newer/older" independent of how the backend happened to order the raw results. This explicit client-side
* sort guarantees a single chronological order on the merged stepping list regardless of the backend's
* ordering, so stepping stays "newer/older" in every case.
* `Array.prototype.sort` is stable, so matches sharing a timestamp keep their backend order; a match whose
* event has no timestamp sinks to the end (treated as oldest). Results whose matched event is missing an event
* id or room id are skipped — they cannot be jumped to in the timeline.
*/
export function extractSearchMatches(results: ISearchResults): SearchMatch[] {
const matches: Array<SearchMatch & { ts: number }> = [];
for (const result of results.results ?? []) {
const event = result.context.getEvent();
const eventId = event.getId();
const roomId = event.getRoomId();
if (eventId && roomId) {
// getTs() is typed as number but masks a possibly-absent origin_server_ts with a non-null
// assertion; default to 0 so an undated match can never produce a NaN comparison and corrupt order.
matches.push({ roomId, eventId, ts: event.getTs() ?? 0 });
}
}
matches.sort((a, b) => b.ts - a.ts);
return matches.map(({ roomId, eventId }) => ({ roomId, eventId }));
}

/**
* A single search result enriched for the Telegram-style results dropdown: the jumpable location
* ({@link SearchMatch}) plus the data a compact row needs — sender MXID, the matched message body and timestamp.
*/
export interface SearchResultPreview extends SearchMatch {
/** The MXID of the matched event's sender. */
sender: string;
/** The matched message body (plain text) shown as the row preview. */
body: string;
/** The matched event's origin-server timestamp (ms), used to render the row date. */
ts: number;
}

/**
* Build the ordered list of result previews for the search results dropdown.
*
* Ordered identically to {@link extractSearchMatches} (newest-first by timestamp, stable, undated last, results
* missing an event/room id skipped) so that preview row index `i` maps to match `i` — letting a row click reuse the
* existing {@link SearchMatch}-based live-timeline stepping. Pure: the backend results are not mutated.
*/
export function extractSearchResultPreviews(results: ISearchResults): SearchResultPreview[] {
const previews: SearchResultPreview[] = [];
for (const result of results.results ?? []) {
const event = result.context.getEvent();
const eventId = event.getId();
const roomId = event.getRoomId();
if (eventId && roomId) {
previews.push({
roomId,
eventId,
sender: event.getSender() ?? "",
body: event.getContent().body ?? "",
// Default an absent timestamp to 0 so it sorts last and never produces a NaN comparison.
ts: event.getTs() ?? 0,
});
}
}
previews.sort((a, b) => b.ts - a.ts);
return previews;
}

/**
* Build the ordered list of terms to highlight in matched message bodies for a set of search results.
*
* Mirrors the enrichment the results list applies (see RoomSearchView): the literal search term is always
* highlighted even if the backend (Synapse/Seshat) did not echo it back, and terms are ordered longest-first so
* that overlapping highlights favour the more specific term. Pure — the backend `highlights` array is not mutated.
*/
export function extractSearchHighlights(results: ISearchResults, term: string): string[] {
const highlights = [...(results.highlights ?? [])];
if (!highlights.includes(term)) {
highlights.push(term);
}
return highlights.sort((a, b) => b.length - a.length);
}

/**
* Information about a message search in progress.
*/
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/structures/MessagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ interface IProps {
// ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId?: string;

// Terms to highlight in the body of the focused search match while stepping through search results in the
// live timeline. Only applied to the tile whose id matches `searchHighlightEventId`.
searchHighlights?: string[];

// Id of the event whose body should have `searchHighlights` applied. If undefined, no body is highlighted.
searchHighlightEventId?: string;

// The room these events are all in together, if any.
// (The notification panel won't have a room here, for example.)
room?: Room;
Expand Down Expand Up @@ -801,6 +808,13 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const eventId = mxEv.getId()!;
const highlight = eventId === this.props.highlightedEventId;

// While stepping through search matches in the live timeline, highlight the matched terms in the body of
// the focused match only (the same `mx_EventTile_searchHighlight` styling used in the results list), and
// mark that whole tile as the active stepping match so it stands out (`mx_EventTile_searchHighlightActive`).
const isSearchHighlightMatch =
this.props.searchHighlightEventId !== undefined && eventId === this.props.searchHighlightEventId;
const searchHighlights = isSearchHighlightMatch ? this.props.searchHighlights : undefined;

const readReceipts = this.readReceiptsByEvent.get(eventId);

const callEventGrouper = this.props.callEventGroupers.get(mxEv.getContent().call_id);
Expand Down Expand Up @@ -828,6 +842,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
lastInSection={lastInSection}
lastSuccessful={wrappedEvent.lastSuccessfulWeSent}
isSelectedEvent={highlight}
highlights={searchHighlights}
isSearchHighlightMatch={isSearchHighlightMatch}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
Expand Down
Loading
Loading