atagen

malice

public
Star0
Watch0

Commit a4555a7eae2d

tarball

atagen <boss@atagen.co>2026-10-25 10:10 UTC

15 files changed+197-6
keywords: highlight configured keywords like mentions

Keywords are Matrix push rule content patterns managed through macltok's notification keyword calls, so they follow the account to other clients. Rows matching one get the mention treatment, using the event's highlight flag once macltok provides it and a client-side match on the same word-boundary glob semantics until then. The mentions inbox accepts a Keyword reason, because its parser previously dropped entries with unknown reasons.
Modified file: crates/malice-core/src/feed.rs+2 additions−1 deletions
@@ -29,6 +29,7 @@ pub enum WireEvent {
2929 room_id: String,
3030 state: RecentHistoryState,
3131 },
32+ NotificationSettings,
3233 ImagePacks {
3334 packs: ImagePacks,
3435 },
@@ -198,6 +199,7 @@ pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {
198199 room_id: room_id.as_str().to_owned(),
199200 state: state.clone(),
200201 }],
202+ StateChange::NotificationSettings => vec![WireEvent::NotificationSettings],
201203 StateChange::Failed(failure) => vec![error(&failure.message)],
202204 StateChange::SessionSaveFailed(_) => {
203205 vec![error(
@@ -223,7 +225,6 @@ pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {
223225 | StateChange::Session(_)
224226 | StateChange::SessionSaved
225227 | StateChange::VerificationTarget(_)
226- | StateChange::NotificationSettings
227228 | StateChange::History { .. }
228229 | StateChange::HistoryRepair(_)
229230 | StateChange::Typing(_) => vec![],
Modified file: crates/malice-core/src/request.rs+30 additions
@@ -23,6 +23,16 @@ fn default_avatar_size() -> u32 {
2323 AVATAR_SIZE
2424 }
2525
26+fn parse_keyword(raw: &str) -> Result<String, CoreError> {
27+ let keyword = raw.trim();
28+ if keyword.is_empty() || keyword.chars().count() > 128 {
29+ return Err(CoreError::bad_request(
30+ "keyword must be 1 to 128 characters",
31+ ));
32+ }
33+ Ok(keyword.to_owned())
34+}
35+
2636 #[derive(Debug, Deserialize)]
2737 #[serde(tag = "op", rename_all = "snake_case")]
2838 pub enum Request {
@@ -285,6 +295,13 @@ pub enum Request {
285295 user_id: String,
286296 muted: bool,
287297 },
298+ NotificationKeywords,
299+ AddNotificationKeyword {
300+ keyword: String,
301+ },
302+ RemoveNotificationKeyword {
303+ keyword: String,
304+ },
288305 Ignore {
289306 user_id: String,
290307 },
@@ -1011,6 +1028,19 @@ pub async fn dispatch(
10111028 .await?;
10121029 Ok(Reply::Done)
10131030 }
1031+ Request::NotificationKeywords => Ok(Reply::Json(
1032+ json!({ "keywords": handle.notification_keywords().await }),
1033+ )),
1034+ Request::AddNotificationKeyword { keyword } => {
1035+ handle
1036+ .add_notification_keyword(parse_keyword(&keyword)?)
1037+ .await?;
1038+ Ok(Reply::Done)
1039+ }
1040+ Request::RemoveNotificationKeyword { keyword } => {
1041+ handle.remove_notification_keyword(keyword).await?;
1042+ Ok(Reply::Done)
1043+ }
10141044 Request::UploadMedia { bytes } => {
10151045 let content_type = image_upload_type(&bytes)?;
10161046 let mxc = handle.upload_media(content_type, bytes).await?;
Modified file: crates/malice-server/src/routes.rs+3 additions
@@ -581,6 +581,9 @@ async fn post_command(
581581 | Request::SetRoomDisplayName { .. }
582582 | Request::SetRoomAvatar { .. }
583583 | Request::Mute { .. }
584+ | Request::NotificationKeywords
585+ | Request::AddNotificationKeyword { .. }
586+ | Request::RemoveNotificationKeyword { .. }
584587 | Request::Ignore { .. }
585588 | Request::Unignore { .. }
586589 | Request::PowerLevels { .. }
Modified file: web/src/App.svelte+4 additions
@@ -87,6 +87,7 @@
8787 import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
8888 import type { Mention, MentionQuery } from './lib/mentions';
8989 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
90+ import { loadKeywords } from './lib/keywords';
9091 import {
9192 linkPreviews,
9293 loadUrlPreviewSettings,
@@ -192,6 +193,7 @@
192193 const online = $connection === 'online';
193194 if (online && !wasOnline) {
194195 void loadQuickReactions();
196+ void loadKeywords();
195197 void loadPackPreferences();
196198 void loadUrlPreviewSettings();
197199 }
@@ -415,6 +417,7 @@
415417 void loadProfile();
416418 void loadEncryption();
417419 void loadQuickReactions();
420+ void loadKeywords();
418421 void loadPackPreferences();
419422 void loadUrlPreviewSettings();
420423 } else {
@@ -472,6 +475,7 @@
472475 void loadProfile();
473476 void loadEncryption();
474477 void loadQuickReactions();
478+ void loadKeywords();
475479 void loadPackPreferences();
476480 void loadUrlPreviewSettings();
477481 }
Added file: web/src/lib/KeywordSettings.svelte+62 additions
@@ -0,0 +1,62 @@
1+<script lang="ts">
2+ import { addKeyword, notificationKeywords, removeKeyword } from './keywords';
3+ import { pushBanner } from './store';
4+
5+ let draft = '';
6+ let busy = false;
7+
8+ $: trimmed = draft.trim();
9+ $: duplicate = $notificationKeywords.some((k) => k.toLowerCase() === trimmed.toLowerCase());
10+
11+ async function run(action: () => Promise<void>, failure: string): Promise<void> {
12+ if (busy) return;
13+ busy = true;
14+ try {
15+ await action();
16+ } catch (e) {
17+ pushBanner(e instanceof Error ? e.message : failure);
18+ } finally {
19+ busy = false;
20+ }
21+ }
22+
23+ function add(): void {
24+ if (!trimmed || duplicate) return;
25+ const keyword = trimmed;
26+ void run(async () => {
27+ await addKeyword(keyword);
28+ draft = '';
29+ }, 'Adding the keyword failed');
30+ }
31+</script>
32+
33+<section aria-label="Keywords">
34+ <h3 class="sec-h">Keywords</h3>
35+ {#if $notificationKeywords.length}
36+ <ul class="keywords">
37+ {#each $notificationKeywords as keyword (keyword)}
38+ <li>
39+ <span>{keyword}</span>
40+ <button type="button" class="icon-btn" aria-label={`Remove ${keyword}`} disabled={busy} on:click={() => void run(() => removeKeyword(keyword), 'Removing the keyword failed')}>
41+ <span aria-hidden="true">✕</span>
42+ </button>
43+ </li>
44+ {/each}
45+ </ul>
46+ {/if}
47+ <form class="add" on:submit|preventDefault={add}>
48+ <input type="text" placeholder="Keyword" bind:value={draft} maxlength="128" aria-label="New keyword" />
49+ <button type="submit" class="ghost-btn" disabled={busy || !trimmed || duplicate}>Add</button>
50+ </form>
51+</section>
52+
53+<style>
54+ section { margin-top: 18px; }
55+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }
56+ .keywords { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
57+ .keywords li { display: inline-flex; align-items: center; gap: 2px; padding: 2px 2px 2px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); font-size: 13px; animation: pop-in var(--pop); }
58+ .keywords .icon-btn { width: 22px; height: 22px; font-size: 11px; }
59+ .add { display: flex; gap: 8px; }
60+ .add input { flex: 1; min-width: 0; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); padding: 6px 8px; }
61+ .add input:focus { outline: none; border-color: var(--accent); }
62+</style>
Modified file: web/src/lib/MentionsInbox.svelte+1 additions−1 deletions
@@ -52,7 +52,7 @@
5252 return entry.room_name || joinedRooms.get(entry.key.room_id)?.name || entry.key.room_id;
5353 }
5454 function reasonLabel(reason: string): string {
55- return reason === 'Direct' ? 'Direct mention' : reason === 'Reply' ? 'Reply' : 'Room mention';
55+ return reason === 'Direct' ? 'Direct mention' : reason === 'Reply' ? 'Reply' : reason === 'Keyword' ? 'Keyword' : 'Room mention';
5656 }
5757 function captureLabel(state: MentionEntry['capture']): string {
5858 if (state.kind === 'Failed') return state.message;
Modified file: web/src/lib/MessageRow.svelte+3 additions−1 deletions
@@ -7,6 +7,7 @@
77 import ReactionPicker from './ReactionPicker.svelte';
88 import LinkCard from './LinkCard.svelte';
99 import { mediaUrl, messageMediaUrl } from './api';
10+ import { keywordMatcher } from './keywords';
1011 import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
1112 import { senderLabel } from './types';
1213 import { formatClock, formatDay, initialsOf, senderHue } from './format';
@@ -101,6 +102,7 @@
101102 $: linked = message ? [...mentionsFromHtml(message.formatted_body), ...stripped.mentions] : [];
102103 $: mentions = resolveMentions(stripped.text, linked);
103104 $: mentionsMe = self !== null && [...linked, ...mentions].some((m) => m.user_id === self);
105+ $: keywordHit = !event.own && (event.highlight === true || (message !== null && ($keywordMatcher?.test(stripped.text) ?? false)));
104106 $: previews = findUrls(stripped.text);
105107 $: bundled = message ? message.link_previews : [];
106108 $: cards = previews
@@ -265,7 +267,7 @@
265267 class="msg"
266268 class:own={event.own}
267269 class:editing
268- class:mentioned={mentionsMe}
270+ class:mentioned={mentionsMe || keywordHit}
269271 class:system={!interactive}
270272 class:has-readers={interactive && readers.length > 0}
271273 class:selected
Modified file: web/src/lib/SettingsPanel.svelte+3 additions
@@ -13,6 +13,7 @@
1313 import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';
1414 import ReactionPicker from './ReactionPicker.svelte';
1515 import ThemeSettings from './ThemeSettings.svelte';
16+ import KeywordSettings from './KeywordSettings.svelte';
1617 import type { ImagePack, QuickReaction } from './types';
1718
1819 export let quick: QuickReaction[];
@@ -79,6 +80,8 @@
7980 </div>
8081 </section>
8182
83+ <KeywordSettings />
84+
8285 <section aria-label="Quick reactions">
8386 <div class="sec-head">
8487 <h3 class="sec-h">Quick reactions</h3>
Added file: web/src/lib/keyword-pattern.ts+13 additions
@@ -0,0 +1,13 @@
1+function escape(text: string): string {
2+ return text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
3+}
4+
5+/** Push rule content patterns are case-insensitive globs that match whole words. */
6+export function keywordPattern(keywords: string[]): RegExp | null {
7+ const parts = keywords
8+ .map((k) => k.trim())
9+ .filter(Boolean)
10+ .map((k) => escape(k).replace(/\*/g, '.*').replace(/\?/g, '.'));
11+ if (parts.length === 0) return null;
12+ return new RegExp(`(?<![\\p{L}\\p{N}_])(?:${parts.join('|')})(?![\\p{L}\\p{N}_])`, 'iu');
13+}
Added file: web/src/lib/keywords.ts+35 additions
@@ -0,0 +1,35 @@
1+import { derived, writable } from 'svelte/store';
2+import { runCommand } from './api';
3+import { keywordPattern } from './keyword-pattern';
4+
5+export const notificationKeywords = writable<string[]>([]);
6+
7+export const keywordMatcher = derived(notificationKeywords, keywordPattern);
8+
9+function parse(raw: unknown): string[] {
10+ if (typeof raw !== 'object' || raw === null) return [];
11+ const list = (raw as Record<string, unknown>)['keywords'];
12+ return Array.isArray(list) ? list.filter((k): k is string => typeof k === 'string') : [];
13+}
14+
15+export async function loadKeywords(): Promise<void> {
16+ try {
17+ notificationKeywords.set(parse(await runCommand({ op: 'notification_keywords' })));
18+ } catch {
19+ notificationKeywords.set([]);
20+ }
21+}
22+
23+export async function addKeyword(keyword: string): Promise<void> {
24+ await runCommand({ op: 'add_notification_keyword', keyword: keyword.trim() });
25+ await loadKeywords();
26+}
27+
28+export async function removeKeyword(keyword: string): Promise<void> {
29+ await runCommand({ op: 'remove_notification_keyword', keyword });
30+ await loadKeywords();
31+}
32+
33+export function resetKeywords(): void {
34+ notificationKeywords.set([]);
35+}
Modified file: web/src/lib/mentions-model.ts+2 additions−2 deletions
@@ -2,7 +2,7 @@ import { parseEventContext, parseTimelineItem } from './types';
22 import type { EventContext, TimelineItem } from './types';
33
44 export interface MentionKey { room_id: string; event_id: string }
5-export type MentionReason = 'Direct' | 'Reply' | 'Room';
5+export type MentionReason = 'Direct' | 'Reply' | 'Room' | 'Keyword';
66 export type MentionCaptureState =
77 | { kind: 'Pending' | 'Partial' | 'Ready' | 'Unavailable' }
88 | { kind: 'Failed'; message: string; retry_at_ms: number };
@@ -41,7 +41,7 @@ function capture(v: unknown): MentionCaptureState | null {
4141 function entry(v: unknown): MentionEntry | null {
4242 if (!obj(v)) return null;
4343 const k = key(v.key), ev = parseTimelineItem(v.event), c = capture(v.capture);
44- if (!k || !ev || !c || !nat(v.timestamp_ms) || !nat(v.discovered_at_ms) || !nat(v.discovery_revision) || !nat(v.revision) || !Array.isArray(v.reasons) || !v.reasons.every(x => str(x) && ['Direct','Reply','Room'].includes(x)) || !bool(v.read) || !(v.room_name === null || str(v.room_name)) || !bool(v.notification_suppressed)) return null;
44+ if (!k || !ev || !c || !nat(v.timestamp_ms) || !nat(v.discovered_at_ms) || !nat(v.discovery_revision) || !nat(v.revision) || !Array.isArray(v.reasons) || !v.reasons.every(x => str(x) && ['Direct','Reply','Room','Keyword'].includes(x)) || !bool(v.read) || !(v.room_name === null || str(v.room_name)) || !bool(v.notification_suppressed)) return null;
4545 return { key:k, timestamp_ms:v.timestamp_ms, discovered_at_ms:v.discovered_at_ms, discovery_revision:v.discovery_revision, revision:v.revision, event:ev, reasons:v.reasons as MentionReason[], read:v.read, capture:c, room_name:v.room_name, notification_suppressed:v.notification_suppressed };
4646 }
4747 function gap(v: unknown): MentionGap | null {
Modified file: web/src/lib/store.ts+6 additions
@@ -5,6 +5,7 @@ import { getSession, subscribeEvents } from './api';
55 import type { SoftLogout } from './api';
66 import { isRecord } from './guards';
77 import { observeArrivals } from './arrivals';
8+import { loadKeywords, resetKeywords } from './keywords';
89 import { resetLinkPreviews } from './link-previews';
910 import { resetQuickReactions } from './quick-reactions';
1011 import { resetPackPreferences } from './pack-preferences';
@@ -97,6 +98,7 @@ export function resetClientState(): void {
9798 resetQuickReactions();
9899 resetPackPreferences();
99100 resetLinkPreviews();
101+ resetKeywords();
100102 onlineSince = 0;
101103 liveSince = {};
102104 }
@@ -197,6 +199,10 @@ function handlePayload(text: string): void {
197199 if (typeof roomId === 'string' && state) recentHistory.update((all) => ({ ...all, [roomId]: state }));
198200 return;
199201 }
202+ if (kind === 'notification_settings') {
203+ void loadKeywords();
204+ return;
205+ }
200206 if (kind === 'image_packs') {
201207 imagePacks.set(parseImagePacks(data['packs']));
202208 return;
Modified file: web/src/lib/transport.ts+2 additions
@@ -35,6 +35,8 @@ export type CommandRequest =
3535 | { op: 'set_room_display_name'; room_id: string; name: string | null }
3636 | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
3737 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
38+ | { op: 'notification_keywords' }
39+ | { op: 'add_notification_keyword' | 'remove_notification_keyword'; keyword: string }
3840 | { op: 'ignore' | 'unignore'; user_id: string }
3941 | { op: 'power_levels'; room_id: string }
4042 | { op: 'open_pinned' | 'close_pinned'; room_id: string }
Modified file: web/src/lib/types.ts+3 additions−1 deletions
@@ -609,6 +609,7 @@ export interface TimelineEvent {
609609 reactions: Reaction[];
610610 read_by: string[];
611611 editable: boolean;
612+ highlight?: boolean;
612613 delivery_lag_ms?: number;
613614 }
614615
@@ -644,7 +645,8 @@ function parseTimelineEvent(raw: Record<string, unknown>): TimelineEvent {
644645 thread_summary: parseThreadSummary(raw['thread_summary']),
645646 reactions: parseReactions(raw['reactions']),
646647 read_by: stringList(raw['read_by']),
647- editable: raw['editable'] === true
648+ editable: raw['editable'] === true,
649+ highlight: raw['highlight'] === true
648650 };
649651 }
650652
Added file: web/test/keyword-pattern.test.ts+28 additions
@@ -0,0 +1,28 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { keywordPattern } from '../src/lib/keyword-pattern.ts';
4+
5+test('keywords match whole words without case', () => {
6+ const pattern = keywordPattern(['bolt']);
7+ assert.ok(pattern?.test('hey Bolt, look'));
8+ assert.ok(pattern?.test('bolt'));
9+ assert.equal(pattern?.test('thunderbolts'), false);
10+ assert.equal(pattern?.test('bolt_two'), false);
11+});
12+
13+test('keywords treat regex syntax literally and globs as wildcards', () => {
14+ assert.ok(keywordPattern(['c++'])?.test('writing c++ today'));
15+ assert.equal(keywordPattern(['a.b'])?.test('axb'), false);
16+ assert.ok(keywordPattern(['deploy*'])?.test('deployment went out'));
17+ assert.ok(keywordPattern(['gr?y'])?.test('grey skies'));
18+});
19+
20+test('keywords outside latin script still respect word edges', () => {
21+ const pattern = keywordPattern(['привет']);
22+ assert.ok(pattern?.test('ну привет'));
23+ assert.equal(pattern?.test('приветствую'), false);
24+});
25+
26+test('an empty list matches nothing', () => {
27+ assert.equal(keywordPattern([' ', '']), null);
28+});