Merge pull request #1143 from deltachat/feat_mute_chat

feat: mute chat
This commit is contained in:
Simon Laux
2020-02-10 01:39:50 +01:00
committed by GitHub
6 changed files with 287 additions and 5 deletions

View File

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

View File

@@ -20,11 +20,13 @@ use std::fmt::Write;
use std::ptr; use std::ptr;
use std::str::FromStr; use std::str::FromStr;
use std::sync::RwLock; use std::sync::RwLock;
use std::time::{Duration, SystemTime};
use libc::uintptr_t; use libc::uintptr_t;
use num_traits::{FromPrimitive, ToPrimitive}; use num_traits::{FromPrimitive, ToPrimitive};
use deltachat::chat::ChatId; use deltachat::chat::ChatId;
use deltachat::chat::MuteDuration;
use deltachat::constants::DC_MSG_ID_LAST_SPECIAL; use deltachat::constants::DC_MSG_ID_LAST_SPECIAL;
use deltachat::contact::Contact; use deltachat::contact::Contact;
use deltachat::context::Context; use deltachat::context::Context;
@@ -1407,6 +1409,37 @@ pub unsafe extern "C" fn dc_set_chat_profile_image(
.unwrap_or(0) .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] #[no_mangle]
pub unsafe extern "C" fn dc_get_msg_info( pub unsafe extern "C" fn dc_get_msg_info(
context: *mut dc_context_t, 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 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] #[no_mangle]
pub unsafe extern "C" fn dc_chat_get_info_json( pub unsafe extern "C" fn dc_chat_get_info_json(
context: *mut dc_context_t, context: *mut dc_context_t,

View File

@@ -58,6 +58,13 @@ class Chat(object):
""" """
return self.id == const.DC_CHAT_ID_DEADDROP 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): def is_promoted(self):
""" return True if this chat is promoted, i.e. """ return True if this chat is promoted, i.e.
the member contacts are aware of their membership, the member contacts are aware of their membership,
@@ -84,12 +91,43 @@ class Chat(object):
def set_name(self, name): def set_name(self, name):
""" set name of this chat. """ set name of this chat.
:param: name as a unicode string. :param name: as a unicode string.
:returns: None :returns: None
""" """
name = as_dc_charpointer(name) name = as_dc_charpointer(name)
return lib.dc_set_chat_name(self._dc_context, self.id, 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): def get_type(self):
""" return type of this chat. """ return type of this chat.

View File

@@ -219,6 +219,18 @@ class TestOfflineChat:
chat.remove_profile_image() chat.remove_profile_image()
assert chat.get_profile_image() is None 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): def test_delete_and_send_fails(self, ac1, chat1):
chat1.delete() chat1.delete()
ac1._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") ac1._evlogger.get_matching("DC_EVENT_MSGS_CHANGED")

View File

@@ -1,6 +1,8 @@
//! # Chat module //! # Chat module
use std::convert::TryFrom;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use itertools::Itertools; use itertools::Itertools;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
@@ -422,6 +424,7 @@ pub struct Chat {
blocked: Blocked, blocked: Blocked,
pub param: Params, pub param: Params,
is_sending_locations: bool, is_sending_locations: bool,
pub mute_duration: MuteDuration,
} }
impl Chat { impl Chat {
@@ -429,7 +432,7 @@ impl Chat {
pub fn load_from_db(context: &Context, chat_id: ChatId) -> Result<Self, Error> { pub fn load_from_db(context: &Context, chat_id: ChatId) -> Result<Self, Error> {
let res = context.sql.query_row( let res = context.sql.query_row(
"SELECT c.type, c.name, c.grpid, c.param, c.archived, "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 FROM chats c
WHERE c.id=?;", WHERE c.id=?;",
params![chat_id], params![chat_id],
@@ -443,6 +446,7 @@ impl Chat {
archived: row.get(4)?, archived: row.get(4)?,
blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(), blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(),
is_sending_locations: row.get(6)?, is_sending_locations: row.get(6)?,
mute_duration: row.get(7)?,
}; };
Ok(c) Ok(c)
}, },
@@ -658,6 +662,7 @@ impl Chat {
profile_image: self.get_profile_image(context).unwrap_or_else(PathBuf::new), profile_image: self.get_profile_image(context).unwrap_or_else(PathBuf::new),
subtitle: self.get_subtitle(context), subtitle: self.get_subtitle(context),
draft, draft,
is_muted: self.is_muted(),
}) })
} }
@@ -684,6 +689,14 @@ impl Chat {
self.is_sending_locations 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( fn prepare_msg_raw(
&mut self, &mut self,
context: &Context, context: &Context,
@@ -968,6 +981,11 @@ pub struct ChatInfo {
/// which contain non-text parts. Perhaps it should be a /// which contain non-text parts. Perhaps it should be a
/// simple `has_draft` bool instead. /// simple `has_draft` bool instead.
pub draft: String, 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: // ToDo:
// - [ ] deaddrop, // - [ ] deaddrop,
// - [ ] summary, // - [ ] summary,
@@ -1901,6 +1919,66 @@ pub fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<boo
Ok(needs_attach) Ok(needs_attach)
} }
#[derive(Debug, Clone, PartialEq)]
pub enum MuteDuration {
NotMuted,
Forever,
Until(SystemTime),
}
impl rusqlite::types::ToSql for MuteDuration {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
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<Self> {
// 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( pub fn remove_contact_from_chat(
context: &Context, context: &Context,
chat_id: ChatId, chat_id: ChatId,
@@ -2398,7 +2476,7 @@ mod tests {
let chat = Chat::load_from_db(&t.ctx, chat_id).unwrap(); let chat = Chat::load_from_db(&t.ctx, chat_id).unwrap();
let info = chat.get_info(&t.ctx).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()); println!("{}", serde_json::to_string_pretty(&info).unwrap());
let expected = r#" let expected = r#"
@@ -2413,11 +2491,12 @@ mod tests {
"color": 15895624, "color": 15895624,
"profile_image": "", "profile_image": "",
"subtitle": "bob@example.com", "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(); let loaded: ChatInfo = serde_json::from_str(expected).unwrap();
assert_eq!(info, loaded); assert_eq!(info, loaded);
} }
@@ -2777,4 +2856,49 @@ mod tests {
assert!(chat_id.set_selfavatar_timestamp(&t.ctx, time()).is_ok()); assert!(chat_id.set_selfavatar_timestamp(&t.ctx, time()).is_ok());
assert!(!shall_attach_selfavatar(&t.ctx, chat_id).unwrap()); 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
);
}
} }

View File

@@ -885,6 +885,14 @@ fn open(
update_icons = true; update_icons = true;
sql.set_raw_config_int(context, "dbversion", 61)?; 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 // (2) updates that require high-level objects
// (the structure is complete now and all objects are usable) // (the structure is complete now and all objects are usable)