From 75c4e64955d9ae9d1d274ac5afcdfde767e86a60 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:17:37 -0500 Subject: [PATCH 1/3] Send into a thread mx_send_text() gains a thread parameter: given a root event id it attaches m.relates_to with rel_type m.thread, alongside the reply fallback (is_falling_back plus m.in_reply_to pointing at the root) so a client that does not implement threads renders the message as a reply rather than as loose chatter in the room. The inbound half already existed -- mx_extract_text_events() has carried relates_to since 0.2.0.4 -- so this closes the round trip. --- R/messages.R | 23 ++++++++++++++- inst/tinytest/test_mx.client.R | 51 ++++++++++++++++++++++++++++++++++ man/mx_send_text.Rd | 14 +++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/R/messages.R b/R/messages.R index acf4d30..df12add 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,8 @@ #' @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 +59,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. From b65fe8d40e82fa08445965b8d2701284eb3a33d8 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:17:50 -0500 Subject: [PATCH 2/3] rformat + document --- R/messages.R | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/R/messages.R b/R/messages.R index df12add..b46e67a 100644 --- a/R/messages.R +++ b/R/messages.R @@ -39,8 +39,7 @@ #' @export mx_send_text <- function(client, text, room = NULL, msgtype = "m.text", room_cache = NULL, dry_run = FALSE, - markdown = FALSE, mentions = NULL, - thread = 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", @@ -61,13 +60,13 @@ mx_send_text <- function(client, text, room = NULL, msgtype = "m.text", } 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]])))) + 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) From 8dd21fc362dda1ef2cb7d65dd06ffa8c8111bba8 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:18:16 -0500 Subject: [PATCH 3/3] Bump version to 0.2.0.5 --- DESCRIPTION | 2 +- NEWS.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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