diff --git a/pkg/connector/mentions.go b/pkg/connector/mentions.go
new file mode 100644
index 0000000..ca4681e
--- /dev/null
+++ b/pkg/connector/mentions.go
@@ -0,0 +1,66 @@
+package connector
+
+import (
+ "fmt"
+ "html"
+ "regexp"
+ "strings"
+
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+var mentionPattern = regexp.MustCompile(`(?:^|[^\w@/])(@[\w-]{3,20})`)
+
+// Reddit sends mentions as plain "@username" text with no Matrix mention data.
+func (r *RedditClient) convertMentions(portal *bridgev2.Portal, content *event.MessageEventContent) {
+ meta, _ := portal.Metadata.(*PortalMetadata)
+ if content.Format != "" || meta == nil || meta.State == nil {
+ return
+ }
+ formatted, mentioned := linkMentions(content.Body, meta.State.Members, r.beeperMXID)
+ if len(mentioned) == 0 {
+ return
+ }
+ content.Format = event.FormatHTML
+ content.FormattedBody = formatted
+ content.Mentions = &event.Mentions{UserIDs: mentioned}
+}
+
+func (r *RedditClient) beeperMXID(redditMXID id.UserID) id.UserID {
+ if userID := makeUserID(redditMXID); userID != r.userID {
+ return r.main.Bridge.Matrix.FormatGhostMXID(userID)
+ }
+ return r.userLogin.UserMXID
+}
+
+func linkMentions(body string, members map[id.UserID]RoomMember, beeperMXID func(id.UserID) id.UserID) (string, []id.UserID) {
+ usernames := make(map[string]id.UserID, len(members))
+ for mxid, member := range members {
+ if member.Name != "" {
+ usernames[strings.ToLower(stripUserPrefix(member.Name))] = mxid
+ }
+ }
+ var formatted strings.Builder
+ var mentioned []id.UserID
+ last := 0
+ for _, match := range mentionPattern.FindAllStringSubmatchIndex(body, -1) {
+ start, end := match[2], match[3]
+ redditMXID, ok := usernames[strings.ToLower(body[start+1:end])]
+ if !ok {
+ continue
+ }
+ target := beeperMXID(redditMXID)
+ formatted.WriteString(plainToHTML(body[last:start]))
+ _, _ = fmt.Fprintf(&formatted, `%s`, target.URI().MatrixToURL(), html.EscapeString(body[start:end]))
+ mentioned = append(mentioned, target)
+ last = end
+ }
+ formatted.WriteString(plainToHTML(body[last:]))
+ return formatted.String(), mentioned
+}
+
+func plainToHTML(text string) string {
+ return strings.ReplaceAll(html.EscapeString(text), "\n", "
")
+}
diff --git a/pkg/connector/mentions_test.go b/pkg/connector/mentions_test.go
new file mode 100644
index 0000000..29fd19b
--- /dev/null
+++ b/pkg/connector/mentions_test.go
@@ -0,0 +1,34 @@
+package connector
+
+import (
+ "slices"
+ "testing"
+
+ "maunium.net/go/mautrix/id"
+)
+
+func TestLinkMentions(t *testing.T) {
+ members := map[id.UserID]RoomMember{
+ "@t2_a:reddit.com": {Name: "Some_User-1"},
+ "@t2_b:reddit.com": {Name: "other"},
+ "@t2_c:reddit.com": {},
+ }
+ beeperMXID := func(mxid id.UserID) id.UserID { return "@ghost_" + id.UserID(makeUserID(mxid)) + ":beeper.local" }
+ for _, tc := range []struct {
+ name, body, want string
+ mentioned []id.UserID
+ }{
+ {"start", "@some_user-1 hi", `@some_user-1 hi`, []id.UserID{"@ghost_t2_a:beeper.local"}},
+ {"two", "hey @other & @Some_User-1!", `hey @other & @Some_User-1!`, []id.UserID{"@ghost_t2_b:beeper.local", "@ghost_t2_a:beeper.local"}},
+ {"not a member", "@stranger ", "@stranger <b>", nil},
+ {"email and url", "a@other.com reddit.com/@other", "a@other.com reddit.com/@other", nil},
+ {"newline", "line\n@other", `line
@other`, []id.UserID{"@ghost_t2_b:beeper.local"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ formatted, mentioned := linkMentions(tc.body, members, beeperMXID)
+ if formatted != tc.want || !slices.Equal(mentioned, tc.mentioned) {
+ t.Fatalf("linkMentions(%q) = %q, %v", tc.body, formatted, mentioned)
+ }
+ })
+ }
+}
diff --git a/pkg/connector/tomatrix.go b/pkg/connector/tomatrix.go
index 22abddf..f8f7bcc 100644
--- a/pkg/connector/tomatrix.go
+++ b/pkg/connector/tomatrix.go
@@ -47,6 +47,9 @@ func (r *RedditClient) convertMessage(ctx context.Context, portal *bridgev2.Port
return nil, err
}
case event.MsgText, event.MsgNotice, event.MsgEmote:
+ if portal != nil {
+ r.convertMentions(portal, &content)
+ }
default:
return nil, bridgev2.ErrUnsupportedMessageType
}