api!: replace was_seen_recently by freshness in contact object

BREAKING CHANGE: use contact's `freshness` instead of `seen_recently`
This commit is contained in:
B. Petersen
2026-09-21 12:21:51 +02:00
committed by biørn
parent 6b62fed6fa
commit 94cc8a3858
7 changed files with 136 additions and 42 deletions

View File

@@ -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);
/**

View File

@@ -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)]

View File

@@ -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<String>,
}
@@ -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

View File

@@ -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<u32>,
was_seen_recently: bool,
freshness: ContactFreshness,
last_message_type: Option<MessageViewtype>,
last_message_id: Option<u32>,
},
@@ -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()),
})

View File

@@ -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<contact::Freshness> 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(),
})
}

View File

@@ -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<Freshness> 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.

View File

@@ -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;