diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 277660061..98ee3e5c4 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1171,6 +1171,16 @@ void dc_delete_chat (dc_context_t* context, uint32_t ch */ dc_array_t* dc_get_chat_contacts (dc_context_t* context, uint32_t chat_id); +/** + * Get the chat's ephemeral message timer. + * + * @memberof dc_context_t + * @param context The context as created by dc_context_new(). + * @param chat_id The chat ID. + * + * @return ephemeral timer value in seconds, 0 if the timer is disabled or if there is an error + */ +uint32_t dc_get_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id); /** * Search messages containing the given query string. @@ -1303,6 +1313,21 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch */ int dc_set_chat_name (dc_context_t* context, uint32_t chat_id, const char* name); +/** + * Set the chat's ephemeral message timer. + * + * This timer is applied to all messages in a chat and starts when the + * message is read. The setting is synchronized to all clients + * participating in a chat. + * + * @memberof dc_context_t + * @param context The context as created by dc_context_new(). + * @param chat_id The chat ID to set the ephemeral message timer for. + * @param timer The timer value in seconds or 0 to disable the timer. + * + * @return 1=success, 0=error + */ +int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32_t timer); /** * Set group profile image. @@ -4168,6 +4193,11 @@ void dc_event_unref(dc_event_t* event); */ #define DC_EVENT_CHAT_MODIFIED 2020 +/** + * Chat ephemeral timer changed. + */ +#define DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED 2021 + /** * Contact(s) created, renamed, verified, blocked or deleted. diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 096e452f8..9836039ab 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -27,6 +27,7 @@ use deltachat::chat::{ChatId, ChatVisibility, MuteDuration}; use deltachat::constants::DC_MSG_ID_LAST_SPECIAL; use deltachat::contact::{Contact, Origin}; use deltachat::context::Context; +use deltachat::ephemeral::Timer as EphemeralTimer; use deltachat::key::DcKey; use deltachat::message::MsgId; use deltachat::stock::StockMessage; @@ -349,7 +350,8 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: | Event::MsgDelivered { chat_id, .. } | Event::MsgFailed { chat_id, .. } | Event::MsgRead { chat_id, .. } - | Event::ChatModified(chat_id) => chat_id.to_u32() as libc::c_int, + | Event::ChatModified(chat_id) + | Event::ChatEphemeralTimerModified { chat_id, .. } => chat_id.to_u32() as libc::c_int, Event::ContactsChanged(id) | Event::LocationChanged(id) => { let id = id.unwrap_or_default(); id as libc::c_int @@ -399,6 +401,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: | Event::MsgRead { msg_id, .. } => msg_id.to_u32() as libc::c_int, Event::SecurejoinInviterProgress { progress, .. } | Event::SecurejoinJoinerProgress { progress, .. } => *progress as libc::c_int, + Event::ChatEphemeralTimerModified { timer, .. } => *timer as libc::c_int, } } @@ -439,7 +442,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | Event::ConfigureProgress(_) | Event::ImexProgress(_) | Event::SecurejoinInviterProgress { .. } - | Event::SecurejoinJoinerProgress { .. } => ptr::null_mut(), + | Event::SecurejoinJoinerProgress { .. } + | Event::ChatEphemeralTimerModified { .. } => ptr::null_mut(), Event::ImexFileWritten(file) => { let data2 = file.to_c_string().unwrap_or_default(); data2.into_raw() @@ -1279,6 +1283,49 @@ pub unsafe extern "C" fn dc_set_chat_mute_duration( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_get_chat_ephemeral_timer( + context: *mut dc_context_t, + chat_id: u32, +) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_get_chat_ephemeral_timer()"); + return 0; + } + let ctx = &*context; + + // Timer value 0 is returned in the rare case of a database error, + // but it is not dangerous since it is only meant to be used as a + // default when changing the value. Such errors should not be + // ignored when ephemeral timer value is used to construct + // message headers. + block_on(async move { ChatId::new(chat_id).get_ephemeral_timer(ctx).await }) + .log_err(ctx, "Failed to get ephemeral timer") + .unwrap_or_default() + .to_u32() +} + +#[no_mangle] +pub unsafe extern "C" fn dc_set_chat_ephemeral_timer( + context: *mut dc_context_t, + chat_id: u32, + timer: u32, +) -> libc::c_int { + if context.is_null() { + eprintln!("ignoring careless call to dc_set_chat_ephemeral_timer()"); + return 0; + } + let ctx = &*context; + + block_on(async move { + ChatId::new(chat_id) + .set_ephemeral_timer(ctx, EphemeralTimer::from_u32(timer)) + .await + .log_err(ctx, "Failed to set ephemeral timer") + .is_ok() as libc::c_int + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_get_msg_info( context: *mut dc_context_t, diff --git a/python/src/deltachat/chat.py b/python/src/deltachat/chat.py index f512f8e7b..54a7ede0b 100644 --- a/python/src/deltachat/chat.py +++ b/python/src/deltachat/chat.py @@ -139,6 +139,22 @@ class Chat(object): """ return bool(lib.dc_chat_get_remaining_mute_duration(self.id)) + def get_ephemeral_timer(self): + """ get ephemeral timer. + + :returns: ephemeral timer value in seconds + """ + return lib.dc_get_chat_ephemeral_timer(self.account._dc_context, self.id) + + def set_ephemeral_timer(self, timer): + """ set ephemeral timer. + + :param: timer value in seconds + + :returns: None + """ + return lib.dc_set_chat_ephemeral_timer(self.account._dc_context, self.id, timer) + def get_type(self): """ (deprecated) return type of this chat. diff --git a/python/tests/test_account.py b/python/tests/test_account.py index aedb90d0d..d52faf28d 100644 --- a/python/tests/test_account.py +++ b/python/tests/test_account.py @@ -1542,6 +1542,54 @@ class TestOnlineAccount: assert msg.is_encrypted(), "Message is not encrypted" assert msg.chat == ac2.create_chat(ac4) + def test_ephemeral_timer(self, acfactory, lp): + ac1, ac2 = acfactory.get_two_online_accounts() + + lp.sec("ac1: create chat with ac2") + chat1 = ac1.create_chat(ac2) + chat2 = ac2.create_chat(ac1) + + lp.sec("ac1: set ephemeral timer to 60") + chat1.set_ephemeral_timer(60) + + lp.sec("ac1: check that ephemeral timer is set for chat") + assert chat1.get_ephemeral_timer() == 60 + chat1_summary = chat1.get_summary() + assert chat1_summary["ephemeral_timer"] == 60 + + lp.sec("ac2: receive system message about ephemeral timer modification") + ac2._evtracker.get_matching("DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED") + system_message1 = ac2._evtracker.wait_next_incoming_message() + assert chat2.get_ephemeral_timer() == 60 + assert system_message1.is_system_message() + assert "Ephemeral timer: 60\n" in system_message1.get_message_info() + + lp.sec("ac2: send message to ac1") + sent_message = chat2.send_text("message") + assert "Ephemeral timer: 60\n" in sent_message.get_message_info() + + # Timer is started immediately for sent messages + assert "Expires: " in sent_message.get_message_info() + + lp.sec("ac1: waiting for message from ac2") + text_message = ac1._evtracker.wait_next_incoming_message() + assert text_message.text == "message" + assert "Ephemeral timer: 60\n" in text_message.get_message_info() + + # Timer should not start until message is displayed + assert "Expires: " not in text_message.get_message_info() + text_message.mark_seen() + assert "Expires: " in text_message.get_message_info() + + lp.sec("ac2: set ephemeral timer to 0") + chat2.set_ephemeral_timer(0) + + lp.sec("ac1: receive system message about ephemeral timer modification") + ac1._evtracker.get_matching("DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED") + system_message2 = ac1._evtracker.wait_next_incoming_message() + assert "Ephemeral timer: " not in system_message2.get_message_info() + assert chat1.get_ephemeral_timer() == 0 + class TestGroupStressTests: def test_group_many_members_add_leave_remove(self, acfactory, lp): diff --git a/src/chat.rs b/src/chat.rs index d9989fe9c..1503a7f6f 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -15,6 +15,7 @@ use crate::constants::*; use crate::contact::*; use crate::context::Context; use crate::dc_tools::*; +use crate::ephemeral::{delete_expired_messages, schedule_ephemeral_task, Timer as EphemeralTimer}; use crate::error::{bail, ensure, format_err, Error}; use crate::events::Event; use crate::job::{self, Action}; @@ -726,6 +727,7 @@ impl Chat { .unwrap_or_else(std::path::PathBuf::new), draft, is_muted: self.is_muted(), + ephemeral_timer: self.id.get_ephemeral_timer(context).await?, }) } @@ -954,10 +956,17 @@ impl Chat { .await?; } + // get ephemeral message timer + let ephemeral_timer = self.id.get_ephemeral_timer(context).await?; + let ephemeral_timestamp = match ephemeral_timer { + EphemeralTimer::Disabled => 0, + EphemeralTimer::Enabled { duration } => timestamp + i64::from(duration), + }; + // add message to the database if context.sql.execute( - "INSERT INTO msgs (rfc724_mid, chat_id, from_id, to_id, timestamp, type, state, txt, param, hidden, mime_in_reply_to, mime_references, location_id) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?);", + "INSERT INTO msgs (rfc724_mid, chat_id, from_id, to_id, timestamp, type, state, txt, param, hidden, mime_in_reply_to, mime_references, location_id, ephemeral_timer, ephemeral_timestamp) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?);", paramsv![ new_rfc724_mid, self.id, @@ -972,6 +981,8 @@ impl Chat { new_in_reply_to, new_references, location_id as i32, + ephemeral_timer, + ephemeral_timestamp ] ).await.is_ok() { msg_id = context.sql.get_rowid( @@ -990,6 +1001,7 @@ impl Chat { } else { error!(context, "Cannot send message, not configured.",); } + schedule_ephemeral_task(context).await; Ok(MsgId::new(msg_id)) } @@ -1086,6 +1098,9 @@ pub struct ChatInfo { /// /// The exact time its muted can be found out via the `chat.mute_duration` property pub is_muted: bool, + + /// Ephemeral message timer. + pub ephemeral_timer: EphemeralTimer, // ToDo: // - [ ] deaddrop, // - [ ] summary, @@ -1597,10 +1612,15 @@ pub async fn get_chat_msgs( flags: u32, marker1before: Option, ) -> Vec { - match delete_device_expired_messages(context).await { + match delete_expired_messages(context).await { Err(err) => warn!(context, "Failed to delete expired messages: {}", err), Ok(messages_deleted) => { if messages_deleted { + // Trigger reload of chatlist. + // + // On desktop chatlist is always shown on the side, + // and it is important to update the last message shown + // there. context.emit_event(Event::MsgsChanged { msg_id: MsgId::new(0), chat_id: ChatId::new(0), @@ -1762,52 +1782,6 @@ pub async fn marknoticed_all_chats(context: &Context) -> Result<(), Error> { Ok(()) } -/// Deletes messages which are expired according to "delete_device_after" setting. -/// -/// Returns true if any message is deleted, so event can be emitted. If nothing -/// has been deleted, returns false. -pub async fn delete_device_expired_messages(context: &Context) -> Result { - if let Some(delete_device_after) = context.get_config_delete_device_after().await { - let threshold_timestamp = time() - delete_device_after; - - let self_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_SELF) - .await - .unwrap_or_default() - .0; - let device_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_DEVICE) - .await - .unwrap_or_default() - .0; - - // Delete expired messages - // - // Only update the rows that have to be updated, to avoid emitting - // unnecessary "chat modified" events. - let rows_modified = context - .sql - .execute( - "UPDATE msgs \ - SET txt = 'DELETED', chat_id = ? \ - WHERE timestamp < ? \ - AND chat_id > ? \ - AND chat_id != ? \ - AND chat_id != ?", - paramsv![ - DC_CHAT_ID_TRASH, - threshold_timestamp, - DC_CHAT_ID_LAST_SPECIAL, - self_chat_id, - device_chat_id - ], - ) - .await?; - - Ok(rows_modified > 0) - } else { - Ok(false) - } -} - pub async fn get_chat_media( context: &Context, chat_id: ChatId, @@ -2860,7 +2834,8 @@ mod tests { "color": 15895624, "profile_image": "", "draft": "", - "is_muted": false + "is_muted": false, + "ephemeral_timer": "Disabled" } "#; diff --git a/src/chatlist.rs b/src/chatlist.rs index 7a114b2bc..fce5ff557 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -5,6 +5,7 @@ use crate::chat::*; use crate::constants::*; use crate::contact::*; use crate::context::*; +use crate::ephemeral::delete_expired_messages; use crate::error::{bail, ensure, Result}; use crate::lot::Lot; use crate::message::{Message, MessageState, MsgId}; @@ -99,7 +100,7 @@ impl Chatlist { // Note that we do not emit DC_EVENT_MSGS_MODIFIED here even if some // messages get deleted to avoid reloading the same chatlist. - if let Err(err) = delete_device_expired_messages(context).await { + if let Err(err) = delete_expired_messages(context).await { warn!(context, "Failed to hide expired messages: {}", err); } diff --git a/src/context.rs b/src/context.rs index 07e3881f9..016c27d4f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,6 +6,7 @@ use std::ops::Deref; use async_std::path::{Path, PathBuf}; use async_std::sync::{channel, Arc, Mutex, Receiver, RwLock, Sender}; +use async_std::task; use crate::chat::*; use crate::config::Config; @@ -56,6 +57,7 @@ pub struct InnerContext { pub(crate) events: Events, pub(crate) scheduler: RwLock, + pub(crate) ephemeral_task: RwLock>>, creation_time: SystemTime, } @@ -121,6 +123,7 @@ impl Context { translated_stockstrings: RwLock::new(HashMap::new()), events: Events::default(), scheduler: RwLock::new(Scheduler::Stopped), + ephemeral_task: RwLock::new(None), creation_time: std::time::SystemTime::now(), }; diff --git a/src/dc_receive_imf.rs b/src/dc_receive_imf.rs index a82622548..f9ce6ffb3 100644 --- a/src/dc_receive_imf.rs +++ b/src/dc_receive_imf.rs @@ -10,6 +10,7 @@ use crate::constants::*; use crate::contact::*; use crate::context::Context; use crate::dc_tools::*; +use crate::ephemeral::Timer as EphemeralTimer; use crate::error::{bail, ensure, format_err, Result}; use crate::events::Event; use crate::headerdef::HeaderDef; @@ -619,6 +620,62 @@ async fn add_parts( *chat_id = ChatId::new(DC_CHAT_ID_TRASH); } } + + // Extract ephemeral timer from the message. + let timer = if let Some(value) = mime_parser.get(HeaderDef::EphemeralTimer) { + match value.parse::() { + Ok(timer) => timer, + Err(err) => { + warn!( + context, + "can't parse ephemeral timer \"{}\": {}", value, err + ); + EphemeralTimer::Disabled + } + } + } else { + EphemeralTimer::Disabled + }; + + let location_kml_is = mime_parser.location_kml.is_some(); + let is_mdn = !mime_parser.mdn_reports.is_empty(); + + // Apply ephemeral timer changes to the chat. + // + // Only non-hidden timers are applied now. Timers from hidden + // messages such as read receipts can be useful to detect + // ephemeral timer support, but timer changes without visible + // received messages may be confusing to the user. + if !*hidden + && !location_kml_is + && !is_mdn + && (*chat_id).get_ephemeral_timer(context).await? != timer + { + match (*chat_id).inner_set_ephemeral_timer(context, timer).await { + Ok(()) => { + let stock_str = context + .stock_system_msg( + StockMessage::MsgEphemeralTimerChanged, + timer.to_string(), + "", + from_id, + ) + .await; + chat::add_info_msg(context, *chat_id, stock_str).await; + context.emit_event(Event::ChatEphemeralTimerModified { + chat_id: *chat_id, + timer: timer.to_u32(), + }); + } + Err(err) => { + warn!( + context, + "failed to modify timer for chat {}: {}", chat_id, err + ); + } + } + } + // correct message_timestamp, it should not be used before, // however, we cannot do this earlier as we need from_id to be set let rcvd_timestamp = time(); @@ -655,7 +712,6 @@ async fn add_parts( let mut parts = std::mem::replace(&mut mime_parser.parts, Vec::new()); let server_folder = server_folder.as_ref().to_string(); - let location_kml_is = mime_parser.location_kml.is_some(); let is_system_message = mime_parser.is_system_message; let mime_headers = if save_mime_headers { Some(String::from_utf8_lossy(imf_raw).to_string()) @@ -665,7 +721,6 @@ async fn add_parts( let sent_timestamp = *sent_timestamp; let is_hidden = *hidden; let chat_id = *chat_id; - let is_mdn = !mime_parser.mdn_reports.is_empty(); // TODO: can this clone be avoided? let rfc724_mid = rfc724_mid.to_string(); @@ -682,8 +737,8 @@ async fn add_parts( "INSERT INTO msgs \ (rfc724_mid, server_folder, server_uid, chat_id, from_id, to_id, timestamp, \ timestamp_sent, timestamp_rcvd, type, state, msgrmsg, txt, txt_raw, param, \ - bytes, hidden, mime_headers, mime_in_reply_to, mime_references, error) \ - VALUES (?,?,?,?,?,?, ?,?,?,?,?,?, ?,?,?,?,?,?, ?,?, ?);", + bytes, hidden, mime_headers, mime_in_reply_to, mime_references, error, ephemeral_timer) \ + VALUES (?,?,?,?,?,?, ?,?,?,?,?,?, ?,?,?,?,?,?, ?,?, ?,?);", )?; let is_location_kml = location_kml_is @@ -728,6 +783,7 @@ async fn add_parts( mime_in_reply_to, mime_references, part.error, + timer ])?; drop(stmt); diff --git a/src/ephemeral.rs b/src/ephemeral.rs new file mode 100644 index 000000000..338e37f78 --- /dev/null +++ b/src/ephemeral.rs @@ -0,0 +1,413 @@ +//! # Ephemeral messages +//! +//! Ephemeral messages are messages that have an Ephemeral-Timer +//! header attached to them, which specifies time in seconds after +//! which the message should be deleted both from the device and from +//! the server. The timer is started when the message is marked as +//! seen, which usually happens when its contents is displayed on +//! device screen. +//! +//! Each chat, including 1:1, group chats and "saved messages" chat, +//! has its own ephemeral timer setting, which is applied to all +//! messages sent to the chat. The setting is synchronized to all the +//! devices participating in the chat by applying the timer value from +//! all received messages, including BCC-self ones, to the chat. This +//! way the setting is eventually synchronized among all participants. +//! +//! When user changes ephemeral timer setting for the chat, a system +//! message is automatically sent to update the setting for all +//! participants. This allows changing the setting for a chat like any +//! group chat setting, e.g. name and avatar, without the need to +//! write an actual message. +//! +//! ## Device settings +//! +//! In addition to per-chat ephemeral message setting, each device has +//! two global user-configured settings that complement per-chat +//! settings: `delete_device_after` and `delete_server_after`. These +//! settings are not synchronized among devices and apply to all +//! messages known to the device, including messages sent or received +//! before configuring the setting. +//! +//! `delete_device_after` configures the maximum time device is +//! storing the messages locally. `delete_server_after` configures the +//! time after which device will delete the messages it knows about +//! from the server. +//! +//! ## How messages are deleted +//! +//! When the message is deleted locally, its contents is removed and +//! it is moved to the trash chat. This database entry is then used to +//! track the Message-ID and corresponding IMAP folder and UID until +//! the message is deleted from the server. Vice versa, when device +//! deletes the message from the server, it removes IMAP folder and +//! UID information, but keeps the message contents. When database +//! entry is both moved to trash chat and does not contain UID +//! information, it is deleted from the database, leaving no trace of +//! the message. +//! +//! ## When messages are deleted +//! +//! Local deletion happens when the chatlist or chat is loaded. A +//! `MsgsChanged` event is emitted when a message deletion is due, to +//! make UI reload displayed messages and cause actual deletion. +//! +//! Server deletion happens by generating IMAP deletion jobs based on +//! the database entries which are expired either according to their +//! ephemeral message timers or global `delete_server_after` setting. + +use crate::chat::{lookup_by_contact_id, send_msg, ChatId}; +use crate::constants::{ + Viewtype, DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_CONTACT_ID_DEVICE, DC_CONTACT_ID_SELF, +}; +use crate::context::Context; +use crate::dc_tools::time; +use crate::error::{ensure, Error}; +use crate::events::Event; +use crate::message::{Message, MsgId}; +use crate::mimeparser::SystemMessage; +use crate::sql; +use crate::stock::StockMessage; +use async_std::task; +use serde::{Deserialize, Serialize}; +use std::convert::{TryFrom, TryInto}; +use std::num::ParseIntError; +use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)] +pub enum Timer { + Disabled, + Enabled { duration: u32 }, +} + +impl Timer { + pub fn to_u32(self) -> u32 { + match self { + Self::Disabled => 0, + Self::Enabled { duration } => duration, + } + } + + pub fn from_u32(duration: u32) -> Self { + if duration == 0 { + Self::Disabled + } else { + Self::Enabled { duration } + } + } +} + +impl Default for Timer { + fn default() -> Self { + Self::Disabled + } +} + +impl ToString for Timer { + fn to_string(&self) -> String { + self.to_u32().to_string() + } +} + +impl FromStr for Timer { + type Err = ParseIntError; + + fn from_str(input: &str) -> Result { + input.parse::().map(Self::from_u32) + } +} + +impl rusqlite::types::ToSql for Timer { + fn to_sql(&self) -> rusqlite::Result { + let val = rusqlite::types::Value::Integer(match self { + Self::Disabled => 0, + Self::Enabled { duration } => i64::from(*duration), + }); + let out = rusqlite::types::ToSqlOutput::Owned(val); + Ok(out) + } +} + +impl rusqlite::types::FromSql for Timer { + fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { + i64::column_result(value).and_then(|value| { + if value == 0 { + Ok(Self::Disabled) + } else if let Ok(duration) = u32::try_from(value) { + Ok(Self::Enabled { duration }) + } else { + Err(rusqlite::types::FromSqlError::OutOfRange(value)) + } + }) + } +} + +impl ChatId { + /// Get ephemeral message timer value in seconds. + pub async fn get_ephemeral_timer(self, context: &Context) -> Result { + let timer = context + .sql + .query_get_value_result( + "SELECT ephemeral_timer FROM chats WHERE id=?;", + paramsv![self], + ) + .await?; + Ok(timer.unwrap_or_default()) + } + + /// Set ephemeral timer value without sending a message. + /// + /// Used when a message arrives indicating that someone else has + /// changed the timer value for a chat. + pub(crate) async fn inner_set_ephemeral_timer( + self, + context: &Context, + timer: Timer, + ) -> Result<(), Error> { + ensure!(!self.is_special(), "Invalid chat ID"); + + context + .sql + .execute( + "UPDATE chats + SET ephemeral_timer=? + WHERE id=?;", + paramsv![timer, self], + ) + .await?; + Ok(()) + } + + /// Set ephemeral message timer value in seconds. + /// + /// If timer value is 0, disable ephemeral message timer. + pub async fn set_ephemeral_timer(self, context: &Context, timer: Timer) -> Result<(), Error> { + if timer == self.get_ephemeral_timer(context).await? { + return Ok(()); + } + self.inner_set_ephemeral_timer(context, timer).await?; + let mut msg = Message::new(Viewtype::Text); + msg.text = Some( + context + .stock_system_msg( + StockMessage::MsgEphemeralTimerChanged, + timer.to_string(), + "", + 0, + ) + .await, + ); + msg.param.set_cmd(SystemMessage::EphemeralTimerChanged); + if let Err(err) = send_msg(context, self, &mut msg).await { + error!( + context, + "Failed to send a message about ephemeral message timer change: {:?}", err + ); + } + Ok(()) + } +} + +impl MsgId { + /// Returns ephemeral message timer value for the message. + pub(crate) async fn ephemeral_timer( + self, + context: &Context, + ) -> crate::sql::Result> { + let res = match context + .sql + .query_get_value_result( + "SELECT ephemeral_timer FROM msgs WHERE id=?", + paramsv![self], + ) + .await? + { + None | Some(0) => None, + Some(timer) => Some(timer), + }; + Ok(res) + } + + /// Starts ephemeral message timer for the message if it is not started yet. + pub(crate) async fn start_ephemeral_timer(self, context: &Context) -> crate::sql::Result<()> { + if let Some(ephemeral_timer) = self.ephemeral_timer(context).await? { + let ephemeral_timestamp = time() + ephemeral_timer; + + context + .sql + .execute( + "UPDATE msgs SET ephemeral_timestamp = ? \ + WHERE (ephemeral_timestamp == 0 OR ephemeral_timestamp > ?) \ + AND id = ?", + paramsv![ephemeral_timestamp, ephemeral_timestamp, self], + ) + .await?; + schedule_ephemeral_task(context).await; + } + Ok(()) + } +} + +/// Deletes messages which are expired according to +/// `delete_device_after` setting or `ephemeral_timestamp` column. +/// +/// Returns true if any message is deleted, so caller can emit +/// MsgsChanged event. If nothing has been deleted, returns +/// false. This function does not emit the MsgsChanged event itself, +/// because it is also called when chatlist is reloaded, and emitting +/// MsgsChanged there will cause infinite reload loop. +pub(crate) async fn delete_expired_messages(context: &Context) -> Result { + let mut updated = context + .sql + .execute( + "UPDATE msgs \ + SET txt = 'DELETED', chat_id = ? \ + WHERE \ + ephemeral_timestamp != 0 \ + AND ephemeral_timestamp < ? \ + AND chat_id != ?", + paramsv![DC_CHAT_ID_TRASH, time(), DC_CHAT_ID_TRASH], + ) + .await? + > 0; + + if let Some(delete_device_after) = context.get_config_delete_device_after().await { + let self_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_SELF) + .await + .unwrap_or_default() + .0; + let device_chat_id = lookup_by_contact_id(context, DC_CONTACT_ID_DEVICE) + .await + .unwrap_or_default() + .0; + + let threshold_timestamp = time() - delete_device_after; + + // Delete expired messages + // + // Only update the rows that have to be updated, to avoid emitting + // unnecessary "chat modified" events. + let rows_modified = context + .sql + .execute( + "UPDATE msgs \ + SET txt = 'DELETED', chat_id = ? \ + WHERE timestamp < ? \ + AND chat_id > ? \ + AND chat_id != ? \ + AND chat_id != ?", + paramsv![ + DC_CHAT_ID_TRASH, + threshold_timestamp, + DC_CHAT_ID_LAST_SPECIAL, + self_chat_id, + device_chat_id + ], + ) + .await?; + + updated |= rows_modified > 0; + } + + if updated { + schedule_ephemeral_task(context).await; + } + Ok(updated) +} + +/// Schedule a task to emit MsgsChanged event when the next local +/// deletion happens. Existing task is cancelled to make sure at most +/// one such task is scheduled at a time. +/// +/// UI is expected to reload the chatlist or the chat in response to +/// MsgsChanged event, this will trigger actual deletion. +/// +/// This takes into account only per-chat timeouts, because global device +/// timeouts are at least one hour long and deletion is triggered often enough +/// by user actions. +pub async fn schedule_ephemeral_task(context: &Context) { + let ephemeral_timestamp: Option = match context + .sql + .query_get_value_result( + "SELECT ephemeral_timestamp \ + FROM msgs \ + WHERE ephemeral_timestamp != 0 \ + AND chat_id != ? \ + ORDER BY ephemeral_timestamp ASC \ + LIMIT 1", + paramsv![DC_CHAT_ID_TRASH], // Trash contains already deleted messages, skip them + ) + .await + { + Err(err) => { + warn!(context, "Can't calculate next ephemeral timeout: {}", err); + return; + } + Ok(ephemeral_timestamp) => ephemeral_timestamp, + }; + + // Cancel existing task, if any + if let Some(ephemeral_task) = context.ephemeral_task.write().await.take() { + ephemeral_task.cancel().await; + } + + if let Some(ephemeral_timestamp) = ephemeral_timestamp { + let now = SystemTime::now(); + let until = + UNIX_EPOCH + Duration::from_secs(ephemeral_timestamp.try_into().unwrap_or(u64::MAX)); + + if let Ok(duration) = until.duration_since(now) { + // Schedule a task, ephemeral_timestamp is in the future + let context1 = context.clone(); + let ephemeral_task = task::spawn(async move { + async_std::task::sleep(duration).await; + emit_event!( + context1, + Event::MsgsChanged { + chat_id: ChatId::new(0), + msg_id: MsgId::new(0) + } + ); + }); + *context.ephemeral_task.write().await = Some(ephemeral_task); + } else { + // Emit event immediately + emit_event!( + context, + Event::MsgsChanged { + chat_id: ChatId::new(0), + msg_id: MsgId::new(0) + } + ); + } + } +} + +/// Returns ID of any expired message that should be deleted from the server. +/// +/// It looks up the trash chat too, to find messages that are already +/// deleted locally, but not deleted on the server. +pub(crate) async fn load_imap_deletion_msgid(context: &Context) -> sql::Result> { + let now = time(); + + let threshold_timestamp = match context.get_config_delete_server_after().await { + None => 0, + Some(delete_server_after) => now - delete_server_after, + }; + + context + .sql + .query_row_optional( + "SELECT id FROM msgs \ + WHERE ( \ + timestamp < ? \ + OR (ephemeral_timestamp != 0 AND ephemeral_timestamp < ?) \ + ) \ + AND server_uid != 0 \ + LIMIT 1", + paramsv![threshold_timestamp, now], + |row| row.get::<_, MsgId>(0), + ) + .await +} diff --git a/src/events.rs b/src/events.rs index 156e19348..828e97e4d 100644 --- a/src/events.rs +++ b/src/events.rs @@ -189,9 +189,16 @@ pub enum Event { /// Or the verify state of a chat has changed. /// See dc_set_chat_name(), dc_set_chat_profile_image(), dc_add_contact_to_chat() /// and dc_remove_contact_from_chat(). + /// + /// This event does not include ephemeral timer modification, which + /// is a separate event. #[strum(props(id = "2020"))] ChatModified(ChatId), + /// Chat ephemeral timer changed. + #[strum(props(id = "2021"))] + ChatEphemeralTimerModified { chat_id: ChatId, timer: u32 }, + /// Contact(s) created, renamed, blocked or deleted. /// /// @param data1 (int) If set, this is the contact_id of an added contact that should be selected. diff --git a/src/headerdef.rs b/src/headerdef.rs index 36d763b8d..d87929435 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -42,6 +42,7 @@ pub enum HeaderDef { SecureJoinFingerprint, SecureJoinInvitenumber, SecureJoinAuth, + EphemeralTimer, _TestHeader, } diff --git a/src/job.rs b/src/job.rs index 30b6eccfb..86c26ce57 100644 --- a/src/job.rs +++ b/src/job.rs @@ -21,6 +21,7 @@ use crate::constants::*; use crate::contact::Contact; use crate::context::Context; use crate::dc_tools::*; +use crate::ephemeral::load_imap_deletion_msgid; use crate::error::{bail, ensure, format_err, Error, Result}; use crate::events::Event; use crate::imap::*; @@ -828,25 +829,6 @@ pub(crate) enum Connection<'a> { Smtp(&'a mut Smtp), } -async fn load_imap_deletion_msgid(context: &Context) -> sql::Result> { - if let Some(delete_server_after) = context.get_config_delete_server_after().await { - let threshold_timestamp = time() - delete_server_after; - - context - .sql - .query_row_optional( - "SELECT id FROM msgs \ - WHERE timestamp < ? \ - AND server_uid != 0", - paramsv![threshold_timestamp], - |row| row.get::<_, MsgId>(0), - ) - .await - } else { - Ok(None) - } -} - async fn load_imap_deletion_job(context: &Context) -> sql::Result> { let res = if let Some(msg_id) = load_imap_deletion_msgid(context).await? { Some(Job::new( diff --git a/src/lib.rs b/src/lib.rs index 8ada41560..0503121c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod constants; pub mod contact; pub mod context; mod e2ee; +pub mod ephemeral; mod imap; pub mod imex; mod scheduler; diff --git a/src/message.rs b/src/message.rs index 8ec6b5a82..60ed9e40b 100644 --- a/src/message.rs +++ b/src/message.rs @@ -223,6 +223,8 @@ pub struct Message { pub(crate) timestamp_sort: i64, pub(crate) timestamp_sent: i64, pub(crate) timestamp_rcvd: i64, + pub(crate) ephemeral_timer: i64, + pub(crate) ephemeral_timestamp: i64, pub(crate) text: Option, pub(crate) rfc724_mid: String, pub(crate) in_reply_to: Option, @@ -265,6 +267,8 @@ impl Message { " m.timestamp AS timestamp,", " m.timestamp_sent AS timestamp_sent,", " m.timestamp_rcvd AS timestamp_rcvd,", + " m.ephemeral_timer AS ephemeral_timer,", + " m.ephemeral_timestamp AS ephemeral_timestamp,", " m.type AS type,", " m.state AS state,", " m.error AS error,", @@ -293,6 +297,8 @@ impl Message { msg.timestamp_sort = row.get("timestamp")?; msg.timestamp_sent = row.get("timestamp_sent")?; msg.timestamp_rcvd = row.get("timestamp_rcvd")?; + msg.ephemeral_timer = row.get("ephemeral_timer")?; + msg.ephemeral_timestamp = row.get("ephemeral_timestamp")?; msg.viewtype = row.get("type")?; msg.state = row.get("state")?; msg.error = row.get("error")?; @@ -868,6 +874,17 @@ pub async fn get_msg_info(context: &Context, msg_id: MsgId) -> String { ret += "\n"; } + if msg.ephemeral_timer != 0 { + ret += &format!("Ephemeral timer: {}\n", msg.ephemeral_timer); + } + + if msg.ephemeral_timestamp != 0 { + ret += &format!( + "Expires: {}\n", + dc_timestamp_to_str(msg.ephemeral_timestamp) + ); + } + if msg.from_id == DC_CONTACT_ID_INFO || msg.to_id == DC_CONTACT_ID_INFO { // device-internal message, no further details needed return ret; @@ -1073,6 +1090,14 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec) -> bool { let mut send_event = false; for (id, curr_state, curr_blocked) in msgs.into_iter() { + if let Err(err) = id.start_ephemeral_timer(context).await { + error!( + context, + "Failed to start ephemeral timer for message {}: {}", id, err + ); + continue; + } + if curr_blocked == Blocked::Not { if curr_state == MessageState::InFresh || curr_state == MessageState::InNoticed { update_msg_state(context, id, MessageState::InSeen).await; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 1e674e560..c1f9320ce 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -9,6 +9,7 @@ use crate::contact::*; use crate::context::{get_version_str, Context}; use crate::dc_tools::*; use crate::e2ee::*; +use crate::ephemeral::Timer as EphemeralTimer; use crate::error::{bail, ensure, format_err, Error}; use crate::location; use crate::message::{self, Message}; @@ -526,6 +527,14 @@ impl<'a, 'b> MimeFactory<'a, 'b> { Loaded::MDN { .. } => dc_create_outgoing_rfc724_mid(None, &self.from_addr), }; + let ephemeral_timer = self.msg.chat_id.get_ephemeral_timer(self.context).await?; + if let EphemeralTimer::Enabled { duration } = ephemeral_timer { + protected_headers.push(Header::new( + "Ephemeral-Timer".to_string(), + duration.to_string(), + )); + } + // we could also store the message-id in the protected headers // which would probably help to survive providers like // Outlook.com or hotmail which mangle the Message-ID. @@ -776,6 +785,12 @@ impl<'a, 'b> MimeFactory<'a, 'b> { "location-streaming-enabled".into(), )); } + SystemMessage::EphemeralTimerChanged => { + protected_headers.push(Header::new( + "Chat-Content".to_string(), + "ephemeral-timer-changed".to_string(), + )); + } SystemMessage::AutocryptSetupMessage => { unprotected_headers .push(Header::new("Autocrypt-Setup-Message".into(), "v1".into())); diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 189956316..1b6feaa51 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -76,6 +76,9 @@ pub enum SystemMessage { SecurejoinMessage = 7, LocationStreamingEnabled = 8, LocationOnly = 9, + + /// Chat ephemeral message timer is changed. + EphemeralTimerChanged = 10, } impl Default for SystemMessage { @@ -214,6 +217,8 @@ impl MimeMessage { } else if let Some(value) = self.get(HeaderDef::ChatContent) { if value == "location-streaming-enabled" { self.is_system_message = SystemMessage::LocationStreamingEnabled; + } else if value == "ephemeral-timer-changed" { + self.is_system_message = SystemMessage::EphemeralTimerChanged; } } Ok(()) diff --git a/src/sql.rs b/src/sql.rs index ec90a38cf..a3f0adac8 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1250,6 +1250,32 @@ async fn open( .await?; sql.set_raw_config_int(context, "dbversion", 64).await?; } + if dbversion < 65 { + info!(context, "[migration] v65"); + sql.execute( + "ALTER TABLE chats ADD COLUMN ephemeral_timer INTEGER", + paramsv![], + ) + .await?; + // Timer value in seconds. For incoming messages this + // timer starts when message is read, so we want to have + // the value stored here until the timer starts. + sql.execute( + "ALTER TABLE msgs ADD COLUMN ephemeral_timer INTEGER DEFAULT 0", + paramsv![], + ) + .await?; + // Timestamp indicating when the message should be + // deleted. It is convenient to store it here because UI + // needs this value to display how much time is left until + // the message is deleted. + sql.execute( + "ALTER TABLE msgs ADD COLUMN ephemeral_timestamp INTEGER DEFAULT 0", + paramsv![], + ) + .await?; + sql.set_raw_config_int(context, "dbversion", 65).await?; + } // (2) updates that require high-level objects // (the structure is complete now and all objects are usable) diff --git a/src/stock.rs b/src/stock.rs index 00b34fd0b..75375c9e0 100644 --- a/src/stock.rs +++ b/src/stock.rs @@ -185,6 +185,9 @@ pub enum StockMessage { #[strum(props(fallback = "Failed to send message to %1$s."))] FailedSendingTo = 74, + + #[strum(props(fallback = "Ephemeral message timer changed to %1$s."))] + MsgEphemeralTimerChanged = 75, } /*