Commit a4555a7eae2d
tarball15 files changed+197-6
@@ -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::SessionSaved225227
| StateChange::VerificationTarget(_)226
- | StateChange::NotificationSettings227228
| StateChange::History { .. }228229
| StateChange::HistoryRepair(_)229230
| StateChange::Typing(_) => vec![],@@ -23,6 +23,16 @@ fn default_avatar_size() -> u32 {
2323
AVATAR_SIZE2424
}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
+ handle1036
+ .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?;@@ -581,6 +581,9 @@ async fn post_command(
581581
| Request::SetRoomDisplayName { .. }582582
| Request::SetRoomAvatar { .. }583583
| Request::Mute { .. }584
+ | Request::NotificationKeywords585
+ | Request::AddNotificationKeyword { .. }586
+ | Request::RemoveNotificationKeyword { .. }584587
| Request::Ignore { .. }585588
| Request::Unignore { .. }586589
| Request::PowerLevels { .. }@@ -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
}@@ -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>@@ -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;@@ -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:editing268
- class:mentioned={mentionsMe}270
+ class:mentioned={mentionsMe || keywordHit}269271
class:system={!interactive}270272
class:has-readers={interactive && readers.length > 0}271273
class:selected@@ -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>@@ -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 = keywords8
+ .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
+}@@ -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
+}@@ -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 {@@ -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;@@ -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 }@@ -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'] === true648
+ editable: raw['editable'] === true,649
+ highlight: raw['highlight'] === true648650
};649651
}650652
@@ -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
+});