iris-chat 0.1.66

Iris Chat command line client and shared encrypted chat core
Documentation
use crate::{AccountSnapshot, ChatKind, ChatThreadSnapshot, DeviceAuthorizationState};

pub(crate) const NOTE_TO_SELF: &str = "Note to self";

/// Expose self-chat before its first message without persisting an empty thread.
pub(crate) fn include_note_to_self(chats: &mut Vec<ChatThreadSnapshot>, account: &AccountSnapshot) {
    if chats
        .iter()
        .any(|chat| chat.chat_id == account.public_key_hex)
    {
        return;
    }
    chats.push(note_to_self_thread(account));
}

fn note_to_self_thread(account: &AccountSnapshot) -> ChatThreadSnapshot {
    ChatThreadSnapshot {
        new_chat_notification_id: None,
        social_connection: Some(crate::SocialConnectionSnapshot {
            is_favorite: false,
            badge: Some(crate::SocialBadge::Following),
            follow_distance: Some(0),
            followed_by_friends: 0,
            description: "You".into(),
        }),
        chat_id: account.public_key_hex.clone(),
        kind: ChatKind::Direct,
        display_name: NOTE_TO_SELF.to_string(),
        nickname: None,
        contact_note: None,
        profile_name: Some(account.display_name.clone()),
        subtitle: None,
        picture_url: account.picture_url.clone(),
        about: account.about.clone(),
        member_count: 0,
        last_message_preview: None,
        last_message_at_secs: None,
        last_message_is_outgoing: None,
        last_message_id: None,
        unread_mention_messages: Vec::new(),
        last_message_delivery: None,
        unread_count: 0,
        is_typing: false,
        is_muted: false,
        is_pinned: false,
        draft: String::new(),
        is_request: false,
    }
}

/// A picker can offer the account's own conversation before it has any history.
#[uniffi::export]
pub fn share_target_chats(
    account: Option<AccountSnapshot>,
    mut chats: Vec<ChatThreadSnapshot>,
) -> Vec<ChatThreadSnapshot> {
    let Some(account) =
        account.filter(|a| a.authorization_state == DeviceAuthorizationState::Authorized)
    else {
        return Vec::new();
    };
    let mut own_chat = match chats
        .iter()
        .position(|chat| chat.chat_id == account.public_key_hex)
    {
        Some(index) => chats.remove(index),
        None => note_to_self_thread(&account),
    };
    own_chat.display_name = NOTE_TO_SELF.into();
    own_chat.subtitle = Some(account.display_name);
    own_chat.picture_url = account.picture_url;
    own_chat.is_request = false;
    chats.insert(0, own_chat);
    chats
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum NameMatchRank {
    Exact,
    WordPrefix,
    Substring,
    OtherField,
}

/// Shared contact and people matching, with names ahead of descriptive fields.
pub(crate) struct SearchQuery {
    text: String,
    compact: String,
    terms: Vec<(String, String)>,
}

impl SearchQuery {
    pub(crate) fn new(query: &str) -> Self {
        let text = query.trim().to_lowercase();
        Self {
            compact: compact_search_text(&text),
            terms: text
                .split_whitespace()
                .map(|term| (term.to_string(), compact_search_text(term)))
                .collect(),
            text,
        }
    }

    pub(crate) fn matches(&self, fields: &[&str]) -> bool {
        let fields = fields
            .iter()
            .flat_map(|field| [field.to_lowercase(), compact_search_text(field)])
            .collect::<Vec<_>>();
        !self.terms.is_empty()
            && self.terms.iter().all(|(term, compact)| {
                fields.iter().any(|field| {
                    field.contains(term) || (!compact.is_empty() && field.contains(compact))
                })
            })
    }

    pub(crate) fn name_rank(&self, names: &[&str]) -> NameMatchRank {
        let mut rank = NameMatchRank::OtherField;
        for name in names {
            let name = name.to_lowercase();
            let compact = compact_search_text(&name);
            if name == self.text || (!self.compact.is_empty() && compact == self.compact) {
                return NameMatchRank::Exact;
            }
            // Match surnames and punctuation-separated words as well as the
            // first word. Keep joined-name searches such as "AnnaSm" working.
            let words = name
                .split(|character: char| !character.is_alphanumeric())
                .filter(|word| !word.is_empty())
                .collect::<Vec<_>>();
            if !self.terms.is_empty()
                && (name.starts_with(&self.text)
                    || (!self.compact.is_empty() && compact.starts_with(&self.compact))
                    || self
                        .terms
                        .iter()
                        .all(|(term, _)| words.iter().any(|word| word.starts_with(term))))
            {
                rank = NameMatchRank::WordPrefix;
            }
        }
        if rank == NameMatchRank::OtherField && self.matches(names) {
            NameMatchRank::Substring
        } else {
            rank
        }
    }
}

fn compact_search_text(value: &str) -> String {
    value
        .chars()
        .filter(|character| character.is_alphanumeric())
        .flat_map(char::to_lowercase)
        .collect()
}

pub(crate) fn filter_threads_for_search(
    chat_list: &[ChatThreadSnapshot],
    query: &str,
) -> (Vec<ChatThreadSnapshot>, Vec<ChatThreadSnapshot>) {
    let query = SearchQuery::new(query);
    let mut matches = Vec::new();
    for chat in chat_list {
        let names = [
            chat.display_name.as_str(),
            chat.nickname.as_deref().unwrap_or_default(),
            chat.profile_name.as_deref().unwrap_or_default(),
        ];
        let fields = [
            names.as_slice(),
            &[
                chat.about.as_deref().unwrap_or_default(),
                chat.subtitle.as_deref().unwrap_or_default(),
                &chat.draft,
                &chat.chat_id,
            ],
        ]
        .concat();
        if query.matches(&fields) {
            matches.push((query.name_rank(&names), chat));
        }
    }
    // Stable sorting preserves the existing chat order for equally good hits.
    matches.sort_by_key(|(rank, _)| *rank);
    let mut contacts = Vec::new();
    let mut groups = Vec::new();
    for (_, chat) in matches {
        match chat.kind {
            ChatKind::Direct => contacts.push(chat.clone()),
            ChatKind::Group => groups.push(chat.clone()),
        }
    }
    (contacts, groups)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn name_ranking_preserves_unicode_and_stronger_later_matches() {
        use NameMatchRank::{Exact, OtherField, Substring, WordPrefix};
        let cases: &[(&str, &[&str], NameMatchRank)] = &[
            ("anna", &["Annabelle", "Anna"], Exact),
            ("anna", &["Joanna", "Annabelle"], WordPrefix),
            ("anna", &["Joanna"], Substring),
            ("anna sm", &["Anna Smith"], WordPrefix),
            ("smith anna", &["Anna Smith"], WordPrefix),
            ("annasm", &["Anna Smith"], WordPrefix),
            ("anna cooper", &["Anna Jones", "Bob Cooper"], Substring),
            ("coding", &["Anna Smith"], OtherField),
            ("i", &["İ"], Exact),
            ("İ", &["I"], Exact),
            ("ος", &["ΟΣ"], Exact),
            ("σ", &["ΟΣ"], Substring),
        ];
        for &(query, names, expected) in cases {
            assert_eq!(
                SearchQuery::new(query).name_rank(names),
                expected,
                "{query:?}: {names:?}"
            );
        }
    }
}