diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 053f5fc12..1e36c51e4 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -458,6 +458,12 @@ char* dc_get_blobdir (const dc_context_t* context); * The library uses the `media_quality` setting to use different defaults * for recoding images sent with type #DC_MSG_IMAGE. * If needed, recoding other file types is up to the UI. + * - `webrtc_instance` = webrtc instance to use for videochats in the form + * `[basicwebrtc:|jitsi:]https://example.com/subdir#roomname=$ROOM` + * if the URL is prefixed by `basicwebrtc`, the server is assumed to be of the type + * https://github.com/cracker0dks/basicwebrtc which some UIs have native support for. + * The type `jitsi:` may be handled by external apps. + * If no type is prefixed, the videochat is handled completely in a browser. * - `bot` = Set to "1" if this is a bot. * Prevents adding the "Device messages" and "Saved messages" chats, * adds Auto-Submitted header to outgoing messages, @@ -569,10 +575,11 @@ int dc_set_stock_translation(dc_context_t* context, uint32_t stock_i /** * Set configuration values from a QR code. * Before this function is called, dc_check_qr() should confirm the type of the - * QR code is DC_QR_ACCOUNT or DC_QR_LOGIN. + * QR code is DC_QR_ACCOUNT, DC_QR_LOGIN or DC_QR_WEBRTC_INSTANCE. * * Internally, the function will call dc_set_config() with the appropriate keys, - * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN. + * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN + * or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE. * * @memberof dc_context_t * @param context The context object. @@ -1045,6 +1052,42 @@ void dc_send_edit_request (dc_context_t* context, uint32_t ms void dc_send_delete_request (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt); +/** + * Send invitation to a videochat. + * + * This function reads the `webrtc_instance` config value, + * may check that the server is working in some way + * and creates a unique room for this chat, if needed doing a TOKEN roundtrip for that. + * + * After that, the function sends out a message that contains information to join the room: + * + * - To allow non-delta-clients to join the chat, + * the message contains a text-area with some descriptive text + * and a URL that can be opened in a supported browser to join the videochat. + * + * - delta-clients can get all information needed from + * the message object, using e.g. + * dc_msg_get_videochat_url() and check dc_msg_get_viewtype() for #DC_MSG_VIDEOCHAT_INVITATION. + * + * dc_send_videochat_invitation() is blocking and may take a while, + * so the UIs will typically call the function from within a thread. + * Moreover, UIs will typically enter the room directly without an additional click on the message, + * for this purpose, the function returns the message id directly. + * + * As for other messages sent, this function + * sends the event #DC_EVENT_MSGS_CHANGED on success, the message has a delivery state, and so on. + * The recipient will get noticed by the call as usual by #DC_EVENT_INCOMING_MSG or #DC_EVENT_MSGS_CHANGED, + * However, UIs might some things differently, e.g. play a different sound. + * + * @memberof dc_context_t + * @param context The context object. + * @param chat_id The chat to start a videochat for. + * @return The ID of the message sent out + * or 0 for errors. + */ +uint32_t dc_send_videochat_invitation (dc_context_t* context, uint32_t chat_id); + + /** * A webxdc instance sends a status update to its other members. * @@ -2572,6 +2615,7 @@ void dc_stop_ongoing_process (dc_context_t* context); #define DC_QR_BACKUP 251 // deprecated #define DC_QR_BACKUP2 252 #define DC_QR_BACKUP_TOO_NEW 255 +#define DC_QR_WEBRTC_INSTANCE 260 // text1=domain, text2=instance pattern #define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050") #define DC_QR_ADDR 320 // id=contact #define DC_QR_TEXT 330 // text1=text @@ -2625,6 +2669,10 @@ void dc_stop_ongoing_process (dc_context_t* context); * show a hint to the user that this backup comes from a newer Delta Chat version * and this device needs an update * + * - DC_QR_WEBRTC_INSTANCE with dc_lot_t::text1=domain: + * ask the user if they want to use the given service for video chats; + * if so, call dc_set_config_from_qr(). + * * - DC_QR_PROXY with dc_lot_t::text1=address: * ask the user if they want to use the given proxy. * if so, call dc_set_config_from_qr() and restart I/O. @@ -4696,6 +4744,22 @@ int dc_msg_is_setupmessage (const dc_msg_t* msg); char* dc_msg_get_setupcodebegin (const dc_msg_t* msg); +/** + * Get URL of a videochat invitation. + * + * Videochat invitations are sent out using dc_send_videochat_invitation() + * and dc_msg_get_viewtype() returns #DC_MSG_VIDEOCHAT_INVITATION for such invitations. + * + * @memberof dc_msg_t + * @param msg The message object. + * @return If the message contains a videochat invitation, + * the URL of the invitation is returned. + * If the message is no videochat invitation, NULL is returned. + * Must be released using dc_str_unref() when done. + */ +char* dc_msg_get_videochat_url (const dc_msg_t* msg); + + /** * Gets the error status of the message. * If there is no error associated with the message, NULL is returned. @@ -4718,6 +4782,41 @@ char* dc_msg_get_setupcodebegin (const dc_msg_t* msg); char* dc_msg_get_error (const dc_msg_t* msg); +/** + * Get type of videochat. + * + * Calling this functions only makes sense for messages of type #DC_MSG_VIDEOCHAT_INVITATION, + * in this case, if `basicwebrtc:` as of https://github.com/cracker0dks/basicwebrtc or `jitsi` + * were used to initiate the videochat, + * dc_msg_get_videochat_type() returns the corresponding type. + * + * The videochat URL can be retrieved using dc_msg_get_videochat_url(). + * To check if a message is a videochat invitation at all, check the message type for #DC_MSG_VIDEOCHAT_INVITATION. + * + * @memberof dc_msg_t + * @param msg The message object. + * @return Type of the videochat as of DC_VIDEOCHATTYPE_BASICWEBRTC, DC_VIDEOCHATTYPE_JITSI or DC_VIDEOCHATTYPE_UNKNOWN. + * + * Example: + * ~~~ + * if (dc_msg_get_viewtype(msg) == DC_MSG_VIDEOCHAT_INVITATION) { + * if (dc_msg_get_videochat_type(msg) == DC_VIDEOCHATTYPE_BASICWEBRTC) { + * // videochat invitation that we ship a client for + * } else { + * // use browser for videochat - or add an additional check for DC_VIDEOCHATTYPE_JITSI + * } + * } else { + * // not a videochat invitation + * } + * ~~~ + */ +int dc_msg_get_videochat_type (const dc_msg_t* msg); + +#define DC_VIDEOCHATTYPE_UNKNOWN 0 +#define DC_VIDEOCHATTYPE_BASICWEBRTC 1 +#define DC_VIDEOCHATTYPE_JITSI 2 + + /** * Checks if the message has a full HTML version. * @@ -5616,6 +5715,17 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); #define DC_MSG_FILE 60 +/** + * Message indicating an incoming or outgoing videochat. + * The message was created via dc_send_videochat_invitation() on this or a remote device. + * + * Typically, such messages are rendered differently by the UIs, + * e.g. contain a button to join the videochat. + * The URL for joining can be retrieved using dc_msg_get_videochat_url(). + */ +#define DC_MSG_VIDEOCHAT_INVITATION 70 + + /** * Message indicating an incoming or outgoing call. * @@ -7166,6 +7276,17 @@ void dc_event_unref(dc_event_t* event); /// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead. #define DC_STR_EPHEMERAL_FOUR_WEEKS 81 +/// "Video chat invitation" +/// +/// Used in summaries. +#define DC_STR_VIDEOCHAT_INVITATION 82 + +/// "You are invited to a video chat, click %1$s to join." +/// +/// Used as message text of outgoing video chat invitations. +/// - %1$s will be replaced by the URL of the video chat +#define DC_STR_VIDEOCHAT_INVITE_MSG_BODY 83 + /// "Error: %1$s" /// /// Used in error strings. diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 96157e4bb..22e45f81d 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1098,6 +1098,25 @@ pub unsafe extern "C" fn dc_send_delete_request( .ok(); } +#[no_mangle] +pub unsafe extern "C" fn dc_send_videochat_invitation( + context: *mut dc_context_t, + chat_id: u32, +) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_send_videochat_invitation()"); + return 0; + } + let ctx = &*context; + + block_on(async move { + chat::send_videochat_invitation(ctx, ChatId::new(chat_id)) + .await + .map(|msg_id| msg_id.to_u32()) + .unwrap_or_log_default(ctx, "Failed to send video chat invitation") + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_send_webxdc_status_update( context: *mut dc_context_t, @@ -3835,6 +3854,31 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int { ffi_msg.message.has_html().into() } +#[no_mangle] +pub unsafe extern "C" fn dc_msg_get_videochat_url(msg: *mut dc_msg_t) -> *mut libc::c_char { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_get_videochat_url()"); + return "".strdup(); + } + let ffi_msg = &*msg; + + ffi_msg + .message + .get_videochat_url() + .unwrap_or_default() + .strdup() +} + +#[no_mangle] +pub unsafe extern "C" fn dc_msg_get_videochat_type(msg: *mut dc_msg_t) -> libc::c_int { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_get_videochat_type()"); + return 0; + } + let ffi_msg = &*msg; + ffi_msg.message.get_videochat_type().unwrap_or_default() as i32 +} + #[no_mangle] pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char { if msg.is_null() { diff --git a/deltachat-ffi/src/lot.rs b/deltachat-ffi/src/lot.rs index 11b6cb405..e77483ef7 100644 --- a/deltachat-ffi/src/lot.rs +++ b/deltachat-ffi/src/lot.rs @@ -51,6 +51,7 @@ impl Lot { Qr::Account { domain } => Some(Cow::Borrowed(domain)), Qr::Backup2 { .. } => None, Qr::BackupTooNew { .. } => None, + Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)), Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))), Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed), Qr::Url { url } => Some(Cow::Borrowed(url)), @@ -104,6 +105,7 @@ impl Lot { Qr::Account { .. } => LotState::QrAccount, Qr::Backup2 { .. } => LotState::QrBackup2, Qr::BackupTooNew { .. } => LotState::QrBackupTooNew, + Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance, Qr::Proxy { .. } => LotState::QrProxy, Qr::Addr { .. } => LotState::QrAddr, Qr::Url { .. } => LotState::QrUrl, @@ -130,6 +132,7 @@ impl Lot { Qr::Account { .. } => Default::default(), Qr::Backup2 { .. } => Default::default(), Qr::BackupTooNew { .. } => Default::default(), + Qr::WebrtcInstance { .. } => Default::default(), Qr::Proxy { .. } => Default::default(), Qr::Addr { contact_id, .. } => contact_id.to_u32(), Qr::Url { .. } => Default::default(), @@ -182,6 +185,9 @@ pub enum LotState { QrBackupTooNew = 255, + /// text1=domain, text2=instance pattern + QrWebrtcInstance = 260, + /// text1=address, text2=protocol QrProxy = 271, diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 148c11285..0773016fb 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -2282,6 +2282,13 @@ impl CommandApi { } } + async fn send_videochat_invitation(&self, account_id: u32, chat_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + chat::send_videochat_invitation(&ctx, ChatId::new(chat_id)) + .await + .map(|msg_id| msg_id.to_u32()) + } + // --------------------------------------------- // misc prototyping functions // that might get removed later again diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index fd47c534d..0369c43e3 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -84,6 +84,9 @@ pub struct MessageObject { dimensions_height: i32, dimensions_width: i32, + videochat_type: Option, + videochat_url: Option, + override_sender_name: Option, sender: ContactObject, @@ -236,6 +239,15 @@ impl MessageObject { dimensions_height: message.get_height(), dimensions_width: message.get_width(), + videochat_type: match message.get_videochat_type() { + Some(vct) => Some( + vct.to_u32() + .context("videochat type conversion to number failed")?, + ), + None => None, + }, + videochat_url: message.get_videochat_url(), + override_sender_name, sender, @@ -309,6 +321,9 @@ pub enum MessageViewtype { /// Message containing any file, eg. a PDF. File, + /// Message is an invitation to a videochat. + VideochatInvitation, + /// Message is a call. Call, @@ -333,6 +348,7 @@ impl From for MessageViewtype { Viewtype::Voice => MessageViewtype::Voice, Viewtype::Video => MessageViewtype::Video, Viewtype::File => MessageViewtype::File, + Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation, Viewtype::Call => MessageViewtype::Call, Viewtype::Webxdc => MessageViewtype::Webxdc, Viewtype::Vcard => MessageViewtype::Vcard, @@ -352,6 +368,7 @@ impl From for Viewtype { MessageViewtype::Voice => Viewtype::Voice, MessageViewtype::Video => Viewtype::Video, MessageViewtype::File => Viewtype::File, + MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation, MessageViewtype::Call => Viewtype::Call, MessageViewtype::Webxdc => Viewtype::Webxdc, MessageViewtype::Vcard => Viewtype::Vcard, diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 0414ec9e5..61d8141f7 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -225,6 +225,13 @@ impl From for QrObject { auth_token, }, Qr::BackupTooNew {} => QrObject::BackupTooNew {}, + Qr::WebrtcInstance { + domain, + instance_pattern, + } => QrObject::WebrtcInstance { + domain, + instance_pattern, + }, Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port }, Qr::Addr { contact_id, draft } => { let contact_id = contact_id.to_u32(); diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index 93e9cc2f4..29f11c20f 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -210,7 +210,13 @@ async fn log_msg(context: &Context, prefix: impl AsRef, msg: &Message) { } else { "" }, - if msg.get_viewtype() == Viewtype::Webxdc { + if msg.get_viewtype() == Viewtype::VideochatInvitation { + format!( + "[VIDEOCHAT-INVITATION: {}, type={}]", + msg.get_videochat_url().unwrap_or_default(), + msg.get_videochat_type().unwrap_or_default() + ) + } else if msg.get_viewtype() == Viewtype::Webxdc { match msg.get_webxdc_info(context).await { Ok(info) => format!( "[WEBXDC: {}, icon={}, document={}, summary={}, source_code_url={}]", @@ -365,6 +371,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu sendhtml []\n\ sendsyncmsg\n\ sendupdate \n\ + videochat\n\ draft []\n\ devicemsg \n\ listmedia\n\ @@ -955,6 +962,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu let msg_id = MsgId::new(arg1.parse()?); context.send_webxdc_status_update(msg_id, arg2).await?; } + "videochat" => { + ensure!(sel_chat.is_some(), "No chat selected."); + chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?; + } "listmsgs" => { ensure!(!arg1.is_empty(), "Argument missing."); diff --git a/deltachat-repl/src/main.rs b/deltachat-repl/src/main.rs index 0d80cf13e..8b06cc558 100644 --- a/deltachat-repl/src/main.rs +++ b/deltachat-repl/src/main.rs @@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 38] = [ +const CHAT_COMMANDS: [&str; 39] = [ "listchats", "listarchived", "start-realtime", @@ -206,6 +206,7 @@ const CHAT_COMMANDS: [&str; 38] = [ "sendhtml", "sendsyncmsg", "sendupdate", + "videochat", "draft", "devicemsg", "listmedia", diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py index c985bdfe1..522fd4ab9 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/const.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -160,6 +160,7 @@ class ViewType(str, Enum): VOICE = "Voice" VIDEO = "Video" FILE = "File" + VIDEOCHAT_INVITATION = "VideochatInvitation" WEBXDC = "Webxdc" VCARD = "Vcard" @@ -278,3 +279,11 @@ class SocketSecurity(IntEnum): SSL = 1 STARTTLS = 2 PLAIN = 3 + + +class VideochatType(IntEnum): + """Video chat URL type.""" + + UNKNOWN = 0 + BASICWEBRTC = 1 + JITSI = 2 diff --git a/python/src/deltachat/message.py b/python/src/deltachat/message.py index 12813eba8..ddab1069b 100644 --- a/python/src/deltachat/message.py +++ b/python/src/deltachat/message.py @@ -435,6 +435,10 @@ class Message: """return True if it's a video message.""" return self._view_type == const.DC_MSG_VIDEO + def is_videochat_invitation(self): + """return True if it's a videochat invitation message.""" + return self._view_type == const.DC_MSG_VIDEOCHAT_INVITATION + def is_webxdc(self): """return True if it's a Webxdc message.""" return self._view_type == const.DC_MSG_WEBXDC @@ -475,6 +479,7 @@ _view_type_mapping = { "video": const.DC_MSG_VIDEO, "file": const.DC_MSG_FILE, "sticker": const.DC_MSG_STICKER, + "videochat": const.DC_MSG_VIDEOCHAT_INVITATION, "webxdc": const.DC_MSG_WEBXDC, } diff --git a/src/chat.rs b/src/chat.rs index 9a1b8fddf..7cbabf121 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2691,7 +2691,10 @@ impl ChatIdBlocked { } async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> { - if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::Call { + if msg.viewtype == Viewtype::Text + || msg.viewtype == Viewtype::VideochatInvitation + || msg.viewtype == Viewtype::Call + { // the caller should check if the message text is empty } else if msg.viewtype.has_file() { let viewtype_orig = msg.viewtype; @@ -3162,6 +3165,10 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin ); ensure!(!original_msg.is_info(), "Cannot edit info messages"); ensure!(!original_msg.has_html(), "Cannot edit HTML messages"); + ensure!( + original_msg.viewtype != Viewtype::VideochatInvitation, + "Cannot edit videochat invitations" + ); ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls"); ensure!( !original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings @@ -3210,6 +3217,34 @@ pub(crate) async fn save_text_edit_to_db( Ok(()) } +/// Sends invitation to a videochat. +pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Result { + ensure!( + !chat_id.is_special(), + "video chat invitation cannot be sent to special chat: {}", + chat_id + ); + + let instance = if let Some(instance) = context.get_config(Config::WebrtcInstance).await? { + if !instance.is_empty() { + instance + } else { + bail!("webrtc_instance is empty"); + } + } else { + bail!("webrtc_instance not set"); + }; + + let instance = Message::create_webrtc_instance(&instance, &create_id()); + + let mut msg = Message::new(Viewtype::VideochatInvitation); + msg.param.set(Param::WebrtcRoom, &instance); + msg.text = + stock_str::videochat_invite_msg_body(context, &Message::parse_webrtc_instance(&instance).1) + .await; + send_msg(context, chat_id, &mut msg).await +} + async fn donation_request_maybe(context: &Context) -> Result<()> { let secs_between_checks = 30 * 24 * 60 * 60; let now = time(); diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 2fda9eef4..e018e6de5 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -4568,6 +4568,17 @@ async fn test_cannot_send_edit_request() -> Result<()> { .is_err() ); + // Videochat invitations cannot be edited + alice + .set_config(Config::WebrtcInstance, Some("https://foo.bar")) + .await?; + let msg_id = send_videochat_invitation(alice, chat_id).await?; + assert!( + send_edit_request(alice, msg_id, "bar".to_string()) + .await + .is_err() + ); + // If not text was given initally, there is nothing to edit // (this also avoids complexity in UI element changes; focus is typos and rewordings) let mut msg = Message::new(Viewtype::File); diff --git a/src/config.rs b/src/config.rs index 9bc13d317..511404b21 100644 --- a/src/config.rs +++ b/src/config.rs @@ -346,6 +346,9 @@ pub enum Config { /// Unset, when quota falls below minimal warning threshold again. QuotaExceeding, + /// address to webrtc instance to use for videochats + WebrtcInstance, + /// Timestamp of the last time housekeeping was run LastHousekeeping, diff --git a/src/constants.rs b/src/constants.rs index a3c29223f..6d4595909 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -60,6 +60,23 @@ pub enum MediaQuality { Worse = 1, } +/// Video chat URL type. +#[derive( + Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql, +)] +#[repr(i8)] +pub enum VideochatType { + /// Unknown type. + #[default] + Unknown = 0, + + /// [basicWebRTC](https://github.com/cracker0dks/basicwebrtc) instance. + BasicWebrtc = 1, + + /// [Jitsi Meet](https://jitsi.org/jitsi-meet/) instance. + Jitsi = 2, +} + pub const DC_HANDSHAKE_CONTINUE_NORMAL_PROCESSING: i32 = 0x01; pub const DC_HANDSHAKE_STOP_NORMAL_PROCESSING: i32 = 0x02; pub const DC_HANDSHAKE_ADD_DELETE_JOB: i32 = 0x04; @@ -291,4 +308,16 @@ mod tests { assert_eq!(MediaQuality::Balanced, MediaQuality::from_i32(0).unwrap()); assert_eq!(MediaQuality::Worse, MediaQuality::from_i32(1).unwrap()); } + + #[test] + fn test_videochattype_values() { + // values may be written to disk and must not change + assert_eq!(VideochatType::Unknown, VideochatType::default()); + assert_eq!(VideochatType::Unknown, VideochatType::from_i32(0).unwrap()); + assert_eq!( + VideochatType::BasicWebrtc, + VideochatType::from_i32(1).unwrap() + ); + assert_eq!(VideochatType::Jitsi, VideochatType::from_i32(2).unwrap()); + } } diff --git a/src/context.rs b/src/context.rs index 57c14e01f..fc96ec87a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -972,6 +972,12 @@ impl Context { res.insert("private_key_count", prv_key_cnt.to_string()); res.insert("public_key_count", pub_key_cnt.to_string()); res.insert("fingerprint", fingerprint_str); + res.insert( + "webrtc_instance", + self.get_config(Config::WebrtcInstance) + .await? + .unwrap_or_else(|| "".to_string()), + ); res.insert( "media_quality", self.get_config_int(Config::MediaQuality).await?.to_string(), diff --git a/src/message.rs b/src/message.rs index 4c5deda70..c89b549f0 100644 --- a/src/message.rs +++ b/src/message.rs @@ -15,7 +15,9 @@ use crate::blob::BlobObject; use crate::chat::{Chat, ChatId, ChatIdBlocked, ChatVisibility, send_msg}; use crate::chatlist_events; use crate::config::Config; -use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL}; +use crate::constants::{ + Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL, VideochatType, +}; use crate::contact::{self, Contact, ContactId}; use crate::context::Context; use crate::debug_logging::set_debug_logging_xdc; @@ -1015,6 +1017,85 @@ impl Message { None } + // add room to a webrtc_instance as defined by the corresponding config-value; + // the result may still be prefixed by the type + pub(crate) fn create_webrtc_instance(instance: &str, room: &str) -> String { + let (videochat_type, mut url) = Message::parse_webrtc_instance(instance); + + // make sure, there is a scheme in the url + if !url.contains(':') { + url = format!("https://{url}"); + } + + // add/replace room + let url = if url.contains("$ROOM") { + url.replace("$ROOM", room) + } else if url.contains("$NOROOM") { + // there are some usecases where a separate room is not needed to use a service + // eg. if you let in people manually anyway, see discussion at + // . + // hacks as hiding the room behind `#` are not reliable, therefore, + // these services are supported by adding the string `$NOROOM` to the url. + url.replace("$NOROOM", "") + } else { + // if there nothing that would separate the room, add a slash as a separator; + // this way, urls can be given as "https://meet.jit.si" as well as "https://meet.jit.si/" + let maybe_slash = if url.ends_with('/') + || url.ends_with('?') + || url.ends_with('#') + || url.ends_with('=') + { + "" + } else { + "/" + }; + format!("{url}{maybe_slash}{room}") + }; + + // re-add and normalize type + match videochat_type { + VideochatType::BasicWebrtc => format!("basicwebrtc:{url}"), + VideochatType::Jitsi => format!("jitsi:{url}"), + VideochatType::Unknown => url, + } + } + + /// split a webrtc_instance as defined by the corresponding config-value into a type and a url + pub fn parse_webrtc_instance(instance: &str) -> (VideochatType, String) { + let instance: String = instance.split_whitespace().collect(); + let mut split = instance.splitn(2, ':'); + let type_str = split.next().unwrap_or_default().to_lowercase(); + let url = split.next(); + match type_str.as_str() { + "basicwebrtc" => ( + VideochatType::BasicWebrtc, + url.unwrap_or_default().to_string(), + ), + "jitsi" => (VideochatType::Jitsi, url.unwrap_or_default().to_string()), + _ => (VideochatType::Unknown, instance.to_string()), + } + } + + /// Returns videochat URL if the message is a videochat invitation. + pub fn get_videochat_url(&self) -> Option { + if self.viewtype == Viewtype::VideochatInvitation { + if let Some(instance) = self.param.get(Param::WebrtcRoom) { + return Some(Message::parse_webrtc_instance(instance).1); + } + } + None + } + + /// Returns videochat type if the message is a videochat invitation. + pub fn get_videochat_type(&self) -> Option { + if self.viewtype == Viewtype::VideochatInvitation { + if let Some(instance) = self.param.get(Param::WebrtcRoom) { + return Some(Message::parse_webrtc_instance(instance).0); + } + } + None + } + /// Sets or unsets message text. pub fn set_text(&mut self, text: String) { self.text = text; @@ -2196,6 +2277,9 @@ pub enum Viewtype { /// and retrieved via dc_msg_get_file(). File = 60, + /// Message is an invitation to a videochat. + VideochatInvitation = 70, + /// Message is an incoming or outgoing call. Call = 71, @@ -2221,6 +2305,7 @@ impl Viewtype { Viewtype::Voice => true, Viewtype::Video => true, Viewtype::File => true, + Viewtype::VideochatInvitation => false, Viewtype::Call => false, Viewtype::Webxdc => true, Viewtype::Vcard => true, diff --git a/src/message/message_tests.rs b/src/message/message_tests.rs index 267c7acbb..f020c42a3 100644 --- a/src/message/message_tests.rs +++ b/src/message/message_tests.rs @@ -25,6 +25,82 @@ fn test_guess_msgtype_from_suffix() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_parse_webrtc_instance() { + let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar"); + assert_eq!(webrtc_type, VideochatType::BasicWebrtc); + assert_eq!(url, "https://foo/bar"); + + let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url"); + assert_eq!(webrtc_type, VideochatType::BasicWebrtc); + assert_eq!(url, "url"); + + let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val"); + assert_eq!(webrtc_type, VideochatType::Unknown); + assert_eq!(url, "https://foo/bar?key=val#key=val"); + + let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo"); + assert_eq!(webrtc_type, VideochatType::Jitsi); + assert_eq!(url, "https://j.si/foo"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_webrtc_instance() { + // webrtc_instance may come from an input field of the ui, be pretty tolerant on input + let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123"); + assert_eq!(instance, "https://meet.jit.si/123"); + + let instance = Message::create_webrtc_instance("https://meet.jit.si", "456"); + assert_eq!(instance, "https://meet.jit.si/456"); + + let instance = Message::create_webrtc_instance("meet.jit.si", "789"); + assert_eq!(instance, "https://meet.jit.si/789"); + + let instance = Message::create_webrtc_instance("bla.foo?", "123"); + assert_eq!(instance, "https://bla.foo?123"); + + let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456"); + assert_eq!(instance, "jitsi:https://bla.foo#456"); + + let instance = Message::create_webrtc_instance("bla.foo#room=", "789"); + assert_eq!(instance, "https://bla.foo#room=789"); + + let instance = Message::create_webrtc_instance("https://bla.foo#room", "123"); + assert_eq!(instance, "https://bla.foo#room/123"); + + let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123"); + assert_eq!(instance, "https://bla.foo#room123"); + + let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234"); + assert_eq!(instance, "https://bla.foo#room=234&after=cont"); + + let instance = Message::create_webrtc_instance(" meet.jit .si ", "789"); + assert_eq!(instance, "https://meet.jit.si/789"); + + let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab"); + assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_webrtc_instance_noroom() { + // webrtc_instance may come from an input field of the ui, be pretty tolerant on input + let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123"); + assert_eq!(instance, "https://bla.foo"); + + let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456"); + assert_eq!(instance, "https://bla.foo"); + + let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789"); + assert_eq!(instance, "https://bla.foo"); + + let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123"); + assert_eq!(instance, "https://bla.foo/?a=b"); + + // $ROOM has a higher precedence + let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123"); + assert_eq!(instance, "https://bla.foo/?$NOROOM=123"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_width_height() { let t = TestContext::new_alice().await; @@ -572,6 +648,10 @@ fn test_viewtype_values() { assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap()); assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap()); assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap()); + assert_eq!( + Viewtype::VideochatInvitation, + Viewtype::from_i32(70).unwrap() + ); assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap()); assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap()); } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 59da34031..4cee76a49 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1565,6 +1565,11 @@ impl MimeFactory { "Chat-Content", mail_builder::headers::raw::Raw::new("sticker").into(), )); + } else if msg.viewtype == Viewtype::VideochatInvitation { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("videochat-invitation").into(), + )); } else if msg.viewtype == Viewtype::Call { headers.push(( "Chat-Content", diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 3640bab25..924280eb3 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -732,10 +732,12 @@ impl MimeMessage { .map(|s| s.to_string()); if let Some(part) = self.parts.first_mut() { if let Some(room) = room { - if content == "call" { - part.typ = Viewtype::Call; - part.param.set(Param::WebrtcRoom, room); + if content == "videochat-invitation" { + part.typ = Viewtype::VideochatInvitation; + } else if content == "call" { + part.typ = Viewtype::Call } + part.param.set(Param::WebrtcRoom, room); } else if let Some(accepted) = accepted { part.param.set(Param::WebrtcAccepted, accepted); } @@ -763,7 +765,10 @@ impl MimeMessage { | Viewtype::Vcard | Viewtype::File | Viewtype::Webxdc => true, - Viewtype::Unknown | Viewtype::Text | Viewtype::Call => false, + Viewtype::Unknown + | Viewtype::Text + | Viewtype::VideochatInvitation + | Viewtype::Call => false, }) { let mut parts = std::mem::take(&mut self.parts); diff --git a/src/mimeparser/mimeparser_tests.rs b/src/mimeparser/mimeparser_tests.rs index 09800f53e..d2864d7e9 100644 --- a/src/mimeparser/mimeparser_tests.rs +++ b/src/mimeparser/mimeparser_tests.rs @@ -476,10 +476,6 @@ async fn test_mimeparser_with_avatars() { assert!(mimeparser.group_avatar.unwrap().is_change()); } -/// Tests that video chat invitations that are not supported anymore -/// are displayed as text messages. -/// -/// User can still click on the link manually. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_mimeparser_with_videochat() { let t = TestContext::new_alice().await; @@ -487,8 +483,14 @@ async fn test_mimeparser_with_videochat() { let raw = include_bytes!("../../test-data/message/videochat_invitation.eml"); let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap(); assert_eq!(mimeparser.parts.len(), 1); - assert_eq!(mimeparser.parts[0].typ, Viewtype::Text); - assert_eq!(mimeparser.parts[0].param.get(Param::WebrtcRoom), None); + assert_eq!(mimeparser.parts[0].typ, Viewtype::VideochatInvitation); + assert_eq!( + mimeparser.parts[0] + .param + .get(Param::WebrtcRoom) + .unwrap_or_default(), + "https://example.org/p2p/?roomname=6HiduoAn4xN" + ); assert!( mimeparser.parts[0] .msg diff --git a/src/qr.rs b/src/qr.rs index dad90a4de..553a2a924 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -16,6 +16,7 @@ use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::events::EventType; use crate::key::Fingerprint; +use crate::message::Message; use crate::net::http::post_empty; use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig}; use crate::token; @@ -26,6 +27,7 @@ const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#"; const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#"; const DCACCOUNT_SCHEME: &str = "DCACCOUNT:"; pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:"; +const DCWEBRTC_SCHEME: &str = "DCWEBRTC:"; const TG_SOCKS_SCHEME: &str = "https://t.me/socks"; const MAILTO_SCHEME: &str = "mailto:"; const MATMSG_SCHEME: &str = "MATMSG:"; @@ -120,6 +122,15 @@ pub enum Qr { /// The QR code is a backup, but it is too new. The user has to update its Delta Chat. BackupTooNew {}, + /// Ask the user if they want to use the given service for video chats. + WebrtcInstance { + /// Server domain name. + domain: String, + + /// URL pattern for video chat rooms. + instance_pattern: String, + }, + /// Ask the user if they want to use the given proxy. /// /// Note that HTTP(S) URLs without a path @@ -283,6 +294,8 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result { decode_account(qr)? } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) { dclogin_scheme::decode_login(qr)? + } else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) { + decode_webrtc_instance(context, qr)? } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) { decode_tg_socks_proxy(context, qr)? } else if qr.starts_with(SHADOWSOCKS_SCHEME) { @@ -560,6 +573,28 @@ fn decode_account(qr: &str) -> Result { } } +/// scheme: `DCWEBRTC:https://meet.jit.si/$ROOM` +fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result { + let payload = qr + .get(DCWEBRTC_SCHEME.len()..) + .context("Invalid DCWEBRTC payload")?; + + let (_type, url) = Message::parse_webrtc_instance(payload); + let url = url::Url::parse(&url).context("Invalid WebRTC instance")?; + + if url.scheme() == "http" || url.scheme() == "https" { + Ok(Qr::WebrtcInstance { + domain: url + .host_str() + .context("can't extract WebRTC instance domain")? + .to_string(), + instance_pattern: payload.to_string(), + }) + } else { + bail!("Bad URL scheme for WebRTC instance: {:?}", url.scheme()); + } +} + /// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123` fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result { let url = url::Url::parse(qr).context("Invalid t.me/socks url")?; @@ -697,6 +732,14 @@ pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<( pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { match check_qr(context, qr).await? { Qr::Account { .. } => set_account_from_qr(context, qr).await?, + Qr::WebrtcInstance { + domain: _, + instance_pattern, + } => { + context + .set_config_internal(Config::WebrtcInstance, Some(&instance_pattern)) + .await?; + } Qr::Proxy { url, .. } => { let old_proxy_url_value = context .get_config(Config::ProxyUrl) diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs index e0358d31e..307cbbf5d 100644 --- a/src/qr/qr_tests.rs +++ b/src/qr/qr_tests.rs @@ -712,6 +712,32 @@ async fn test_decode_account() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_webrtc_instance() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?; + assert_eq!( + qr, + Qr::WebrtcInstance { + domain: "basicurl.com".to_string(), + instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string() + } + ); + + // Test it again with mixcased "dcWebRTC:" uri scheme + let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?; + assert_eq!( + qr, + Qr::WebrtcInstance { + domain: "example.org".to_string(), + instance_pattern: "https://example.org/".to_string() + } + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_tg_socks_proxy() -> Result<()> { let t = TestContext::new().await; @@ -794,6 +820,34 @@ async fn test_decode_account_bad_scheme() { assert!(res.is_err()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_webrtc_instance_config_from_qr() -> Result<()> { + let ctx = TestContext::new().await; + + assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); + + let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await; + assert!(res.is_err()); + assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); + + let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await; + assert!(res.is_ok()); + assert_eq!( + ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), + "https://example.org/" + ); + + let res = + set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await; + assert!(res.is_ok()); + assert_eq!( + ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), + "basicwebrtc:https://foo.bar/?$ROOM&test" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_set_proxy_config_from_qr() -> Result<()> { let t = TestContext::new().await; diff --git a/src/stock_str.rs b/src/stock_str.rs index 45548c264..1ec89e373 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -130,6 +130,12 @@ pub enum StockMessage { #[strum(props(fallback = "Failed to send message to %1$s."))] FailedSendingTo = 74, + #[strum(props(fallback = "Video chat invitation"))] + VideochatInvitation = 82, + + #[strum(props(fallback = "You are invited to a video chat, click %1$s to join."))] + VideochatInviteMsgBody = 83, + #[strum(props(fallback = "Error:\n\nā€œ%1$sā€"))] ConfigurationFailed = 84, @@ -1053,6 +1059,18 @@ pub(crate) async fn msg_ephemeral_timer_year(context: &Context, by_contact: Cont } } +/// Stock string: `Video chat invitation`. +pub(crate) async fn videochat_invitation(context: &Context) -> String { + translated(context, StockMessage::VideochatInvitation).await +} + +/// Stock string: `You are invited to a video chat, click %1$s to join.`. +pub(crate) async fn videochat_invite_msg_body(context: &Context, url: &str) -> String { + translated(context, StockMessage::VideochatInviteMsgBody) + .await + .replace1(url) +} + /// Stock string: `Error:\n\nā€œ%1$sā€`. pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String { translated(context, StockMessage::ConfigurationFailed) diff --git a/src/summary.rs b/src/summary.rs index 90ddd1e35..b7ae5cb4c 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -211,6 +211,12 @@ impl Message { type_file = self.get_filename(); append_text = true } + Viewtype::VideochatInvitation => { + emoji = None; + type_name = Some(stock_str::videochat_invitation(context).await); + type_file = None; + append_text = false; + } Viewtype::Webxdc => { emoji = None; type_name = None; @@ -422,6 +428,13 @@ mod tests { .unwrap(); assert_summary_texts(&msg, ctx, "šŸ“Ž foo.bar \u{2013} bla bla").await; // file name is added for files + let file = write_file_to_blobdir(&d).await; + let mut msg = Message::new(Viewtype::VideochatInvitation); + msg.set_text(some_text.clone()); + msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None) + .unwrap(); + assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations + let mut msg = Message::new(Viewtype::Vcard); msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap(); chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap(); diff --git a/src/test_utils.rs b/src/test_utils.rs index ca2d931ac..0c6c590db 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -35,7 +35,7 @@ use crate::context::Context; use crate::events::{Event, EventEmitter, EventType, Events}; use crate::key::{self, DcKey, DcSecretKey, self_fingerprint}; use crate::log::warn; -use crate::message::{Message, MessageState, MsgId, update_msg_state}; +use crate::message::{Message, MessageState, MsgId, Viewtype, update_msg_state}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::pgp::KeyPair; use crate::receive_imf::receive_imf; @@ -1535,7 +1535,7 @@ async fn write_msg(context: &Context, prefix: &str, msg: &Message, buf: &mut Str let msgtext = msg.get_text(); writeln!( buf, - "{}{}{}{}: {} (Contact#{}): {} {}{}{}{}", + "{}{}{}{}: {} (Contact#{}): {} {}{}{}{}{}", prefix, msg.get_id(), if msg.get_showpadlock() { "šŸ”’" } else { "" }, @@ -1563,6 +1563,15 @@ async fn write_msg(context: &Context, prefix: &str, msg: &Message, buf: &mut Str } else { "" }, + if msg.get_viewtype() == Viewtype::VideochatInvitation { + format!( + "[VIDEOCHAT-INVITATION: {}, type={}]", + msg.get_videochat_url().unwrap_or_default(), + msg.get_videochat_type().unwrap_or_default() + ) + } else { + "".to_string() + }, if msg.is_forwarded() { "[FORWARDED]" } else {