diff --git a/DESCRIPTION b/DESCRIPTION index 8df026c..05ae834 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.0.4 +Version: 0.2.0.5 Date: 2026-08-04 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 51536a7..7129d04 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,14 @@ +# mx.client 0.2.0.5 + +## New + +* `mx_send_text(thread =)` sends into a Matrix thread. Given a root + event id it attaches `m.relates_to` with `rel_type` `m.thread`, plus + the reply fallback (`is_falling_back` and an `m.in_reply_to` pointing + at the root) so thread-unaware clients render the message as a reply + instead of as loose chatter. `mx_extract_text_events()` has carried + the inbound `relates_to` since 0.2.0.4, so the round trip now closes. + # mx.client 0.2.0.4 ## New diff --git a/R/messages.R b/R/messages.R index acf4d30..b46e67a 100644 --- a/R/messages.R +++ b/R/messages.R @@ -16,6 +16,16 @@ #' \code{@localpart} in the body becomes a \code{matrix.to} pill in the #' HTML. Implies an HTML formatted body even when \code{markdown} is #' FALSE -- pills only render from HTML. +#' @param thread Event id of a thread root, or NULL for an ordinary +#' message. The event is sent as a threaded reply +#' (\code{m.relates_to} with \code{rel_type} \code{"m.thread"}). +#' +#' It also carries the reply fallback the spec asks for -- +#' \code{is_falling_back} with an \code{m.in_reply_to} pointing at the +#' root -- so a client that does not implement threads renders the +#' message as a reply to the root rather than as a loose message in +#' the room. Without it those clients show a threaded conversation +#' as unattached chatter. #' @return Event id, or NULL on dry-run. #' @examples #' client <- list(room_id = "!default:example.org") @@ -29,7 +39,7 @@ #' @export mx_send_text <- function(client, text, room = NULL, msgtype = "m.text", room_cache = NULL, dry_run = FALSE, - markdown = FALSE, mentions = NULL) { + markdown = FALSE, mentions = NULL, thread = NULL) { rid <- mx_resolve_room(client, room, room_cache = room_cache) if (isTRUE(dry_run)) { message("=== mx_send_text (dry-run) [", room %||% "default", @@ -48,6 +58,16 @@ mx_send_text <- function(client, text, room = NULL, msgtype = "m.text", extra <- c(extra, list("m.mentions" = list(user_ids = as.list(mentions)))) } + if (!is.null(thread) && length(thread) && nzchar(thread[[1L]])) { + extra <- c(extra, list("m.relates_to" = list( + rel_type = "m.thread", + event_id = as.character(thread)[[1L]], + # The reply fallback: thread-unaware clients read + # m.in_reply_to and render this as a reply to the root. + is_falling_back = TRUE, + "m.in_reply_to" = list( + event_id = as.character(thread)[[1L]])))) + } mx.api::mx_send(mx_client_session(client), rid, text, msgtype = msgtype, extra = extra) } diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index 724c468..b8d80b7 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -147,6 +147,57 @@ expect_equal(rel[[3]]$relates_to$`m.in_reply_to`$event_id, "$orig") expect_equal(rel[[2]]$body, "in a thread") expect_equal(rel[[2]]$event_id, "$thread") +# --- Threaded sends --- +# The outbound half of the relation the extraction tests above read. +# mx_send_text() hands mx.api::mx_send() an `extra` block, so what is +# asserted here is the content the homeserver would receive. +local({ + seen <- NULL + orig_send <- mx.api::mx_send + orig_sess <- mx.client::mx_client_session + assignInNamespace("mx_send", function(session, room_id, body, + msgtype = "m.text", + extra = NULL) { + seen <<- list(room_id = room_id, body = body, msgtype = msgtype, + extra = extra) + "$sent" + }, ns = "mx.api") + assignInNamespace("mx_client_session", function(client, ...) list(), + ns = "mx.client") + on.exit({ + assignInNamespace("mx_send", orig_send, ns = "mx.api") + assignInNamespace("mx_client_session", orig_sess, ns = "mx.client") + }, add = TRUE) + client <- list(room_id = "!r:ex") + + # No thread: no relation at all, rather than an empty one a + # homeserver would have to interpret. + mx.client::mx_send_text(client, "plain") + expect_null(seen$extra$`m.relates_to`) + + expect_equal(mx.client::mx_send_text(client, "in a thread", + thread = "$root"), "$sent") + rel <- seen$extra$`m.relates_to` + expect_equal(rel$rel_type, "m.thread") + expect_equal(rel$event_id, "$root") + # The reply fallback, so a thread-unaware client renders this as a + # reply to the root instead of as loose chatter in the room. + expect_true(rel$is_falling_back) + expect_equal(rel$`m.in_reply_to`$event_id, "$root") + + # A thread rides alongside markdown and mentions rather than + # replacing them: all three land in one content block. + mx.client::mx_send_text(client, "**bold**", markdown = TRUE, + mentions = "@troy:ex", thread = "$root") + expect_equal(seen$extra$format, "org.matrix.custom.html") + expect_equal(seen$extra$`m.mentions`$user_ids[[1]], "@troy:ex") + expect_equal(seen$extra$`m.relates_to`$rel_type, "m.thread") + + # An empty thread id is "no thread", not a relation pointing nowhere. + mx.client::mx_send_text(client, "x", thread = "") + expect_null(seen$extra$`m.relates_to`) +}) + # --- General reaction extraction --- # mx_extract_reaction_verdict() answers one approve/deny question about # one event, with the key semantics baked in. This reports every reaction diff --git a/man/mx_send_text.Rd b/man/mx_send_text.Rd index 17b627e..12d063e 100644 --- a/man/mx_send_text.Rd +++ b/man/mx_send_text.Rd @@ -11,7 +11,8 @@ mx_send_text( room_cache = NULL, dry_run = FALSE, markdown = FALSE, - mentions = NULL + mentions = NULL, + thread = NULL ) } \arguments{ @@ -36,6 +37,17 @@ from a conservative markdown subset.} \code{@localpart} in the body becomes a \code{matrix.to} pill in the HTML. Implies an HTML formatted body even when \code{markdown} is FALSE -- pills only render from HTML.} + +\item{thread}{Event id of a thread root, or NULL for an ordinary +message. The event is sent as a threaded reply +(\code{m.relates_to} with \code{rel_type} \code{"m.thread"}). + +It also carries the reply fallback the spec asks for -- +\code{is_falling_back} with an \code{m.in_reply_to} pointing at the +root -- so a client that does not implement threads renders the +message as a reply to the root rather than as a loose message in +the room. Without it those clients show a threaded conversation +as unattached chatter.} } \value{ Event id, or NULL on dry-run.