Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 21 additions & 1 deletion R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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",
Expand All @@ -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)
}
Expand Down
51 changes: 51 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion man/mx_send_text.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ mx_send_text(
room_cache = NULL,
dry_run = FALSE,
markdown = FALSE,
mentions = NULL
mentions = NULL,
thread = NULL
)
}
\arguments{
Expand All @@ -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.
Expand Down
Loading