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.2
Version: 0.2.0.3
Date: 2026-08-04
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export(mx_crypto_sessions_save)
export(mx_crypto_store_dir)
export(mx_extract_invites)
export(mx_extract_reaction_verdict)
export(mx_extract_reactions)
export(mx_extract_text_events)
export(mx_markdown_to_html)
export(mx_pill_mentions)
Expand Down
18 changes: 18 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# mx.client 0.2.0.3

## New

* `mx_extract_reactions()` returns every `m.reaction` in a sync as a
record carrying `room_id`, `event_id` (the reaction, not its target),
`sender`, `is_self`, `target_event_id`, `key`, and `ts`. It is the
general form of `mx_extract_reaction_verdict()`, which answers one
approve/deny question about one event with the key semantics baked in;
that stays for callers who want it. Only additions are reported --
removing a reaction is a redaction of the `m.reaction` event, which a
consumer that needs to notice un-reactions has to read itself.

* `mx_extract_text_events()` records now carry `relates_to`, the event's
`m.relates_to` content verbatim. It is what distinguishes a threaded
reply from a rich reply from an edit, and dropping it left every caller
unable to tell any of them from an ordinary message.

# mx.client 0.2.0.2

## Security
Expand Down
82 changes: 80 additions & 2 deletions R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ mx_sync_update <- function(client, timeout = 0L, filter = NULL, save = TRUE,
#' \code{room_id}, \code{event_id}, \code{sender}, \code{is_self},
#' \code{body}, \code{msgtype}, \code{ts} (the event's
#' \code{origin_server_ts}, in milliseconds since the epoch, or NULL
#' when the server omits it), and \code{mentions}.
#' when the server omits it), \code{mentions}, and \code{relates_to}.
#'
#' \code{relates_to} is the event's \code{m.relates_to} content,
#' verbatim, or NULL. It is what distinguishes a threaded reply
#' (\code{rel_type} of \code{m.thread}) from a rich reply (an
#' \code{m.in_reply_to} with no \code{rel_type}) from an edit
#' (\code{m.replace}), and dropping it left every caller unable to tell
#' any of them from an ordinary message. Passed through rather than
#' interpreted: which of those a caller cares about is its own business.
#' @examples
#' sync_resp <- list(rooms = list(join = list("!room:example.org" = list(
#' timeline = list(events = list(list(type = "m.room.message",
Expand Down Expand Up @@ -132,7 +140,8 @@ mx_extract_text_events <- function(sync_resp, self_id, msgtypes = "m.text") {
is_self = isTRUE(ev$sender == self_id),
body = ev$content$body, msgtype = ev$content$msgtype,
ts = ev$origin_server_ts,
mentions = ev$content$`m.mentions`$user_ids)
mentions = ev$content$`m.mentions`$user_ids,
relates_to = ev$content$`m.relates_to`)
}
}
}
Expand Down Expand Up @@ -190,6 +199,75 @@ mx_accept_invites <- function(client, invites) {
joined
}

#' Extract reaction events from a sync response
#'
#' Walks joined-room timelines and returns every \code{m.reaction} that
#' annotates another event. Self reactions are retained and tagged with
#' \code{is_self}, the same way \code{\link{mx_extract_text_events}}
#' treats the client's own messages: a consumer that wants only other
#' people's reactions filters on the flag, and one tracking its own
#' (which reactions it has already placed) needs them.
#'
#' This is the general form of \code{\link{mx_extract_reaction_verdict}},
#' which answers one approve/deny question about one event. That function
#' bakes in which keys mean yes and which mean no, and stays for callers
#' who want that; this one reports keys and leaves their meaning alone.
#'
#' Only additions are reported. Removing a reaction is an
#' \code{m.room.redaction} of the \code{m.reaction} event, which is not an
#' \code{m.reaction} and so does not appear here -- a consumer that has to
#' notice un-reactions needs to read redactions itself.
#'
#' @param sync_resp Parsed \code{/sync} response.
#' @param self_id Current user's Matrix id.
#' @return List of records, each carrying \code{room_id},
#' \code{event_id} (the reaction's own id, not its target),
#' \code{sender}, \code{is_self}, \code{target_event_id} (the annotated
#' event), \code{key} (the reaction text, usually an emoji), and
#' \code{ts} (\code{origin_server_ts} in milliseconds, or NULL when the
#' server omits it). An \code{m.reaction} carrying no annotation
#' relation, no target, or no key is not a reaction to anything and is
#' skipped.
#' @examples
#' sync_resp <- list(rooms = list(join = list("!room:example.org" = list(
#' timeline = list(events = list(list(type = "m.reaction",
#' event_id = "$r1", sender = "@alice:example.org",
#' content = list("m.relates_to" = list(rel_type = "m.annotation",
#' event_id = "$msg", key = "+1")))))))))
#' mx_extract_reactions(sync_resp, self_id = "@bot:example.org")
#' @export
mx_extract_reactions <- function(sync_resp, self_id) {
joined <- sync_resp$rooms$join
if (!length(joined)) {
return(list())
}
out <- list()
for (rid in names(joined)) {
for (ev in joined[[rid]]$timeline$events %||% list()) {
if (!isTRUE(ev$type == "m.reaction")) {
next
}
rel <- ev$content$`m.relates_to`
# rel_type is checked rather than assumed: m.reaction is
# defined to carry m.annotation, and an event using it for
# something else is not a reaction whose target we can name.
if (!is.list(rel) || !isTRUE(rel$rel_type == "m.annotation")) {
next
}
if (!is.character(rel$event_id) || !length(rel$event_id) ||
!is.character(rel$key) || !length(rel$key)) {
next
}
out[[length(out) + 1L]] <- list(room_id = rid,
event_id = ev$event_id, sender = ev$sender,
is_self = isTRUE(ev$sender == self_id),
target_event_id = rel$event_id, key = rel$key,
ts = ev$origin_server_ts)
}
}
out
}

#' Extract a reaction approval verdict from sync events
#'
#' Scans a room timeline for a reaction on \code{target_event_id} from
Expand Down
118 changes: 118 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,124 @@ expect_null(mx.client::mx_extract_reaction_verdict(
reaction_sync, "!missing:ex", "@bot:ex", "$target"
))

# --- Message relations survive extraction ---
# m.relates_to is what tells a threaded reply from a rich reply from an
# edit. Dropping it left every caller unable to tell any of them from an
# ordinary message.

rel_sync <- list(rooms = list(join = list("!r:ex" = list(timeline = list(
events = list(
list(type = "m.room.message", event_id = "$plain", sender = "@a:ex",
content = list(msgtype = "m.text", body = "hello")),
list(type = "m.room.message", event_id = "$thread", sender = "@a:ex",
content = list(msgtype = "m.text", body = "in a thread",
`m.relates_to` = list(rel_type = "m.thread",
event_id = "$root"))),
list(type = "m.room.message", event_id = "$reply", sender = "@a:ex",
content = list(msgtype = "m.text", body = "a rich reply",
`m.relates_to` = list(
`m.in_reply_to` = list(event_id = "$orig"))))
))))))
rel <- mx.client::mx_extract_text_events(rel_sync, "@bot:ex")
expect_equal(length(rel), 3L)
# An ordinary message has no relation, and NULL is the answer -- not an
# empty list a caller has to test the shape of.
expect_null(rel[[1]]$relates_to)
# A thread carries its rel_type and root.
expect_equal(rel[[2]]$relates_to$rel_type, "m.thread")
expect_equal(rel[[2]]$relates_to$event_id, "$root")
# A rich reply has no rel_type at all, which is exactly how it differs
# from a thread. Passed through verbatim rather than normalized, so the
# caller can tell.
expect_null(rel[[3]]$relates_to$rel_type)
expect_equal(rel[[3]]$relates_to$`m.in_reply_to`$event_id, "$orig")
# The fields that were already there are unchanged.
expect_equal(rel[[2]]$body, "in a thread")
expect_equal(rel[[2]]$event_id, "$thread")

# --- 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
# and leaves what the keys mean to the caller.

rx_sync <- list(rooms = list(join = list(
"!r:ex" = list(timeline = list(events = list(
list(type = "m.reaction", event_id = "$r1", sender = "@alice:ex",
origin_server_ts = 1700000000000,
content = list(`m.relates_to` = list(rel_type = "m.annotation",
event_id = "$msg",
key = "\U0001F44D"))),
list(type = "m.reaction", event_id = "$r2", sender = "@bot:ex",
content = list(`m.relates_to` = list(rel_type = "m.annotation",
event_id = "$msg",
key = "eyes"))),
# An ordinary message in the same timeline is not a reaction.
list(type = "m.room.message", event_id = "$m1", sender = "@alice:ex",
content = list(msgtype = "m.text", body = "hello"))
))),
"!other:ex" = list(timeline = list(events = list(
list(type = "m.reaction", event_id = "$r3", sender = "@carol:ex",
content = list(`m.relates_to` = list(rel_type = "m.annotation",
event_id = "$elsewhere",
key = "x")))
)))
)))

rx <- mx.client::mx_extract_reactions(rx_sync, "@bot:ex")
expect_equal(length(rx), 3L)
# Every room is walked, not just the first.
expect_equal(sort(vapply(rx, function(r) r$room_id, "")),
c("!other:ex", "!r:ex", "!r:ex"))
# event_id is the reaction's own, and the annotated event is separate --
# conflating them is the mistake this record shape exists to prevent.
expect_equal(rx[[1]]$event_id, "$r1")
expect_equal(rx[[1]]$target_event_id, "$msg")
expect_equal(rx[[1]]$sender, "@alice:ex")
expect_equal(rx[[1]]$key, "\U0001F44D")
expect_equal(rx[[1]]$ts, 1700000000000)
# Self reactions are kept and tagged, like self messages: a consumer
# tracking which reactions it has already placed needs them.
expect_false(rx[[1]]$is_self)
expect_true(rx[[2]]$is_self)
expect_equal(rx[[2]]$key, "eyes")
# A server that omits origin_server_ts leaves ts NULL rather than
# inventing one.
expect_null(rx[[2]]$ts)

# An m.reaction that annotates nothing is not a reaction to anything.
malformed <- list(rooms = list(join = list("!r:ex" = list(timeline = list(
events = list(
# no m.relates_to at all
list(type = "m.reaction", event_id = "$a", sender = "@alice:ex",
content = list()),
# a relation that is not an annotation
list(type = "m.reaction", event_id = "$b", sender = "@alice:ex",
content = list(`m.relates_to` = list(rel_type = "m.thread",
event_id = "$msg",
key = "x"))),
# annotation with no target
list(type = "m.reaction", event_id = "$c", sender = "@alice:ex",
content = list(`m.relates_to` = list(rel_type = "m.annotation",
key = "x"))),
# annotation with no key
list(type = "m.reaction", event_id = "$d", sender = "@alice:ex",
content = list(`m.relates_to` = list(rel_type = "m.annotation",
event_id = "$msg")))
))))))
expect_equal(length(mx.client::mx_extract_reactions(malformed, "@bot:ex")), 0L)

# Empty and message-only syncs return an empty list, not an error.
expect_equal(length(mx.client::mx_extract_reactions(list(), "@bot:ex")), 0L)
expect_equal(length(mx.client::mx_extract_reactions(
list(rooms = list(join = list())), "@bot:ex")), 0L)
expect_equal(length(mx.client::mx_extract_reactions(
list(rooms = list(join = list("!r:ex" = list(timeline = list(
events = list(list(type = "m.room.message", event_id = "$m",
sender = "@a:ex",
content = list(msgtype = "m.text",
body = "hi")))))))),
"@bot:ex")), 0L)

if (is.na(old_env)) {
Sys.unsetenv("MX_CLIENT_TEST_MATRIX_CONFIG")
} else {
Expand Down
50 changes: 50 additions & 0 deletions man/mx_extract_reactions.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_extract_reactions}
\alias{mx_extract_reactions}
\title{Extract reaction events from a sync response}
\usage{
mx_extract_reactions(sync_resp, self_id)
}
\arguments{
\item{sync_resp}{Parsed \code{/sync} response.}

\item{self_id}{Current user's Matrix id.}
}
\value{
List of records, each carrying \code{room_id},
\code{event_id} (the reaction's own id, not its target),
\code{sender}, \code{is_self}, \code{target_event_id} (the annotated
event), \code{key} (the reaction text, usually an emoji), and
\code{ts} (\code{origin_server_ts} in milliseconds, or NULL when the
server omits it). An \code{m.reaction} carrying no annotation
relation, no target, or no key is not a reaction to anything and is
skipped.
}
\description{
Walks joined-room timelines and returns every \code{m.reaction} that
annotates another event. Self reactions are retained and tagged with
\code{is_self}, the same way \code{\link{mx_extract_text_events}}
treats the client's own messages: a consumer that wants only other
people's reactions filters on the flag, and one tracking its own
(which reactions it has already placed) needs them.
}
\details{
This is the general form of \code{\link{mx_extract_reaction_verdict}},
which answers one approve/deny question about one event. That function
bakes in which keys mean yes and which mean no, and stays for callers
who want that; this one reports keys and leaves their meaning alone.

Only additions are reported. Removing a reaction is an
\code{m.room.redaction} of the \code{m.reaction} event, which is not an
\code{m.reaction} and so does not appear here -- a consumer that has to
notice un-reactions needs to read redactions itself.

}
\examples{
sync_resp <- list(rooms = list(join = list("!room:example.org" = list(
timeline = list(events = list(list(type = "m.reaction",
event_id = "$r1", sender = "@alice:example.org",
content = list("m.relates_to" = list(rel_type = "m.annotation",
event_id = "$msg", key = "+1")))))))))
mx_extract_reactions(sync_resp, self_id = "@bot:example.org")
}
10 changes: 9 additions & 1 deletion man/mx_extract_text_events.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ List of normalized event records, each carrying
\code{room_id}, \code{event_id}, \code{sender}, \code{is_self},
\code{body}, \code{msgtype}, \code{ts} (the event's
\code{origin_server_ts}, in milliseconds since the epoch, or NULL
when the server omits it), and \code{mentions}.
when the server omits it), \code{mentions}, and \code{relates_to}.

\code{relates_to} is the event's \code{m.relates_to} content,
verbatim, or NULL. It is what distinguishes a threaded reply
(\code{rel_type} of \code{m.thread}) from a rich reply (an
\code{m.in_reply_to} with no \code{rel_type}) from an edit
(\code{m.replace}), and dropping it left every caller unable to tell
any of them from an ordinary message. Passed through rather than
interpreted: which of those a caller cares about is its own business.
}
\description{
Walks joined-room timeline events and returns normalized text-message
Expand Down
Loading