diff --git a/format/mdext/rainbow/goldmark.go b/format/mdext/rainbow/goldmark.go
new file mode 100644
index 00000000..59a36178
--- /dev/null
+++ b/format/mdext/rainbow/goldmark.go
@@ -0,0 +1,120 @@
+// Copyright (c) 2024 Tulir Asokan
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package rainbow
+
+import (
+ "fmt"
+ "unicode"
+
+ "github.com/rivo/uniseg"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+ "go.mau.fi/util/random"
+)
+
+// Extension is a goldmark extension that adds rainbow text coloring to the HTML renderer.
+var Extension = &extRainbow{}
+
+type extRainbow struct{}
+type rainbowRenderer struct {
+ HardWraps bool
+ ColorID string
+}
+
+var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: random.String(16)}
+
+func (er *extRainbow) Extend(m goldmark.Markdown) {
+ m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0)))
+}
+
+func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindText, rb.renderText)
+ reg.Register(ast.KindString, rb.renderString)
+}
+
+type rainbowBufWriter struct {
+ util.BufWriter
+ ColorID string
+}
+
+func (rbw rainbowBufWriter) WriteString(s string) (int, error) {
+ i := 0
+ graphemes := uniseg.NewGraphemes(s)
+ for graphemes.Next() {
+ runes := graphemes.Runes()
+ if len(runes) == 1 && unicode.IsSpace(runes[0]) {
+ i2, err := rbw.BufWriter.WriteRune(runes[0])
+ i += i2
+ if err != nil {
+ return i, err
+ }
+ continue
+ }
+ i2, err := fmt.Fprintf(rbw.BufWriter, "%s", rbw.ColorID, graphemes.Str())
+ i += i2
+ if err != nil {
+ return i, err
+ }
+ }
+ return i, nil
+}
+
+func (rbw rainbowBufWriter) Write(data []byte) (int, error) {
+ return rbw.WriteString(string(data))
+}
+
+func (rbw rainbowBufWriter) WriteByte(c byte) error {
+ _, err := rbw.WriteRune(rune(c))
+ return err
+}
+
+func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) {
+ if unicode.IsSpace(r) {
+ return rbw.BufWriter.WriteRune(r)
+ } else {
+ return fmt.Fprintf(rbw.BufWriter, "%c", rbw.ColorID, r)
+ }
+}
+
+func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ n := node.(*ast.Text)
+ segment := n.Segment
+ if n.IsRaw() {
+ html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
+ } else {
+ html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
+ if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) {
+ _, _ = w.WriteString("
\n")
+ } else if n.SoftLineBreak() {
+ _ = w.WriteByte('\n')
+ }
+ }
+ return ast.WalkContinue, nil
+}
+
+func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ n := node.(*ast.String)
+ if n.IsCode() {
+ _, _ = w.Write(n.Value)
+ } else {
+ if n.IsRaw() {
+ html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value)
+ } else {
+ html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value)
+ }
+ }
+ return ast.WalkContinue, nil
+}
diff --git a/format/mdext/rainbow/gradient.go b/format/mdext/rainbow/gradient.go
new file mode 100644
index 00000000..34c499e6
--- /dev/null
+++ b/format/mdext/rainbow/gradient.go
@@ -0,0 +1,56 @@
+// Copyright (c) 2024 Tulir Asokan
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package rainbow
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/lucasb-eyer/go-colorful"
+)
+
+// GradientTable from https://github.com/lucasb-eyer/go-colorful/blob/master/doc/gradientgen/gradientgen.go
+type GradientTable []struct {
+ Col colorful.Color
+ Pos float64
+}
+
+func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
+ for i := 0; i < len(gt)-1; i++ {
+ c1 := gt[i]
+ c2 := gt[i+1]
+ if c1.Pos <= t && t <= c2.Pos {
+ t := (t - c1.Pos) / (c2.Pos - c1.Pos)
+ return c1.Col.BlendHcl(c2.Col, t).Clamped()
+ }
+ }
+ return gt[len(gt)-1].Col
+}
+
+var Gradient = GradientTable{
+ {colorful.LinearRgb(1, 0, 0), 0 / 11.0},
+ {colorful.LinearRgb(1, 0.5, 0), 1 / 11.0},
+ {colorful.LinearRgb(1, 1, 0), 2 / 11.0},
+ {colorful.LinearRgb(0.5, 1, 0), 3 / 11.0},
+ {colorful.LinearRgb(0, 1, 0), 4 / 11.0},
+ {colorful.LinearRgb(0, 1, 0.5), 5 / 11.0},
+ {colorful.LinearRgb(0, 1, 1), 6 / 11.0},
+ {colorful.LinearRgb(0, 0.5, 1), 7 / 11.0},
+ {colorful.LinearRgb(0, 0, 1), 8 / 11.0},
+ {colorful.LinearRgb(0.5, 0, 1), 9 / 11.0},
+ {colorful.LinearRgb(1, 0, 1), 10 / 11.0},
+ {colorful.LinearRgb(1, 0, 0.5), 11 / 11.0},
+}
+
+func ApplyColor(htmlBody string) string {
+ count := strings.Count(htmlBody, defaultRB.ColorID)
+ i := -1
+ return regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string {
+ i++
+ return Gradient.GetInterpolatedColorFor(float64(i) / float64(count)).Hex()
+ })
+}
diff --git a/go.mod b/go.mod
index 8ef08be8..a1e97f8c 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,9 @@ require (
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
+ github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.23
+ github.com/rivo/uniseg v0.4.7
github.com/rs/xid v1.6.0
github.com/rs/zerolog v1.33.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
diff --git a/go.sum b/go.sum
index ac8e03f6..b825326f 100644
--- a/go.sum
+++ b/go.sum
@@ -19,6 +19,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -31,6 +33,8 @@ github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7c
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
diff --git a/hicli/json-commands.go b/hicli/json-commands.go
index 12026f6b..a81cf097 100644
--- a/hicli/json-commands.go
+++ b/hicli/json-commands.go
@@ -38,7 +38,11 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return true, nil
})
case "send_message":
- return unmarshalAndCall(req.Data, func(params *sendParams) (*database.Event, error) {
+ return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) {
+ return h.SendMessage(ctx, params.RoomID, params.Text, params.MediaPath)
+ })
+ case "send_event":
+ return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
})
case "get_event":
@@ -100,7 +104,13 @@ type cancelRequestParams struct {
Reason string `json:"reason"`
}
-type sendParams struct {
+type sendMessageParams struct {
+ RoomID id.RoomID `json:"room_id"`
+ Text string `json:"text"`
+ MediaPath string `json:"media_path"`
+}
+
+type sendEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventType event.Type `json:"type"`
Content json.RawMessage `json:"content"`
diff --git a/hicli/send.go b/hicli/send.go
index 42b309a0..d16cadf6 100644
--- a/hicli/send.go
+++ b/hicli/send.go
@@ -11,17 +11,43 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strings"
"time"
"github.com/rs/zerolog"
+ "github.com/yuin/goldmark"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+ "maunium.net/go/mautrix/format/mdext/rainbow"
"maunium.net/go/mautrix/hicli/database"
"maunium.net/go/mautrix/id"
)
+var (
+ rainbowWithHTML = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension))
+)
+
+func (h *HiClient) SendMessage(ctx context.Context, roomID id.RoomID, text, mediaPath string) (*database.Event, error) {
+ var content event.MessageEventContent
+ if strings.HasPrefix(text, "/rainbow ") {
+ text = strings.TrimPrefix(text, "/rainbow ")
+ content = format.RenderMarkdownCustom(text, rainbowWithHTML)
+ content.FormattedBody = rainbow.ApplyColor(content.FormattedBody)
+ } else if strings.HasPrefix(text, "/plain ") {
+ text = strings.TrimPrefix(text, "/plain ")
+ content = format.RenderMarkdown(text, false, false)
+ } else if strings.HasPrefix(text, "/html ") {
+ text = strings.TrimPrefix(text, "/html ")
+ content = format.RenderMarkdown(text, false, true)
+ } else {
+ content = format.RenderMarkdown(text, true, false)
+ }
+ return h.Send(ctx, roomID, event.EventMessage, &content)
+}
+
func (h *HiClient) Send(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (*database.Event, error) {
roomMeta, err := h.DB.Room.Get(ctx, roomID)
if err != nil {