use super::*;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
use nostr::Tag;
use rusqlite::OptionalExtension;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
mod block_policy;
mod control_preview;
mod diagnostics;
mod invite_owner;
pub(super) mod mention_policy;
mod read_state;
pub(super) mod subscriptions;
use invite_owner::pending_invite_response_owner;
pub(crate) use read_state::read_mobile_push_notification_indexes;
pub(crate) use subscriptions::{
build_mobile_push_create_subscription_request, build_mobile_push_delete_subscription_request,
build_mobile_push_list_subscriptions_request, build_mobile_push_update_subscription_request,
mobile_push_stored_subscription_id_key, resolve_mobile_push_server_url,
};
const MOBILE_PUSH_REACTION_KIND: u64 = 7;
const MOBILE_PUSH_AUTH_KIND: u16 = 27_235;
const MOBILE_PUSH_CHAT_MESSAGE_KIND: u64 = CHAT_MESSAGE_KIND as u64;
const MOBILE_PUSH_INVITE_RESPONSE_KIND: u64 = INVITE_RESPONSE_KIND as u64;
const MOBILE_PUSH_OUTER_MESSAGE_EVENT_KIND: u64 = MESSAGE_EVENT_KIND as u64;
const MOBILE_PUSH_PRODUCTION_SERVER_URL: &str = "https://notifications.iris.to";
const MOBILE_PUSH_SANDBOX_SERVER_URL: &str = "https://notifications-sandbox.iris.to";
const MAX_PENDING_MOBILE_PUSH_EVENTS: usize = 32;
const MOBILE_PUSH_OUTER_EVENT_PAYLOAD_KEYS: &[&str] = &[
"event",
"outer_event",
"outer_event_json",
"nostr_event",
"nostr_event_json",
];
const MOBILE_PUSH_EVENT_PAYLOAD_KEYS: &[&str] = &[
"event",
"outer_event",
"outer_event_json",
"nostr_event",
"nostr_event_json",
"inner_event_json",
];
impl AppCore {
pub(super) fn build_mobile_push_sync_snapshot(&self) -> MobilePushSyncSnapshot {
let Some(logged_in) = self.logged_in.as_ref() else {
return MobilePushSyncSnapshot::default();
};
let mut message_author_pubkeys: HashSet<String> = self
.subscribable_message_author_hexes()
.into_iter()
.collect();
let mut delayed_message_authors = Vec::new();
if let Some(engine) = self.protocol_engine.as_ref() {
let owner_pubkey =
nostr_double_ratchet::OwnerPubkey::from_bytes(logged_in.owner_pubkey.to_bytes());
let group_peers = self
.groups
.values()
.filter(|group| {
group.members.contains(&owner_pubkey)
&& !self.thread_is_message_request(&group_chat_id(&group.group_id))
})
.flat_map(|group| group.members.iter().map(ToString::to_string))
.collect::<HashSet<_>>();
let now = unix_now().get();
let muted_authors = |chat_id: &str| -> Vec<PublicKey> {
if parse_group_id_from_chat_id(chat_id).is_some() || group_peers.contains(chat_id) {
Vec::new()
} else {
PublicKey::from_hex(chat_id)
.ok()
.map(|owner| engine.message_author_pubkeys_for_owner(owner))
.unwrap_or_default()
}
};
for chat_id in &self.preferences.muted_chat_ids {
if mention_policy::keep_group_subscription(&self.preferences, chat_id) {
continue;
}
for key in muted_authors(chat_id) {
message_author_pubkeys.remove(&key.to_hex());
}
}
for mute in &self.preferences.timed_chat_mutes {
if mute.until_secs <= now
|| self.preferences.muted_chat_ids.contains(&mute.chat_id)
|| mention_policy::keep_group_subscription(&self.preferences, &mute.chat_id)
{
continue;
}
for key in muted_authors(&mute.chat_id) {
let author_pubkey = key.to_hex();
if message_author_pubkeys.remove(&author_pubkey) {
delayed_message_authors.push(MobilePushDelayedAuthor {
author_pubkey,
since_secs: mute.until_secs,
});
}
}
}
delayed_message_authors.sort_by(|a, b| a.author_pubkey.cmp(&b.author_pubkey));
}
let background_message_author_pubkeys = self
.protocol_engine
.as_ref()
.map(|engine| {
let authors = engine.message_author_pubkeys_for_owner(logged_in.owner_pubkey);
sorted_hexes(authors.into_iter().map(|key| key.to_hex()).collect())
})
.unwrap_or_default();
message_author_pubkeys.extend(background_message_author_pubkeys.iter().cloned());
let message_author_pubkeys = sorted_hexes(message_author_pubkeys);
let invite_response_pubkeys = if self.preferences.invite_acceptance_notifications_enabled {
let mut pubkeys = HashSet::new();
if let Some(pubkey) = self
.protocol_engine
.as_ref()
.and_then(ProtocolEngine::local_invite_response_pubkey)
{
pubkeys.insert(pubkey.to_hex());
}
pubkeys.extend(
self.private_chat_invite_response_pubkeys()
.into_iter()
.map(|pubkey| pubkey.to_hex()),
);
sorted_hexes(pubkeys)
} else {
Vec::new()
};
MobilePushSyncSnapshot {
call_device_pubkey_hex: Some(logged_in.device_keys.public_key().to_hex()),
owner_pubkey_hex: Some(logged_in.owner_pubkey.to_string()),
message_author_pubkeys,
delayed_message_authors,
background_message_author_pubkeys,
invite_response_pubkeys,
sessions: Vec::new(),
}
}
pub(super) fn ingest_mobile_push_payload(&mut self, raw_payload_json: &str) {
let Some(event) = mobile_push_event_from_payload(raw_payload_json) else {
return;
};
let event_id = event.id.to_string();
if self.logged_in.is_none() {
if !self.has_seen_event(&event_id)
&& !self
.pending_mobile_push_events
.iter()
.any(|pending| pending.id.to_string() == event_id)
{
self.pending_mobile_push_events.push_back(event);
while self.pending_mobile_push_events.len() > MAX_PENDING_MOBILE_PUSH_EVENTS {
self.pending_mobile_push_events.pop_front();
}
}
return;
}
self.push_debug_log("push.event.ingest", format!("id={event_id}"));
self.ingest_push_event(event);
}
fn ingest_push_event(&mut self, event: Event) {
if event.kind.as_u16() == super::calls::push::CALL_WAKE_KIND {
self.receive_call_push(&event);
} else {
self.handle_relay_event(event);
}
}
pub(super) fn drain_pending_mobile_push_events(&mut self) {
if self.logged_in.is_none() || self.pending_mobile_push_events.is_empty() {
return;
}
self.enter_batch();
while let Some(event) = self.pending_mobile_push_events.pop_front() {
self.push_debug_log("push.event.ingest", format!("id={}", event.id));
self.ingest_push_event(event);
}
self.exit_batch();
}
}
pub(super) fn mobile_push_event_from_payload(raw_payload_json: &str) -> Option<Event> {
let payload_value: serde_json::Value = serde_json::from_str(raw_payload_json).ok()?;
let payload_object = payload_value.as_object()?;
for key in MOBILE_PUSH_EVENT_PAYLOAD_KEYS {
let Some(value) = payload_object.get(*key) else {
continue;
};
let Some(event_json) = payload_event_json(value) else {
continue;
};
if let Ok(event) = serde_json::from_str::<Event>(&event_json) {
if event.verify().is_ok() {
return Some(event);
}
}
}
None
}
pub(crate) fn decrypt_mobile_push_notification(
data_dir: String,
owner_pubkey_hex: String,
device_nsec: String,
raw_payload_json: String,
) -> MobilePushNotificationResolution {
let event = mobile_push_event_from_payload(&raw_payload_json);
let mut resolution = decrypt_mobile_push_notification_inner(
data_dir,
owner_pubkey_hex,
device_nsec,
raw_payload_json,
true,
&mut None,
);
if let (Some(event), Ok(mut payload)) = (
event,
serde_json::from_str::<serde_json::Value>(&resolution.payload_json),
) {
if let Some(object) = payload.as_object_mut() {
object.insert("event".to_string(), serde_json::json!(event));
resolution.payload_json = payload.to_string();
}
}
resolution
}
fn decrypt_mobile_push_notification_inner(
data_dir: String,
owner_pubkey_hex: String,
device_nsec: String,
raw_payload_json: String,
wait_for_cache: bool,
preview_engine: &mut Option<ProtocolEngine>,
) -> MobilePushNotificationResolution {
let fallback = || resolve_mobile_push_notification(raw_payload_json.clone());
let payload_value: serde_json::Value = match serde_json::from_str(&raw_payload_json) {
Ok(value) => value,
Err(_) => return fallback(),
};
let payload_object = match payload_value.as_object() {
Some(object) => object,
None => return fallback(),
};
let outer_event_json = first_payload_event_json(payload_object, MOBILE_PUSH_EVENT_PAYLOAD_KEYS);
let Some(outer_event_json) = outer_event_json else {
return fallback();
};
let outer_event: nostr::Event = match serde_json::from_str(&outer_event_json) {
Ok(event) => event,
Err(_)
if first_payload_event_kind(payload_object, MOBILE_PUSH_EVENT_PAYLOAD_KEYS)
== Some(MOBILE_PUSH_OUTER_MESSAGE_EVENT_KIND) =>
{
return suppressed_resolution();
}
Err(_) => return fallback(),
};
let outer_event_id = outer_event.id.to_string();
let is_invite_response = outer_event.kind.as_u16() as u64 == MOBILE_PUSH_INVITE_RESPONSE_KIND;
if is_invite_response {
let owner_pubkey = match nostr::PublicKey::parse(owner_pubkey_hex.trim()) {
Ok(pubkey) => pubkey,
Err(_) => return suppressed_resolution(),
};
let device_keys = match nostr::Keys::parse(device_nsec.trim()) {
Ok(keys) => keys,
Err(_) => return suppressed_resolution(),
};
return match pending_invite_response_owner(
&data_dir,
owner_pubkey,
&device_keys,
&outer_event,
) {
Some(chat_id) => {
invite_acceptance_push_resolution(payload_object, outer_event, chat_id)
}
None => suppressed_resolution(),
};
}
let cached_fallback = || {
if !wait_for_cache {
return lookup_mobile_push_preview(&data_dir, &outer_event_id, &owner_pubkey_hex)
.unwrap_or_else(suppressed_resolution);
}
let is_legacy_group_outer = !mobile_push_event_has_tag(&outer_event, "header")
&& parse_group_sender_key_message_event(&outer_event).is_ok();
lookup_mobile_push_preview_after_short_wait(&data_dir, &outer_event_id, &owner_pubkey_hex)
.unwrap_or_else(|| {
if is_legacy_group_outer {
lookup_recent_group_mobile_push_preview_after_short_wait(
&data_dir,
outer_event.created_at.as_secs().saturating_sub(5),
&owner_pubkey_hex,
)
.unwrap_or_else(suppressed_resolution)
} else {
suppressed_resolution()
}
})
};
if let Some(resolution) =
lookup_mobile_push_preview(&data_dir, &outer_event_id, &owner_pubkey_hex)
{
return resolution;
}
let owner_pubkey = match nostr::PublicKey::parse(owner_pubkey_hex.trim()) {
Ok(pubkey) => pubkey,
Err(_) => return cached_fallback(),
};
let device_keys = match nostr::Keys::parse(device_nsec.trim()) {
Ok(keys) => keys,
Err(_) => return cached_fallback(),
};
if preview_engine.is_none() {
*preview_engine = direct_message_preview_engine(&data_dir, owner_pubkey, &device_keys);
}
let Some(engine) = preview_engine.as_mut() else {
return cached_fallback();
};
let outer_has_header = mobile_push_event_has_tag(&outer_event, "header");
let should_try_group_outer =
!outer_has_header || engine.is_known_group_sender_event_author(outer_event.pubkey);
let group_outer_parse = should_try_group_outer.then(|| {
if outer_has_header {
parse_group_sender_key_message_event_unchecked(&outer_event)
} else {
parse_group_sender_key_message_event(&outer_event)
}
});
if group_outer_parse
.as_ref()
.is_some_and(|parse| parse.is_ok())
{
let group_result = match engine.process_group_outer_event(&outer_event) {
Ok(result) => result,
Err(_) => return cached_fallback(),
};
let Some(message) = group_result
.events
.into_iter()
.find_map(|event| match event {
GroupIncomingEvent::Message(message) => Some(message),
GroupIncomingEvent::MetadataUpdated(_)
| GroupIncomingEvent::SenderKeyRepairRequested(_) => None,
})
else {
return cached_fallback();
};
let Ok(sender_owner) = nostr::PublicKey::from_slice(&message.sender_owner.to_bytes())
else {
return cached_fallback();
};
let inner_json = String::from_utf8_lossy(&message.body).to_string();
let Some(runtime_rumor) = parse_runtime_rumor(&inner_json) else {
return cached_fallback();
};
if runtime_rumor.pubkey != sender_owner {
return cached_fallback();
}
return decrypted_mobile_push_resolution(
payload_object,
&data_dir,
sender_owner,
owner_pubkey,
Some(message.group_id),
runtime_rumor.kind as u64,
runtime_rumor.content,
inner_json,
);
}
let decrypted = match engine.process_direct_message_event(&outer_event) {
Ok(Some(decrypted)) => decrypted,
Ok(None) | Err(_) => return cached_fallback(),
};
let inner_json = decrypted.content;
let sender_owner = decrypted.sender;
let inner_value: serde_json::Value = match serde_json::from_str(&inner_json) {
Ok(value) => value,
Err(_) => return cached_fallback(),
};
let inner_kind = inner_value
.get("kind")
.and_then(|value| value.as_u64())
.unwrap_or(MOBILE_PUSH_CHAT_MESSAGE_KIND);
let inner_content = inner_value
.get("content")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
if inner_kind == super::private_group_wake::GROUP_WAKE_KIND as u64 {
let Some(group_event) = super::private_group_wake::decode(&inner_content) else {
return suppressed_resolution();
};
if !super::private_group_wake::sender_matches(engine, sender_owner, &group_event) {
return suppressed_resolution();
}
let mut group_payload = payload_object.clone();
group_payload.insert("event".into(), serde_json::json!(group_event));
return decrypt_mobile_push_notification_inner(
data_dir,
owner_pubkey_hex,
device_nsec,
serde_json::Value::Object(group_payload).to_string(),
false,
preview_engine,
);
}
let inner_tags: Vec<Vec<String>> = inner_value
.get("tags")
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default();
let group_id = inner_tags.iter().find_map(|tag| match tag.as_slice() {
[name, value, ..] if name == "l" && !value.is_empty() => Some(value.clone()),
_ => None,
});
decrypted_mobile_push_resolution(
payload_object,
&data_dir,
sender_owner,
owner_pubkey,
group_id,
inner_kind,
inner_content,
inner_json,
)
}
#[allow(clippy::too_many_arguments)]
fn decrypted_mobile_push_resolution(
payload_object: &serde_json::Map<String, serde_json::Value>,
data_dir: &str,
sender_owner: nostr::PublicKey,
local_owner: nostr::PublicKey,
group_id: Option<String>,
inner_kind: u64,
inner_content: String,
inner_json: String,
) -> MobilePushNotificationResolution {
if inner_kind == super::calls::push::CALL_OFFER_KIND as u64 {
return suppressed_resolution();
}
let created = serde_json::from_str::<serde_json::Value>(&inner_json)
.ok()
.and_then(|value| value.get("created_at").and_then(serde_json::Value::as_u64));
if block_policy::suppresses_in_data_dir(data_dir, &sender_owner.to_hex(), created) {
return suppressed_resolution();
}
let sender_name = lookup_sender_display_name(data_dir, &sender_owner)
.or_else(|| lookup_direct_thread_sender_name(data_dir, &sender_owner));
let group_title = group_id
.as_ref()
.and_then(|id| lookup_group_name(data_dir, id));
let resolved_chat_id = group_id
.as_ref()
.map(|id| group_chat_id(id))
.unwrap_or_else(|| sender_owner.to_hex());
if sender_owner == local_owner
|| read_state::rumor_is_read(data_dir, &resolved_chat_id, &inner_json)
{
return read_state::dismissed_resolution();
}
if mention_policy::suppresses_in_data_dir(
data_dir,
&resolved_chat_id,
&local_owner.to_hex(),
&inner_content,
inner_kind == MOBILE_PUSH_CHAT_MESSAGE_KIND,
) {
return suppressed_resolution();
}
let resolved_sender_name = sender_name.unwrap_or_else(|| {
payload_object
.get("sender_name")
.and_then(|value| value.as_str())
.map(str::to_string)
.unwrap_or_else(|| "Iris Chat".to_string())
});
let body_preview = control_preview::body(inner_kind, &inner_content, &inner_json);
let (title, body) =
group_notification_title_and_body(group_title, &resolved_sender_name, body_preview.clone());
let mut resolved_payload = serde_json::Map::new();
for (key, value) in payload_object {
resolved_payload.insert(key.clone(), value.clone());
}
resolved_payload.insert(
"title".to_string(),
serde_json::Value::String(title.clone()),
);
resolved_payload.insert("body".to_string(), serde_json::Value::String(body.clone()));
resolved_payload.insert(
"message_preview".to_string(),
serde_json::Value::String(body_preview),
);
resolved_payload.insert(
"inner_event_json".to_string(),
serde_json::Value::String(inner_json),
);
resolved_payload.insert(
"inner_kind".to_string(),
serde_json::Value::String(inner_kind.to_string()),
);
resolved_payload.insert(
"sender_pubkey".to_string(),
serde_json::Value::String(sender_owner.to_hex()),
);
resolved_payload.insert(
"chat_id".to_string(),
serde_json::Value::String(resolved_chat_id),
);
if let Some(group_id) = group_id {
resolved_payload.insert("group_id".to_string(), serde_json::Value::String(group_id));
}
MobilePushNotificationResolution {
should_show: should_show_mobile_push_kind(inner_kind),
title,
body,
payload_json: serde_json::to_string(&serde_json::Value::Object(resolved_payload))
.unwrap_or_else(|_| "{}".to_string()),
}
}
fn group_notification_title_and_body(
group_title: Option<String>,
sender_name: &str,
body_preview: String,
) -> (String, String) {
match (group_title, sender_name) {
(Some(group), sender) if !sender.is_empty() && sender != "Iris Chat" => {
(group, format!("{sender}: {body_preview}"))
}
(Some(group), _) => (group, body_preview),
(None, sender) => (sender.to_string(), body_preview),
}
}
fn lookup_mobile_push_preview(
data_dir: &str,
outer_event_id: &str,
owner_pubkey_hex: &str,
) -> Option<MobilePushNotificationResolution> {
let conn = open_lookup_connection(data_dir)?;
let source_id = super::private_group_wake::preview_source_id(&conn, outer_event_id);
let outer_event_id = source_id.as_str();
if let Some(control) =
control_preview::lookup(&conn, data_dir, outer_event_id, owner_pubkey_hex)
{
return Some(control);
}
let (chat_id, body, author_hex, dismiss, author_owner, created, attachments): (String, String, String, bool, Option<String>, u64, String) = conn
.query_row(
"SELECT chat_id, body, author, is_outgoing = 1 OR delivery = 'seen' OR deleted_for_everyone = 1, author_owner_pubkey_hex, created_at_secs, attachments_json
FROM messages
WHERE source_event_id = ?1
LIMIT 1",
[outer_event_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?)),
)
.ok()?;
if block_policy::suppresses(
&conn,
author_owner.as_deref().unwrap_or(&author_hex),
Some(created),
) {
return Some(suppressed_resolution());
}
if dismiss {
return Some(read_state::dismissed_resolution());
}
let group_id = chat_id.strip_prefix(GROUP_CHAT_PREFIX);
if mention_policy::suppresses(&conn, &chat_id, owner_pubkey_hex, &body, true) {
return Some(suppressed_resolution());
}
let sender_pubkey = nostr::PublicKey::parse(&author_hex).ok();
let sender_name = sender_pubkey
.as_ref()
.and_then(|pubkey| lookup_owner_display_name(&conn, pubkey))
.filter(|value| !is_generic_sender_title(value))
.unwrap_or_else(|| {
let fallback = author_hex.trim().to_string();
if !fallback.is_empty()
&& !is_generic_sender_title(&fallback)
&& !looks_like_hex_pubkey(&fallback)
{
fallback
} else {
"Iris Chat".to_string()
}
});
let group_title = group_id.and_then(|id| lookup_group_name_in(&conn, id));
let body_preview = persisted_message_body_preview(&body, &attachments);
let body_text = if body_preview.is_empty() {
decrypted_mobile_push_body(MOBILE_PUSH_CHAT_MESSAGE_KIND, "")
} else {
body_preview
};
let (title, body_text) =
group_notification_title_and_body(group_title, &sender_name, body_text);
let mut payload = serde_json::Map::new();
payload.insert(
"title".to_string(),
serde_json::Value::String(title.clone()),
);
payload.insert(
"body".to_string(),
serde_json::Value::String(body_text.clone()),
);
payload.insert(
"inner_kind".to_string(),
serde_json::Value::String(MOBILE_PUSH_CHAT_MESSAGE_KIND.to_string()),
);
payload.insert(
"chat_id".to_string(),
serde_json::Value::String(chat_id.clone()),
);
if let Some(pubkey) = sender_pubkey {
payload.insert(
"sender_pubkey".to_string(),
serde_json::Value::String(pubkey.to_hex()),
);
}
if let Some(group_id) = group_id {
payload.insert(
"group_id".to_string(),
serde_json::Value::String(group_id.to_string()),
);
}
let payload_json = serde_json::to_string(&serde_json::Value::Object(payload))
.unwrap_or_else(|_| "{}".to_string());
Some(MobilePushNotificationResolution {
should_show: true,
title,
body: body_text,
payload_json,
})
}
fn lookup_recent_group_mobile_push_preview(
data_dir: &str,
min_created_at_secs: u64,
owner_pubkey_hex: &str,
) -> Option<MobilePushNotificationResolution> {
let conn = open_lookup_connection(data_dir)?;
let min_created_at_secs = i64::try_from(min_created_at_secs).ok()?;
let (chat_id, body, author_owner_hex, author, created, attachments): (
String,
String,
Option<String>,
String,
u64,
String,
) = conn
.query_row(
"SELECT chat_id, body, author_owner_pubkey_hex, author, created_at_secs, attachments_json
FROM messages
WHERE chat_id LIKE 'group:%'
AND is_outgoing = 0
AND created_at_secs >= ?1
ORDER BY created_at_secs DESC, rowid DESC
LIMIT 1",
[min_created_at_secs],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
},
)
.ok()?;
if block_policy::suppresses(
&conn,
author_owner_hex.as_deref().unwrap_or(&author),
Some(created),
) {
return Some(suppressed_resolution());
}
let group_id = chat_id.strip_prefix(GROUP_CHAT_PREFIX)?.to_string();
if mention_policy::suppresses(&conn, &chat_id, owner_pubkey_hex, &body, true) {
return Some(suppressed_resolution());
}
let sender_name = author_owner_hex
.as_deref()
.and_then(|hex| nostr::PublicKey::parse(hex).ok())
.and_then(|pubkey| lookup_owner_display_name(&conn, &pubkey))
.filter(|value| !is_generic_sender_title(value))
.or_else(|| {
let fallback = author.trim().to_string();
(!fallback.is_empty() && !is_generic_sender_title(&fallback)).then_some(fallback)
})
.unwrap_or_else(|| "Iris Chat".to_string());
let body_preview = persisted_message_body_preview(&body, &attachments);
let body_text = if body_preview.is_empty() {
decrypted_mobile_push_body(MOBILE_PUSH_CHAT_MESSAGE_KIND, "")
} else {
body_preview
};
let group_title = lookup_group_name_in(&conn, &group_id);
let (title, body_text) =
group_notification_title_and_body(group_title, &sender_name, body_text);
let mut payload = serde_json::Map::new();
payload.insert(
"title".to_string(),
serde_json::Value::String(title.clone()),
);
payload.insert(
"body".to_string(),
serde_json::Value::String(body_text.clone()),
);
payload.insert(
"inner_kind".to_string(),
serde_json::Value::String(MOBILE_PUSH_CHAT_MESSAGE_KIND.to_string()),
);
payload.insert("chat_id".to_string(), serde_json::Value::String(chat_id));
payload.insert("group_id".to_string(), serde_json::Value::String(group_id));
if let Some(author_owner_hex) = author_owner_hex {
payload.insert(
"sender_pubkey".to_string(),
serde_json::Value::String(author_owner_hex),
);
}
Some(MobilePushNotificationResolution {
should_show: true,
title,
body: body_text,
payload_json: serde_json::to_string(&serde_json::Value::Object(payload))
.unwrap_or_else(|_| "{}".to_string()),
})
}
fn lookup_recent_group_mobile_push_preview_after_short_wait(
data_dir: &str,
min_created_at_secs: u64,
owner_pubkey_hex: &str,
) -> Option<MobilePushNotificationResolution> {
for delay_ms in [0_u64, 25, 75, 150, 300, 600, 1_200, 2_400] {
if delay_ms > 0 {
std::thread::sleep(Duration::from_millis(delay_ms));
}
if let Some(resolution) =
lookup_recent_group_mobile_push_preview(data_dir, min_created_at_secs, owner_pubkey_hex)
{
return Some(resolution);
}
}
None
}
fn lookup_mobile_push_preview_after_short_wait(
data_dir: &str,
outer_event_id: &str,
owner_pubkey_hex: &str,
) -> Option<MobilePushNotificationResolution> {
for delay_ms in [0_u64, 25, 75, 150, 300, 600, 1_200, 2_400] {
if delay_ms > 0 {
std::thread::sleep(Duration::from_millis(delay_ms));
}
if let Some(resolution) =
lookup_mobile_push_preview(data_dir, outer_event_id, owner_pubkey_hex)
{
return Some(resolution);
}
}
None
}
fn looks_like_hex_pubkey(value: &str) -> bool {
value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn lookup_owner_display_name(
conn: &rusqlite::Connection,
pubkey: &nostr::PublicKey,
) -> Option<String> {
let (display_name, name): (Option<String>, Option<String>) = conn
.query_row(
"SELECT display_name, name FROM owner_profiles WHERE owner_pubkey_hex = ?1",
[pubkey.to_hex()],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok()?;
display_name
.or(name)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn lookup_group_name_in(conn: &rusqlite::Connection, group_id: &str) -> Option<String> {
let name: String = conn
.query_row(
"SELECT name FROM groups WHERE group_id = ?1",
[group_id],
|row| row.get(0),
)
.ok()?;
let trimmed = name.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
pub(super) fn is_chat_muted_in(conn: &rusqlite::Connection, chat_id: &str) -> bool {
let indefinite = conn
.query_row(
"SELECT muted_chat_ids_json FROM preferences WHERE id = 1",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
.unwrap_or_default();
let timed = conn
.query_row(
"SELECT timed_chat_mutes_json FROM preferences WHERE id = 1",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|json| serde_json::from_str::<Vec<ChatMuteDeadline>>(&json).ok())
.unwrap_or_default();
super::chat_settings::chat_is_muted_at(&indefinite, &timed, chat_id, unix_now().get())
}
pub(in crate::core) fn preview_direct_messages(
data_dir: &str,
owner: PublicKey,
keys: &Keys,
events: &[Event],
) -> Option<ProtocolDecryptedMessage> {
let mut engine = direct_message_preview_engine(data_dir, owner, keys)?;
preview_direct_messages_in_engine(&mut engine, events)
}
pub(in crate::core) fn direct_message_preview_engine(
data_dir: &str,
owner: PublicKey,
keys: &Keys,
) -> Option<ProtocolEngine> {
#[cfg(test)]
PREVIEW_WORK.with(|work| {
let (loads, bytes) = work.get();
work.set((loads + 1, bytes));
});
let conn = open_lookup_connection(data_dir)?;
conn.busy_timeout(Duration::from_millis(200)).ok()?;
super::storage::validate_account_storage(&conn, &owner.to_hex()).ok()?;
let base = Arc::new(super::storage::SqliteStorageAdapter::new(
Arc::new(Mutex::new(conn)),
owner.to_hex(),
keys.public_key().to_hex(),
)) as Arc<dyn StorageAdapter>;
let overlay = Arc::new(NotificationPreviewStorage::new(base)) as Arc<dyn StorageAdapter>;
let engine = ProtocolEngine::load_or_create_for_local_device(overlay, owner, keys).ok()?;
engine.enter_batch();
Some(engine)
}
pub(in crate::core) fn preview_direct_messages_in_engine(
engine: &mut ProtocolEngine,
events: &[Event],
) -> Option<ProtocolDecryptedMessage> {
let expected = events.last()?.id.to_hex();
let mut decrypted = None;
for event in events {
if event.kind.as_u16() as u32 == INVITE_RESPONSE_KIND {
let Ok(batch) = engine.observe_invite_response_event(event) else {
continue;
};
if let Some(message) = batch
.direct_messages
.into_iter()
.find(|message| message.event_id.as_deref() == Some(&expected))
{
decrypted = Some(message);
}
} else if let Some(message) = engine.process_direct_message_event(event).ok()? {
decrypted = Some(message);
}
}
decrypted
}
#[cfg(test)]
thread_local! {
pub(in crate::core) static PREVIEW_WORK: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
}
struct NotificationPreviewStorage {
base: Arc<dyn StorageAdapter>,
overlay: Mutex<BTreeMap<String, String>>,
deleted: Mutex<HashSet<String>>,
}
fn lock_preview_storage<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|poison| poison.into_inner())
}
impl NotificationPreviewStorage {
fn new(base: Arc<dyn StorageAdapter>) -> Self {
Self {
base,
overlay: Mutex::new(BTreeMap::new()),
deleted: Mutex::new(HashSet::new()),
}
}
}
impl StorageAdapter for NotificationPreviewStorage {
fn get(&self, key: &str) -> StorageResult<Option<String>> {
if let Some(value) = lock_preview_storage(&self.overlay).get(key).cloned() {
return Ok(Some(value));
}
if lock_preview_storage(&self.deleted).contains(key) {
return Ok(None);
}
self.base.get(key)
}
fn put(&self, key: &str, value: String) -> StorageResult<()> {
#[cfg(test)]
PREVIEW_WORK.with(|work| {
let (loads, bytes) = work.get();
work.set((loads, bytes + value.len()));
});
lock_preview_storage(&self.overlay).insert(key.to_string(), value);
lock_preview_storage(&self.deleted).remove(key);
Ok(())
}
fn del(&self, key: &str) -> StorageResult<()> {
lock_preview_storage(&self.overlay).remove(key);
lock_preview_storage(&self.deleted).insert(key.to_string());
Ok(())
}
fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
let mut keys: HashSet<String> = self.base.list(prefix)?.into_iter().collect();
let deleted = lock_preview_storage(&self.deleted);
keys.retain(|key| !deleted.contains(key));
for key in lock_preview_storage(&self.overlay).keys() {
if key.starts_with(prefix) && !deleted.contains(key) {
keys.insert(key.clone());
}
}
let mut keys: Vec<String> = keys.into_iter().collect();
keys.sort();
Ok(keys)
}
}
fn suppressed_resolution() -> MobilePushNotificationResolution {
MobilePushNotificationResolution {
should_show: false,
title: String::new(),
body: String::new(),
payload_json: "{}".to_string(),
}
}
pub(in crate::core) fn open_lookup_connection(data_dir: &str) -> Option<rusqlite::Connection> {
let path = PathBuf::from(data_dir).join(super::storage::CORE_DB_FILENAME);
if !path.exists() {
return None;
}
rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()
}
fn lookup_sender_display_name(data_dir: &str, sender: &nostr::PublicKey) -> Option<String> {
let conn = open_lookup_connection(data_dir)?;
let (display_name, name): (Option<String>, Option<String>) = conn
.query_row(
"SELECT display_name, name FROM owner_profiles WHERE owner_pubkey_hex = ?1",
[sender.to_hex()],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok()?;
display_name
.or(name)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn lookup_direct_thread_sender_name(data_dir: &str, sender: &nostr::PublicKey) -> Option<String> {
let conn = open_lookup_connection(data_dir)?;
let mut stmt = conn
.prepare(
"SELECT author FROM messages
WHERE chat_id = ?1 AND is_outgoing = 0
ORDER BY created_at_secs DESC, id DESC",
)
.ok()?;
let rows = stmt
.query_map([sender.to_hex()], |row| row.get::<_, String>(0))
.ok()?;
for row in rows.flatten() {
let author = row.trim().to_string();
if !author.is_empty() && !is_generic_sender_title(&author) {
return Some(author);
}
}
None
}
fn lookup_group_name(data_dir: &str, group_id: &str) -> Option<String> {
let conn = open_lookup_connection(data_dir)?;
let name: String = conn
.query_row(
"SELECT name FROM groups WHERE group_id = ?1",
[group_id],
|row| row.get(0),
)
.ok()?;
let trimmed = name.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
pub(crate) fn resolve_mobile_push_notification(
raw_payload_json: String,
) -> MobilePushNotificationResolution {
let payload = normalized_payload(&raw_payload_json);
let title = resolved_title(&payload);
let body = normalized_value(payload.get("body")).unwrap_or_else(|| "New activity".to_string());
let inner_kind = payload
.get("inner_kind")
.and_then(|value| value.trim().parse::<u64>().ok())
.or_else(|| event_kind(payload.get("inner_event_json")))
.or_else(|| event_kind(payload.get("inner_event")));
let outer_kind =
first_normalized_payload_event_kind(&payload, MOBILE_PUSH_OUTER_EVENT_PAYLOAD_KEYS);
if inner_kind.is_none() && outer_kind == Some(MOBILE_PUSH_INVITE_RESPONSE_KIND) {
return suppressed_resolution();
}
if inner_kind.is_some_and(|kind| !should_show_mobile_push_kind(kind)) {
return MobilePushNotificationResolution {
should_show: false,
title: String::new(),
body: String::new(),
payload_json: "{}".to_string(),
};
}
if inner_kind.is_none() && outer_kind == Some(MOBILE_PUSH_OUTER_MESSAGE_EVENT_KIND) {
return MobilePushNotificationResolution {
should_show: false,
title: String::new(),
body: String::new(),
payload_json: "{}".to_string(),
};
}
let body = if inner_kind == Some(MOBILE_PUSH_REACTION_KIND) {
let emoji = normalized_value(payload.get("body"))
.or_else(|| event_content(payload.get("inner_event_json")))
.or_else(|| event_content(payload.get("inner_event")))
.unwrap_or_default();
if emoji.is_empty() {
"Reacted".to_string()
} else if emoji.to_lowercase().starts_with("reacted") {
emoji
} else {
format!("Reacted {emoji}")
}
} else if inner_kind == Some(MOBILE_PUSH_CHAT_MESSAGE_KIND) {
decrypted_mobile_push_body(MOBILE_PUSH_CHAT_MESSAGE_KIND, &body)
} else {
body
};
let mut resolved_payload = payload;
resolved_payload.insert("title".to_string(), title.clone());
resolved_payload.insert("body".to_string(), body.clone());
if let Some(kind) = inner_kind {
resolved_payload.insert("inner_kind".to_string(), kind.to_string());
}
MobilePushNotificationResolution {
should_show: true,
title,
body,
payload_json: serde_json::to_string(&resolved_payload).unwrap_or_else(|_| "{}".to_string()),
}
}
fn normalized_payload(raw_payload_json: &str) -> BTreeMap<String, String> {
let mut payload = BTreeMap::new();
let Ok(decoded) = serde_json::from_str::<serde_json::Value>(raw_payload_json) else {
return payload;
};
let Some(object) = decoded.as_object() else {
return payload;
};
for (key, value) in object {
if value.is_null() {
continue;
}
let value = value
.as_str()
.map(ToString::to_string)
.unwrap_or_else(|| value.to_string());
if !value.trim().is_empty() {
payload.insert(key.clone(), value);
}
}
payload
}
fn payload_event_json(value: &serde_json::Value) -> Option<String> {
if let Some(raw) = value.as_str() {
return Some(raw.to_string());
}
if value.is_object() {
return serde_json::to_string(value).ok();
}
None
}
fn first_payload_event_json(
payload_object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| payload_object.get(*key))
.find_map(payload_event_json)
}
fn first_payload_event_kind(
payload_object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<u64> {
keys.iter()
.filter_map(|key| payload_object.get(*key))
.find_map(|value| payload_value_event_kind(Some(value)))
}
fn payload_value_event_kind(value: Option<&serde_json::Value>) -> Option<u64> {
let value = value?;
if let Some(kind) = value.get("kind").and_then(json_u64) {
return Some(kind);
}
let raw = value.as_str()?;
let decoded = serde_json::from_str::<serde_json::Value>(raw).ok()?;
decoded.get("kind").and_then(json_u64)
}
fn json_u64(value: &serde_json::Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_str()?.trim().parse::<u64>().ok())
}
fn resolved_title(payload: &BTreeMap<String, String>) -> String {
for value in [payload.get("sender_name"), payload.get("title")] {
if let Some(title) = normalized_sender_title(value) {
if !is_generic_sender_title(&title) {
return title;
}
}
}
"Iris Chat".to_string()
}
fn normalized_sender_title(value: Option<&String>) -> Option<String> {
let normalized = normalized_value(value)?;
if normalized.to_lowercase().starts_with("dm by ") && normalized.len() > 6 {
let stripped = normalized[6..].trim().to_string();
return (!stripped.is_empty()).then_some(stripped);
}
Some(normalized)
}
fn normalized_value(value: Option<&String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn is_generic_sender_title(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"" | "someone" | "new message" | "new activity" | "iris chat"
)
}
fn event_kind(value: Option<&String>) -> Option<u64> {
let decoded = serde_json::from_str::<serde_json::Value>(value?).ok()?;
decoded.get("kind").and_then(json_u64)
}
fn first_normalized_payload_event_kind(
payload: &BTreeMap<String, String>,
keys: &[&str],
) -> Option<u64> {
keys.iter().find_map(|key| event_kind(payload.get(*key)))
}
fn event_content(value: Option<&String>) -> Option<String> {
let decoded = serde_json::from_str::<serde_json::Value>(value?).ok()?;
let content = decoded.get("content")?.as_str()?.to_string();
normalized_value(Some(&content))
}
fn invite_acceptance_push_resolution(
payload_object: &serde_json::Map<String, serde_json::Value>,
event: nostr::Event,
chat_id: String,
) -> MobilePushNotificationResolution {
let title = "Invite accepted".to_string();
let body = "Someone joined your chat".to_string();
let mut resolved_payload = serde_json::Map::new();
for (key, value) in payload_object {
resolved_payload.insert(key.clone(), value.clone());
}
resolved_payload.insert(
"title".to_string(),
serde_json::Value::String(title.clone()),
);
resolved_payload.insert("body".to_string(), serde_json::Value::String(body.clone()));
resolved_payload.insert(
"inner_kind".to_string(),
serde_json::Value::String(MOBILE_PUSH_INVITE_RESPONSE_KIND.to_string()),
);
resolved_payload.insert(
"invite_response_event_id".to_string(),
serde_json::Value::String(event.id.to_hex()),
);
resolved_payload.insert("chat_id".to_string(), serde_json::Value::String(chat_id));
MobilePushNotificationResolution {
should_show: true,
title,
body,
payload_json: serde_json::to_string(&serde_json::Value::Object(resolved_payload))
.unwrap_or_else(|_| "{}".to_string()),
}
}
fn persisted_message_body_preview(body: &str, attachments: &str) -> String {
let preview = chat_message_body_preview(body);
if !preview.is_empty() {
return preview;
}
serde_json::from_str::<Vec<MessageAttachmentSnapshot>>(attachments)
.ok()
.map(|attachments| attachments::attachment_list_preview(&attachments))
.unwrap_or_default()
}
fn decrypted_mobile_push_body(kind: u64, content: &str) -> String {
match kind {
MOBILE_PUSH_CHAT_MESSAGE_KIND => {
let preview = chat_message_body_preview(content);
if preview.is_empty() {
"New message".to_string()
} else {
preview
}
}
MOBILE_PUSH_REACTION_KIND => reaction_push_body(content.trim()),
kind if kind == TYPING_KIND as u64 => "Typing…".to_string(),
kind if kind == RECEIPT_KIND as u64 => match content.trim() {
"seen" => "Seen".to_string(),
"delivered" => "Delivered".to_string(),
_ => "Message status updated".to_string(),
},
kind if kind == CHAT_SETTINGS_KIND as u64 => "Updated chat".to_string(),
kind if kind == APP_KEYS_EVENT_KIND as u64 => "Updated devices".to_string(),
MOBILE_PUSH_INVITE_RESPONSE_KIND => "Someone joined your chat".to_string(),
_ => {
let content = content.trim();
if content.is_empty() {
"New activity".to_string()
} else {
"Updated chat".to_string()
}
}
}
}
fn reaction_push_body(content: &str) -> String {
let emoji = content.trim();
if emoji.is_empty() {
"Reacted".to_string()
} else if emoji.to_lowercase().starts_with("reacted") {
emoji.to_string()
} else {
format!("Reacted {emoji}")
}
}
fn should_show_mobile_push_kind(kind: u64) -> bool {
matches!(
kind,
MOBILE_PUSH_CHAT_MESSAGE_KIND | MOBILE_PUSH_INVITE_RESPONSE_KIND
)
}
fn mobile_push_event_has_tag(event: &Event, name: &str) -> bool {
event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(|value| value.as_str()) == Some(name))
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
#[test]
fn sqlite_preview_resolution_keeps_chat_id_for_foreground_suppression() {
let tmp = tempfile::tempdir().expect("tempdir");
let conn = open_database(tmp.path()).expect("open database");
let conn = conn.lock().expect("lock database");
conn.execute(
"INSERT INTO threads(chat_id, unread_count, updated_at_secs) VALUES (?1, 1, 99)",
["direct-chat-id"],
)
.expect("insert thread");
conn.execute(
"INSERT INTO messages(
chat_id, id, kind, author, body, is_outgoing, created_at_secs, delivery, source_event_id
) VALUES (?1, ?2, 'user', ?3, ?4, 0, 99, 'received', ?5)",
params![
"direct-chat-id",
"message-1",
"Alice",
"hello",
"outer-event-id"
],
)
.expect("insert message");
let resolution =
lookup_mobile_push_preview(tmp.path().to_str().unwrap(), "outer-event-id", "")
.expect("resolved preview");
let payload: serde_json::Value =
serde_json::from_str(&resolution.payload_json).expect("payload json");
assert_eq!(
payload.get("chat_id").and_then(|value| value.as_str()),
Some("direct-chat-id")
);
}
#[test]
fn sqlite_preview_resolution_omits_replied_message_quote() {
let tmp = tempfile::tempdir().expect("tempdir");
let conn = open_database(tmp.path()).expect("open database");
let conn = conn.lock().expect("lock database");
conn.execute(
"INSERT INTO threads(chat_id, unread_count, updated_at_secs) VALUES (?1, 1, 99)",
["direct-chat-id"],
)
.expect("insert thread");
conn.execute(
"INSERT INTO messages(
chat_id, id, kind, author, body, is_outgoing, created_at_secs, delivery, source_event_id
) VALUES (?1, ?2, 'user', ?3, ?4, 0, 99, 'received', ?5)",
params![
"direct-chat-id",
"message-1",
"Alice",
"\u{21A9} Bob: the older post\n\nonly show the new reply",
"outer-event-id"
],
)
.expect("insert message");
let resolution =
lookup_mobile_push_preview(tmp.path().to_str().unwrap(), "outer-event-id", "")
.expect("resolved preview");
assert_eq!(resolution.body, "only show the new reply");
}
#[test]
fn group_notification_format_matches_signal() {
let (title, body) = group_notification_title_and_body(
Some("Trip crew".to_string()),
"Alice",
"Landing at 7".to_string(),
);
assert_eq!(title, "Trip crew");
assert_eq!(body, "Alice: Landing at 7");
}
#[test]
fn decrypted_chat_push_body_omits_replied_message_quote() {
assert_eq!(
decrypted_mobile_push_body(
MOBILE_PUSH_CHAT_MESSAGE_KIND,
"\u{21A9} Bob: the older post\n\nonly show the new reply",
),
"only show the new reply"
);
}
}