Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add fuzzy matching support in room searches #6182

Closed
wants to merge 3 commits into from
Closed
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
2 changes: 1 addition & 1 deletion src/components/structures/RoomSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface IState {
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
private searchFilter: NameFilterCondition = new NameFilterCondition();
private searchFilter: NameFilterCondition = new NameFilterCondition({ fuzzy: true });

constructor(props: IProps) {
super(props);
Expand Down
16 changes: 14 additions & 2 deletions src/stores/room-list/filters/NameFilterCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,23 @@ import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition
import { EventEmitter } from "events";
import { normalize } from "matrix-js-sdk/src/utils";
import { throttle } from "lodash";
import { fuzzyMatch } from "../../../utils/strings";

interface IOpts {
fuzzy?: boolean;
}

/**
* A filter condition for the room list which reveals rooms of a particular
* name, or associated name (like a room alias).
*/
export class NameFilterCondition extends EventEmitter implements IFilterCondition {
private _search = "";
private fuzzy = false;

constructor() {
constructor(options: IOpts = {}) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure how we feel about this more generally, but making this private fuzzy = false is slightly cleaner and works well with IDEs. The options approach does allow for expandability, however this is internal API and can be changed with relative ease.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I understand the change that you're suggesting.

As far as I can tell I have created private fuzzy = false and this options parameter is only here to let a developer choose the type of matching the NameFilterCondition should apply

Could you show me an example of what you had in mind instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, merging the field declaration with the constructor declaration: constructor(private fuzzy = false) {

It's a little bit cleaner for the API contract this part of the code is after.

super();
this.fuzzy = options.fuzzy === true;
}

public get kind(): FilterKind {
Expand Down Expand Up @@ -66,6 +73,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
}

public matches(normalizedName: string): boolean {
return normalizedName.includes(normalize(this.search));
const normalizedSearch = normalize(this.search);
if (this.fuzzy) {
return fuzzyMatch(normalizedName, normalizedSearch);
} else {
return normalizedName.includes(normalizedSearch);
}
}
}
26 changes: 25 additions & 1 deletion src/utils/strings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2021 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { memoize } from "lodash";

/**
* Copy plaintext to user's clipboard
* It will overwrite user's selection range
Expand Down Expand Up @@ -84,3 +86,25 @@ const collator = new Intl.Collator();
export function compare(a: string, b: string): number {
return collator.compare(a, b);
}

/**
* A cache for regular expression used in fuzzy matching.
* With memoization to avoid performance pitfalls.
* @param str The string to inspect.
* @return {RegExp} a regular expression to inspect `str`.
*/
const fuzzyCache = memoize((str: string): RegExp => {
return new RegExp("^"+str.replace(/./g, function(x) {
// eslint-disable-next-line no-useless-escape
return /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/.test(x) ? "\\"+x+"?" : x+"?";
})+"$");
});

/**
* @param str The string to inspect.
* @param target The string to search for.
* @returns {bool} Whether the string has a fuzzy match.
*/
export const fuzzyMatch = (str: string, target: string): boolean => {
return fuzzyCache(str).test(target);
};
53 changes: 53 additions & 0 deletions test/utils/strings-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { fuzzyMatch } from "../../src/utils/strings";

describe("strings", () => {
describe("fuzzyMatch()", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a test that deals with numbers and symbols interspersed with characters would be nice as well, to ensure the regex cleaning works (of which there doesn't appear to be any?) and that it handles non-ascii well (including emoji).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I have added more test cases to deal with more exotic character set.

I'm not sure if I know what regex cleaning is and what you expect it to do in this scenario?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"regex cleaning" is my horrible way of referring to the action the regex performs. There should be tests to make sure that the regex does the right thing, where the "right thing" is cleaning/sanitizing the input from what I can see.

it("should return exact matches", () => {
expect(fuzzyMatch("alice", "alice")).toBe(true);
expect(fuzzyMatch("", "")).toBe(true);
});
it("supports non ASCII characters", () => {
expect(fuzzyMatch("éè?]!", "é]")).toBe(true);
expect(fuzzyMatch("안녕하세요", "녕세")).toBe(true);
});
it("works with emojis", () => {
expect(fuzzyMatch("BoB 🥳", "🥳")).toBe(true);
expect(fuzzyMatch("1️⃣2️⃣3️⃣4️⃣5️⃣", "1️⃣3️⃣5️⃣")).toBe(true);
});
it("matches across multiple words", () => {
expect(fuzzyMatch("lorem ipsum dolor sit amet", "lidsa")).toBe(true);
});
it("doesn't match over multiple lines", () => {
expect(fuzzyMatch(`Hello
World`, "HW")).toBe(false);
})
it("should be case sensitive", () => {
expect(fuzzyMatch("BoB", "bOb")).toBe(false);
});
it("should match anywhere in the string", () => {
expect(fuzzyMatch("Alice", "Al")).toBe(true);
expect(fuzzyMatch("Alice", "lic")).toBe(true);
expect(fuzzyMatch("Alice", "ce")).toBe(true);
});
it("should allow for gaps in search", () => {
expect(fuzzyMatch("Alice", "Aie")).toBe(true);
expect(fuzzyMatch("Alice", "Ale")).toBe(true);
expect(fuzzyMatch("Alice", "lc")).toBe(true);
})
});
});