diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index ff9128fd3..bb883edaf 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1577,6 +1577,22 @@ int dc_set_chat_name (dc_context_t* context, uint32_t ch int dc_set_chat_profile_image (dc_context_t* context, uint32_t chat_id, const char* image); + +/** + * Set mute duration of a chat. + * + * This value can be checked by the ui upon receiving a new message to decide whether it should trigger an notification. + * + * Sends out #DC_EVENT_CHAT_MODIFIED. + * + * @memberof dc_context_t + * @param chat_id The chat ID to set the mute duration. + * @param duration The duration (0 for no mute, -1 for forever mute, everything else is is the relative mute duration from now in seconds) + * @param context The context as created by dc_context_new(). + * @return 1=success, 0=error + */ +int dc_set_chat_mute_duration (dc_context_t* context, uint32_t chat_id, int64_t duration); + // handle messages /** @@ -2919,6 +2935,26 @@ int dc_chat_is_verified (const dc_chat_t* chat); int dc_chat_is_sending_locations (const dc_chat_t* chat); +/** + * Check whether the chat is currently muted + * + * @memberof dc_chat_t + * @param chat The chat object. + * @return 1=muted, 0=not muted + */ +int dc_chat_is_muted (const dc_chat_t* chat); + + +/** + * Get the exact state of the mute of a chat + * + * @memberof dc_chat_t + * @param chat The chat object. + * @return 0=not muted, -1=forever muted, (x>0)=remaining seconds until the mute is lifted + */ +int64_t dc_chat_get_remaining_mute_duration (const dc_chat_t* chat); + + /** * @class dc_msg_t * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 383e4c242..6d57a1906 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -20,11 +20,13 @@ use std::fmt::Write; use std::ptr; use std::str::FromStr; use std::sync::RwLock; +use std::time::{Duration, SystemTime}; use libc::uintptr_t; use num_traits::{FromPrimitive, ToPrimitive}; use deltachat::chat::ChatId; +use deltachat::chat::MuteDuration; use deltachat::constants::DC_MSG_ID_LAST_SPECIAL; use deltachat::contact::Contact; use deltachat::context::Context; @@ -1407,6 +1409,37 @@ pub unsafe extern "C" fn dc_set_chat_profile_image( .unwrap_or(0) } +#[no_mangle] +pub unsafe extern "C" fn dc_set_chat_mute_duration( + context: *mut dc_context_t, + chat_id: u32, + duration: i64, +) -> libc::c_int { + if context.is_null() { + eprintln!("ignoring careless call to dc_set_chat_mute_duration()"); + return 0; + } + let ffi_context = &*context; + let muteDuration = match duration { + 0 => MuteDuration::NotMuted, + -1 => MuteDuration::Forever, + n if n > 0 => MuteDuration::Until(SystemTime::now() + Duration::from_secs(duration as u64)), + _ => { + ffi_context.warning( + "dc_chat_set_mute_duration(): Can not use negative duration other than -1", + ); + return 0; + } + }; + ffi_context + .with_inner(|ctx| { + chat::set_muted(ctx, ChatId::new(chat_id), muteDuration) + .map(|_| 1) + .unwrap_or_log_default(ctx, "Failed to set mute duration") + }) + .unwrap_or(0) +} + #[no_mangle] pub unsafe extern "C" fn dc_get_msg_info( context: *mut dc_context_t, @@ -2481,6 +2514,37 @@ pub unsafe extern "C" fn dc_chat_is_sending_locations(chat: *mut dc_chat_t) -> l ffi_chat.chat.is_sending_locations() as libc::c_int } +#[no_mangle] +pub unsafe extern "C" fn dc_chat_is_muted(chat: *mut dc_chat_t) -> libc::c_int { + if chat.is_null() { + eprintln!("ignoring careless call to dc_chat_is_muted()"); + return 0; + } + let ffi_chat = &*chat; + ffi_chat.chat.is_muted() as libc::c_int +} + +#[no_mangle] +pub unsafe extern "C" fn dc_chat_get_remaining_mute_duration(chat: *mut dc_chat_t) -> i64 { + if chat.is_null() { + eprintln!("ignoring careless call to dc_chat_get_remaining_mute_duration()"); + return 0; + } + let ffi_chat = &*chat; + if !ffi_chat.chat.is_muted() { + return 0; + } + // If the chat was muted to before the epoch, it is not muted. + match ffi_chat.chat.mute_duration { + MuteDuration::NotMuted => 0, + MuteDuration::Forever => -1, + MuteDuration::Until(when) => when + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + } +} + #[no_mangle] pub unsafe extern "C" fn dc_chat_get_info_json( context: *mut dc_context_t, diff --git a/python/src/deltachat/chat.py b/python/src/deltachat/chat.py index 00787b795..c2517f156 100644 --- a/python/src/deltachat/chat.py +++ b/python/src/deltachat/chat.py @@ -58,6 +58,13 @@ class Chat(object): """ return self.id == const.DC_CHAT_ID_DEADDROP + def is_muted(self): + """ return true if this chat is muted. + + :returns: True if chat is muted, False otherwise. + """ + return lib.dc_chat_is_muted(self._dc_chat) + def is_promoted(self): """ return True if this chat is promoted, i.e. the member contacts are aware of their membership, @@ -84,12 +91,43 @@ class Chat(object): def set_name(self, name): """ set name of this chat. - :param: name as a unicode string. + :param name: as a unicode string. :returns: None """ name = as_dc_charpointer(name) return lib.dc_set_chat_name(self._dc_context, self.id, name) + def mute(self, duration=None): + """ mutes the chat + + :param duration: Number of seconds to mute the chat for. None to mute until unmuted again. + :returns: None + """ + if duration is None: + mute_duration = -1 + else: + mute_duration = duration + ret = lib.dc_set_chat_mute_duration(self._dc_context, self.id, mute_duration) + if not bool(ret): + raise ValueError("Call to dc_set_chat_mute_duration failed") + + def unmute(self): + """ unmutes the chat + + :returns: None + """ + ret = lib.dc_set_chat_mute_duration(self._dc_context, self.id, 0) + if not bool(ret): + raise ValueError("Failed to unmute chat") + + def get_mute_duration(self): + """ Returns the number of seconds until the mute of this chat is lifted. + + :param duration: + :returns: Returns the number of seconds the chat is still muted for. (0 for not muted, -1 forever muted) + """ + return bool(lib.dc_chat_get_remaining_mute_duration(self.id)) + def get_type(self): """ return type of this chat. diff --git a/python/tests/test_account.py b/python/tests/test_account.py index 0620dcac4..2b401f55e 100644 --- a/python/tests/test_account.py +++ b/python/tests/test_account.py @@ -219,6 +219,18 @@ class TestOfflineChat: chat.remove_profile_image() assert chat.get_profile_image() is None + def test_mute(self, ac1): + chat = ac1.create_group_chat(name="title1") + assert not chat.is_muted() + chat.mute() + assert chat.is_muted() + chat.unmute() + assert not chat.is_muted() + chat.mute(50) + assert chat.is_muted() + with pytest.raises(ValueError): + chat.mute(-51) + def test_delete_and_send_fails(self, ac1, chat1): chat1.delete() ac1._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") diff --git a/src/chat.rs b/src/chat.rs index dc485afc9..02f81a661 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,6 +1,8 @@ //! # Chat module +use std::convert::TryFrom; use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; use itertools::Itertools; use num_traits::FromPrimitive; @@ -422,6 +424,7 @@ pub struct Chat { blocked: Blocked, pub param: Params, is_sending_locations: bool, + pub mute_duration: MuteDuration, } impl Chat { @@ -429,7 +432,7 @@ impl Chat { pub fn load_from_db(context: &Context, chat_id: ChatId) -> Result { let res = context.sql.query_row( "SELECT c.type, c.name, c.grpid, c.param, c.archived, - c.blocked, c.locations_send_until + c.blocked, c.locations_send_until, c.muted_until FROM chats c WHERE c.id=?;", params![chat_id], @@ -443,6 +446,7 @@ impl Chat { archived: row.get(4)?, blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(), is_sending_locations: row.get(6)?, + mute_duration: row.get(7)?, }; Ok(c) }, @@ -658,6 +662,7 @@ impl Chat { profile_image: self.get_profile_image(context).unwrap_or_else(PathBuf::new), subtitle: self.get_subtitle(context), draft, + is_muted: self.is_muted(), }) } @@ -684,6 +689,14 @@ impl Chat { self.is_sending_locations } + pub fn is_muted(&self) -> bool { + match self.mute_duration { + MuteDuration::NotMuted => false, + MuteDuration::Forever => true, + MuteDuration::Until(when) => when > SystemTime::now(), + } + } + fn prepare_msg_raw( &mut self, context: &Context, @@ -968,6 +981,11 @@ pub struct ChatInfo { /// which contain non-text parts. Perhaps it should be a /// simple `has_draft` bool instead. pub draft: String, + + /// Whether the chat is muted + /// + /// The exact time its muted can be found out via the `chat.mute_duration` property + pub is_muted: bool, // ToDo: // - [ ] deaddrop, // - [ ] summary, @@ -1901,6 +1919,66 @@ pub fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result rusqlite::Result { + let duration: i64 = match &self { + MuteDuration::NotMuted => 0, + MuteDuration::Forever => -1, + MuteDuration::Until(when) => { + let duration = when + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?; + i64::try_from(duration.as_secs()) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))? + } + }; + let val = rusqlite::types::Value::Integer(duration); + let out = rusqlite::types::ToSqlOutput::Owned(val); + Ok(out) + } +} + +impl rusqlite::types::FromSql for MuteDuration { + fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { + // Negative values other than -1 should not be in the + // database. If found they'll be NotMuted. + match i64::column_result(value)? { + 0 => Ok(MuteDuration::NotMuted), + -1 => Ok(MuteDuration::Forever), + n if n > 0 => match SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(n as u64)) { + Some(t) => Ok(MuteDuration::Until(t)), + None => Err(rusqlite::types::FromSqlError::OutOfRange(n)), + }, + _ => Ok(MuteDuration::NotMuted), + } + } +} + +pub fn set_muted(context: &Context, chat_id: ChatId, duration: MuteDuration) -> Result<(), Error> { + ensure!(!chat_id.is_special(), "Invalid chat ID"); + if real_group_exists(context, chat_id) + && sql::execute( + context, + &context.sql, + "UPDATE chats SET muted_until=? WHERE id=?;", + params![duration, chat_id], + ) + .is_ok() + { + context.call_cb(Event::ChatModified(chat_id)); + } else { + bail!("Failed to set name"); + } + Ok(()) +} + pub fn remove_contact_from_chat( context: &Context, chat_id: ChatId, @@ -2398,7 +2476,7 @@ mod tests { let chat = Chat::load_from_db(&t.ctx, chat_id).unwrap(); let info = chat.get_info(&t.ctx).unwrap(); - // Ensure we can serialise this. + // Ensure we can serialize this. println!("{}", serde_json::to_string_pretty(&info).unwrap()); let expected = r#" @@ -2413,11 +2491,12 @@ mod tests { "color": 15895624, "profile_image": "", "subtitle": "bob@example.com", - "draft": "" + "draft": "", + "is_muted": false } "#; - // Ensure we can deserialise this. + // Ensure we can deserialize this. let loaded: ChatInfo = serde_json::from_str(expected).unwrap(); assert_eq!(info, loaded); } @@ -2777,4 +2856,49 @@ mod tests { assert!(chat_id.set_selfavatar_timestamp(&t.ctx, time()).is_ok()); assert!(!shall_attach_selfavatar(&t.ctx, chat_id).unwrap()); } + + #[test] + fn test_set_mute_duration() { + let t = dummy_context(); + let chat_id = create_group_chat(&t.ctx, VerifiedStatus::Unverified, "foo").unwrap(); + // Initial + assert_eq!( + Chat::load_from_db(&t.ctx, chat_id).unwrap().is_muted(), + false + ); + // Forever + set_muted(&t.ctx, chat_id, MuteDuration::Forever).unwrap(); + assert_eq!( + Chat::load_from_db(&t.ctx, chat_id).unwrap().is_muted(), + true + ); + // unMute + set_muted(&t.ctx, chat_id, MuteDuration::NotMuted).unwrap(); + assert_eq!( + Chat::load_from_db(&t.ctx, chat_id).unwrap().is_muted(), + false + ); + // Timed in the future + set_muted( + &t.ctx, + chat_id, + MuteDuration::Until(SystemTime::now() + Duration::from_secs(3600)), + ) + .unwrap(); + assert_eq!( + Chat::load_from_db(&t.ctx, chat_id).unwrap().is_muted(), + true + ); + // Time in the past + set_muted( + &t.ctx, + chat_id, + MuteDuration::Until(SystemTime::now() - Duration::from_secs(3600)), + ) + .unwrap(); + assert_eq!( + Chat::load_from_db(&t.ctx, chat_id).unwrap().is_muted(), + false + ); + } } diff --git a/src/sql.rs b/src/sql.rs index e722d539c..181be75df 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -885,6 +885,14 @@ fn open( update_icons = true; sql.set_raw_config_int(context, "dbversion", 61)?; } + if dbversion < 62 { + info!(context, "[migration] v62"); + sql.execute( + "ALTER TABLE chats ADD COLUMN muted_until INTEGER DEFAULT 0;", + NO_PARAMS, + )?; + sql.set_raw_config_int(context, "dbversion", 62)?; + } // (2) updates that require high-level objects // (the structure is complete now and all objects are usable)