atagen

macltok

public
Star0
Watch0

Commit 96680d38cc31

tarball

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

4 files changed+138-5
mentions: keep highlighting keywords as Keyword mentions

An event that notifies and highlights with no Direct, Reply or Room reason was dropped, so loud keywords never reached the inbox. MentionReason::Keyword keeps them with the same capture and read state as any other mention.
Modified file: docs/mentions-discovery.md+5 additions−2 deletions
@@ -196,8 +196,11 @@ A room mute can suppress evaluated notification actions while structured
196196 mention metadata remains present. The classifier retains explicit user
197197 mentions in muted rooms and marks notification delivery as suppressed. Room-wide
198198 reasons require the relevant push conditions and sender power level. Missing
199-authorisation context remains unresolved for reevaluation. Keyword-only
200-notifications are outside the inbox. The classifier excludes the signed-in
199+authorisation context remains unresolved for reevaluation. An event that
200+notifies and highlights with no other reason, such as a loud keyword, enters the
201+inbox as `Keyword`. `add_notification_keyword` gives keyword rules a highlight
202+tweak, and upgrades an existing rule for the pattern that lacks one, since the
203+SDK's keyword rules only notify and play a sound. The classifier excludes the signed-in
201204 user's own events even when they contain explicit mention metadata.
202205
203206 The existing `NotificationClient` combines notification evaluation with
Modified file: src/mentions/service.rs+3 additions
@@ -988,6 +988,9 @@ impl Service {
988988 _ => {}
989989 }
990990 }
991+ if reasons.is_empty() && notify && highlight {
992+ reasons.push(MentionReason::Keyword);
993+ }
991994 (reasons, !notify, unresolved)
992995 }
993996
Modified file: src/mentions_model.rs+3 additions
@@ -17,6 +17,9 @@ pub enum MentionReason {
1717 Direct,
1818 Reply,
1919 Room,
20+ /// a highlighting push rule matched with no structured mention or reply, such as a
21+ /// notification keyword
22+ Keyword,
2023 }
2124
2225 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: tests/mentions_inbox.rs+127 additions−3 deletions
@@ -11,21 +11,24 @@ use std::{
1111
1212 #[cfg(feature = "sqlite")]
1313 use macltok::{EncryptedSessionStore, PasswordLogin, Restore, Session, SessionStore, StoreConfig};
14-use macltok::{Engine, EngineConfig, Event, MentionsPage};
14+use macltok::{Engine, EngineConfig, Event, MentionReason, MentionsPage};
1515 use matrix_sdk::{
1616 Client,
1717 ruma::{OwnedRoomId, OwnedUserId, event_id, events::Mentions, room_id, user_id},
1818 test_utils::mocks::{MatrixMockServer, RoomContextResponseTemplate},
1919 };
2020 use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
21-use ruma::push::{EventMatchConditionData, NewConditionalPushRule, PushCondition, Ruleset};
21+use ruma::push::{
22+ Action, EventMatchConditionData, HighlightTweakValue, NewConditionalPushRule,
23+ NewPatternedPushRule, PushCondition, Ruleset, SoundTweakValue, Tweak,
24+};
2225 use serde_json::{Value, json};
2326 #[cfg(feature = "sqlite")]
2427 use support::receive_until;
2528 use support::shut_down;
2629 use wiremock::{
2730 Mock, Request, Respond, ResponseTemplate,
28- matchers::{method, path, path_regex, query_param},
31+ matchers::{body_string_contains, method, path, path_regex, query_param},
2932 };
3033
3134 #[cfg(feature = "serde")]
@@ -774,6 +777,127 @@ async fn event_context_uses_independent_direction_limits_and_saved_mention_conte
774777 server.server().verify().await;
775778 }
776779
780+#[tokio::test]
781+async fn highlighting_keywords_are_mentions_and_quiet_ones_are_not() {
782+ let server = MatrixMockServer::new().await;
783+ let client = server.client_builder().build().await;
784+ let own_user = client.user_id().unwrap().to_owned();
785+ let room_id = room_id!("!keyword-policy:localhost");
786+ let sender = user_id!("@alice:localhost");
787+ let mut rules = Ruleset::server_default(&own_user);
788+ let notify = [
789+ Action::Notify,
790+ Action::SetTweak(Tweak::Sound(SoundTweakValue::Default)),
791+ ];
792+ let mut loud = notify.to_vec();
793+ loud.push(Action::SetTweak(Tweak::Highlight(HighlightTweakValue::Yes)));
794+ rules
795+ .content
796+ .insert(NewPatternedPushRule::new("deploy".to_owned(), "deploy".to_owned(), loud).into());
797+ rules.content.insert(
798+ NewPatternedPushRule::new("lunch".to_owned(), "lunch".to_owned(), notify.to_vec()).into(),
799+ );
800+ server
801+ .mock_sync()
802+ .ok_and_run(&client, {
803+ let own_user = own_user.clone();
804+ move |builder| {
805+ let factory = EventFactory::new().room(room_id);
806+ builder.add_global_account_data(factory.push_rules(rules));
807+ builder.add_joined_room(
808+ JoinedRoomBuilder::new(room_id).add_state_event(factory.member(&own_user)),
809+ );
810+ }
811+ })
812+ .await;
813+ let feed = SlidingFeed::new(
814+ room_id.to_owned(),
815+ vec![
816+ message(
817+ "$keyword",
818+ sender.as_str(),
819+ 1_000,
820+ "the deploy is done",
821+ None,
822+ ),
823+ message("$quiet", sender.as_str(), 2_000, "lunch anyone", None),
824+ message("$plain", sender.as_str(), 3_000, "nothing to see", None),
825+ message(
826+ "$keyword-direct",
827+ sender.as_str(),
828+ 4_000,
829+ "deploy now",
830+ Some(own_user.as_str()),
831+ ),
832+ message("$own-keyword", own_user.as_str(), 5_000, "my deploy", None),
833+ ],
834+ );
835+ feed.mount(&server).await;
836+
837+ let (engine, handle, mut events) = Engine::from_client(
838+ client,
839+ EngineConfig::default().with_initial_history_limit(0),
840+ )
841+ .await
842+ .unwrap();
843+ let running = tokio::spawn(engine.run());
844+
845+ let page = mentions_page(&mut events, room_id, |page| page.entries.len() >= 2).await;
846+ assert_eq!(
847+ entry_ids(&page),
848+ HashSet::from(["$keyword".to_owned(), "$keyword-direct".to_owned()])
849+ );
850+ let reasons: BTreeMap<_, _> = page
851+ .entries
852+ .iter()
853+ .map(|entry| (entry.key.event_id.to_string(), entry.reasons.clone()))
854+ .collect();
855+ assert_eq!(reasons["$keyword"], vec![MentionReason::Keyword]);
856+ assert_eq!(reasons["$keyword-direct"], vec![MentionReason::Direct]);
857+ assert_eq!(page.unread_count, 2);
858+ assert!(
859+ page.entries
860+ .iter()
861+ .all(|entry| !entry.notification_suppressed)
862+ );
863+
864+ shut_down(&handle, &mut events, running).await;
865+}
866+
867+#[tokio::test]
868+async fn adding_a_keyword_gives_its_rule_a_highlight() {
869+ let server = MatrixMockServer::new().await;
870+ let room_id = room_id!("!keyword-rule:localhost");
871+ let (client, _) = joined_client(&server, room_id).await;
872+ Mock::given(method("PUT"))
873+ .and(path("/_matrix/client/v3/pushrules/global/content/deploy"))
874+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
875+ .expect(1)
876+ .mount(server.server())
877+ .await;
878+ Mock::given(method("PUT"))
879+ .and(path(
880+ "/_matrix/client/v3/pushrules/global/content/deploy/actions",
881+ ))
882+ .and(body_string_contains("highlight"))
883+ .and(body_string_contains("notify"))
884+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
885+ .expect(1)
886+ .mount(server.server())
887+ .await;
888+ SlidingFeed::new(room_id.to_owned(), Vec::new())
889+ .mount(&server)
890+ .await;
891+
892+ let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
893+ .await
894+ .unwrap();
895+ let running = tokio::spawn(engine.run());
896+ handle.add_notification_keyword("deploy").await.unwrap();
897+ shut_down(&handle, &mut events, running).await;
898+ server.server().verify().await;
899+}
900+
777901 #[cfg(feature = "sqlite")]
778902 #[tokio::test]
779903 async fn sqlite_mentions_survive_engine_shutdown_and_session_resume() {