diff --git a/apps/web/src/components/views/settings/Notifications.tsx b/apps/web/src/components/views/settings/Notifications.tsx index 1d8803759b..ff2a2945e3 100644 --- a/apps/web/src/components/views/settings/Notifications.tsx +++ b/apps/web/src/components/views/settings/Notifications.tsx @@ -53,6 +53,7 @@ import { SettingsSubsection } from "./shared/SettingsSubsection"; import { doesRoomHaveUnreadMessages } from "../../../Unread"; import SettingsFlag from "../elements/SettingsFlag"; import { onSubmitPreventDefault } from "../../../utils/form.ts"; +import { keywordRuleId } from "../../../models/notificationsettings/keywordRuleId.ts"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. @@ -565,13 +566,16 @@ export default class Notifications extends React.PureComponent r.rule_id)); for (const word of diff.added) { - await MatrixClientPeg.safeGet().addPushRule("global", kind, word, { + const ruleId = keywordRuleId(word, ruleIds); + ruleIds.add(ruleId); + await MatrixClientPeg.safeGet().addPushRule("global", kind, ruleId, { actions: PushRuleVectorState.actionsFor(ruleVectorState), pattern: word, }); if (ruleVectorState === VectorState.Off) { - await MatrixClientPeg.safeGet().setPushRuleEnabled("global", kind, word, false); + await MatrixClientPeg.safeGet().setPushRuleEnabled("global", kind, ruleId, false); } } diff --git a/apps/web/src/models/notificationsettings/keywordRuleId.ts b/apps/web/src/models/notificationsettings/keywordRuleId.ts new file mode 100644 index 0000000000..987ab1dba8 --- /dev/null +++ b/apps/web/src/models/notificationsettings/keywordRuleId.ts @@ -0,0 +1,31 @@ +/* +Copyright 2026 hayaksi1 + +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. +*/ + +/** + * Work out which rule ID a keyword should be stored under. + * + * A keyword is normally its own rule ID, but homeservers reserve the IDs beginning with a dot for + * the rules they define themselves and refuse to store any other, so a keyword such as + * `...push complete` cannot be one. The ID drops those leading dots; the rule's pattern, which is + * what messages are actually matched against, keeps the keyword exactly as it was typed. That can + * leave two keywords wanting the same ID, so one of them is numbered off the end. + * + * @param keyword - The keyword as the user typed it. + * @param existingIds - The rule IDs already in use. + * @returns A rule ID the homeserver will accept and no other rule is using. + */ +export function keywordRuleId(keyword: string, existingIds: Iterable): string { + // A keyword of nothing but dots leaves nothing to name the rule after. + const base = keyword.replace(/^\.+/, "") || "keyword"; + + const taken = new Set(existingIds); + if (!taken.has(base)) return base; + + let suffix = 2; + while (taken.has(`${base}-${suffix}`)) suffix++; + return `${base}-${suffix}`; +} diff --git a/apps/web/src/models/notificationsettings/reconcileNotificationSettings.ts b/apps/web/src/models/notificationsettings/reconcileNotificationSettings.ts index fdf4f4925b..f32cfbeae7 100644 --- a/apps/web/src/models/notificationsettings/reconcileNotificationSettings.ts +++ b/apps/web/src/models/notificationsettings/reconcileNotificationSettings.ts @@ -15,6 +15,7 @@ import { RoomNotifState } from "../../RoomNotifs"; import { type NotificationSettings } from "./NotificationSettings"; import { type PushRuleDiff, type PushRuleUpdate } from "./PushRuleDiff"; import { buildPushRuleMap } from "./PushRuleMap"; +import { keywordRuleId } from "./keywordRuleId"; function toStandardRules( model: NotificationSettings, @@ -225,9 +226,12 @@ export function reconcileNotificationSettings( } newKeywords.delete(rule.pattern!); } + const ruleIds = new Set(contentRules.map((rule) => rule.rule_id)); for (const keyword of newKeywords) { + const ruleId = keywordRuleId(keyword, ruleIds); + ruleIds.add(ruleId); changes.added.push({ - rule_id: keyword, + rule_id: ruleId, kind: PushRuleKind.ContentSpecific, default: false, enabled: model.mentions.keywords, diff --git a/apps/web/test/unit-tests/components/views/settings/Notifications-test.tsx b/apps/web/test/unit-tests/components/views/settings/Notifications-test.tsx index 864ca485fa..c0468a2166 100644 --- a/apps/web/test/unit-tests/components/views/settings/Notifications-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/Notifications-test.tsx @@ -858,6 +858,20 @@ describe("", () => { }); }); + it("adds a keyword that starts with a dot", async () => { + await getComponentAndWait(); + + await userEvent.type(screen.getByLabelText("Keyword"), ".jest"); + + fireEvent.click(screen.getByText("Add")); + + // The homeserver rejects a rule id beginning with a dot, so only the pattern keeps it. + expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", { + actions: [PushRuleActionName.Notify, { set_tweak: "highlight", value: false }], + pattern: ".jest", + }); + }); + it("adds a new keyword with same actions as existing rules when keywords rule is off", async () => { const offContentRule = { ...bananaRule, diff --git a/apps/web/test/unit-tests/models/notificationsettings/NotificationSettings-test.ts b/apps/web/test/unit-tests/models/notificationsettings/NotificationSettings-test.ts index a51540129b..79045eb20f 100644 --- a/apps/web/test/unit-tests/models/notificationsettings/NotificationSettings-test.ts +++ b/apps/web/test/unit-tests/models/notificationsettings/NotificationSettings-test.ts @@ -215,4 +215,29 @@ describe("NotificationSettings", () => { expect(pendingChanges.deleted).toHaveLength(0); expect(pendingChanges.updated).toHaveLength(0); }); + + it("stores a keyword that starts with a dot under an id the server will accept", async () => { + const pushRules = (await import("./pushrules_default.json")) as IPushRules; + const model = { ...DefaultNotificationSettings, keywords: ["...push complete"] }; + + const pendingChanges = reconcileNotificationSettings(pushRules, model, false); + + expect(pendingChanges.added).toEqual([ + expect.objectContaining({ + kind: PushRuleKind.ContentSpecific, + rule_id: "push complete", + pattern: "...push complete", + }), + ]); + }); + + it("keeps the ids of two keywords that differ only by a leading dot apart", async () => { + const pushRules = (await import("./pushrules_default.json")) as IPushRules; + const model = { ...DefaultNotificationSettings, keywords: ["banana", ".banana"] }; + + const pendingChanges = reconcileNotificationSettings(pushRules, model, false); + + expect(pendingChanges.added.map((rule) => rule.rule_id)).toEqual(["banana", "banana-2"]); + expect(pendingChanges.added.map((rule) => rule.pattern)).toEqual(["banana", ".banana"]); + }); });