diff --git a/src/api/client/sync/v3.rs b/src/api/client/sync/v3.rs index feee0c3a..33014b2e 100644 --- a/src/api/client/sync/v3.rs +++ b/src/api/client/sync/v3.rs @@ -133,6 +133,9 @@ pub(crate) async fn sync_events_route( .await .log_err() .ok(); + + // Record that this user was actively syncing now (for push suppression heuristic) + services.presence.note_sync(sender_user).await; } let mut since = body diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 5720ea06..5532a5b2 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -1288,6 +1288,18 @@ pub struct Config { #[serde(default = "true_fn")] pub presence_timeout_remote_users: bool, + + /// Suppresses push notifications for users marked as active. + /// + /// when enabled, users with `Online` presence and recent activity + /// (based on presence state and sync activity) won’t receive push + /// notifications, reducing duplicate alerts while they're active + /// elsewhere. + /// + /// Disabled by default to preserve legacy behavior. + #[serde(default)] + pub suppress_push_when_active: bool, + /// Allow receiving incoming read receipts from remote servers. #[serde(default = "true_fn")] pub allow_incoming_read_receipts: bool, diff --git a/src/service/presence/mod.rs b/src/service/presence/mod.rs index 41e38734..b564cd84 100644 --- a/src/service/presence/mod.rs +++ b/src/service/presence/mod.rs @@ -1,7 +1,8 @@ mod data; mod presence; -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::sync::RwLock; use async_trait::async_trait; use futures::{Stream, StreamExt, TryFutureExt, stream::FuturesUnordered}; @@ -19,6 +20,7 @@ pub struct Service { offline_timeout: u64, db: Data, services: Arc, + last_sync_seen: RwLock>, } type TimerType = (OwnedUserId, Duration); @@ -36,6 +38,7 @@ impl crate::Service for Service { offline_timeout: checked!(offline_timeout_s * 1_000)?, db: Data::new(&args), services: args.services.clone(), + last_sync_seen: RwLock::new(HashMap::new()), })) } @@ -88,6 +91,21 @@ impl crate::Service for Service { } impl Service { + /// record that a user has just successfully completed a /sync (or equivalent activity) + pub async fn note_sync(&self, user_id: &UserId) { + let now = tuwunel_core::utils::millis_since_unix_epoch(); + self.last_sync_seen.write().await.insert(user_id.to_owned(), now); + } + + /// Returns milliseconds since last observed sync for user (if any) + pub async fn last_sync_gap_ms(&self, user_id: &UserId) -> Option { + let now = tuwunel_core::utils::millis_since_unix_epoch(); + self.last_sync_seen + .read() + .await + .get(user_id) + .map(|ts| now.saturating_sub(*ts)) + } /// Returns the latest presence event for the given user. pub async fn get_presence(&self, user_id: &UserId) -> Result { self.db diff --git a/src/service/sending/sender.rs b/src/service/sending/sender.rs index ada0a857..eb29fb81 100644 --- a/src/service/sending/sender.rs +++ b/src/service/sending/sender.rs @@ -19,14 +19,13 @@ use ruma::{ MilliSecondsSinceUnixEpoch, OwnedRoomId, OwnedServerName, OwnedUserId, RoomId, ServerName, UInt, api::{ - appservice::event::push_events::v1::EphemeralData, - federation::transactions::{ + appservice::event::push_events::v1::EphemeralData, federation::transactions::{ edu::{ DeviceListUpdateContent, Edu, PresenceContent, PresenceUpdate, ReceiptContent, ReceiptData, ReceiptMap, }, send_transaction_message, - }, + } }, device_id, events::{ @@ -834,6 +833,26 @@ impl Service { continue; } + // optional suppression: heuristic combining presence age and recent sync activity. + if self.services.server.config.suppress_push_when_active { + if let Ok(presence) = self.services.presence.get_presence(&user_id).await { + let is_online = presence.content.presence == ruma::presence::PresenceState::Online; + let presence_age_ms = presence + .content + .last_active_ago + .map(u64::from) + .unwrap_or(u64::MAX); + let sync_gap_ms = self.services.presence.last_sync_gap_ms(&user_id).await; + let considered_active = is_online + && presence_age_ms < 65_000 + && sync_gap_ms.is_some_and(|gap| gap < 32_000); + if considered_active { + trace!(?user_id, presence_age_ms, sync_gap_ms, "suppressing push: active heuristic"); + continue; + } + } + } + let rules_for_user = self .services .account_data diff --git a/tuwunel-example.toml b/tuwunel-example.toml index f2993004..e6f00ac8 100644 --- a/tuwunel-example.toml +++ b/tuwunel-example.toml @@ -1094,6 +1094,17 @@ # #presence_timeout_remote_users = true +# Suppresses push notifications for users marked as active. +# +# When enabled, users with `Online` presence and recent activity +# (based on presence state and sync activity) won’t receive push +# notifications, reducing duplicate alerts while they're active +# elsewhere. +# +# Disabled by default to preserve legacy behavior. +# +#suppress_push_when_active = false + # Allow receiving incoming read receipts from remote servers. # #allow_incoming_read_receipts = true