iris-chat 0.1.24

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;

const TYPING_INDICATOR_TTL_SECS: u64 = 10;

impl AppCore {
    pub(super) fn send_typing(&mut self, chat_id: &str) {
        if !self.preferences.send_typing_indicators {
            return;
        }
        let Some(normalized_chat_id) = self.normalize_chat_id(chat_id) else {
            return;
        };
        let Some(owner_pubkey) = self
            .logged_in
            .as_ref()
            .map(|logged_in| logged_in.owner_pubkey)
        else {
            return;
        };
        if is_group_chat_id(&normalized_chat_id) {
            self.send_group_event(&normalized_chat_id, TYPING_KIND, "typing", Vec::new(), None);
        } else if let Ok((_, peer)) = parse_peer_input(&normalized_chat_id) {
            let now = unix_now();
            if let Ok(unsigned) = pairwise_codec::typing_event(
                owner_pubkey,
                pairwise_codec::EncodeOptions::new(now.get(), now.get().saturating_mul(1000)),
            ) {
                self.send_protocol_engine_unsigned_event(
                    peer,
                    &normalized_chat_id,
                    unsigned,
                    "typing",
                );
            }
        }
    }

    pub(super) fn stop_typing(&mut self, chat_id: &str) {
        if !self.preferences.send_typing_indicators {
            return;
        }
        let Some(normalized_chat_id) = self.normalize_chat_id(chat_id) else {
            return;
        };
        let Some(owner_pubkey) = self
            .logged_in
            .as_ref()
            .map(|logged_in| logged_in.owner_pubkey)
        else {
            return;
        };
        if is_group_chat_id(&normalized_chat_id) {
            self.send_group_event(
                &normalized_chat_id,
                TYPING_KIND,
                "typing",
                vec![vec!["expiration".to_string(), "1".to_string()]],
                None,
            );
        } else if let Ok((_, peer)) = parse_peer_input(&normalized_chat_id) {
            let now = unix_now();
            if let Ok(unsigned) = pairwise_codec::typing_event(
                owner_pubkey,
                pairwise_codec::EncodeOptions::new(now.get(), now.get().saturating_mul(1000))
                    .with_expiration(1),
            ) {
                self.send_protocol_engine_unsigned_event(
                    peer,
                    &normalized_chat_id,
                    unsigned,
                    "stop_typing",
                );
            }
        }
    }

    pub(super) fn apply_typing_event(
        &mut self,
        chat_id: String,
        author_owner_hex: String,
        event_secs: u64,
        expires_at_secs: Option<u64>,
    ) {
        // Explicit stop-typing: sender marked the rumor expired relative to
        // its own clock (used by `stop_typing` and by the invite bootstrap).
        if expires_at_secs.is_some_and(|expires_at| expires_at <= event_secs) {
            self.clear_typing_indicator(&chat_id, &author_owner_hex);
            return;
        }
        // Already expired vs our wall clock — late delivery of a rumor whose
        // sender-supplied expiration is in the past for us. Treat as stop so
        // an old typing rumor (e.g., one re-delivered via backfill) doesn't
        // show up as "is typing right now".
        if expires_at_secs.is_some_and(|expires_at| expires_at <= unix_now().get()) {
            self.clear_typing_indicator(&chat_id, &author_owner_hex);
            return;
        }
        // Don't re-arm an indicator at or before the chat's typing
        // floor. The floor is bumped to the wire-clock timestamp of
        // every message we add to the thread, so a stray typing
        // rumor that races (or a peer that doesn't send a stop-
        // typing event) can't keep the indicator alive after we've
        // already seen the message.
        if let Some(floor) = self.typing_floor_secs.get(&chat_id).copied() {
            if event_secs <= floor {
                self.clear_typing_indicator(&chat_id, &author_owner_hex);
                return;
            }
        }
        self.set_typing_indicator(chat_id, author_owner_hex, event_secs);
    }

    /// Raise the per-chat typing floor to `ts` if it's higher than
    /// what we already have. Called at every message-add site so the
    /// floor tracks the latest message wire-clock timestamp seen for
    /// the chat, monotonically.
    pub(super) fn bump_typing_floor(&mut self, chat_id: &str, ts: u64) {
        let entry = self
            .typing_floor_secs
            .entry(chat_id.to_string())
            .or_insert(0);
        if *entry < ts {
            *entry = ts;
        }
    }

    pub(super) fn set_typing_indicator(
        &mut self,
        chat_id: String,
        author_owner_hex: String,
        event_secs: u64,
    ) {
        let expires_at_secs = unix_now().get().saturating_add(TYPING_INDICATOR_TTL_SECS);
        let key = typing_indicator_key(&chat_id, &author_owner_hex);
        self.typing_indicators.insert(
            key,
            TypingIndicatorRecord {
                chat_id: chat_id.clone(),
                author_owner_hex: author_owner_hex.clone(),
                expires_at_secs,
                last_event_secs: event_secs,
            },
        );
        self.schedule_typing_indicator_expiry(chat_id, author_owner_hex);
    }

    pub(super) fn clear_typing_indicator(&mut self, chat_id: &str, author_owner_hex: &str) {
        self.typing_indicators
            .remove(&typing_indicator_key(chat_id, author_owner_hex));
    }

    pub(super) fn schedule_typing_indicator_expiry(&self, chat_id: String, author: String) {
        let tx = self.core_sender.clone();
        self.runtime.spawn(async move {
            sleep(Duration::from_secs(TYPING_INDICATOR_TTL_SECS)).await;
            let _ = tx.send(CoreMsg::Internal(Box::new(
                InternalEvent::TypingIndicatorExpired { chat_id, author },
            )));
        });
    }
}

fn typing_indicator_key(chat_id: &str, author_owner_hex: &str) -> String {
    format!("{chat_id}\n{author_owner_hex}")
}