Four drafts that other clients already ship

The line this draws: a draft nobody has built can still change underneath an
implementation, and a draft WeeChat, Halloy and bIRC interoperate on is a
protocol whatever the registry calls it. Four of the twelve qualify. Each
degrades to exactly the old behaviour where a network does not offer it.

**A message longer than a line** now goes as one message in several pieces
rather than being truncated at a limit this client cannot see - and where the
network has no multiline, the pieces go as separate messages, which is still
better than the server cutting the end off.

**Taking a message back**, which IRC never had and every other service here
always did. The message is not removed when the delete is sent: the server
decides whether it was allowed and says so by sending REDACT back, and that is
what removes it. Showing a deletion that may not happen is worse than a
moment's delay.

**Where a conversation was read to**, sent when a buffer is marked read and
received from whatever other client of the account last looked at it. Passed
straight to the frontend rather than stored - which messages count as read is
a question the thing with the screen owns, and a daemon keeping a second
answer would be keeping one that disagrees.

**A channel that renamed itself** is followed rather than appearing as a
second channel with the first one's history stranded in it. A buffer's
identity is its name, so it is a move: the old one goes, the new one arrives
carrying what was said.

Verified against testnet.ergo.chat, which offers forty capabilities and
granted twenty-one of them in one line. Three things only a real server was
ever going to catch:

- `BATCH` is a parsed variant in the irc crate, not a raw command, so the arm
  written for it never fired at all.
- The batch *type* comes back uppercased, so comparing it exactly meant the
  batch was never opened and every piece fell through as its own message.
- The split broke *at* a space and trimmed both sides, and the pieces are
  rejoined with nothing between them - so a 659-byte message arrived as 658
  with two words run together at the seam. The invariant that the pieces
  rejoin to exactly the message is now a test.

A 659-byte send now leaves as a batch and comes back byte-identical as one
message; a redaction goes out, is echoed, and takes the line off screen.

Closes #158, #159, #160, #161.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 16:29:45 -04:00
co-authored by Claude Opus 5
parent de0ad864dc
commit d271fb7903
8 changed files with 568 additions and 0 deletions
+17
View File
@@ -506,6 +506,23 @@ pub(super) const WANTED_CAPS: &[&str] = &[
// - who they are identified as and whether they are away, rather than a
// bare "they are online".
"extended-monitor",
// Four drafts, requested because WeeChat, Halloy and bIRC already ship
// them: a draft nobody has built can still change underneath an
// implementation, and a draft three clients interoperate on is a protocol
// whatever the registry calls it. Each degrades to exactly the old
// behaviour where it is not offered. See `drafts.rs`.
//
// A message longer than a line, sent as one message rather than cut.
"draft/multiline",
// Taking a message back, which IRC has never had and every other service
// here has always had.
"draft/message-redaction",
// Where this conversation was read up to, kept by the server so a second
// client starts where the first left off.
"draft/read-marker",
// A channel changing its name without becoming a second channel with the
// first one's history stranded in it.
"draft/channel-rename",
];
/// The capabilities named by a CAP ACK, from whichever field they arrived in.
+323
View File
@@ -0,0 +1,323 @@
//! The IRCv3 extensions that are still drafts, and that clients ship anyway.
//!
//! Four of the twelve drafts are implemented in WeeChat, Halloy and bIRC
//! today, which is the line this backend draws: a draft nobody has built is a
//! specification that can still change underneath an implementation, and a
//! draft three clients interoperate on is a de facto protocol.
//!
//! - `multiline` - one message that is longer than a line
//! - `message-redaction` - taking a message back, which IRC never had
//! - `read-marker` - where you had read up to, kept by the server
//! - `channel-rename` - a channel changing its name without becoming a new one
//!
//! They are together in one file because they are the same shape of work -
//! a capability, something to send, and a line to read - and because they
//! share the one property worth stating in a single place: every one of them
//! degrades to exactly the old behaviour on a network that does not offer it.
use super::*;
/// How long a single IRC line may be, in bytes, including the trailing CRLF.
///
/// The 512 of RFC 1459, minus room for the parts the server prepends that
/// this client cannot see: our own `nick!user@host` and the command and
/// target in front of the text. 400 is what other clients settle on for the
/// same reason - it is a conservative guess at a number the server knows and
/// we do not.
pub(super) const SAFE_LINE: usize = 400;
/// Splits a message into pieces that will each fit on the wire.
///
/// Split on character boundaries, never inside one: a UTF-8 sequence cut in
/// half arrives as replacement characters at both ends of the join, which is
/// worse than the truncation this exists to avoid.
///
/// Prefers to break at a space near the limit, because a line broken
/// mid-word reads as a fault even when it is reassembled correctly at the
/// other end - and on a client without the capability, mid-word is exactly
/// what everybody sees.
pub(super) fn split_for_wire(body: &str, limit: usize) -> Vec<String> {
if body.len() <= limit {
return vec![body.to_string()];
}
let mut pieces = Vec::new();
let mut rest = body;
while rest.len() > limit {
// The last byte index at or below the limit that starts a character.
let mut cut = limit;
while cut > 0 && !rest.is_char_boundary(cut) {
cut -= 1;
}
// Back up to a space if there is one reasonably close, so words stay
// whole. "Reasonably" is a quarter of the line: further back than
// that and the pieces get lopsided enough to look like a bug of
// their own.
if let Some(space) = rest[..cut].rfind(' ') {
if space > cut.saturating_sub(limit / 4) {
// After the space, not at it. The pieces are rejoined with
// nothing between them, so a separator dropped here is a
// separator gone: a 659-byte message came back as 658 with
// two words run together at the seam.
cut = space + 1;
}
}
if cut == 0 {
// One character longer than the limit, which cannot be split any
// further. Send it whole and let the server truncate; there is
// no better answer.
break;
}
pieces.push(rest[..cut].to_string());
rest = &rest[cut..];
}
if !rest.is_empty() {
pieces.push(rest.to_string());
}
pieces
}
/// Sends a message as a multiline batch.
///
/// The batch is opened, each piece goes out as an ordinary PRIVMSG carrying
/// the batch tag, and the batch is closed. A client that understands the
/// batch sees one message; one that does not sees the pieces, which is the
/// same thing every client sees today and therefore no worse.
///
/// The pieces carry `draft/multiline-concat` so a receiver joins them without
/// inserting the newline that would otherwise separate them - this splits a
/// long paragraph, not a poem.
pub(super) fn send_multiline(sender: &Sender, target: &str, pieces: &[String]) -> Result<()> {
// A reference this client picked, unique for as long as the batch is
// open. The clock is enough: batches do not overlap here, because a send
// completes before the next one starts.
let reference = format!(
"ml{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() % 100_000
);
sender.send(Command::BATCH(
format!("+{reference}"),
Some(irc::proto::BatchSubCommand::CUSTOM("draft/multiline".to_string())),
Some(vec![target.to_string()]),
))?;
for piece in pieces {
let mut line = Message::new(None, "PRIVMSG", vec![target, piece])?;
line.tags = Some(vec![
irc::proto::message::Tag("batch".to_string(), Some(reference.clone())),
irc::proto::message::Tag("draft/multiline-concat".to_string(), None),
]);
sender.send(line)?;
}
sender.send(Command::BATCH(format!("-{reference}"), None, None))?;
Ok(())
}
/// A multiline message being assembled as its pieces arrive.
///
/// The pieces are ordinary PRIVMSGs carrying a `batch` tag, so without this
/// they are ordinary messages: a paragraph somebody sent as one thing arrives
/// as four lines, and our own echo of it arrives the same way - which is how
/// this was found, a 659-byte send coming back as its first 395 bytes.
pub(super) struct Assembling {
pub target: String,
/// Each piece, and whether it joins the previous one without a newline.
pub pieces: Vec<(String, bool)>,
}
impl Assembling {
/// The message the pieces make.
///
/// `draft/multiline-concat` on a piece means it continues the one before
/// rather than starting a line of its own - the difference between a long
/// paragraph that had to be split and a message somebody wrote with line
/// breaks in it.
pub(super) fn finish(&self) -> String {
let mut body = String::new();
for (index, (piece, concat)) in self.pieces.iter().enumerate() {
if index > 0 && !concat {
body.push('\n');
}
body.push_str(piece);
}
body
}
}
/// Batches being assembled, keyed by account and by the reference the server
/// gave them.
///
/// Per process rather than per connection, for the same reason `pending_sends`
/// is: the dispatch that sees the pieces has no place to keep state of its own,
/// and a reference is unique for as long as the batch is open.
pub(super) fn open_batches() -> &'static std::sync::Mutex<HashMap<(String, String), Assembling>> {
static OPEN: std::sync::OnceLock<std::sync::Mutex<HashMap<(String, String), Assembling>>> = std::sync::OnceLock::new();
OPEN.get_or_init(Default::default)
}
/// Reads a BATCH line: which reference, whether it is opening or closing, and
/// what kind it is.
///
/// `BATCH +<ref> <type> [params]` opens one and `BATCH -<ref>` closes it. Only
/// multiline batches are collected - a chathistory batch is a container for
/// messages that are each their own message, and folding those into one would
/// turn a replayed conversation into a wall of text.
pub(super) fn read_batch_tag(tag: &str) -> Option<(bool, String)> {
match tag.split_at_checked(1)? {
("+", rest) => Some((true, rest.to_string())),
("-", rest) => Some((false, rest.to_string())),
_ => None,
}
}
/// Asks the server to take a message back.
///
/// `REDACT <target> <msgid> [:reason]`. Whether it is allowed is the server's
/// decision - your own message, or somebody else's if you have the rank - and
/// the answer comes back as a standard reply, which is already handled.
pub fn redact(sender: &Sender, target: &str, msg_id: &str, reason: Option<&str>) -> Result<()> {
let mut args = vec![target.to_string(), msg_id.to_string()];
if let Some(reason) = reason.filter(|r| !r.is_empty()) {
args.push(reason.to_string());
}
sender.send(Command::Raw("REDACT".to_string(), args))?;
Ok(())
}
/// Tells the server where this conversation has been read up to.
///
/// `MARKREAD <target> timestamp=<iso8601>`. The point is other clients: the
/// same account open on a phone starts where you left off here, which every
/// other protocol in this daemon has done for a while and IRC could not.
pub fn mark_read(sender: &Sender, target: &str, at: i64) -> Result<()> {
let stamp = chrono::DateTime::from_timestamp(at, 0)
.unwrap_or_else(chrono::Utc::now)
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
sender.send(Command::Raw("MARKREAD".to_string(), vec![target.to_string(), format!("timestamp={stamp}")]))?;
Ok(())
}
/// Reads the timestamp out of a MARKREAD line.
///
/// `MARKREAD <target> timestamp=<iso8601>` or `MARKREAD <target> *`, the
/// second meaning nothing has been read. The star is not an error and not a
/// time: it is answered with None and left alone, because "never read" is not
/// a position to scroll to.
pub(super) fn read_marker(args: &[String]) -> Option<(String, i64)> {
let target = args.first()?.clone();
let value = args.get(1)?;
let stamp = value.strip_prefix("timestamp=")?;
let at = chrono::DateTime::parse_from_rfc3339(stamp).ok()?.timestamp();
Some((target, at))
}
#[cfg(test)]
mod tests {
use super::*;
/// The limit is in bytes and the text is in characters, which is the
/// whole difficulty: a cut inside a multi-byte character produces
/// replacement characters at both ends of the join.
#[test]
fn a_split_never_cuts_a_character_in_half() {
// Every one of these is three bytes and one character.
let body = "日".repeat(300);
let pieces = split_for_wire(&body, 100);
assert!(pieces.len() > 1);
for piece in &pieces {
assert!(piece.len() <= 100, "piece was {} bytes", piece.len());
// If a character had been cut the string would not round-trip.
assert!(piece.chars().all(|c| c == '日'));
}
assert_eq!(pieces.concat().chars().count(), 300);
}
/// A line broken mid-word reads as a fault, and on a client without the
/// capability mid-word is what everybody sees - so a space near the limit
/// wins over the limit itself.
#[test]
fn a_split_prefers_to_break_between_words() {
let body = format!("{}tail", "word ".repeat(30));
let pieces = split_for_wire(&body, 60);
assert!(pieces.len() > 1);
for piece in &pieces {
// Whole words, with the separator kept on the end of the piece
// it was cut from rather than thrown away.
assert!(piece.trim_end().split(' ').all(|w| w == "word" || w == "tail"), "got {piece:?}");
}
}
/// The invariant that matters more than any of the preferences above: the
/// pieces put back together are the message. Found the hard way - the
/// split used to break *at* a space and trim both sides, so a 659-byte
/// message arrived as 658 with two words run together at the seam.
#[test]
fn the_pieces_are_exactly_the_message() {
for body in [
"word ".repeat(200),
"日".repeat(400),
format!("{}{}", "x".repeat(500), " and some words after it"),
"nospacesatallinthiswholeverylongmessage".repeat(20),
] {
let pieces = split_for_wire(&body, 100);
assert_eq!(pieces.concat(), body, "{} pieces did not rejoin", pieces.len());
}
}
/// Short enough to fit is one piece and no batch at all - the common
/// case, and the one that must not grow a wrapper it does not need.
#[test]
fn something_that_fits_is_left_alone() {
assert_eq!(split_for_wire("hello", 400), vec!["hello".to_string()]);
let exact = "x".repeat(400);
assert_eq!(split_for_wire(&exact, 400), vec![exact]);
}
/// The difference between a paragraph that had to be split and a message
/// somebody wrote with line breaks in it. Getting this wrong turns one
/// into the other, in both directions.
#[test]
fn concat_decides_whether_a_piece_starts_a_line() {
let split = Assembling {
target: "#chat".into(),
pieces: vec![("a long ".into(), false), ("paragraph".into(), true)],
};
assert_eq!(split.finish(), "a long paragraph");
let written = Assembling {
target: "#chat".into(),
pieces: vec![("first".into(), false), ("second".into(), false)],
};
assert_eq!(written.finish(), "first\nsecond");
// The first piece never gets a newline in front of it, whatever it
// says about itself.
let one = Assembling { target: "#chat".into(), pieces: vec![("only".into(), true)] };
assert_eq!(one.finish(), "only");
}
/// Which batch, and which way it goes.
#[test]
fn a_batch_tag_says_which_way_it_goes() {
assert_eq!(read_batch_tag("+ml42"), Some((true, "ml42".to_string())));
assert_eq!(read_batch_tag("-ml42"), Some((false, "ml42".to_string())));
// Anything without the sign is not a batch reference.
assert!(read_batch_tag("ml42").is_none());
assert!(read_batch_tag("").is_none());
}
/// A time, and the two things that are not one.
#[test]
fn a_read_marker_is_a_time_or_it_is_nothing() {
let args = ["#chat".to_string(), "timestamp=2026-09-10T12:00:00.000Z".to_string()];
let (target, at) = read_marker(&args).expect("a marker");
assert_eq!(target, "#chat");
assert!(at > 1_700_000_000);
// "Never read" is not a position to scroll to.
assert!(read_marker(&["#chat".to_string(), "*".to_string()]).is_none());
// And neither is a malformed one.
assert!(read_marker(&["#chat".to_string(), "timestamp=yesterday".to_string()]).is_none());
assert!(read_marker(&["#chat".to_string()]).is_none());
}
}
+106
View File
@@ -127,6 +127,22 @@ pub(super) async fn handle_message(
}
}
// A piece of a multiline message, rather than a message. Held until the
// batch closes and then recorded once - without this a paragraph somebody
// sent as one thing arrives as four lines, and our own echo of a long
// send comes back as its first piece only.
if let Some(reference) = message_tag(&msg, "batch").map(str::to_string) {
let key = (account_id.to_string(), reference);
let mut open = drafts::open_batches().lock().unwrap();
if let Some(batch) = open.get_mut(&key) {
if let Command::PRIVMSG(_, ref body) = msg.command {
let concat = message_tag(&msg, "draft/multiline-concat").is_some();
batch.pieces.push((body.clone(), concat));
return;
}
}
}
match msg.command {
Command::PRIVMSG(target, body) => {
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
@@ -617,6 +633,96 @@ pub(super) async fn handle_message(
state.runtime.grant_irc_caps(account_id, caps);
}
// The frame around a multiline message. Only multiline batches are
// collected: a chathistory batch contains messages that are each
// their own message, and folding those together would turn a replayed
// conversation into a wall of text.
// `Command::BATCH`, not `Raw`: the crate parses this one into a
// variant of its own, and a Raw arm for it silently never fires -
// which is exactly what happened, and why a 659-byte message came
// back as its two pieces after the reassembly was written.
Command::BATCH(ref tag, ref sub, ref params) => {
let kind = sub.as_ref().map(|s| s.to_str().to_string());
let param = params.as_ref().and_then(|p| p.first().cloned());
let Some((opening, reference)) = drafts::read_batch_tag(tag) else { return };
let key = (account_id.to_string(), reference);
if opening {
// Case-insensitively: the crate parses the type through an
// uppercasing enum, so what a server sent as
// `draft/multiline` arrives as `DRAFT/MULTILINE`. Comparing
// exactly meant the batch was never opened, every piece fell
// through as an ordinary message, and the reassembly below
// had nothing to reassemble.
if kind.as_deref().is_some_and(|k| k.eq_ignore_ascii_case("draft/multiline")) {
if let Some(target) = param {
drafts::open_batches()
.lock()
.unwrap()
.insert(key, drafts::Assembling { target, pieces: Vec::new() });
}
}
return;
}
let Some(batch) = drafts::open_batches().lock().unwrap().remove(&key) else { return };
if batch.pieces.is_empty() {
return;
}
let body = batch.finish();
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
let kind = buffer_kind_hint(&batch.target);
// Whose conversation it belongs in, by the same rule a single
// PRIVMSG follows: a channel is itself, and a direct message is
// filed under whoever is not us.
let buffer = if is_channel(&batch.target) || from.eq_ignore_ascii_case(&own) {
batch.target.clone()
} else {
from.clone()
};
state.runtime.record_message_at(state, account_id, &buffer, kind, &from, &body, false, "chat", None, msg_id, false, None, Vec::new(), Vec::new(), None, sent_at, None, None);
}
// A message somebody took back. IRC never had this; every other
// service here always did, which is why the entry existed in the menu
// and did nothing on this one.
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("REDACT") => {
let (Some(target), Some(msg_id)) = (args.first(), args.get(1)) else { return };
let buffer = if is_channel(target) { target.clone() } else { from.clone() };
state.runtime.delete_message(state, &crate::model::buffer_id(account_id, &buffer), msg_id);
}
// Where this conversation has been read up to, according to the
// server - which is to say, according to whatever other client of
// this account last looked at it.
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("MARKREAD") => {
if let Some((target, at)) = drafts::read_marker(args) {
state.runtime.note_irc_read_marker(state, account_id, &target, at);
}
}
// A channel that changed its name. Without this it looks like leaving
// one channel and joining another, and the conversation is split in
// two with the history stranded in a room nobody is in.
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("RENAME") => {
let (Some(old_name), Some(new_name)) = (args.first(), args.get(1)) else { return };
if let Some(members) = channels.remove(old_name.as_str()) {
channels.insert(new_name.clone(), members);
}
// The buffer's identity is its name, so this is a move rather
// than a rename: the old one goes and the new one arrives
// carrying what was said. Ordered that way round on purpose -
// creating first would briefly show the channel twice.
state.runtime.rename_irc_buffer(state, account_id, old_name, new_name);
let reason = args.get(2).map(String::as_str).filter(|r| !r.is_empty());
let line = match reason {
Some(why) => format!("{old_name} is now called {new_name} ({why})"),
None => format!("{old_name} is now called {new_name}"),
};
state.runtime.record_message(state, account_id, new_name, "channel", "*", &line, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
if let Some(members) = channels.get(new_name.as_str()) {
emit_presence(state, account_id, new_name, members);
}
}
// Somebody's realname changed without them reconnecting, which is
// the whole point of `setname`. Reported where they can be seen; a
// realname is not shown in a roster, so this is the only place the
+4
View File
@@ -15,6 +15,7 @@
//! - `who` - asking the server who is in a room, and their hostmasks
//! - `typing` - the one notification IRC learned late
//! - `history` - CHATHISTORY, where a network has it
//! - `drafts` - the IRCv3 drafts that clients ship anyway
//!
//! And three that are IRC's own and nothing else's: `dcc`, its direct file
//! transfer; `sasl`, how a client proves who it is during registration; and
@@ -24,6 +25,7 @@
//! say `backend::irc::send_message` without knowing which file that is, and
//! each file opens with `use super::*` to share the import list below.
pub mod connect;
pub mod drafts;
pub mod history;
pub mod incoming;
pub mod presence;
@@ -36,6 +38,8 @@ pub mod sasl;
pub use connect::*;
pub use history::*;
// Not `pub use`: these are asked and answered inside this folder.
use drafts::*;
// Not `pub use`: the dispatch is nobody else's business, but its helpers
// are wanted by the files beside it.
use incoming::*;
+24
View File
@@ -330,6 +330,30 @@ pub(super) fn set_channel_mode(sender: &Sender, channel: &str, mode: ChannelMode
}
pub(super) fn send_plain(state: &AppState, account_id: &str, sender: &Sender, target: &str, body: &str) -> Result<()> {
// Longer than a line. Without `draft/multiline` the server truncates at
// its own limit and the rest is simply gone, which is the one failure
// here that loses what somebody wrote - so where the network offers the
// capability the message goes as one message in several pieces, and where
// it does not the pieces go as separate messages rather than as a cut.
let pieces = drafts::split_for_wire(body, drafts::SAFE_LINE);
if pieces.len() > 1 {
let own_nick = state.runtime.irc_current_nick(account_id).unwrap_or_default();
if state.runtime.irc_has_cap(account_id, "draft/multiline") {
drafts::send_multiline(sender, target, &pieces)?;
// Recorded whole, because whole is what was sent - the pieces are
// a transport detail and putting them in the log as separate
// lines would be showing the reader the envelope.
if !state.runtime.irc_has_cap(account_id, "echo-message") {
state.runtime.record_message(state, account_id, target, buffer_kind_hint(target), &own_nick, body, false, "chat", None, None, false, None, Vec::new(), Vec::new(), None);
}
return Ok(());
}
for piece in &pieces {
send_plain(state, account_id, sender, target, piece)?;
}
return Ok(());
}
// Where the server echoes what we send, it is the echo that gets written:
// it carries the server's id and time, and it is proof the message was
// actually delivered rather than merely handed over. Without the
+40
View File
@@ -836,6 +836,27 @@ pub async fn dispatch(
}
(Some(ok_node()), None)
}
// IRC keeps a read position too, where the network offers
// `draft/read-marker` - so a channel read here is read on the
// phone, which is the whole point of the server holding it.
Some(buffer) if buffer.account_id.starts_with("irc:") || state.accounts.get_irc(&buffer.account_id).is_some() => {
if state.runtime.irc_has_cap(&buffer.account_id, "draft/read-marker") {
if let Some(sender) = state.runtime.irc_sender(&buffer.account_id) {
let target = buffer.name.clone();
let at = params
.get("ts")
.and_then(|v| v.as_i64())
.unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64);
// Best-effort, like the Matrix one above: failing
// to say a channel has been read must not make
// reading it look like an error.
if let Err(e) = backend::irc::drafts::mark_read(&sender, &target, at) {
tracing::debug!("irc read marker: {e:#}");
}
}
}
(Some(ok_node()), None)
}
_ => (Some(ok_node()), None),
}
}
@@ -2546,6 +2567,25 @@ pub async fn dispatch(
Err(e) => (None, Some(e.to_string())),
},
},
// IRC, where the network offers `draft/message-redaction`.
// Every other service here has always had this; IRC is where
// the menu entry existed and did nothing.
Some(buffer) if state.accounts.get_irc(&buffer.account_id).is_some() => {
if !state.runtime.irc_has_cap(&buffer.account_id, "draft/message-redaction") {
return (None, Some("this network has no way to take a message back".to_string()));
}
let Some(sender) = state.runtime.irc_sender(&buffer.account_id) else {
return (None, Some("account not connected".to_string()));
};
match backend::irc::drafts::redact(&sender, &buffer.name, msg_id, None) {
// Not removed here: the server decides whether this
// was allowed and says so by sending REDACT back,
// which is what removes it. Taking it off screen now
// would be showing a deletion that may not happen.
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
Some(_) => (None, Some("deleting isn't supported for this service".to_string())),
}
}
+34
View File
@@ -1167,6 +1167,40 @@ impl Runtime {
}
}
/// Follows a channel that changed its name, carrying its history with it.
///
/// The buffer's identity is its name, so this is a move rather than a
/// rename: the old buffer goes and a new one arrives holding what was
/// said. Done in that order deliberately - creating first would show the
/// channel twice for as long as it took to remove the old one.
pub fn rename_irc_buffer(&self, state: &AppState, account_id: &str, old_name: &str, new_name: &str) {
let old_id = crate::model::buffer_id(account_id, old_name);
let new_id = crate::model::buffer_id(account_id, new_name);
match state.store.move_buffer(&old_id, &new_id) {
Ok(moved) => tracing::debug!("irc[{account_id}]: {old_name} renamed to {new_name}, {moved} messages moved"),
Err(e) => tracing::warn!("irc[{account_id}]: moving {old_name} to {new_name}: {e:#}"),
}
self.remove_buffer(state, &old_id);
self.ensure_buffer(state, account_id, new_name, "channel");
self.refresh_buffer_activity(state, &new_id);
}
/// Where another client of this account says a conversation was read to.
///
/// Passed straight on rather than stored. Which messages count as read is
/// a question the frontend owns - it is the thing that knows what is on
/// screen - and a daemon that kept a second answer would be keeping one
/// that disagrees.
pub fn note_irc_read_marker(&self, state: &AppState, account_id: &str, target: &str, at: i64) {
state.events.emit(
"readMarker",
serde_json::json!({
"bufferId": crate::model::buffer_id(account_id, target),
"ts": at,
}),
);
}
pub fn remove_buffer(&self, state: &AppState, buffer_id: &str) {
self.buffers.lock().unwrap().remove(buffer_id);
// And what pointed at it. A Matrix room whose buffer went away but
+20
View File
@@ -385,6 +385,26 @@ impl Store {
/// with any messages already (from a prior backfill or from having
/// seen live traffic) is left alone rather than re-fetched, since
/// there's no per-message dedup against Discord's own message ids here.
/// Moves everything said in one buffer to another name.
///
/// A buffer's identity here is its name, so a channel that renames itself
/// is a different buffer as far as storage is concerned - and its history
/// would be stranded under a name nobody is in. Returns how many messages
/// moved, which is worth logging: a rename that silently moved nothing is
/// the shape of this going wrong.
pub fn move_buffer(&self, old_id: &str, new_id: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
// OR IGNORE rather than a plain UPDATE: the destination may already
// hold a message with the same id if the server sent anything under
// the new name before the rename was processed, and a primary-key
// collision would abort the whole move.
let moved = conn.execute(
"UPDATE OR IGNORE messages SET buffer_id = ?2 WHERE buffer_id = ?1",
params![old_id, new_id],
)?;
Ok(moved)
}
pub fn has_messages(&self, buffer_id: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let exists: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM messages WHERE buffer_id = ?1)", params![buffer_id], |row| row.get(0))?;