From 94cc8a3858640b140202fb38841e20d2b5d029b2 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Mon, 21 Sep 2026 12:21:51 +0200 Subject: [PATCH] api!: replace `was_seen_recently` by `freshness` in contact object BREAKING CHANGE: use contact's `freshness` instead of `seen_recently` --- deltachat-ffi/deltachat.h | 44 ++++++++++++++++--- deltachat-ffi/src/lib.rs | 6 +-- deltachat-jsonrpc/src/api/types/chat.rs | 19 +++++---- deltachat-jsonrpc/src/api/types/chat_list.rs | 22 +++++----- deltachat-jsonrpc/src/api/types/contact.rs | 26 ++++++++++- src/contact.rs | 45 ++++++++++++++++++-- src/contact/contact_tests.rs | 16 +++---- 7 files changed, 136 insertions(+), 42 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 8ca28e7cf..8a03e7132 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -4912,6 +4912,38 @@ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg); int dc_msg_is_pinned (const dc_msg_t* msg); +/** + * @defgroup DC_FRESHNESS DC_FRESHNESS + * + * These constants describe the freshness of a contact, + * as returned by dc_contact_get_freshness(). + * + * @addtogroup DC_FRESHNESS + * @{ + */ + +/** + * Contact shall not be highlighted, e.g. neither shown with a "seen recently" dot + * nor with a "not seen for a long time" hint. + */ +#define DC_FRESHNESS_NORMAL 0 + +/** + * Contact was seen recently, the UI shall highlight it e.g. with a little green dot on the avatar. + */ +#define DC_FRESHNESS_RECENTLY_SEEN 1 + +/** + * Contact was not seen for a long time, the UI shall highlight it e.g. with a string + * below the contact name (e.g. "Seen 2 months ago"). + */ +#define DC_FRESHNESS_OLD 2 + +/** + * @} + */ + + /** * @class dc_contact_t * @@ -5084,18 +5116,18 @@ int64_t dc_contact_get_last_seen (const dc_contact_t* contact); /** - * Check if the contact was seen recently. + * Get the contact's freshness. + * + * The UI shall hightlight contacts that are recently seen by a little green dot on the avatar + * and contacts that were not seen for a long time by a string below the contact name (e.g. "Seen 2 months ago") * - * The UI may highlight these contacts, - * eg. draw a little green dot on the avatars of the users recently seen. - * DC_CONTACT_ID_SELF and other special contact IDs are defined as never seen recently (they should not get a dot). * To get the time a contact was seen, use dc_contact_get_last_seen(). * * @memberof dc_contact_t * @param contact The contact object. - * @return 1=contact seen recently, 0=contact not seen recently. + * @return One of the @ref DC_FRESHNESS constants. */ -int dc_contact_was_seen_recently (const dc_contact_t* contact); +int dc_contact_get_freshness (const dc_contact_t* contact); /** diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index d2910d665..d087d3fce 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -4034,13 +4034,13 @@ pub unsafe extern "C" fn dc_contact_get_last_seen(contact: *mut dc_contact_t) -> } #[unsafe(no_mangle)] -pub unsafe extern "C" fn dc_contact_was_seen_recently(contact: *mut dc_contact_t) -> libc::c_int { +pub unsafe extern "C" fn dc_contact_get_freshness(contact: *mut dc_contact_t) -> libc::c_int { if contact.is_null() { - eprintln!("ignoring careless call to dc_contact_was_seen_recently()"); + eprintln!("ignoring careless call to dc_contact_get_freshness()"); return 0; } let ffi_contact = unsafe { &*contact }; - ffi_contact.contact.was_seen_recently() as libc::c_int + u32::from(ffi_contact.contact.get_freshness()) as libc::c_int } #[unsafe(no_mangle)] diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index 9d1f0adbc..01cc8f0ef 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -9,6 +9,8 @@ use deltachat::context::Context; use serde::{Deserialize, Serialize}; use typescript_type_def::TypeDef; +use crate::api::types::contact::ContactFreshness; + use super::color_int_to_hex_string; #[derive(Serialize, TypeDef, schemars::JsonSchema)] @@ -69,7 +71,7 @@ pub struct FullChat { is_muted: bool, ephemeral_timer: u32, can_send: bool, - was_seen_recently: bool, + freshness: ContactFreshness, mailing_list_address: Option, } @@ -92,16 +94,17 @@ impl FullChat { let can_send = chat.can_send(context).await?; - let was_seen_recently = if chat.get_type() == Chattype::Single { + let freshness = if chat.get_type() == Chattype::Single { match contact_ids.first() { Some(contact) => Contact::get_by_id(context, *contact) .await - .context("failed to load contact for was_seen_recently")? - .was_seen_recently(), - None => false, + .context("failed to load contact for get_freshness")? + .get_freshness() + .into(), + None => ContactFreshness::Normal, } } else { - false + ContactFreshness::Normal }; let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string()); @@ -126,7 +129,7 @@ impl FullChat { is_muted: chat.is_muted(), ephemeral_timer, can_send, - was_seen_recently, + freshness, mailing_list_address, }) } @@ -137,7 +140,7 @@ impl FullChat { /// - fresh_message_counter /// - ephemeral_timer /// - self_in_group -/// - was_seen_recently +/// - freshness /// - can_send /// /// used when you only need the basic metadata of a chat like type, name, profile picture diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs index f302c64f4..20cc4acd7 100644 --- a/deltachat-jsonrpc/src/api/types/chat_list.rs +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -11,6 +11,8 @@ use num_traits::cast::ToPrimitive; use serde::Serialize; use typescript_type_def::TypeDef; +use crate::api::types::contact::ContactFreshness; + use super::chat::JsonrpcChatType; use super::color_int_to_hex_string; use super::message::MessageViewtype; @@ -68,7 +70,7 @@ pub enum ChatListItemFetchResult { is_contact_request: bool, /// contact id if this is a dm chat (for view profile entry in context menu) dm_chat_contact: Option, - was_seen_recently: bool, + freshness: ContactFreshness, last_message_type: Option, last_message_id: Option, }, @@ -127,22 +129,20 @@ pub(crate) async fn get_chat_list_item_by_id( None => (None, None), }; - let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single { + let (dm_chat_contact, freshness) = if chat.get_type() == Chattype::Single { let chat_contacts = get_chat_contacts(ctx, chat_id).await?; let contact = chat_contacts.first(); - let was_seen_recently = match contact { + let freshness = match contact { Some(contact) => Contact::get_by_id(ctx, *contact) .await .context("contact")? - .was_seen_recently(), - None => false, + .get_freshness() + .into(), + None => ContactFreshness::Normal, }; - ( - contact.map(|contact_id| contact_id.to_u32()), - was_seen_recently, - ) + (contact.map(|contact_id| contact_id.to_u32()), freshness) } else { - (None, false) + (None, ContactFreshness::Normal) }; let color = color_int_to_hex_string(chat.get_color(ctx).await?); @@ -170,7 +170,7 @@ pub(crate) async fn get_chat_list_item_by_id( is_muted: chat.is_muted(), is_contact_request: chat.is_contact_request(), dm_chat_contact, - was_seen_recently, + freshness, last_message_type: message_type, last_message_id: last_msgid.map(|id| id.to_u32()), }) diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs index 8dfb4ba2c..7e0d37463 100644 --- a/deltachat-jsonrpc/src/api/types/contact.rs +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use deltachat::contact; use deltachat::context::Context; use deltachat::key::{DcKey, SignedPublicKey}; use serde::Serialize; @@ -6,6 +7,27 @@ use typescript_type_def::TypeDef; use super::color_int_to_hex_string; +/// Freshness of a contact, based on when it was last seen. +#[derive(Serialize, TypeDef, schemars::JsonSchema)] +pub enum ContactFreshness { + /// Contact shall not be highlighted. + Normal, + /// Contact was seen recently. + RecentlySeen, + /// Contact was not seen for a long time. + Old, +} + +impl From for ContactFreshness { + fn from(freshness: contact::Freshness) -> Self { + match freshness { + contact::Freshness::Normal => ContactFreshness::Normal, + contact::Freshness::RecentlySeen => ContactFreshness::RecentlySeen, + contact::Freshness::Old => ContactFreshness::Old, + } + } +} + #[derive(Serialize, TypeDef, schemars::JsonSchema)] #[serde(rename = "Contact", rename_all = "camelCase")] pub struct ContactObject { @@ -32,7 +54,7 @@ pub struct ContactObject { /// the contact's last seen timestamp last_seen: i64, - was_seen_recently: bool, + freshness: ContactFreshness, /// If the contact is a bot. is_bot: bool, @@ -60,7 +82,7 @@ impl ContactObject { is_key_contact: contact.is_key_contact(), e2ee_avail: contact.e2ee_avail(context).await?, last_seen: contact.last_seen(), - was_seen_recently: contact.was_seen_recently(), + freshness: contact.get_freshness().into(), is_bot: contact.is_bot(), }) } diff --git a/src/contact.rs b/src/contact.rs index d5d5b9353..215e52b60 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -40,9 +40,33 @@ use crate::sync::{self, Sync::*}; use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase}; use crate::{chat, chatlist_events, ensure_and_debug_assert, stock_str}; -/// Time during which a contact is considered as seen recently. +/// If the contact's "last seen" is newer, the contact freshness is set to "recently seen". const SEEN_RECENTLY_SECONDS: i64 = 600; +/// If the contact's "last seen" is older, the contact freshness is set to "old". +const CONTACT_OLD_SECONDS: i64 = 60 * 24 * 60 * 60; + +/// Freshness of a contact, based on when it was last seen. +/// +/// Used by the UI to highlight contacts: +/// recently seen contacts get a little green dot on the avatar, +/// contacts not seen for a long time get a string below the name (e.g. "Seen 2 months ago"). +#[derive(Debug, PartialEq, Eq)] +pub enum Freshness { + /// Contact shall not be highlighted. + Normal = 0, + /// Contact was seen recently. + RecentlySeen = 1, + /// Contact was not seen for a long time. + Old = 2, +} + +impl From for u32 { + fn from(freshness: Freshness) -> Self { + freshness as u32 + } +} + /// Contact ID, including reserved IDs. /// /// Some contact IDs are reserved to identify special contacts. This @@ -726,9 +750,22 @@ impl Contact { } /// Returns `true` if this contact was seen recently. - #[expect(clippy::arithmetic_side_effects)] - pub fn was_seen_recently(&self) -> bool { - time() - self.last_seen <= SEEN_RECENTLY_SECONDS + pub fn get_freshness(&self) -> Freshness { + if self.id.is_special() { + return Freshness::Normal; + } + + let is_old = time().saturating_sub(self.last_seen) > CONTACT_OLD_SECONDS; + if is_old || self.last_seen <= 0 { + return Freshness::Old; + } + + let seen_recently = time().saturating_sub(self.last_seen) <= SEEN_RECENTLY_SECONDS; + if seen_recently { + return Freshness::RecentlySeen; + } + + Freshness::Normal } /// Check if a contact is blocked. diff --git a/src/contact/contact_tests.rs b/src/contact/contact_tests.rs index c0181cf7d..5fce9ff27 100644 --- a/src/contact/contact_tests.rs +++ b/src/contact/contact_tests.rs @@ -1048,7 +1048,7 @@ async fn test_last_seen() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_was_seen_recently() -> Result<()> { +async fn test_contact_freshness() -> Result<()> { let _n = TimeShiftFalsePositiveNote; let mut tcm = TestContextManager::new(); @@ -1061,15 +1061,15 @@ async fn test_was_seen_recently() -> Result<()> { let chat = bob.create_chat(&alice).await; let contacts = chat::get_chat_contacts(&bob, chat.id).await?; let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?; - assert!(!contact.was_seen_recently()); + assert_eq!(contact.get_freshness(), Freshness::Old); bob.recv_msg(&sent_msg).await; let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?; - assert!(contact.was_seen_recently()); + assert_eq!(contact.get_freshness(), Freshness::RecentlySeen); let self_contact = Contact::get_by_id(&bob, ContactId::SELF).await?; - assert!(!self_contact.was_seen_recently()); + assert_eq!(self_contact.get_freshness(), Freshness::Normal); Ok(()) } @@ -1087,11 +1087,11 @@ async fn test_was_seen_recently_event() -> Result<()> { let chat = alice.create_chat(&bob).await; let sent_msg = alice.send_text(chat.id, "moin").await; let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?; - assert!(!contact.was_seen_recently()); + assert_ne!(contact.get_freshness(), Freshness::RecentlySeen); bob.evtracker.clear_events(); bob.recv_msg(&sent_msg).await; let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?; - assert!(contact.was_seen_recently()); + assert_eq!(contact.get_freshness(), Freshness::RecentlySeen); bob.evtracker .get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. })) .await; @@ -1099,12 +1099,12 @@ async fn test_was_seen_recently_event() -> Result<()> { .interrupt(contact.id, contact.last_seen) .await; - // Wait for `was_seen_recently()` to turn off. + // Wait for "seen recently" to turn off. bob.evtracker.clear_events(); SystemTime::shift(Duration::from_secs(SEEN_RECENTLY_SECONDS as u64 * 2)); recently_seen_loop.interrupt(ContactId::UNDEFINED, 0).await; let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?; - assert!(!contact.was_seen_recently()); + assert_eq!(contact.get_freshness(), Freshness::Normal); bob.evtracker .get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. })) .await;