atagen

macltok

public
Star0
Watch0

Commit 038cc6faeb14

tarball

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

4 files changed+85-25
mentions: keep direct rooms out of the inbox

DMs have their own list and badges, so a ping there showed up twice. Malice can't drop them itself, because unread_count, coverage, paging and the read boundaries are all worked out here. The filter reads the room's m.direct targets each tick rather than at classify time, so entries already saved come back when a room stops being direct, and a room that turns direct drops out with a revision bump. Captures wait while a room is direct.
Modified file: src/mentions/service.rs+31 additions−11 deletions
@@ -107,6 +107,9 @@ pub(crate) struct Service {
107107 unpublished: bool,
108108 flush_after: u64,
109109 ignored: BTreeSet<OwnedUserId>,
110+ /// kept out of everything the inbox shows, while their entries stay saved for when the
111+ /// room stops being direct
112+ direct: BTreeSet<OwnedRoomId>,
110113 leaving: BTreeSet<OwnedRoomId>,
111114 timed_out: Arc<AtomicBool>,
112115 config: EngineConfig,
@@ -282,7 +285,8 @@ impl Service {
282285 for reason in &loaded.gaps {
283286 loaded.data.gap(None, *reason, None);
284287 }
285- let page = loaded.data.page(&account, None, 50)?;
288+ let direct = direct_rooms(&client);
289+ let page = loaded.data.page(&account, None, 50, &direct)?;
286290 let media_scope = MediaScope::bounded(config.max_mention_context_events);
287291 media
288292 .register(
@@ -313,6 +317,7 @@ impl Service {
313317 unpublished: false,
314318 flush_after: 0,
315319 ignored: BTreeSet::new(),
320+ direct,
316321 leaving: BTreeSet::new(),
317322 timed_out: timed_out.clone(),
318323 config,
@@ -347,7 +352,7 @@ impl Service {
347352
348353 async fn publish(&mut self) {
349354 self.unpublished = false;
350- let Ok(page) = self.data.page(&self.account, None, 50) else {
355+ let Ok(page) = self.data.page(&self.account, None, 50, &self.direct) else {
351356 return;
352357 };
353358 self.media
@@ -518,7 +523,9 @@ impl Service {
518523 async fn command(&mut self, command: MentionsCommand) -> Result<CommandOutput, Error> {
519524 match command {
520525 MentionsCommand::Page { cursor, limit } => {
521- let page = self.data.page(&self.account, cursor.as_deref(), limit)?;
526+ let page = self
527+ .data
528+ .page(&self.account, cursor.as_deref(), limit, &self.direct)?;
522529 self.media
523530 .register(
524531 &self.media_scope,
@@ -569,8 +576,11 @@ impl Service {
569576 }
570577 MentionsCommand::SetRead { keys, read } => {
571578 let mut next = self.data.clone();
572- for key in keys {
573- if let Some(entry) = next.entries.get_mut(&id(&key)) {
579+ for key in keys
580+ .iter()
581+ .filter(|key| !self.direct.contains(&key.room_id))
582+ {
583+ if let Some(entry) = next.entries.get_mut(&id(key)) {
574584 entry.read = read;
575585 entry.revision = self.data.revision + 1;
576586 }
@@ -583,11 +593,10 @@ impl Service {
583593 return Err(invalid("invalid mentions read boundary"));
584594 }
585595 let mut next = self.data.clone();
586- for entry in next
587- .entries
588- .values_mut()
589- .filter(|entry| entry.discovery_revision <= boundary.revision)
590- {
596+ for entry in next.entries.values_mut().filter(|entry| {
597+ entry.discovery_revision <= boundary.revision
598+ && !self.direct.contains(&entry.key.room_id)
599+ }) {
591600 entry.read = true;
592601 entry.revision = self.data.revision + 1;
593602 }
@@ -1200,6 +1209,8 @@ impl Service {
12001209 () = &mut tick => {
12011210 tick = Box::pin(matrix_sdk::sleep::sleep(Duration::from_secs(1)));
12021211 if now() >= next_scan && !history { self.recovery_budget = 20; self.latest_scan = true; next_scan = now() + 60_000; }
1212+ let direct = direct_rooms(&self.client);
1213+ if direct != self.direct { self.direct = direct; self.dirty = true; self.unpublished = true; }
12031214 if now() >= next_expiry { let expired = self.data.expire_candidates(now()); self.dirty |= expired; self.unpublished |= expired; next_expiry = now() + 60_000; }
12041215 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
12051216 if self.store.is_none() && self.reopen.is_some() && now() >= self.flush_after { self.reopen().await; }
@@ -1385,7 +1396,7 @@ impl Service {
13851396 ) {
13861397 for (key, entry) in self.data.entries.iter().filter(|(_, entry)| matches!(entry.capture, MentionCaptureState::Pending) || matches!(entry.capture, MentionCaptureState::Failed {retry_at_ms, ..} if retry_at_ms <= now())).take(128) {
13871398 if captures.len() >= 2 { break; }
1388- if captures.contains(key) { continue; }
1399+ if captures.contains(key) || self.direct.contains(&entry.key.room_id) { continue; }
13891400 let Some(room) = self.client.get_room(&entry.key.room_id).filter(|room| room.state() == RoomState::Joined) else { continue; };
13901401 captures.insert(key.clone());
13911402 let key = key.clone(); let event_id = entry.key.event_id.clone(); let account = self.account.clone(); let revision = entry.revision;
@@ -1649,6 +1660,15 @@ impl Service {
16491660 }
16501661 }
16511662
1663+fn direct_rooms(client: &Client) -> BTreeSet<OwnedRoomId> {
1664+ client
1665+ .rooms()
1666+ .into_iter()
1667+ .filter(|room| room.direct_targets_length() > 0)
1668+ .map(|room| room.room_id().to_owned())
1669+ .collect()
1670+}
1671+
16521672 fn edited(mut original: Value, edit: &Value) -> Value {
16531673 original["content"] = edit["content"]["m.new_content"].clone();
16541674 original["unsigned"]["macltok_edited"] = Value::Bool(true);
Modified file: src/mentions/service_tests.rs+10 additions−3 deletions
@@ -707,7 +707,9 @@ async fn observing_again_keeps_the_profiles_a_capture_fetched() {
707707 value["origin_server_ts"] = json!(timestamp);
708708 value
709709 };
710- let factory = EventFactory::new().room(room_id).sender(user_id!("@bob:localhost"));
710+ let factory = EventFactory::new()
711+ .room(room_id)
712+ .sender(user_id!("@bob:localhost"));
711713 let mention = raw(
712714 factory
713715 .text_msg("ping")
@@ -738,7 +740,9 @@ async fn observing_again_keeps_the_profiles_a_capture_fetched() {
738740 item
739741 };
740742 let target = profiled(&data.entries[&ping].event);
741- let mut neighbour = normalise_observed(&following, None, &following_id).unwrap().0;
743+ let mut neighbour = normalise_observed(&following, None, &following_id)
744+ .unwrap()
745+ .0;
742746 neighbour = profiled(&neighbour);
743747 let revision = data.entries[&ping].revision;
744748 service.captured(
@@ -768,6 +772,9 @@ async fn observing_again_keeps_the_profiles_a_capture_fetched() {
768772 };
769773 assert_eq!(name(&data.entries[&ping].event).as_deref(), Some("Bob"));
770774 assert_eq!(name(&data.events[&ping].item).as_deref(), Some("Bob"));
771- assert_eq!(name(&data.events[&following_id].item).as_deref(), Some("Bob"));
775+ assert_eq!(
776+ name(&data.events[&following_id].item).as_deref(),
777+ Some("Bob")
778+ );
772779 assert_eq!(data.entries[&ping].capture, capture);
773780 }
Modified file: src/mentions/state.rs+19 additions−4 deletions
@@ -1,6 +1,6 @@
11 use std::collections::{BTreeMap, BTreeSet};
22
3-use ruma::OwnedUserId;
3+use ruma::{OwnedRoomId, OwnedUserId};
44 use serde_json::Value;
55
66 use crate::{
@@ -304,6 +304,7 @@ impl Data {
304304 account: &OwnedUserId,
305305 cursor: Option<&str>,
306306 limit: u16,
307+ hidden: &BTreeSet<OwnedRoomId>,
307308 ) -> Result<MentionsPage, Error> {
308309 let (boundary, last) = if let Some(cursor) = cursor {
309310 let value: Value =
@@ -332,7 +333,8 @@ impl Data {
332333 .entries
333334 .iter()
334335 .filter(|(key, entry)| {
335- entry.discovery_revision <= boundary
336+ !hidden.contains(&entry.key.room_id)
337+ && entry.discovery_revision <= boundary
336338 && last.as_ref().is_none_or(|last| {
337339 (entry.timestamp_ms, key.as_str()) < (last.0, last.1.as_str())
338340 })
@@ -351,13 +353,26 @@ impl Data {
351353 }).to_string())
352354 };
353355 let mut coverage = self.coverage.clone();
354- coverage.unresolved_candidates = self.candidates.len();
356+ coverage.gaps.retain(|gap| {
357+ gap.room_id
358+ .as_ref()
359+ .is_none_or(|room_id| !hidden.contains(room_id))
360+ });
361+ coverage.unresolved_candidates = self
362+ .candidates
363+ .values()
364+ .filter(|candidate| !hidden.contains(&candidate.room_id))
365+ .count();
355366 Ok(MentionsPage {
356367 entries,
357368 next_cursor,
358369 end_reached,
359370 coverage,
360- unread_count: self.entries.values().filter(|entry| !entry.read).count(),
371+ unread_count: self
372+ .entries
373+ .values()
374+ .filter(|entry| !entry.read && !hidden.contains(&entry.key.room_id))
375+ .count(),
361376 revision: self.revision,
362377 boundary: MentionsBoundary {
363378 account: account.clone(),
Modified file: src/mentions/state_tests.rs+25 additions−7 deletions
@@ -1,3 +1,5 @@
1+use std::collections::BTreeSet;
2+
13 use ruma::{event_id, user_id};
24
35 use super::state::{Data, SavedEvent, Window, id};
@@ -90,7 +92,7 @@ async fn pagination_keeps_its_discovery_boundary_and_stable_ties() {
9092 .insert(id(&mention), entry(mention, timestamp, 1, false));
9193 }
9294
93- let first = data.page(&account, None, 2).unwrap();
95+ let first = data.page(&account, None, 2, &BTreeSet::new()).unwrap();
9496 assert_eq!(
9597 first
9698 .entries
@@ -111,7 +113,9 @@ async fn pagination_keeps_its_discovery_boundary_and_stable_ties() {
111113 .map(|entry| entry.key)
112114 .collect::<Vec<_>>();
113115 loop {
114- let page = data.page(&account, Some(&cursor), 2).unwrap();
116+ let page = data
117+ .page(&account, Some(&cursor), 2, &BTreeSet::new())
118+ .unwrap();
115119 paged.extend(page.entries.into_iter().map(|entry| entry.key));
116120 match page.next_cursor {
117121 Some(next) => cursor = next,
@@ -134,20 +138,34 @@ async fn pagination_rejects_a_forged_account_and_a_future_revision() {
134138 data.entries
135139 .insert(id(&mention), entry(mention, 10, 1, false));
136140 }
137- let cursor = data.page(&account, None, 1).unwrap().next_cursor.unwrap();
141+ let cursor = data
142+ .page(&account, None, 1, &BTreeSet::new())
143+ .unwrap()
144+ .next_cursor
145+ .unwrap();
138146
139147 let mut forged_account: serde_json::Value = serde_json::from_str(&cursor).unwrap();
140148 forged_account["account"] = serde_json::Value::String("@mallory:mentions.example".to_owned());
141149 assert!(
142- data.page(&account, Some(&forged_account.to_string()), 1)
143- .is_err()
150+ data.page(
151+ &account,
152+ Some(&forged_account.to_string()),
153+ 1,
154+ &BTreeSet::new()
155+ )
156+ .is_err()
144157 );
145158
146159 let mut future_revision: serde_json::Value = serde_json::from_str(&cursor).unwrap();
147160 future_revision["revision"] = serde_json::json!(data.revision + 1);
148161 assert!(
149- data.page(&account, Some(&future_revision.to_string()), 1)
150- .is_err()
162+ data.page(
163+ &account,
164+ Some(&future_revision.to_string()),
165+ 1,
166+ &BTreeSet::new()
167+ )
168+ .is_err()
151169 );
152170 }