feat: allow to prepare WebXDC in draft in JSON-RPC

To be precise, when sending a message,
reuse the existing draft message's ID.

Closes https://github.com/chatmail/core/issues/8485.
Closes https://github.com/chatmail/core/issues/4643.
Supersedes https://github.com/chatmail/core/pull/6426.

This feature already works in CFFI, however CFFI API requires
a fully formed `Message` object, with all its fields set,
and this is probably not something that public API should offer
or require.

I decided not to go for exposing the draft ID in the JSON-RPC API,
and go for `bool reuse_existing_draft` instead.
Exposing and requiring the ID would make the API more complex,
yet I can't come up with an example of how this bool API
could be problematic, at least in the case
where there aren't multiple concurrent users of the same
account's database.
Perhaps the ID system would make sense
if it was possible to have multiple drafts per chat,
and treat those like "almost sent messages" that you can edit
before finally sending (or deleting) them,
but it's not what we have now.

This adds the check for whether the .xdc file
gets replaced or removed and creates a new draft message if so,
which makes sure that old WebXDC status updates
don't apply to the new app, as was suggested in
https://github.com/chatmail/core/pull/6426#issuecomment-2585657331.
So the API user doesn't have to be careful not to ask Core
to reuse the draft when that would be incorrect.
This commit is contained in:
WofWca
2026-08-04 19:10:35 +04:00
parent 40796156d0
commit 1bbcacb89c
3 changed files with 103 additions and 17 deletions
+10 -4
View File
@@ -2457,14 +2457,20 @@ impl CommandApi {
async fn send_msg(&self, account_id: u32, chat_id: u32, data: MessageData) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let reuse_existing_draft = data.reuse_existing_draft;
let mut message = data
.create_message(&ctx)
.await
.context("Failed to create message")?;
let msg_id = chat::send_msg(&ctx, ChatId::new(chat_id), &mut message)
.await
.context("Failed to send created message")?
.to_u32();
let msg_id = chat::send_msg_ex(
&ctx,
ChatId::new(chat_id),
&mut message,
reuse_existing_draft.into(),
)
.await
.context("Failed to send created message")?
.to_u32();
Ok(msg_id)
}
@@ -616,6 +616,27 @@ pub struct MessageData {
/// Quoted message id. Takes preference over `quoted_text` (see below).
pub quoted_message_id: Option<u32>,
pub quoted_text: Option<String>,
/// Useful for WebXDC app attachments, which can also be opened
/// for draft messages.
/// Setting this to `true` will ensure that the WebXDC status updates
/// of the current draft are preserved when sending the message
/// or updating the draft.
///
/// `false` by default, for backwards compatibility.
/// However, you probably want to set it to `true`
/// when sending or updating the draft
/// from the main message composer section,
/// and to `false` when sending a message from secondary places,
/// such as a notification "Reply" input.
///
/// Reusing the draft will also automatically remove the draft
/// when it's sent.
///
/// Note that sometimes the draft cannot be reused,
/// for example when the WebXDC attachment [`Self::file`] changes.
/// If the draft cannot be reused, it will not get auto-removed.
#[serde(default)]
pub reuse_existing_draft: bool,
}
impl MessageData {
+72 -13
View File
@@ -769,6 +769,30 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
Ok(count > 0)
}
fn can_reuse_draft(context: &Context, old: &Message, new: &Message) -> Result<bool> {
ensure!(
old.chat_id.is_unset() || new.chat_id.is_unset() || new.chat_id == old.chat_id,
"messages belong to different chats"
);
ensure!(
old.get_state() == MessageState::Undefined || old.get_state() == MessageState::OutDraft,
"old message is a real message, not a draft"
);
// Reusing the draft is only useful for WebXDC messages,
// but for consistency let's reuse it whenever possible.
if old.get_viewtype() == Viewtype::Webxdc
&& (new.get_viewtype() != Viewtype::Webxdc
|| old.get_file(context) != new.get_file(context))
{
// Old draft's WebXDC attachment got removed or replaced.
return Ok(false);
}
Ok(true)
}
/// Returns draft message, if there is one.
pub async fn get_draft(self, context: &Context) -> Result<Option<Message>> {
let query_only = true;
@@ -818,6 +842,9 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// thus preserving the ID and possible WebXDC status updates
/// associated with the draft message.
///
/// If `msg.id` is specified, the [`Self::can_reuse_draft`] check
/// will be skipped.
///
/// Returns `false` if the existing draft is already at the state
/// that the caller tried to set it to, so it was unchanged.
async fn do_set_draft(self, context: &Context, msg: &mut Message) -> Result<bool> {
@@ -856,7 +883,20 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
let trans_fn = |transaction: &mut rusqlite::Transaction| {
// if possible, replace existing draft and keep id
if !msg.id.is_special() && self.has_draft_with_id(transaction, &msg.id)? {
let reuse_existing_id: Option<MsgId> = if !msg.id.is_special() {
if self.has_draft_with_id(transaction, &msg.id)? {
Some(msg.id)
} else {
None
}
} else if let Some(existing) = self.get_draft_trans(context, transaction)?
&& Self::can_reuse_draft(context, &existing, msg)?
{
Some(existing.id)
} else {
None
};
if let Some(reuse_existing_id) = reuse_existing_id {
let affected_rows = transaction.execute(
"UPDATE msgs
SET timestamp=?1,type=?2,txt=?3,txt_normalized=?4,param=?5,mime_in_reply_to=?6
@@ -873,11 +913,11 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
normalize_text(&msg.text),
msg.param.to_string(),
msg.in_reply_to.as_deref().unwrap_or_default(),
msg.id,
reuse_existing_id,
),
)?;
let changed = affected_rows > 0;
return Ok((msg.id, changed));
return Ok((reuse_existing_id, changed));
}
// Delete existing draft if it exists.
@@ -1789,8 +1829,12 @@ impl Chat {
/// writes the record to the database.
///
/// If `update_existing_draft == `[`UseExistingDraftPolicy::Reuse`],
/// the existing draft with ID == msg.id is reused.
/// If no such draft exists, an error is returned.
/// we will reuse the draft by the specified `msg.id`,
/// or if it can be reused according to [`ChatId::can_reuse_draft`].
/// If `msg.id` is specified, the [`ChatId::can_reuse_draft`] check
/// will be skipped.
/// If ID was specified but no such draft exists, an error is returned.
///
/// If `update_existing_draft == `[`UseExistingDraftPolicy::DontReuse`],
/// a new record is created.
async fn prepare_msg_raw(
@@ -1967,11 +2011,22 @@ impl Chat {
// add message to the database
let trans_fn = |transaction: &mut rusqlite::Transaction| {
if update_existing_draft == UseExistingDraftPolicy::Reuse {
// This check also covers the `msg.id.is_special()` case.
let try_reuse: bool = update_existing_draft == UseExistingDraftPolicy::Reuse;
let reuse_existing_id: Option<MsgId> = if !try_reuse {
None
} else if !msg.id.is_special() {
Some(msg.id)
} else if let Some(existing) = self.id.get_draft_trans(context, transaction)?
&& ChatId::can_reuse_draft(context, &existing, msg)?
{
Some(existing.id)
} else {
None
};
if let Some(reuse_existing_id) = reuse_existing_id {
// Maybe we could try to somehow gracefully recover from this,
// but better safe than sorry.
if !self.id.has_draft_with_id(transaction, &msg.id)? {
if !self.id.has_draft_with_id(transaction, &reuse_existing_id)? {
bail!(
concat!(
"wanted to prepare existing draft for sending in chat {0}, ",
@@ -1979,7 +2034,7 @@ impl Chat {
"(it might have been sent or deleted)"
),
self.id,
msg.id
reuse_existing_id
);
}
@@ -2011,11 +2066,11 @@ impl Chat {
location_id as i32,
ephemeral_timer,
ephemeral_timestamp,
msg.id
reuse_existing_id
],
)?;
let inserted = false;
Ok((msg.id, inserted))
Ok((reuse_existing_id, inserted))
} else {
transaction.execute(
"INSERT INTO msgs (
@@ -2678,7 +2733,11 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) ->
send_msg_ex(context, chat_id, msg, update_existing_draft.into()).await
}
/// See [`send_msg`] and [`send_msg_sync`].
/// Unlike [`send_msg`] (and [`send_msg_sync`]),
/// this function allows for reusing the draft even if `msg`
/// is not a fully initialized [`Message`],
/// i.e. if [`Message::get_id`], [`Message::get_viewtype`],
/// [`Message::rfc724_mid`] etc. are unset.
pub async fn send_msg_ex(
context: &Context,
chat_id: ChatId,
@@ -2830,7 +2889,7 @@ async fn prepare_send_msg(
///
/// This is basically a more explicit bool.
#[derive(Debug, PartialEq)]
enum UseExistingDraftPolicy {
pub enum UseExistingDraftPolicy {
/// Create a brand new draft or message, don't reuse the existing one's ID.
DontReuse,
/// Resuse the existing draft, so that the new draft or message