iris-chat 0.1.58

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;
pub(in crate::core) mod mention_notifications;
use nostr_identity::{build_private_setting, parse_private_setting};

impl AppCore {
    pub(super) fn private_setting_events(&self) -> Vec<PrivateEvent> {
        let Some(login) = &self.logged_in else {
            return Vec::new();
        };
        let owner = login.owner_pubkey;
        let snapshot = self.build_device_sync_snapshot(0, false);
        let mut events = Vec::new();
        let mut add = |setting: &str, target: &str, value: serde_json::Value| {
            if let Ok(event) = build_private_setting(owner, setting, target, &value) {
                events.push(event);
            }
        };
        for value in snapshot.chat_pins {
            if let Ok(json) = serde_json::to_value(&value) {
                add("chatPin", &value.chat_id, json);
            }
        }
        for value in snapshot.chat_mutes {
            if let Ok(json) = serde_json::to_value(&value) {
                add("chatMute", &value.chat_id, json);
            }
        }
        for value in snapshot.private_device_labels_v2 {
            let Ok(value) = serde_json::to_value(value) else {
                continue;
            };
            let Some(target) = value
                .get("device")
                .and_then(|value| value.as_str())
                .map(str::to_owned)
            else {
                continue;
            };
            add("deviceLabel", &target, value);
        }
        for value in snapshot.chats {
            add(
                "chat",
                &value.id,
                serde_json::json!({"id":value.id,"updatedAt":value.updated_at}),
            );
        }
        for value in snapshot.deleted_chats {
            if let Ok(json) = serde_json::to_value(&value) {
                add("chatDeletion", &value.id, json);
            }
        }
        for target in &self.preferences.accepted_owner_pubkeys {
            if self.request_decision(target).is_none() {
                add(
                    "requestDecision",
                    target,
                    serde_json::json!({"chatId":target,"decision":"accepted","updatedAtMs":1}),
                );
            }
        }
        let _ = self.visit_sync_records(|record| {
            if let DeviceSyncRecord::PrivateEvent { event } = record {
                events.push(event);
            }
            true
        });
        events
    }

    pub(super) fn private_setting_snapshot(
        &self,
        event: &PrivateEvent,
    ) -> Option<DeviceSyncSnapshot> {
        if serde_json::to_vec(event).ok()?.len() > 8192 {
            return None;
        }
        let (setting, target, value) =
            parse_private_setting(event, self.logged_in.as_ref()?.owner_pubkey).ok()?;
        let mut snapshot = DeviceSyncSnapshot::default();
        let future = unix_now().get().saturating_add(300);
        if setting != "deviceLabel" && !valid_device_sync_chat_id(&target) {
            return None;
        }
        match setting.as_str() {
            "chatPin" => {
                let state: super::super::chat_pin_sync::ChatPinState =
                    serde_json::from_value(value).ok()?;
                if state.chat_id != target
                    || state.updated_at_ms == 0
                    || state.updated_at_ms > unix_now_ms().saturating_add(300_000)
                {
                    return None;
                }
                snapshot.chat_pins.push(state);
            }
            "chatMute" => {
                let state: super::super::chat_mute_sync::ChatMuteState =
                    serde_json::from_value(value).ok()?;
                if state.chat_id != target
                    || state.updated_at_ms == 0
                    || state.updated_at_ms > unix_now_ms().saturating_add(300_000)
                    || state.until_secs.is_some_and(|time| time > 253_402_300_799)
                {
                    return None;
                }
                snapshot.chat_mutes.push(state);
            }
            "deviceLabel" => {
                if value.get("device").and_then(|value| value.as_str()) != Some(target.as_str())
                    || value.get("owner").and_then(|value| value.as_str())
                        != Some(event.pubkey.to_hex().as_str())
                {
                    return None;
                }
                let state: super::super::private_device_labels::PrivateDeviceLabel =
                    serde_json::from_value(value).ok()?;
                if !state.valid_for(&event.pubkey.to_hex()) {
                    return None;
                }
                snapshot.private_device_labels_v2.push(state);
            }
            "chat" => {
                let state: DeviceSyncChat = serde_json::from_value(value).ok()?;
                if state.id != target || state.updated_at > future || state.read_state.is_some() {
                    return None;
                }
                snapshot.chats.push(state);
            }
            "chatDeletion" => {
                let state: DeviceSyncChatDeletion = serde_json::from_value(value).ok()?;
                if state.id != target || state.deleted_at == 0 || state.deleted_at > future {
                    return None;
                }
                snapshot.deleted_chats.push(state);
            }
            "mentionNotifications" => {
                mention_notifications::state(event)?;
            }
            "requestDecision" => {
                request_decision_state(event)?;
            }
            _ => return None,
        }
        Some(snapshot)
    }

    pub(in crate::core) fn merge_private_setting(&mut self, event: &PrivateEvent) -> bool {
        let Some(mut snapshot) = self.private_setting_snapshot(event) else {
            return false;
        };
        if let Some(state) = request_decision_state(event) {
            match self.store_sync_record(&DeviceSyncRecord::PrivateEvent {
                event: event.clone(),
            }) {
                Err(_) => return false,
                Ok(false) => return true,
                Ok(true) => self.project_request_decision(&state),
            }
        }
        if let Some(state) = mention_notifications::state(event) {
            match self.store_sync_record(&DeviceSyncRecord::PrivateEvent {
                event: event.clone(),
            }) {
                Err(_) => return false,
                Ok(false) => return true,
                Ok(true) => self.project_mention_notifications(&state),
            }
        }
        for state in snapshot.chat_pins.drain(..) {
            if self.merge_chat_pin(state).is_err() {
                return false;
            }
        }
        for state in snapshot.chat_mutes.drain(..) {
            if self.merge_chat_mute(state).is_err() {
                return false;
            }
        }
        for deletion in snapshot.deleted_chats.drain(..) {
            self.apply_chat_deletion(&deletion.id, deletion.deleted_at);
            if self.chat_deletions.get(&deletion.id).copied().unwrap_or(0) < deletion.deleted_at {
                return false;
            }
        }
        self.apply_device_sync_snapshot(snapshot, Some(0));
        self.persist_app_state().is_ok()
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(in crate::core) struct RequestDecision {
    chat_id: String,
    pub(in crate::core) decision: String,
    updated_at_ms: u64,
}
pub(super) fn request_decision_state(event: &PrivateEvent) -> Option<RequestDecision> {
    let (setting, target, value) = parse_private_setting(event, event.pubkey).ok()?;
    let state: RequestDecision = serde_json::from_value(value).ok()?;
    (setting == "requestDecision"
        && state.chat_id == target
        && valid_device_sync_chat_id(&target)
        && ["none", "accepted", "rejected"].contains(&state.decision.as_str())
        && state.updated_at_ms > 0
        && state.updated_at_ms <= unix_now_ms().saturating_add(300_000))
    .then_some(state)
}
pub(super) fn request_decision_version(event: &PrivateEvent) -> Option<(u64, u8)> {
    let state = request_decision_state(event)?;
    Some((
        state.updated_at_ms,
        match state.decision.as_str() {
            "accepted" => 1,
            "rejected" => 2,
            _ => 0,
        },
    ))
}
/// A local explicit choice, frozen before either private-link lookup or owner proof.
/// This is never published; only successful verified acceptance is synced privately.
#[derive(Clone, Debug)]
pub(in crate::core) struct InviteAcceptanceIntent {
    requested_at_ms: u64,
    decisions: BTreeMap<String, RequestDecision>,
}

impl AppCore {
    pub(in crate::core) fn capture_invite_acceptance(
        &self,
        target: Option<&str>,
    ) -> anyhow::Result<InviteAcceptanceIntent> {
        let requested_at_ms = unix_now_ms();
        let mut decisions = BTreeMap::new();
        if let Some(target) = target {
            if let Some(state) = self.request_decision(target) {
                decisions.insert(target.to_owned(), state);
            }
        } else {
            // A private link does not reveal its owner until fetched. Read only
            // request-decision heads, never the message/history record stream.
            let owner = self
                .logged_in
                .as_ref()
                .ok_or_else(|| anyhow::anyhow!("No account"))?
                .owner_pubkey;
            let start = format!(
                "iris-chat-sync-record-v1:{}:private-state:[\"privateEvent\",\"requestDecision\",",
                owner.to_hex()
            );
            let end = format!("{start}\u{10ffff}");
            let shared = self.app_store.shared();
            let conn = shared.lock().map_err(|_| anyhow::anyhow!("Storage lock"))?;
            let mut query =
                conn.prepare("SELECT value FROM app_meta WHERE key>=?1 AND key<?2 ORDER BY key")?;
            for row in query.query_map([start, end], |row| row.get::<_, String>(0))? {
                if let DeviceSyncRecord::PrivateEvent { event } = serde_json::from_str(&row?)? {
                    let state = request_decision_state(&event)
                        .ok_or_else(|| anyhow::anyhow!("Invalid request decision"))?;
                    decisions.insert(state.chat_id.clone(), state);
                }
            }
        }
        Ok(InviteAcceptanceIntent {
            requested_at_ms,
            decisions,
        })
    }

    pub(in crate::core) fn sync_invite_acceptance(
        &mut self,
        target: &str,
        intent: &InviteAcceptanceIntent,
    ) -> bool {
        let previous = intent.decisions.get(target);
        // Even a same-millisecond reset/rejection during either async step wins.
        if self.request_decision(target).as_ref() != previous {
            return true;
        }
        let millis = intent
            .requested_at_ms
            .max(previous.map_or(0, |state| state.updated_at_ms.saturating_add(1)));
        self.sync_request_acceptance_at(target, millis)
    }

    pub(in crate::core) fn request_decision(&self, target: &str) -> Option<RequestDecision> {
        let key = serde_json::json!(["privateEvent", "requestDecision", target]).to_string();
        match self.load_sync_record(&records::RecordLocator::Head(key))? {
            DeviceSyncRecord::PrivateEvent { event } => request_decision_state(&event),
            _ => None,
        }
    }
    pub(super) fn project_request_decision(&mut self, state: &RequestDecision) {
        self.preferences
            .accepted_owner_pubkeys
            .retain(|target| target != &state.chat_id);
        if state.decision == "accepted" {
            self.preferences
                .accepted_owner_pubkeys
                .push(state.chat_id.clone());
        }
        self.preferences.accepted_owner_pubkeys.sort();
        self.request_protocol_subscription_refresh();
        self.mark_mobile_push_dirty();
    }
    pub(in crate::core) fn sync_request_acceptance(&mut self, target: &str) -> bool {
        let millis = unix_now_ms().max(
            self.request_decision(target)
                .map_or(0, |state| state.updated_at_ms.saturating_add(1)),
        );
        self.sync_request_acceptance_at(target, millis)
    }

    /// Replay uses the original activity time. Arrival time must never turn
    /// delayed history into a new decision that defeats a later rejection.
    pub(in crate::core) fn observe_chat_engagement(
        &mut self,
        chat: &str,
        author: &str,
        created_at_ms: u64,
    ) -> bool {
        if self
            .logged_in
            .as_ref()
            .is_none_or(|login| login.owner_pubkey.to_hex() != author)
            || self.chat_activity_is_deleted(chat, created_at_ms / 1000)
        {
            return true;
        }
        self.sync_request_acceptance_at(chat, created_at_ms)
    }

    fn sync_request_acceptance_at(&mut self, target: &str, updated_at_ms: u64) -> bool {
        if !valid_device_sync_chat_id(target)
            || updated_at_ms == 0
            || updated_at_ms > unix_now_ms().saturating_add(300_000)
            || self.is_owner_blocked(target)
        {
            return true;
        }
        if self.request_decision(target).is_some_and(|previous| {
            previous.updated_at_ms > updated_at_ms
                || (previous.updated_at_ms == updated_at_ms && previous.decision != "none")
        }) {
            return true;
        }
        let Some(login) = &self.logged_in else {
            return false;
        };
        let state = RequestDecision {
            chat_id: target.into(),
            decision: "accepted".into(),
            updated_at_ms,
        };
        let Ok(value) = serde_json::to_value(state) else {
            return false;
        };
        let Ok(event) =
            build_private_setting(login.owner_pubkey, "requestDecision", target, &value)
        else {
            return false;
        };
        if !self.merge_private_setting(&event) {
            return false;
        }
        self.broadcast_device_sync_snapshot();
        true
    }
}

/// Private settings use their own logical clock, never relay arrival order.
pub(super) fn private_setting_version(event: &PrivateEvent) -> Option<(u64, u8)> {
    request_decision_version(event).or_else(|| mention_notifications::version(event))
}