From aa7ca67976af186ee043f6228f1e4a9bfbba551a Mon Sep 17 00:00:00 2001 From: "jaakko.jokinen" Date: Mon, 24 Feb 2025 18:06:53 +0200 Subject: [PATCH] added 'some' files --- tui/autocomplete.go | 89 +++ tui/command-processor.go | 15 +- tui/commands.go | 1058 +++++++++++++++++++++++++++ tui/crypto-commands.go | 699 ++++++++++++++++++ tui/doc.go | 2 + tui/fuzzy-search-modal.go | 161 ++++ tui/help-modal.go | 115 +++ tui/member-list.go | 5 +- tui/message-view.go | 9 +- tui/messages/base.go | 397 ++++++++++ tui/messages/doc.go | 2 + tui/messages/expandedtextmessage.go | 102 +++ tui/messages/filemessage.go | 190 +++++ tui/messages/html/base.go | 101 +++ tui/messages/html/blockquote.go | 88 +++ tui/messages/html/break.go | 54 ++ tui/messages/html/codeblock.go | 59 ++ tui/messages/html/colormap.go | 156 ++++ tui/messages/html/container.go | 148 ++++ tui/messages/html/entity.go | 63 ++ tui/messages/html/horizontalline.go | 61 ++ tui/messages/html/list.go | 123 ++++ tui/messages/html/parser.go | 555 ++++++++++++++ tui/messages/html/spoiler.go | 120 +++ tui/messages/html/text.go | 156 ++++ tui/messages/htmlmessage.go | 100 +++ tui/messages/parser.go | 324 ++++++++ tui/messages/redactedmessage.go | 66 ++ tui/messages/textbase.go | 96 +++ tui/messages/tstring/cell.go | 53 ++ tui/messages/tstring/doc.go | 4 + tui/messages/tstring/string.go | 270 +++++++ tui/no-crypto-commands.go | 46 ++ tui/password-modal.go | 143 ++++ tui/rainbow.go | 135 ++++ tui/room-list.go | 5 +- tui/room-view.go | 34 +- tui/syncing-modal.go | 8 +- tui/tag-room-list.go | 329 +++++++++ tui/verification-modal.go | 252 +++++++ tui/view-main.go | 187 ++--- tui/widget/border.go | 63 ++ tui/widget/color.go | 224 ++++++ tui/widget/doc.go | 2 + tui/widget/util.go | 73 ++ 45 files changed, 6805 insertions(+), 137 deletions(-) create mode 100644 tui/autocomplete.go create mode 100644 tui/commands.go create mode 100644 tui/crypto-commands.go create mode 100644 tui/doc.go create mode 100644 tui/fuzzy-search-modal.go create mode 100644 tui/help-modal.go create mode 100644 tui/messages/base.go create mode 100644 tui/messages/doc.go create mode 100644 tui/messages/expandedtextmessage.go create mode 100644 tui/messages/filemessage.go create mode 100644 tui/messages/html/base.go create mode 100644 tui/messages/html/blockquote.go create mode 100644 tui/messages/html/break.go create mode 100644 tui/messages/html/codeblock.go create mode 100644 tui/messages/html/colormap.go create mode 100644 tui/messages/html/container.go create mode 100644 tui/messages/html/entity.go create mode 100644 tui/messages/html/horizontalline.go create mode 100644 tui/messages/html/list.go create mode 100644 tui/messages/html/parser.go create mode 100644 tui/messages/html/spoiler.go create mode 100644 tui/messages/html/text.go create mode 100644 tui/messages/htmlmessage.go create mode 100644 tui/messages/parser.go create mode 100644 tui/messages/redactedmessage.go create mode 100644 tui/messages/textbase.go create mode 100644 tui/messages/tstring/cell.go create mode 100644 tui/messages/tstring/doc.go create mode 100644 tui/messages/tstring/string.go create mode 100644 tui/no-crypto-commands.go create mode 100644 tui/password-modal.go create mode 100644 tui/rainbow.go create mode 100644 tui/tag-room-list.go create mode 100644 tui/verification-modal.go create mode 100644 tui/widget/border.go create mode 100644 tui/widget/color.go create mode 100644 tui/widget/doc.go create mode 100644 tui/widget/util.go diff --git a/tui/autocomplete.go b/tui/autocomplete.go new file mode 100644 index 0000000..b4fce1b --- /dev/null +++ b/tui/autocomplete.go @@ -0,0 +1,89 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) { + inputPath, err := filepath.Abs(cmd.RawArgs) + if err != nil { + return + } + + var searchNamePrefix, searchDir string + if strings.HasSuffix(cmd.RawArgs, "/") { + searchDir = inputPath + } else { + searchNamePrefix = filepath.Base(inputPath) + searchDir = filepath.Dir(inputPath) + } + files, err := os.ReadDir(searchDir) + if err != nil { + return + } + for _, file := range files { + name := file.Name() + if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") { + continue + } + fullPath := filepath.Join(searchDir, name) + if file.IsDir() { + fullPath += "/" + } + completions = append(completions, fullPath) + } + if len(completions) == 1 { + newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) + } + return +} + +func autocompleteToggle(cmd *CommandAutocomplete) (completions []string, newText string) { + //?? + completions = make([]string, 0, len(toggleMsg)) + for k := range toggleMsg { + if strings.HasPrefix(k, cmd.RawArgs) { + completions = append(completions, k) + } + } + if len(completions) == 1 { + newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) + } + return +} + +var staticPowerLevelKeys = []string{"ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"} + +func autocompletePowerLevel(cmd *CommandAutocomplete) (completions []string, newText string) { + if len(cmd.Args) > 1 { + return + } + for _, staticKey := range staticPowerLevelKeys { + if strings.HasPrefix(staticKey, cmd.RawArgs) { + completions = append(completions, staticKey) + } + } + for _, cpl := range cmd.Room.AutocompleteUser(cmd.RawArgs) { + completions = append(completions, cpl.id) + } + return +} diff --git a/tui/command-processor.go b/tui/command-processor.go index 2e3dcf5..2149ff8 100644 --- a/tui/command-processor.go +++ b/tui/command-processor.go @@ -22,17 +22,11 @@ import ( "github.com/mattn/go-runewidth" - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" ) type gomuksPointerContainer struct { MainView *MainView - UI *GomuksUI - Matrix ifc.MatrixContainer - Config *config.Config - Gomuks ifc.Gomuks + TUI *GomuksTUI } type Command struct { @@ -54,7 +48,7 @@ func (cmd *Command) Reply(message string, args ...interface{}) { message = fmt.Sprintf(message, args...) } cmd.Room.AddServiceMessage(message) - cmd.UI.Render() + cmd.TUI.App.Redraw() } type Alias struct { @@ -82,10 +76,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { return &CommandProcessor{ gomuksPointerContainer: gomuksPointerContainer{ MainView: parent, - UI: parent.parent, - Matrix: parent.matrix, - Config: parent.config, - Gomuks: parent.gmx, + TUI: parent.parent, }, aliases: map[string]*Alias{ "part": {"leave"}, diff --git a/tui/commands.go b/tui/commands.go new file mode 100644 index 0000000..6ddbd4f --- /dev/null +++ b/tui/commands.go @@ -0,0 +1,1058 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + dbg "runtime/debug" + "runtime/pprof" + "runtime/trace" + "strconv" + "strings" + "time" + "unicode" + + "github.com/lucasb-eyer/go-colorful" + "github.com/yuin/goldmark" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + + "maunium.net/go/gomuks/debug" +) + +func cmdMe(cmd *Command) { + text := strings.Join(cmd.Args, " ") + go cmd.Room.SendMessage(event.MsgEmote, text) +} + +// 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 rainbow = 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}, +} + +var rainbowMark = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(ExtensionRainbow)) + +// TODO this command definitely belongs in a plugin once we have a plugin system. +func makeRainbow(cmd *Command, msgtype event.MessageType) { + text := strings.Join(cmd.Args, " ") + + var buf strings.Builder + _ = rainbowMark.Convert([]byte(text), &buf) + + htmlBody := strings.TrimRight(buf.String(), "\n") + htmlBody = format.AntiParagraphRegex.ReplaceAllString(htmlBody, "$1") + text = format.HTMLToText(htmlBody) + + count := strings.Count(htmlBody, defaultRB.ColorID) + i := -1 + htmlBody = regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string { + i++ + return rainbow.GetInterpolatedColorFor(float64(i) / float64(count)).Hex() + }) + + go cmd.Room.SendMessageHTML(msgtype, text, htmlBody) +} + +func cmdRainbow(cmd *Command) { + makeRainbow(cmd, event.MsgText) +} + +func cmdRainbowMe(cmd *Command) { + makeRainbow(cmd, event.MsgEmote) +} + +func cmdRainbowNotice(cmd *Command) { + makeRainbow(cmd, event.MsgNotice) +} + +func cmdNotice(cmd *Command) { + go cmd.Room.SendMessage(event.MsgNotice, strings.Join(cmd.Args, " ")) +} + +func cmdAccept(cmd *Command) { + room := cmd.Room.MxRoom() + if room.SessionMember.Membership != "invite" { + cmd.Reply("/accept can only be used in rooms you're invited to") + return + } + _, server, _ := room.SessionMember.Sender.Parse() + _, err := cmd.Matrix.JoinRoom(room.ID, server) + if err != nil { + cmd.Reply("Failed to accept invite: %v", err) + } else { + cmd.Reply("Successfully accepted invite") + } + cmd.MainView.UpdateTags(room) + go cmd.MainView.LoadHistory(room.ID) +} + +func cmdReject(cmd *Command) { + room := cmd.Room.MxRoom() + if room.SessionMember.Membership != "invite" { + cmd.Reply("/reject can only be used in rooms you're invited to") + return + } + err := cmd.Matrix.LeaveRoom(room.ID) + if err != nil { + cmd.Reply("Failed to reject invite: %v", err) + } else { + cmd.Reply("Successfully rejected invite") + } + cmd.MainView.RemoveRoom(room) +} + +func cmdID(cmd *Command) { + cmd.Reply("The internal ID of this room is %s", cmd.Room.MxRoom().ID) +} + +type SelectReason string + +const ( + SelectReply SelectReason = "reply to" + SelectReact = "react to" + SelectRedact = "redact" + SelectEdit = "edit" + SelectDownload = "download" + SelectOpen = "open" + SelectCopy = "copy" +) + +func cmdReply(cmd *Command) { + cmd.Room.StartSelecting(SelectReply, strings.Join(cmd.Args, " ")) +} + +func cmdEdit(cmd *Command) { + cmd.Room.StartSelecting(SelectEdit, "") +} + +func findEditorExecutable() (string, string, error) { + if editor := os.Getenv("VISUAL"); len(editor) > 0 { + if path, err := exec.LookPath(editor); err != nil { + return "", "", fmt.Errorf("$VISUAL ('%s') not found in $PATH", editor) + } else { + return editor, path, nil + } + } else if editor = os.Getenv("EDITOR"); len(editor) > 0 { + if path, err := exec.LookPath(editor); err != nil { + return "", "", fmt.Errorf("$EDITOR ('%s') not found in $PATH", editor) + } else { + return editor, path, nil + } + } else if path, _ := exec.LookPath("nano"); len(path) > 0 { + return "nano", path, nil + } else if path, _ = exec.LookPath("vi"); len(path) > 0 { + return "vi", path, nil + } else { + return "", "", fmt.Errorf("$VISUAL and $EDITOR not set, nano and vi not found in $PATH") + } +} + +func cmdExternalEditor(cmd *Command) { + var file *os.File + defer func() { + if file != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + } + }() + + fileExtension := "md" + if cmd.Config.Preferences.DisableMarkdown { + if cmd.Config.Preferences.DisableHTML { + fileExtension = "txt" + } else { + fileExtension = "html" + } + } + + if editorName, executablePath, err := findEditorExecutable(); err != nil { + cmd.Reply("Couldn't find editor to use: %v", err) + return + } else if file, err = os.CreateTemp("", fmt.Sprintf("gomuks-draft-*.%s", fileExtension)); err != nil { + cmd.Reply("Failed to create temp file: %v", err) + return + } else if _, err = file.WriteString(cmd.RawArgs); err != nil { + cmd.Reply("Failed to write to temp file: %v", err) + } else if err = file.Close(); err != nil { + cmd.Reply("Failed to close temp file: %v", err) + } else if err = cmd.RunExternal(executablePath, file.Name()); err != nil { + var exitErr *exec.ExitError + if isExit := errors.As(err, &exitErr); isExit { + cmd.Reply("%s exited with non-zero status %d", editorName, exitErr.ExitCode()) + } else { + cmd.Reply("Failed to run %s: %v", editorName, err) + } + } else if data, err := os.ReadFile(file.Name()); err != nil { + cmd.Reply("Failed to read temp file: %v", err) + } else if len(bytes.TrimSpace(data)) > 0 { + cmd.Room.InputSubmit(string(data)) + } else { + cmd.Reply("Temp file was blank, sending cancelled") + if cmd.Room.editing != nil { + cmd.Room.SetEditing(nil) + } + } +} +func (cmd *Command) RunExternal(executablePath string, args ...string) error { + callback := make(chan error) + cmd.TUI.App.Suspend(func() { + cmd := exec.Command(executablePath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + callback <- cmd.Run() + }) + return <-callback +} + + +func cmdRedact(cmd *Command) { + cmd.Room.StartSelecting(SelectRedact, strings.Join(cmd.Args, " ")) +} + +func cmdDownload(cmd *Command) { + cmd.Room.StartSelecting(SelectDownload, strings.Join(cmd.Args, " ")) +} + +func cmdUpload(cmd *Command) { + var path string + var err error + if len(cmd.Args) == 0 { + if filepicker.IsSupported() { + path, err = filepicker.Open() + if err != nil { + cmd.Reply("Failed to open file picker: %v", err) + return + } else if len(path) == 0 { + cmd.Reply("File picking cancelled") + return + } + } else { + cmd.Reply("Usage: /upload ") + return + } + } else { + path, err = filepath.Abs(cmd.RawArgs) + if err != nil { + cmd.Reply("Failed to get absolute path: %v", err) + return + } + } + + go cmd.Room.SendMessageMedia(path) +} + +func cmdOpen(cmd *Command) { + cmd.Room.StartSelecting(SelectOpen, strings.Join(cmd.Args, " ")) +} + +func cmdCopy(cmd *Command) { + register := strings.Join(cmd.Args, " ") + if len(register) == 0 { + register = "clipboard" + } + if register == "clipboard" || register == "primary" { + cmd.Room.StartSelecting(SelectCopy, register) + } else { + cmd.Reply("Usage: /copy [register], where register is either \"clipboard\" or \"primary\". Defaults to \"clipboard\".") + } +} + +func cmdReact(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /react ") + return + } + + cmd.Room.StartSelecting(SelectReact, strings.Join(cmd.Args, " ")) +} + +func readRoomAlias(cmd *Command) (alias id.RoomAlias, err error) { + param := strings.Join(cmd.Args[1:], " ") + if strings.ContainsRune(param, ':') { + if param[0] != '#' { + return "", errors.New("full aliases must start with #") + } + + alias = id.RoomAlias(param) + } else { + _, homeserver, _ := cmd.Matrix.Client().UserID.Parse() + alias = id.NewRoomAlias(param, homeserver) + } + return +} + +func cmdAlias(cmd *Command) { + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /alias ") + return + } + + alias, err := readRoomAlias(cmd) + if err != nil { + cmd.Reply(err.Error()) + return + } + + subcmd := strings.ToLower(cmd.Args[0]) + switch subcmd { + case "add", "create": + cmdAddAlias(cmd, alias) + case "remove", "delete", "del", "rm": + cmdRemoveAlias(cmd, alias) + case "resolve", "get": + cmdResolveAlias(cmd, alias) + default: + cmd.Reply("Usage: /alias ") + } +} + +func niceError(err error) string { + httpErr, ok := err.(mautrix.HTTPError) + if ok && httpErr.RespError != nil { + return httpErr.RespError.Error() + } + return err.Error() +} + +func cmdAddAlias(cmd *Command, alias id.RoomAlias) { + _, err := cmd.Matrix.Client().CreateAlias(alias, cmd.Room.MxRoom().ID) + if err != nil { + cmd.Reply("Failed to create alias: %v", niceError(err)) + } else { + cmd.Reply("Created alias %s", alias) + } +} + +func cmdRemoveAlias(cmd *Command, alias id.RoomAlias) { + _, err := cmd.Matrix.Client().DeleteAlias(alias) + if err != nil { + cmd.Reply("Failed to delete alias: %v", niceError(err)) + } else { + cmd.Reply("Deleted alias %s", alias) + } +} + +func cmdResolveAlias(cmd *Command, alias id.RoomAlias) { + resp, err := cmd.Matrix.Client().ResolveAlias(alias) + if err != nil { + cmd.Reply("Failed to resolve alias: %v", niceError(err)) + } else { + roomIDText := string(resp.RoomID) + if resp.RoomID == cmd.Room.MxRoom().ID { + roomIDText += " (this room)" + } + cmd.Reply("Alias %s points to room %s\nThere are %d servers in the room.", alias, roomIDText, len(resp.Servers)) + } +} + +func cmdTags(cmd *Command) { + tags := cmd.Room.MxRoom().RawTags + if len(cmd.Args) > 0 && cmd.Args[0] == "--internal" { + tags = cmd.Room.MxRoom().Tags() + } + if len(tags) == 0 { + if cmd.Room.MxRoom().IsDirect { + cmd.Reply("This room has no tags, but it's marked as a direct chat.") + } else { + cmd.Reply("This room has no tags.") + } + return + } + var resp strings.Builder + resp.WriteString("Tags in this room:\n") + for _, tag := range tags { + if tag.Order != "" { + _, _ = fmt.Fprintf(&resp, "%s (order: %s)\n", tag.Tag, tag.Order) + } else { + _, _ = fmt.Fprintf(&resp, "%s (no order)\n", tag.Tag) + } + } + cmd.Reply(strings.TrimSpace(resp.String())) +} + +func cmdTag(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /tag [order]") + return + } + order := math.NaN() + if len(cmd.Args) > 1 { + var err error + order, err = strconv.ParseFloat(cmd.Args[1], 64) + if err != nil { + cmd.Reply("%s is not a valid order: %v", cmd.Args[1], err) + return + } + } + var err error + if len(cmd.Args) > 2 && cmd.Args[2] == "--reset" { + tags := event.Tags{ + cmd.Args[0]: {Order: json.Number(fmt.Sprintf("%f", order))}, + } + for _, tag := range cmd.Room.MxRoom().RawTags { + tags[tag.Tag] = event.Tag{Order: tag.Order} + } + err = cmd.Matrix.Client().SetTags(cmd.Room.MxRoom().ID, tags) + } else { + err = cmd.Matrix.Client().AddTag(cmd.Room.MxRoom().ID, cmd.Args[0], order) + } + if err != nil { + cmd.Reply("Failed to add tag: %v", err) + } +} + +func cmdUntag(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /untag ") + return + } + err := cmd.Matrix.Client().RemoveTag(cmd.Room.MxRoom().ID, cmd.Args[0]) + if err != nil { + cmd.Reply("Failed to remove tag: %v", err) + } +} + +func cmdRoomNick(cmd *Command) { + room := cmd.Room.MxRoom() + member := room.GetMember(room.SessionUserID) + member.Displayname = strings.Join(cmd.Args, " ") + _, err := cmd.Matrix.Client().SendStateEvent(room.ID, event.StateMember, string(room.SessionUserID), member) + if err != nil { + cmd.Reply("Failed to set room nick: %v", err) + } +} + +func cmdFingerprint(cmd *Command) { + c := cmd.Matrix.Crypto() + if c == nil { + cmd.Reply("Encryption support is not enabled") + } else { + cmd.Reply("Device ID: %s\nFingerprint: %s", cmd.Matrix.Client().DeviceID, c.Fingerprint()) + } +} + +func cmdHeapProfile(cmd *Command) { + if len(cmd.Args) == 0 || cmd.Args[0] != "nogc" { + runtime.GC() + dbg.FreeOSMemory() + } + memProfile, err := os.Create("gomuks.heap.prof") + if err != nil { + debug.Print("Failed to open gomuks.heap.prof:", err) + return + } + defer func() { + err := memProfile.Close() + if err != nil { + debug.Print("Failed to close gomuks.heap.prof:", err) + } + }() + if err := pprof.WriteHeapProfile(memProfile); err != nil { + debug.Print("Heap profile error:", err) + } +} + +func runTimedProfile(cmd *Command, start func(writer io.Writer) error, stop func(), task, file string) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /%s ", cmd.Command) + } else if dur, err := strconv.Atoi(cmd.Args[0]); err != nil || dur < 0 { + cmd.Reply("Usage: /%s ", cmd.Command) + } else if cpuProfile, err := os.Create(file); err != nil { + debug.Printf("Failed to open %s: %v", file, err) + } else if err = start(cpuProfile); err != nil { + _ = cpuProfile.Close() + debug.Print(task, "error:", err) + } else { + cmd.Reply("Started %s for %d seconds", task, dur) + go func() { + time.Sleep(time.Duration(dur) * time.Second) + stop() + cmd.Reply("%s finished.", task) + + err := cpuProfile.Close() + if err != nil { + debug.Print("Failed to close gomuks.cpu.prof:", err) + } + }() + } +} + +func cmdCPUProfile(cmd *Command) { + runTimedProfile(cmd, pprof.StartCPUProfile, pprof.StopCPUProfile, "CPU profiling", "gomuks.cpu.prof") +} + +func cmdTrace(cmd *Command) { + runTimedProfile(cmd, trace.Start, trace.Stop, "Call tracing", "gomuks.trace") +} + +func cmdQuit(cmd *Command) { + cmd.Gomuks.Stop(true) +} + +func cmdClearCache(cmd *Command) { + cmd.Config.Clear() + cmd.Gomuks.Stop(false) +} + +func cmdUnknownCommand(cmd *Command) { + cmd.Reply(`Unknown command "/%s". Try "/help" for help.`, cmd.Command) +} + +func cmdHelp(cmd *Command) { + view := cmd.MainView + view.ShowModal(NewHelpModal(view)) +} + +func cmdLeave(cmd *Command) { + err := cmd.Matrix.LeaveRoom(cmd.Room.MxRoom().ID) + debug.Print("Leave room error:", err) + if err == nil { + cmd.MainView.RemoveRoom(cmd.Room.MxRoom()) + } +} + +func cmdInvite(cmd *Command) { + if len(cmd.Args) != 1 { + cmd.Reply("Usage: /invite ") + return + } + _, err := cmd.Matrix.Client().InviteUser(cmd.Room.MxRoom().ID, &mautrix.ReqInviteUser{UserID: id.UserID(cmd.Args[0])}) + if err != nil { + debug.Print("Error in invite call:", err) + cmd.Reply("Failed to invite user: %v", err) + } +} + +func cmdBan(cmd *Command) { + if len(cmd.Args) < 1 { + cmd.Reply("Usage: /ban [reason]") + return + } + reason := "you are the weakest link, goodbye!" + if len(cmd.Args) >= 2 { + reason = strings.Join(cmd.Args[1:], " ") + } + _, err := cmd.Matrix.Client().BanUser(cmd.Room.MxRoom().ID, &mautrix.ReqBanUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) + if err != nil { + debug.Print("Error in ban call:", err) + cmd.Reply("Failed to ban user: %v", err) + } + +} + +func cmdUnban(cmd *Command) { + if len(cmd.Args) != 1 { + cmd.Reply("Usage: /unban ") + return + } + _, err := cmd.Matrix.Client().UnbanUser(cmd.Room.MxRoom().ID, &mautrix.ReqUnbanUser{UserID: id.UserID(cmd.Args[0])}) + if err != nil { + debug.Print("Error in unban call:", err) + cmd.Reply("Failed to unban user: %v", err) + } +} + +func cmdKick(cmd *Command) { + if len(cmd.Args) < 1 { + cmd.Reply("Usage: /kick [reason]") + return + } + reason := "you are the weakest link, goodbye!" + if len(cmd.Args) >= 2 { + reason = strings.Join(cmd.Args[1:], " ") + } + _, err := cmd.Matrix.Client().KickUser(cmd.Room.MxRoom().ID, &mautrix.ReqKickUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) + if err != nil { + debug.Print("Error in kick call:", err) + debug.Print("Failed to kick user:", err) + } +} + +func formatPowerLevels(pl *event.PowerLevelsEventContent) string { + var buf strings.Builder + buf.WriteString("Membership actions:\n") + _, _ = fmt.Fprintf(&buf, " Invite: %d\n", pl.Invite()) + _, _ = fmt.Fprintf(&buf, " Kick: %d\n", pl.Kick()) + _, _ = fmt.Fprintf(&buf, " Ban: %d\n", pl.Ban()) + buf.WriteString("Events:\n") + _, _ = fmt.Fprintf(&buf, " Redact: %d\n", pl.Redact()) + _, _ = fmt.Fprintf(&buf, " State default: %d\n", pl.StateDefault()) + _, _ = fmt.Fprintf(&buf, " Event default: %d\n", pl.EventsDefault) + for evtType, level := range pl.Events { + _, _ = fmt.Fprintf(&buf, " %s: %d\n", evtType, level) + } + buf.WriteString("Users:\n") + _, _ = fmt.Fprintf(&buf, " Default: %d\n", pl.UsersDefault) + for userID, level := range pl.Users { + _, _ = fmt.Fprintf(&buf, " %s: %d\n", userID, level) + } + return strings.TrimSpace(buf.String()) +} + +func copyPtr(ptr *int) *int { + if ptr == nil { + return nil + } + val := *ptr + return &val +} + +func copyMap[Key comparable](m map[Key]int) map[Key]int { + if m == nil { + return nil + } + copied := make(map[Key]int, len(m)) + for k, v := range m { + copied[k] = v + } + return copied +} + +func copyPowerLevels(pl *event.PowerLevelsEventContent) *event.PowerLevelsEventContent { + return &event.PowerLevelsEventContent{ + Users: copyMap(pl.Users), + Events: copyMap(pl.Events), + InvitePtr: copyPtr(pl.InvitePtr), + KickPtr: copyPtr(pl.KickPtr), + BanPtr: copyPtr(pl.BanPtr), + RedactPtr: copyPtr(pl.RedactPtr), + StateDefaultPtr: copyPtr(pl.StateDefaultPtr), + EventsDefault: pl.EventsDefault, + UsersDefault: pl.UsersDefault, + } +} + +var things = ` +[thing] can be one of the following + +Literals: +* invite, kick, ban, redact - special moderation action levels +* state_default, events_default - default level for state and non-state events +* users_default - default level for users + +Patterns: +* user ID - specific user level +* event type - specific event type level + +The default levels are 0 for users, 50 for moderators and 100 for admins.` + +func cmdPowerLevel(cmd *Command) { + evt := cmd.Room.MxRoom().GetStateEvent(event.StatePowerLevels, "") + pl := copyPowerLevels(evt.Content.AsPowerLevels()) + if len(cmd.Args) == 0 { + // TODO open in modal? + cmd.Reply(formatPowerLevels(pl)) + return + } else if len(cmd.Args) < 2 { + cmd.Reply("Usage: /%s [thing] [level]\n%s", cmd.Command, things) + return + } + + value, err := strconv.Atoi(cmd.Args[1]) + if err != nil { + cmd.Reply("Invalid power level %q: %v", cmd.Args[1], err) + return + } + + ownLevel := pl.GetUserLevel(cmd.Matrix.Client().UserID) + plChangeLevel := pl.GetEventLevel(event.StatePowerLevels) + if ownLevel < plChangeLevel { + cmd.Reply("Can't modify power levels (own level is %d, modifying requires %d)", ownLevel, plChangeLevel) + return + } else if value > ownLevel { + cmd.Reply("Can't set level to be higher than own level (%d > %d)", value, ownLevel) + return + } + + var oldValue int + var thing string + switch cmd.Args[0] { + case "invite": + oldValue = pl.Invite() + pl.InvitePtr = &value + thing = "invite level" + case "kick": + oldValue = pl.Kick() + pl.KickPtr = &value + thing = "kick level" + case "ban": + oldValue = pl.Ban() + pl.BanPtr = &value + thing = "ban level" + case "redact": + oldValue = pl.Redact() + pl.RedactPtr = &value + thing = "level for redacting other users' events" + case "state_default": + oldValue = pl.StateDefault() + pl.StateDefaultPtr = &value + thing = "default level for state events" + case "events_default": + oldValue = pl.EventsDefault + pl.EventsDefault = value + thing = "default level for normal events" + case "users_default": + oldValue = pl.UsersDefault + pl.UsersDefault = value + thing = "default level for users" + default: + userID := id.UserID(cmd.Args[0]) + if _, _, err = userID.Parse(); err == nil { + if pl.Users == nil { + pl.Users = make(map[id.UserID]int) + } + oldValue = pl.Users[userID] + if oldValue == ownLevel && userID != cmd.Matrix.Client().UserID { + cmd.Reply("Can't change level of another user which is equal to own level (%d)", ownLevel) + return + } + pl.Users[userID] = value + thing = fmt.Sprintf("level of user %s", userID) + } else { + if pl.Events == nil { + pl.Events = make(map[string]int) + } + oldValue = pl.Events[cmd.Args[0]] + pl.Events[cmd.Args[0]] = value + thing = fmt.Sprintf("level for event %s", cmd.Args[0]) + } + } + + if oldValue == value { + cmd.Reply("%s is already %d", strings.ToUpper(thing[0:1])+thing[1:], value) + } else if oldValue > ownLevel { + cmd.Reply("Can't change level which is higher than own level (%d > %d)", oldValue, ownLevel) + } else if resp, err := cmd.Matrix.Client().SendStateEvent(cmd.Room.MxRoom().ID, event.StatePowerLevels, "", pl); err != nil { + if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil { + err = httpErr.RespError + } + cmd.Reply("Failed to set %s to %d: %v", thing, value, err) + } else { + cmd.Reply("Successfully set %s to %d\n(event ID: %s)", thing, value, resp.EventID) + } +} + +func cmdCreateRoom(cmd *Command) { + req := &mautrix.ReqCreateRoom{} + if len(cmd.Args) > 0 { + req.Name = strings.Join(cmd.Args, " ") + } + room, err := cmd.Matrix.CreateRoom(req) + if err != nil { + cmd.Reply("Failed to create room: %v", err) + return + } + cmd.MainView.SwitchRoom("", room) +} + +func cmdPrivateMessage(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /pm [more user ids...]") + } + invites := make([]id.UserID, len(cmd.Args)) + for i, userID := range cmd.Args { + invites[i] = id.UserID(userID) + _, _, err := invites[i].Parse() + if err != nil { + cmd.Reply("%s isn't a valid user ID", userID) + return + } + } + req := &mautrix.ReqCreateRoom{ + Preset: "trusted_private_chat", + Invite: invites, + IsDirect: true, + } + room, err := cmd.Matrix.CreateRoom(req) + if err != nil { + cmd.Reply("Failed to create room: %v", err) + return + } + cmd.MainView.SwitchRoom("", room) +} + +func cmdJoin(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /join ") + return + } + identifer := id.RoomID(cmd.Args[0]) + server := "" + if len(cmd.Args) > 1 { + server = cmd.Args[1] + } + room, err := cmd.Matrix.JoinRoom(identifer, server) + debug.Print("Join room error:", err) + if err == nil { + cmd.MainView.AddRoom(room) + } +} + +func cmdMSendEvent(cmd *Command) { + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /msend ") + return + } + cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) + cmdSendEvent(cmd) +} + +func cmdSendEvent(cmd *Command) { + if len(cmd.Args) < 3 { + cmd.Reply("Usage: /send ") + return + } + roomID := id.RoomID(cmd.Args[0]) + eventType := event.NewEventType(cmd.Args[1]) + rawContent := strings.Join(cmd.Args[2:], " ") + + var content interface{} + err := json.Unmarshal([]byte(rawContent), &content) + debug.Print(err) + if err != nil { + cmd.Reply("Failed to parse content: %v", err) + return + } + debug.Print("Sending event to", roomID, eventType, content) + + resp, err := cmd.Matrix.Client().SendMessageEvent(roomID, eventType, content) + debug.Print(resp, err) + if err != nil { + cmd.Reply("Error from server: %v", err) + } else { + cmd.Reply("Event sent, ID: %s", resp.EventID) + } +} + +func cmdMSetState(cmd *Command) { + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /msetstate ") + return + } + cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) + cmdSetState(cmd) +} + +func cmdSetState(cmd *Command) { + if len(cmd.Args) < 4 { + cmd.Reply("Usage: /setstate ") + return + } + + roomID := id.RoomID(cmd.Args[0]) + eventType := event.NewEventType(cmd.Args[1]) + stateKey := cmd.Args[2] + if stateKey == "-" { + stateKey = "" + } + rawContent := strings.Join(cmd.Args[3:], " ") + + var content interface{} + err := json.Unmarshal([]byte(rawContent), &content) + if err != nil { + cmd.Reply("Failed to parse content: %v", err) + return + } + debug.Print("Sending state event to", roomID, eventType, stateKey, content) + resp, err := cmd.Matrix.Client().SendStateEvent(roomID, eventType, stateKey, content) + if err != nil { + cmd.Reply("Error from server: %v", err) + } else { + cmd.Reply("State event sent, ID: %s", resp.EventID) + } +} + +type ToggleMessage interface { + Name() string + Format(state bool) string +} + +type HideMessage string + +func (hm HideMessage) Format(state bool) string { + if state { + return string(hm) + " is now hidden" + } else { + return string(hm) + " is now visible" + } +} + +func (hm HideMessage) Name() string { + return string(hm) +} + +type SimpleToggleMessage string + +func (stm SimpleToggleMessage) Format(state bool) string { + if state { + return "Disabled " + string(stm) + } else { + return "Enabled " + string(stm) + } +} + +func (stm SimpleToggleMessage) Name() string { + return string(unicode.ToUpper(rune(stm[0]))) + string(stm[1:]) +} + +type InvertedToggleMessage string + +func (itm InvertedToggleMessage) Format(state bool) string { + if state { + return "Enabled " + string(itm) + } else { + return "Disabled " + string(itm) + } +} + +func (itm InvertedToggleMessage) Name() string { + return string(unicode.ToUpper(rune(itm[0]))) + string(itm[1:]) +} + +var toggleMsg = map[string]ToggleMessage{ + "rooms": HideMessage("Room list sidebar"), + "users": HideMessage("User list sidebar"), + "timestamps": HideMessage("message timestamps"), + "baremessages": InvertedToggleMessage("bare message view"), + "images": SimpleToggleMessage("image rendering"), + "typingnotif": SimpleToggleMessage("typing notifications"), + "emojis": SimpleToggleMessage("emoji shortcode conversion"), + "html": SimpleToggleMessage("HTML input"), + "markdown": SimpleToggleMessage("markdown input"), + "downloads": SimpleToggleMessage("automatic downloads"), + "notifications": SimpleToggleMessage("desktop notifications"), + "unverified": SimpleToggleMessage("sending messages to unverified devices"), + "showurls": SimpleToggleMessage("show URLs in text format"), + "inlineurls": InvertedToggleMessage("use fancy terminal features to render URLs inside text"), +} + +func makeUsage() string { + var buf strings.Builder + buf.WriteString("Usage: /toggle \n\n") + buf.WriteString("List of Things:\n") + for key, value := range toggleMsg { + _, _ = fmt.Fprintf(&buf, "* %s - %s\n", key, value.Name()) + } + return buf.String()[:buf.Len()-1] +} + +func cmdToggle(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply(makeUsage()) + return + } + for _, thing := range cmd.Args { + var val *bool + switch thing { + case "rooms": + val = &cmd.Config.Preferences.HideRoomList + case "users": + val = &cmd.Config.Preferences.HideUserList + case "timestamps": + val = &cmd.Config.Preferences.HideTimestamp + case "baremessages": + val = &cmd.Config.Preferences.BareMessageView + case "images": + val = &cmd.Config.Preferences.DisableImages + case "typingnotif": + val = &cmd.Config.Preferences.DisableTypingNotifs + case "emojis": + val = &cmd.Config.Preferences.DisableEmojis + case "html": + val = &cmd.Config.Preferences.DisableHTML + case "markdown": + val = &cmd.Config.Preferences.DisableMarkdown + case "downloads": + val = &cmd.Config.Preferences.DisableDownloads + case "notifications": + val = &cmd.Config.Preferences.DisableNotifications + case "unverified": + val = &cmd.Config.SendToVerifiedOnly + case "showurls": + val = &cmd.Config.Preferences.DisableShowURLs + case "inlineurls": + switch cmd.Config.Preferences.InlineURLMode { + case "enable": + cmd.Config.Preferences.InlineURLMode = "disable" + cmd.Reply("Force-disabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") + default: + cmd.Config.Preferences.InlineURLMode = "enable" + cmd.Reply("Force-enabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") + } + continue + default: + cmd.Reply("Unknown toggle %s. Use /toggle without arguments for a list of togglable things.", thing) + return + } + *val = !(*val) + debug.Print(thing, *val) + cmd.Reply(toggleMsg[thing].Format(*val)) + if thing == "rooms" { + // Update topic string to include or not include room name + cmd.Room.Update() + } + } + cmd.TUI.App.Redraw() + go cmd.Matrix.SendPreferencesToMatrix() +} + +func cmdLogout(cmd *Command) { + cmd.Matrix.Logout() +} diff --git a/tui/crypto-commands.go b/tui/crypto-commands.go new file mode 100644 index 0000000..05b1d07 --- /dev/null +++ b/tui/crypto-commands.go @@ -0,0 +1,699 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build cgo + +package tui + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/crypto/ssss" + "maunium.net/go/mautrix/id" +) + +func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) { + userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0]) + if len(userCompletions) == 1 { + newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id) + } else { + completions = make([]string, len(userCompletions)) + for i, completion := range userCompletions { + completions[i] = completion.id + } + } + return +} + +func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) { + //???? + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0])) + if len(devices) == 0 || err != nil { + return + } + var completedDeviceID id.DeviceID + if len(cmd.Args) > 1 { + existingID := strings.ToUpper(cmd.Args[1]) + for _, device := range devices { + deviceIDStr := string(device.DeviceID) + if deviceIDStr == existingID { + // We don't want to do any autocompletion if there's already a full device ID there. + return []string{}, "" + } else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) { + completedDeviceID = device.DeviceID + completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)) + } + } + } else { + completions = make([]string, len(devices)) + i := 0 + for _, device := range devices { + completedDeviceID = device.DeviceID + completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name) + i++ + } + } + if len(completions) == 1 { + newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID) + } + return +} + +func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { + if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { + return autocompleteDeviceUserID(cmd) + } + return []string{}, "" +} + +func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { + if len(cmd.Args) == 0 { + return []string{}, "" + } else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { + return autocompleteDeviceUserID(cmd) + } + return autocompleteDeviceDeviceID(cmd) +} + +func getDevice(cmd *Command) *crypto.DeviceIdentity { + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /%s [fingerprint]", cmd.Command) + return nil + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1])) + if err != nil { + cmd.Reply("Failed to get device: %v", err) + return nil + } + return device +} + +func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + err := mach.CryptoStore.PutDevice(device.UserID, device) + if err != nil { + cmd.Reply("Failed to save device: %v", err) + } else { + cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name) + } + mach.OnDevicesChanged(device.UserID) +} + +func cmdDevices(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /devices ") + return + } + userID := id.UserID(cmd.Args[0]) + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + devices, err := mach.CryptoStore.GetDevices(userID) + if err != nil { + cmd.Reply("Failed to get device list: %v", err) + } + if len(devices) == 0 { + cmd.Reply("Fetching device list from server...") + devices = mach.LoadDevices(userID) + } + if len(devices) == 0 { + cmd.Reply("No devices found for %s", userID) + return + } + var buf strings.Builder + for _, device := range devices { + trust := device.Trust.String() + if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { + trust = "verified (transitive)" + } + _, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint()) + } + resp := buf.String() + cmd.Reply("%s", resp[:len(resp)-1]) +} + +func cmdDevice(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + deviceType := "Device" + if device.Deleted { + deviceType = "Deleted device" + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + trustState := device.Trust.String() + if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { + trustState = "verified (transitive)" + } + cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s", + deviceType, device.DeviceID, device.UserID, + device.Fingerprint(), device.IdentityKey, + device.Name, trustState) +} + +func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + err := mach.SignOwnDevice(device) + if err != nil { + cmd.Reply("Failed to upload cross-signing signature: %v", err) + } else { + cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name) + } +} + +func cmdVerifyDevice(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + if device.Trust == crypto.TrustStateVerified { + cmd.Reply("That device is already verified") + return + } + if len(cmd.Args) == 2 { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + mach.DefaultSASTimeout = 120 * time.Second + modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout) + cmd.MainView.ShowModal(modal) + _, err := mach.NewSimpleSASVerificationWith(device, modal) + if err != nil { + cmd.Reply("Failed to start interactive verification: %v", err) + return + } + } else { + fingerprint := strings.Join(cmd.Args[2:], "") + if string(device.SigningKey) != fingerprint { + cmd.Reply("Mismatching fingerprint") + return + } + action := "verified" + if device.Trust == crypto.TrustStateBlacklisted { + action = "unblacklisted and verified" + } + if device.UserID == cmd.Matrix.Client().UserID { + crossSignDevice(cmd, device) + device.Trust = crypto.TrustStateVerified + putDevice(cmd, device, action) + } else { + putDevice(cmd, device, action) + cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing") + } + } +} + +func cmdVerify(cmd *Command) { + if len(cmd.Args) < 1 { + cmd.Reply("Usage: /%s [--force]", cmd.OrigCommand) + return + } + force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force" + userID := id.UserID(cmd.Args[0]) + room := cmd.Room.Room + if !room.Encrypted { + cmd.Reply("In-room verification is only supported in encrypted rooms") + return + } + if (!room.IsDirect || room.OtherUser != userID) && !force { + cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+ + "or use `--force` to start the verification anyway.", userID) + return + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + if mach.CrossSigningKeys == nil && !force { + cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " + + "or use `--force` to start the verification anyway") + return + } + modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout) + _, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second) + if err != nil { + cmd.Reply("Failed to start in-room verification: %v", err) + return + } + cmd.MainView.ShowModal(modal) +} + +func cmdUnverify(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + if device.Trust == crypto.TrustStateUnset { + cmd.Reply("That device is already not verified") + return + } + action := "unverified" + if device.Trust == crypto.TrustStateBlacklisted { + action = "unblacklisted" + } + device.Trust = crypto.TrustStateUnset + putDevice(cmd, device, action) +} + +func cmdBlacklist(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + if device.Trust == crypto.TrustStateBlacklisted { + cmd.Reply("That device is already blacklisted") + return + } + action := "blacklisted" + if device.Trust == crypto.TrustStateVerified { + action = "unverified and blacklisted" + } + device.Trust = crypto.TrustStateBlacklisted + putDevice(cmd, device, action) +} + +func cmdResetSession(cmd *Command) { + err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID) + if err != nil { + cmd.Reply("Failed to remove outbound group session: %v", err) + } else { + cmd.Reply("Removed outbound group session for this room") + } +} + +func cmdImportKeys(cmd *Command) { + path, err := filepath.Abs(cmd.RawArgs) + if err != nil { + cmd.Reply("Failed to get absolute path: %v", err) + return + } + data, err := os.ReadFile(path) + if err != nil { + cmd.Reply("Failed to read %s: %v", path, err) + return + } + passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false) + if !ok { + cmd.Reply("Passphrase entry cancelled") + return + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + imported, total, err := mach.ImportKeys(passphrase, data) + if err != nil { + cmd.Reply("Failed to import sessions: %v", err) + } else { + cmd.Reply("Successfully imported %d/%d sessions", imported, total) + } +} + +func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) { + path, err := filepath.Abs(cmd.RawArgs) + if err != nil { + cmd.Reply("Failed to get absolute path: %v", err) + return + } + passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true) + if !ok { + cmd.Reply("Passphrase entry cancelled") + return + } + export, err := crypto.ExportKeys(passphrase, sessions) + if err != nil { + cmd.Reply("Failed to export sessions: %v", err) + } + err = ioutil.WriteFile(path, export, 0400) + if err != nil { + cmd.Reply("Failed to write sessions to %s: %v", path, err) + } else { + cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path) + } +} + +func cmdExportKeys(cmd *Command) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + sessions, err := mach.CryptoStore.GetAllGroupSessions() + if err != nil { + cmd.Reply("Failed to get sessions to export: %v", err) + return + } + exportKeys(cmd, sessions) +} + +func cmdExportRoomKeys(cmd *Command) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID) + if err != nil { + cmd.Reply("Failed to get sessions to export: %v", err) + return + } + exportKeys(cmd, sessions) +} + +const ssssHelp = `Usage: /%s [...] + +Subcommands: +* status [key ID] - Check the status of your SSSS. +* generate [--set-default] - Generate a SSSS key and optionally set it as the default. +* set-default - Set a SSSS key as the default.` + +func cmdSSSS(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply(ssssHelp, cmd.OrigCommand) + return + } + + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + + switch strings.ToLower(cmd.Args[0]) { + case "status": + keyID := "" + if len(cmd.Args) > 1 { + keyID = cmd.Args[1] + } + cmdS4Status(cmd, mach, keyID) + case "generate": + setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default" + cmdS4Generate(cmd, mach, setDefault) + case "set-default": + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /%s set-default ", cmd.OrigCommand) + return + } + cmdS4SetDefault(cmd, mach, cmd.Args[1]) + default: + cmd.Reply(ssssHelp, cmd.OrigCommand) + } +} + +func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) { + var keyData *ssss.KeyMetadata + var err error + if len(keyID) == 0 { + keyID, keyData, err = mach.SSSS.GetDefaultKeyData(context.TODO) + } else { + keyData, err = mach.SSSS.GetKeyData(context.TODO, keyID) + } + if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) { + cmd.Reply("SSSS is not set up: no default key set") + return + } else if err != nil { + cmd.Reply("Failed to get key data: %v", err) + return + } + hasPassphrase := "no" + if keyData.Passphrase != nil { + hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations) + } + algorithm := keyData.Algorithm + if algorithm != ssss.AlgorithmAESHMACSHA2 { + algorithm += " (not supported!)" + } + cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm) +} + +func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) { + passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true) + if !ok { + return + } + + key, err := ssss.NewKey(passphrase) + if err != nil { + cmd.Reply("Failed to generate new key: %v", err) + return + } + + err = mach.SSSS.SetKeyData(context.TODO(), key.ID, key.Metadata) + if err != nil { + cmd.Reply("Failed to upload key metadata: %v", err) + return + } + + // TODO if we start persisting command replies, the recovery key needs to be moved into a popup + cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey()) + + if setDefault { + err = mach.SSSS.SetDefaultKeyID(context.TODO(), key.ID) + if err != nil { + cmd.Reply("Failed to set key as default: %v", err) + } + } else { + cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID) + } +} + +func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) { + _, err := mach.SSSS.GetKeyData(context.TODO(), keyID) + if err != nil { + if errors.Is(err, mautrix.MNotFound) { + cmd.Reply("Couldn't find key data on server") + } else { + cmd.Reply("Failed to fetch key data: %v", err) + } + return + } + + err = mach.SSSS.SetDefaultKeyID(context.TODO(), keyID) + if err != nil { + cmd.Reply("Failed to set key as default: %v", err) + } else { + cmd.Reply("Successfully set key %s as default", keyID) + } +} + +const crossSigningHelp = `Usage: /%s [...] + +Subcommands: +* status + Check the status of your own cross-signing keys. +* generate [--force] + Generate and upload new cross-signing keys. + This will prompt you to enter your account password. + If you already have existing keys, --force is required. +* self-sign + Sign the current device with cached cross-signing keys. +* fetch [--save-to-disk] + Fetch your cross-signing keys from SSSS and decrypt them. + If --save-to-disk is specified, the keys are saved to disk. +* upload + Upload your cross-signing keys to SSSS.` + +func cmdCrossSigning(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply(crossSigningHelp, cmd.OrigCommand) + return + } + + client := cmd.Matrix.Client() + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + + switch strings.ToLower(cmd.Args[0]) { + case "status": + cmdCrossSigningStatus(cmd, mach) + case "generate": + force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force" + cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force) + case "fetch": + saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk" + cmdCrossSigningFetch(cmd, mach, saveToDisk) + case "upload": + cmdCrossSigningUpload(cmd, mach) + case "self-sign": + cmdCrossSigningSelfSign(cmd, mach) + default: + cmd.Reply(crossSigningHelp, cmd.OrigCommand) + } +} + +func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) { + keys := mach.GetOwnCrossSigningPublicKeys(context.TODO()) + if keys == nil { + if mach.CrossSigningKeys != nil { + cmd.Reply("Cross-signing keys are cached, but not published") + } else { + cmd.Reply("Didn't find published cross-signing keys") + } + return + } + if mach.CrossSigningKeys != nil { + cmd.Reply("Cross-signing keys are published and private keys are cached") + } else { + cmd.Reply("Cross-signing keys are published, but private keys are not cached") + } + cmd.Reply("Master key: %s", keys.MasterKey) + cmd.Reply("User signing key: %s", keys.UserSigningKey) + cmd.Reply("Self-signing key: %s", keys.SelfSigningKey) +} + +func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) { + key := getSSSS(cmd, mach) + if key == nil { + return + } + + err := mach.FetchCrossSigningKeysFromSSSS(context.TODO(), key) + if err != nil { + cmd.Reply("Error fetching cross-signing keys: %v", err) + return + } + if saveToDisk { + cmd.Reply("Saving keys to disk is not yet implemented") + } + cmd.Reply("Successfully unlocked cross-signing keys") +} + +// korjaa +func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) { + if !force { + existingKeys := mach.GetOwnCrossSigningPublicKeys(context.TODO()) + if existingKeys != nil { + cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.") + return + } + } + + keys, err := mach.GenerateCrossSigningKeys() + if err != nil { + cmd.Reply("Failed to generate cross-signing keys: %v", err) + return + } + + err = mach.PublishCrossSigningKeys(context.TODO(), keys, func(uia *mautrix.RespUserInteractive) interface{} { + if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) { + for _, flow := range uia.Flows { + if len(flow.Stages) != 1 { + return nil + } + cmd.Reply("Opening browser for authentication") + err := container.UIAFallback(flow.Stages[0], uia.Session) + if err != nil { + cmd.Reply("Authentication failed: %v", err) + return nil + } + return &mautrix.ReqUIAuthFallback{ + Session: uia.Session, + User: mach.Client.UserID.String(), + } + } + cmd.Reply("No supported authentication mechanisms found") + return nil + } + password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false) + if !ok { + return nil + } + return &mautrix.ReqUIAuthLogin{ + BaseAuthData: mautrix.BaseAuthData{ + Type: mautrix.AuthTypePassword, + Session: uia.Session, + }, + User: mach.Client.UserID.String(), + Password: password, + } + }) + if err != nil { + cmd.Reply("Failed to publish cross-signing keys: %v", err) + return + } + cmd.Reply("Successfully generated and published cross-signing keys") + + err = mach.SignOwnMasterKey(context.TODO()) + if err != nil { + cmd.Reply("Failed to sign master key with device key: %v", err) + } +} + +func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key { + _, keyData, err := mach.SSSS.GetDefaultKeyData(context.TODO()) + if err != nil { + if errors.Is(err, mautrix.MNotFound) { + cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first") + } else { + cmd.Reply("Failed to fetch default SSSS key data: %v", err) + } + return nil + } + + var key *ssss.Key + if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 { + passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false) + if !ok { + return nil + } + key, err = keyData.VerifyPassphrase(passphrase) + if errors.Is(err, ssss.ErrIncorrectSSSSKey) { + cmd.Reply("Incorrect passphrase") + return nil + } + } else { + recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false) + if !ok { + return nil + } + key, err = keyData.VerifyRecoveryKey(recoveryKey) + if errors.Is(err, ssss.ErrInvalidRecoveryKey) { + cmd.Reply("Malformed recovery key") + return nil + } else if errors.Is(err, ssss.ErrIncorrectSSSSKey) { + cmd.Reply("Incorrect recovery key") + return nil + } + } + // All the errors should already be handled above, this is just for backup + if err != nil { + cmd.Reply("Failed to get SSSS key: %v", err) + return nil + } + return key +} + +func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) { + if mach.CrossSigningKeys == nil { + cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand) + return + } + + key := getSSSS(cmd, mach) + if key == nil { + return + } + + err := mach.UploadCrossSigningKeysToSSSS(context.TODO(), key, mach.CrossSigningKeys) + if err != nil { + cmd.Reply("Failed to upload keys to SSSS: %v", err) + } else { + cmd.Reply("Successfully uploaded cross-signing keys to SSSS") + } +} + +func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) { + if mach.CrossSigningKeys == nil { + cmd.Reply("Cross-signing keys not cached") + return + } + + err := mach.SignOwnDevice(context.TODO(), mach.OwnIdentity()) + if err != nil { + cmd.Reply("Failed to self-sign: %v", err) + } else { + cmd.Reply("Successfully self-signed. This device is now trusted by other devices") + } +} diff --git a/tui/doc.go b/tui/doc.go new file mode 100644 index 0000000..147be0c --- /dev/null +++ b/tui/doc.go @@ -0,0 +1,2 @@ +// Package tui contains the main gomuks TUI. +package tui diff --git a/tui/fuzzy-search-modal.go b/tui/fuzzy-search-modal.go new file mode 100644 index 0000000..b5886cd --- /dev/null +++ b/tui/fuzzy-search-modal.go @@ -0,0 +1,161 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "fmt" + "sort" + "strconv" + + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + "maunium.net/go/mautrix/id" + + "maunium.net/go/gomuks/debug" +) + +type FuzzySearchModal struct { + mauview.Component + + container *mauview.Box + + search *mauview.InputArea + results *mauview.TextView + + matches fuzzy.Ranks + selected int + + roomList []*rooms.Room + roomTitles []string + + parent *MainView +} + +func NewFuzzySearchModal(mainView *MainView, width int, height int) *FuzzySearchModal { + fs := &FuzzySearchModal{ + parent: mainView, + } + + fs.InitList(mainView.rooms) + + fs.results = mauview.NewTextView().SetRegions(true) + fs.search = mauview.NewInputArea(). + SetChangedFunc(fs.changeHandler). + SetTextColor(tcell.ColorWhite). + SetBackgroundColor(tcell.ColorDarkCyan) + fs.search.Focus() + + flex := mauview.NewFlex(). + SetDirection(mauview.FlexRow). + AddFixedComponent(fs.search, 1). + AddProportionalComponent(fs.results, 1) + + fs.container = mauview.NewBox(flex). + SetBorder(true). + SetTitle("Quick Room Switcher"). + SetBlurCaptureFunc(func() bool { + fs.parent.HideModal() + return true + }) + + fs.Component = mauview.Center(fs.container, width, height).SetAlwaysFocusChild(true) + + return fs +} + +func (fs *FuzzySearchModal) Focus() { + fs.container.Focus() +} + +func (fs *FuzzySearchModal) Blur() { + fs.container.Blur() +} + +func (fs *FuzzySearchModal) InitList(rooms map[id.RoomID]*RoomView) { + for _, room := range rooms { + if room.Room.IsReplaced() { + //if _, ok := rooms[room.Room.ReplacedBy()]; ok + continue + } + fs.roomList = append(fs.roomList, room.Room) + fs.roomTitles = append(fs.roomTitles, room.Room.GetTitle()) + } +} + +func (fs *FuzzySearchModal) changeHandler(str string) { + // Get matches and display in result box + fs.matches = fuzzy.RankFindFold(str, fs.roomTitles) + if len(str) > 0 && len(fs.matches) > 0 { + sort.Sort(fs.matches) + fs.results.Clear() + for _, match := range fs.matches { + fmt.Fprintf(fs.results, `["%d"]%s[""]%s`, match.OriginalIndex, match.Target, "\n") + } + //fs.parent.parent.Render() + fs.results.Highlight(strconv.Itoa(fs.matches[0].OriginalIndex)) + fs.selected = 0 + fs.results.ScrollToBeginning() + } else { + fs.results.Clear() + fs.results.Highlight() + } +} + +func (fs *FuzzySearchModal) OnKeyEvent(event mauview.KeyEvent) bool { + highlights := fs.results.GetHighlights() + kb := config.Keybind{ + Key: event.Key(), + Ch: event.Rune(), + Mod: event.Modifiers(), + } + switch fs.parent.config.Keybindings.Modal[kb] { + case "cancel": + // Close room finder + fs.parent.HideModal() + return true + case "select_next": + // Cycle highlighted area to next match + if len(highlights) > 0 { + fs.selected = (fs.selected + 1) % len(fs.matches) + fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) + fs.results.ScrollToHighlight() + } + return true + case "select_prev": + if len(highlights) > 0 { + fs.selected = (fs.selected - 1) % len(fs.matches) + if fs.selected < 0 { + fs.selected += len(fs.matches) + } + fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) + fs.results.ScrollToHighlight() + } + return true + case "confirm": + // Switch room to currently selected room + if len(highlights) > 0 { + debug.Print("Fuzzy Selected Room:", fs.roomList[fs.matches[fs.selected].OriginalIndex].GetTitle()) + fs.parent.SwitchRoom(fs.roomList[fs.matches[fs.selected].OriginalIndex].Tags()[0].Tag, fs.roomList[fs.matches[fs.selected].OriginalIndex]) + } + fs.parent.HideModal() + fs.results.Clear() + fs.search.SetText("") + return true + } + return fs.search.OnKeyEvent(event) +} diff --git a/tui/help-modal.go b/tui/help-modal.go new file mode 100644 index 0000000..6415194 --- /dev/null +++ b/tui/help-modal.go @@ -0,0 +1,115 @@ +package tui + +import ( + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +const helpText = `# General +/help - Show this help dialog. +/quit - Quit gomuks. +/clearcache - Clear cache and quit gomuks. +/logout - Log out of Matrix. +/toggle - Temporary command to toggle various UI features. + Run /toggle without arguments to see the list of toggles. + +# Media +/download [path] - Downloads file from selected message. +/open [path] - Download file from selected message and open it with xdg-open. +/upload - Upload the file at the given path to the current room. + +# Sending special messages +/me - Send an emote message. +/notice - Send a notice (generally used for bot messages). +/rainbow - Send rainbow text. +/rainbowme - Send rainbow text in an emote. +/reply [text] - Reply to the selected message. +/react - React to the selected message. +/redact [reason] - Redact the selected message. +/edit - Edit the selected message. + +# Encryption +/fingerprint - View the fingerprint of your device. + +/devices - View the device list of a user. +/device - Show info about a specific device. +/unverify - Un-verify a device. +/blacklist - Blacklist a device. +/verify - Verify a user with in-room verification. Probably broken. +/verify-device [fingerprint] + - Verify a device. If the fingerprint is not provided, + interactive emoji verification will be started. +/reset-session - Reset the outbound Megolm session in the current room. + +/import - Import encryption keys +/export - Export encryption keys +/export-room - Export encryption keys for the current room. + +/cross-signing [...] + - Cross-signing commands. Somewhat experimental. + Run without arguments for help. (alias: /cs) +/ssss [...] + - Secure Secret Storage (and Sharing) commands. Very experimental. + Run without arguments for help. + +# Rooms +/pm <...> - Create a private chat with the given user(s). +/create [room name] - Create a room. + +/join [server] - Join a room. +/accept - Accept the invite. +/reject - Reject the invite. + +/invite - Invite the given user to the room. +/roomnick - Change your per-room displayname. +/tag - Add the room to . +/untag - Remove the room from . +/tags - List the tags the room is in. +/alias - Add or remove local addresses. + +/leave - Leave the current room. +/kick [reason] - Kick a user. +/ban [reason] - Ban a user. +/unban - Unban a user.` + +type HelpModal struct { + mauview.FocusableComponent + parent *MainView +} + +func NewHelpModal(parent *MainView) *HelpModal { + hm := &HelpModal{parent: parent} + + text := mauview.NewTextView(). + SetText(helpText). + SetScrollable(true). + SetWrap(false). + SetTextColor(tcell.ColorDefault) + + box := mauview.NewBox(text). + SetBorder(true). + SetTitle("Help"). + SetBlurCaptureFunc(func() bool { + hm.parent.HideModal() + return true + }) + box.Focus() + + hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5) + + return hm +} + +func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool { + kb := config.Keybind{ + Key: event.Key(), + Ch: event.Rune(), + Mod: event.Modifiers(), + } + // TODO unhardcode q + if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' { + hm.parent.HideModal() + return true + } + return hm.FocusableComponent.OnKeyEvent(event) +} diff --git a/tui/member-list.go b/tui/member-list.go index 15d0e37..657694e 100644 --- a/tui/member-list.go +++ b/tui/member-list.go @@ -23,14 +23,11 @@ import ( "github.com/mattn/go-runewidth" + "github.com/gdamore/tcell/v2" "go.mau.fi/mauview" - "go.mau.fi/tcell" - "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/widget" ) type MemberList struct { diff --git a/tui/message-view.go b/tui/message-view.go index 189774e..2a80bea 100644 --- a/tui/message-view.go +++ b/tui/message-view.go @@ -23,20 +23,14 @@ import ( "sync/atomic" "github.com/mattn/go-runewidth" - sync "github.com/sasha-s/go-deadlock" "go.mau.fi/mauview" - "go.mau.fi/tcell" + "github.com/gdamore/tcell/v2" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/gomuks/ui/widget" ) type MessageView struct { @@ -652,7 +646,6 @@ func (view *MessageView) Draw(screen mauview.Screen) { if msg == prevMsg { debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index) line++ - continue } if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp { diff --git a/tui/messages/base.go b/tui/messages/base.go new file mode 100644 index 0000000..211a983 --- /dev/null +++ b/tui/messages/base.go @@ -0,0 +1,397 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "fmt" + "sort" + "time" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + /* + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/matrix/muksevt" + + "maunium.net/go/gomuks/ui/widget" + */ +) + +type MessageRenderer interface { + Draw(screen mauview.Screen, msg *UIMessage) + NotificationContent() string + PlainText() string + CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage) + Height() int + Clone() MessageRenderer + String() string +} + +type ReactionItem struct { + Key string + Count int +} + +func (ri ReactionItem) String() string { + return fmt.Sprintf("%d×%s", ri.Count, ri.Key) +} + +type ReactionSlice []ReactionItem + +func (rs ReactionSlice) Len() int { + return len(rs) +} + +func (rs ReactionSlice) Less(i, j int) bool { + return rs[i].Key < rs[j].Key +} + +func (rs ReactionSlice) Swap(i, j int) { + rs[i], rs[j] = rs[j], rs[i] +} + +type UIMessage struct { + EventID id.EventID + TxnID string + Relation event.RelatesTo + Type event.MessageType + SenderID id.UserID + SenderName string + DefaultSenderColor tcell.Color + Timestamp time.Time + State muksevt.OutgoingState + IsHighlight bool + IsService bool + IsSelected bool + Edited bool + Event *muksevt.Event + ReplyTo *UIMessage + Reactions ReactionSlice + Renderer MessageRenderer +} + +func (msg *UIMessage) GetEvent() *muksevt.Event { + if msg == nil { + return nil + } + return msg.Event +} + +const DateFormat = "January _2, 2006" +const TimeFormat = "15:04:05" + +func newUIMessage(evt *muksevt.Event, displayname string, renderer MessageRenderer) *UIMessage { + msgContent := evt.Content.AsMessage() + msgtype := msgContent.MsgType + if len(msgtype) == 0 { + msgtype = event.MessageType(evt.Type.String()) + } + + reactions := make(ReactionSlice, 0, len(evt.Unsigned.Relations.Annotations.Map)) + for key, count := range evt.Unsigned.Relations.Annotations.Map { + reactions = append(reactions, ReactionItem{ + Key: key, + Count: count, + }) + } + sort.Sort(reactions) + + return &UIMessage{ + SenderID: evt.Sender, + SenderName: displayname, + Timestamp: unixToTime(evt.Timestamp), + DefaultSenderColor: widget.GetHashColor(evt.Sender), + Type: msgtype, + EventID: evt.ID, + TxnID: evt.Unsigned.TransactionID, + Relation: *msgContent.GetRelatesTo(), + State: evt.Gomuks.OutgoingState, + IsHighlight: false, + IsService: false, + Edited: len(evt.Gomuks.Edits) > 0, + Reactions: reactions, + Event: evt, + Renderer: renderer, + } +} + +func (msg *UIMessage) AddReaction(key string) { + found := false + for i, rs := range msg.Reactions { + if rs.Key == key { + rs.Count++ + msg.Reactions[i] = rs + found = true + break + } + } + if !found { + msg.Reactions = append(msg.Reactions, ReactionItem{ + Key: key, + Count: 1, + }) + } + sort.Sort(msg.Reactions) +} + +func unixToTime(unix int64) time.Time { + timestamp := time.Now() + if unix != 0 { + timestamp = time.Unix(unix/1000, unix%1000*1000) + } + return timestamp +} + +// Sender gets the string that should be displayed as the sender of this message. +// +// If the message is being sent, the sender is "Sending...". +// If sending has failed, the sender is "Error". +// If the message is an emote, the sender is blank. +// In any other case, the sender is the display name of the user who sent the message. +func (msg *UIMessage) Sender() string { + switch msg.State { + case muksevt.StateLocalEcho: + return "Sending..." + case muksevt.StateSendFail: + return "Error" + } + switch msg.Type { + case "m.emote": + // Emotes don't show a separate sender, it's included in the buffer. + return "" + default: + return msg.SenderName + } +} + +func (msg *UIMessage) NotificationSenderName() string { + return msg.SenderName +} + +func (msg *UIMessage) NotificationContent() string { + return msg.Renderer.NotificationContent() +} + +func (msg *UIMessage) getStateSpecificColor() tcell.Color { + switch msg.State { + case muksevt.StateLocalEcho: + return tcell.ColorGray + case muksevt.StateSendFail: + return tcell.ColorRed + case muksevt.StateDefault: + fallthrough + default: + return tcell.ColorDefault + } +} + +// SenderColor returns the color the name of the sender should be shown in. +// +// If the message is being sent, the color is gray. +// If sending has failed, the color is red. +// +// In any other case, the color is whatever is specified in the Message struct. +// Usually that means it is the hash-based color of the sender (see ui/widget/color.go) +func (msg *UIMessage) SenderColor() tcell.Color { + stateColor := msg.getStateSpecificColor() + switch { + case stateColor != tcell.ColorDefault: + return stateColor + case msg.Type == "m.room.member": + return widget.GetHashColor(msg.SenderName) + case msg.IsService: + return tcell.ColorGray + default: + return msg.DefaultSenderColor + } +} + +// TextColor returns the color the actual content of the message should be shown in. +func (msg *UIMessage) TextColor() tcell.Color { + stateColor := msg.getStateSpecificColor() + switch { + case stateColor != tcell.ColorDefault: + return stateColor + case msg.IsService, msg.Type == "m.notice": + return tcell.ColorGray + case msg.IsHighlight: + return tcell.ColorYellow + case msg.Type == "m.room.member": + return tcell.ColorGreen + default: + return tcell.ColorDefault + } +} + +// TimestampColor returns the color the timestamp should be shown in. +// +// As with SenderColor(), messages being sent and messages that failed to be sent are +// gray and red respectively. +// +// However, other messages are the default color instead of a color stored in the struct. +func (msg *UIMessage) TimestampColor() tcell.Color { + if msg.IsService { + return tcell.ColorGray + } + return msg.getStateSpecificColor() +} + +func (msg *UIMessage) ReplyHeight() int { + if msg.ReplyTo != nil { + return 1 + msg.ReplyTo.Height() + } + return 0 +} + +func (msg *UIMessage) ReactionHeight() int { + if len(msg.Reactions) > 0 { + return 1 + } + return 0 +} + +// Height returns the number of rows in the computed buffer (see Buffer()). +func (msg *UIMessage) Height() int { + return msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight() +} + +func (msg *UIMessage) Time() time.Time { + return msg.Timestamp +} + +// FormatTime returns the formatted time when the message was sent. +func (msg *UIMessage) FormatTime() string { + return msg.Timestamp.Format(TimeFormat) +} + +// FormatDate returns the formatted date when the message was sent. +func (msg *UIMessage) FormatDate() string { + return msg.Timestamp.Format(DateFormat) +} + +func (msg *UIMessage) SameDate(message *UIMessage) bool { + year1, month1, day1 := msg.Timestamp.Date() + year2, month2, day2 := message.Timestamp.Date() + return day1 == day2 && month1 == month2 && year1 == year2 +} + +func (msg *UIMessage) ID() id.EventID { + if len(msg.EventID) == 0 { + return id.EventID(msg.TxnID) + } + return msg.EventID +} + +func (msg *UIMessage) SetID(id id.EventID) { + msg.EventID = id +} + +func (msg *UIMessage) SetIsHighlight(isHighlight bool) { + msg.IsHighlight = isHighlight +} + +func (msg *UIMessage) DrawReactions(screen mauview.Screen) { + if len(msg.Reactions) == 0 { + return + } + width, height := screen.Size() + screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1) + + x := 0 + for _, reaction := range msg.Reactions { + _, drawn := mauview.PrintWithStyle(screen, reaction.String(), x, 0, width-x, mauview.AlignLeft, tcell.StyleDefault.Foreground(mauview.Styles.PrimaryTextColor).Background(tcell.ColorDarkGreen)) + x += drawn + 1 + if x >= width { + break + } + } +} + +func (msg *UIMessage) Draw(screen mauview.Screen) { + proxyScreen := msg.DrawReply(screen) + msg.Renderer.Draw(proxyScreen, msg) + msg.DrawReactions(proxyScreen) + if msg.IsSelected { + w, h := screen.Size() + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + mainc, combc, style, _ := screen.GetContent(x, y) + _, bg, _ := style.Decompose() + if bg == tcell.ColorDefault { + screen.SetContent(x, y, mainc, combc, style.Background(tcell.ColorDarkGreen)) + } + } + } + } +} + +func (msg *UIMessage) Clone() *UIMessage { + clone := *msg + clone.ReplyTo = nil + clone.Reactions = nil + clone.Renderer = clone.Renderer.Clone() + return &clone +} + +func (msg *UIMessage) CalculateReplyBuffer(preferences config.UserPreferences, width int) { + if msg.ReplyTo == nil { + return + } + msg.ReplyTo.CalculateBuffer(preferences, width-1) +} + +func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width int) { + msg.Renderer.CalculateBuffer(preferences, width, msg) + msg.CalculateReplyBuffer(preferences, width) +} + +func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen { + if msg.ReplyTo == nil { + return screen + } + width, height := screen.Size() + replyHeight := msg.ReplyTo.Height() + widget.WriteLineSimpleColor(screen, "In reply to", 1, 0, tcell.ColorGreen) + widget.WriteLineSimpleColor(screen, msg.ReplyTo.SenderName, 13, 0, msg.ReplyTo.SenderColor()) + for y := 0; y < 1+replyHeight; y++ { + screen.SetCell(0, y, tcell.StyleDefault, '▊') + } + replyScreen := mauview.NewProxyScreen(screen, 1, 1, width-1, replyHeight) + msg.ReplyTo.Draw(replyScreen) + return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1) +} + +func (msg *UIMessage) String() string { + return fmt.Sprintf(`&messages.UIMessage{ + ID="%s", TxnID="%s", + Type="%s", Timestamp=%s, + Sender={ID="%s", Name="%s", Color=#%X}, + IsService=%t, IsHighlight=%t, + Renderer=%s, +}`, + msg.EventID, msg.TxnID, + msg.Type, msg.Timestamp.String(), + msg.SenderID, msg.SenderName, msg.DefaultSenderColor.Hex(), + msg.IsService, msg.IsHighlight, msg.Renderer.String()) +} + +func (msg *UIMessage) PlainText() string { + return msg.Renderer.PlainText() +} diff --git a/tui/messages/doc.go b/tui/messages/doc.go new file mode 100644 index 0000000..289c308 --- /dev/null +++ b/tui/messages/doc.go @@ -0,0 +1,2 @@ +// Package messages contains different message types and code to generate and render them. +package messages diff --git a/tui/messages/expandedtextmessage.go b/tui/messages/expandedtextmessage.go new file mode 100644 index 0000000..8c2a3c6 --- /dev/null +++ b/tui/messages/expandedtextmessage.go @@ -0,0 +1,102 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + /* + "maunium.net/go/gomuks/matrix/muksevt" + + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/ui/messages/tstring" + */) + +type ExpandedTextMessage struct { + Text tstring.TString + buffer []tstring.TString +} + +// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state. +func NewExpandedTextMessage(evt *muksevt.Event, displayname string, text tstring.TString) *UIMessage { + return newUIMessage(evt, displayname, &ExpandedTextMessage{ + Text: text, + }) +} + +func NewServiceMessage(text string) *UIMessage { + return &UIMessage{ + SenderID: "*", + SenderName: "*", + Timestamp: time.Now(), + IsService: true, + Renderer: &ExpandedTextMessage{ + Text: tstring.NewTString(text), + }, + } +} + +func NewDateChangeMessage(text string) *UIMessage { + midnight := time.Now() + midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), + 0, 0, 0, 0, + midnight.Location()) + return &UIMessage{ + SenderID: "*", + SenderName: "*", + Timestamp: midnight, + IsService: true, + Renderer: &ExpandedTextMessage{ + Text: tstring.NewColorTString(text, tcell.ColorGreen), + }, + } +} + +func (msg *ExpandedTextMessage) Clone() MessageRenderer { + return &ExpandedTextMessage{ + Text: msg.Text.Clone(), + } +} + +func (msg *ExpandedTextMessage) NotificationContent() string { + return msg.Text.String() +} + +func (msg *ExpandedTextMessage) PlainText() string { + return msg.Text.String() +} + +func (msg *ExpandedTextMessage) String() string { + return fmt.Sprintf(`&messages.ExpandedTextMessage{Text="%s"}`, msg.Text.String()) +} + +func (msg *ExpandedTextMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { + msg.buffer = calculateBufferWithText(prefs, msg.Text, width, uiMsg) +} + +func (msg *ExpandedTextMessage) Height() int { + return len(msg.buffer) +} + +func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) { + for y, line := range msg.buffer { + line.Draw(screen, 0, y) + } +} diff --git a/tui/messages/filemessage.go b/tui/messages/filemessage.go new file mode 100644 index 0000000..7324071 --- /dev/null +++ b/tui/messages/filemessage.go @@ -0,0 +1,190 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "bytes" + "fmt" + "image" + "image/color" + + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + /* + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/debug" + ifc "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/lib/ansimage" + "maunium.net/go/gomuks/matrix/muksevt" + "maunium.net/go/gomuks/ui/messages/tstring" + */) + +type FileMessage struct { + Type event.MessageType + Body string + + URL id.ContentURI + File *attachment.EncryptedFile + Thumbnail id.ContentURI + ThumbnailFile *attachment.EncryptedFile + + eventID id.EventID + + imageData []byte + buffer []tstring.TString + + matrix ifc.MatrixContainer +} + +// NewFileMessage creates a new FileMessage object with the provided values and the default state. +func NewFileMessage(matrix ifc.MatrixContainer, evt *muksevt.Event, displayname string) *UIMessage { + content := evt.Content.AsMessage() + var file, thumbnailFile *attachment.EncryptedFile + if content.File != nil { + file = &content.File.EncryptedFile + content.URL = content.File.URL + } + if content.GetInfo().ThumbnailFile != nil { + thumbnailFile = &content.Info.ThumbnailFile.EncryptedFile + content.Info.ThumbnailURL = content.Info.ThumbnailFile.URL + } + return newUIMessage(evt, displayname, &FileMessage{ + Type: content.MsgType, + Body: content.Body, + URL: content.URL.ParseOrIgnore(), + File: file, + Thumbnail: content.GetInfo().ThumbnailURL.ParseOrIgnore(), + ThumbnailFile: thumbnailFile, + eventID: evt.ID, + matrix: matrix, + }) +} + +func (msg *FileMessage) Clone() MessageRenderer { + data := make([]byte, len(msg.imageData)) + copy(data, msg.imageData) + return &FileMessage{ + Body: msg.Body, + URL: msg.URL, + Thumbnail: msg.Thumbnail, + imageData: data, + matrix: msg.matrix, + } +} + +func (msg *FileMessage) NotificationContent() string { + switch msg.Type { + case event.MsgImage: + return "Sent an image" + case event.MsgAudio: + return "Sent an audio file" + case event.MsgVideo: + return "Sent a video" + case event.MsgFile: + fallthrough + default: + return "Sent a file" + } +} + +func (msg *FileMessage) PlainText() string { + return fmt.Sprintf("%s: %s", msg.Body, msg.matrix.GetDownloadURL(msg.URL, msg.File)) +} + +func (msg *FileMessage) String() string { + return fmt.Sprintf(`&messages.FileMessage{Body="%s", URL="%s", Thumbnail="%s"}`, msg.Body, msg.URL, msg.Thumbnail) +} + +func (msg *FileMessage) DownloadPreview() { + var url id.ContentURI + var file *attachment.EncryptedFile + if !msg.Thumbnail.IsEmpty() { + url = msg.Thumbnail + file = msg.ThumbnailFile + } else if msg.Type == event.MsgImage && !msg.URL.IsEmpty() { + msg.Thumbnail = msg.URL + url = msg.URL + file = msg.File + } else { + return + } + debug.Print("Loading file:", url) + data, err := msg.matrix.Download(url, file) + if err != nil { + debug.Printf("Failed to download file %s: %v", url, err) + return + } + debug.Print("File", url, "loaded.") + msg.imageData = data +} + +func (msg *FileMessage) ThumbnailPath() string { + return msg.matrix.GetCachePath(msg.Thumbnail) +} + +func (msg *FileMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { + if width < 2 { + return + } + + if prefs.BareMessageView || prefs.DisableImages || len(msg.imageData) == 0 { + url := msg.matrix.GetDownloadURL(msg.URL, msg.File) + var urlTString tstring.TString + if prefs.EnableInlineURLs() { + urlTString = tstring.NewStyleTString(url, tcell.StyleDefault.Url(url).UrlId(msg.eventID.String())) + } else { + urlTString = tstring.NewTString(url) + } + text := tstring.NewTString(msg.Body). + Append(": "). + AppendTString(urlTString) + msg.buffer = calculateBufferWithText(prefs, text, width, uiMsg) + return + } + + img, _, err := image.DecodeConfig(bytes.NewReader(msg.imageData)) + if err != nil { + debug.Print("File could not be decoded:", err) + } + imgWidth := img.Width + if img.Width > width { + imgWidth = width / 3 + } + + ansFile, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.imageData), 0, imgWidth, color.Black) + if err != nil { + msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)} + debug.Print("Failed to display image:", err) + return + } + + msg.buffer = ansFile.Render() +} + +func (msg *FileMessage) Height() int { + return len(msg.buffer) +} + +func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) { + for y, line := range msg.buffer { + line.Draw(screen, 0, y) + } +} diff --git a/tui/messages/html/base.go b/tui/messages/html/base.go new file mode 100644 index 0000000..203f79a --- /dev/null +++ b/tui/messages/html/base.go @@ -0,0 +1,101 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package html + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +type BaseEntity struct { + // The HTML tag of this entity. + Tag string + // Style for this entity. + Style tcell.Style + // Whether or not this is a block-type entity. + Block bool + // Height to use for entity if both text and children are empty. + DefaultHeight int + + prevWidth int + startX int + height int +} + +// AdjustStyle changes the style of this text entity. +func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + be.Style = fn(be.Style) + return be +} + +func (be *BaseEntity) IsEmpty() bool { + return false +} + +// IsBlock returns whether or not this is a block-type entity. +func (be *BaseEntity) IsBlock() bool { + return be.Block +} + +// GetTag returns the HTML tag of this entity. +func (be *BaseEntity) GetTag() string { + return be.Tag +} + +// Height returns the render height of this entity. +func (be *BaseEntity) Height() int { + return be.height +} + +func (be *BaseEntity) getStartX() int { + return be.startX +} + +// Clone creates a copy of this base entity. +func (be *BaseEntity) Clone() Entity { + return &BaseEntity{ + Tag: be.Tag, + Style: be.Style, + Block: be.Block, + DefaultHeight: be.DefaultHeight, + } +} + +func (be *BaseEntity) PlainText() string { + return "" +} + +// String returns a textual representation of this BaseEntity struct. +func (be *BaseEntity) String() string { + return fmt.Sprintf(`&html.BaseEntity{Tag="%s", Style=%#v, Block=%t, startX=%d, height=%d}`, + be.Tag, be.Style, be.Block, be.startX, be.height) +} + +// CalculateBuffer prepares this entity for rendering with the given parameters. +func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { + be.height = be.DefaultHeight + be.startX = startX + if be.Block { + be.startX = 0 + } + return be.startX +} + +func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) { + panic("Called Draw() of BaseEntity") +} diff --git a/tui/messages/html/blockquote.go b/tui/messages/html/blockquote.go new file mode 100644 index 0000000..25123da --- /dev/null +++ b/tui/messages/html/blockquote.go @@ -0,0 +1,88 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "strings" + + "go.mau.fi/mauview" +) + +type BlockquoteEntity struct { + *ContainerEntity +} + +const BlockQuoteChar = '>' + +func NewBlockquoteEntity(children []Entity) *BlockquoteEntity { + return &BlockquoteEntity{&ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "blockquote", + Block: true, + }, + Children: children, + Indent: 2, + }} +} + +func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) + return be +} + +func (be *BlockquoteEntity) Clone() Entity { + return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)} +} + +func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) { + be.ContainerEntity.Draw(screen, ctx) + for y := 0; y < be.height; y++ { + screen.SetContent(0, y, BlockQuoteChar, nil, be.Style) + } +} + +func (be *BlockquoteEntity) PlainText() string { + if len(be.Children) == 0 { + return "" + } + var buf strings.Builder + newlined := false + for i, child := range be.Children { + if i != 0 && child.IsBlock() && !newlined { + buf.WriteRune('\n') + } + newlined = false + for i, row := range strings.Split(child.PlainText(), "\n") { + if i != 0 { + buf.WriteRune('\n') + } + buf.WriteRune('>') + buf.WriteRune(' ') + buf.WriteString(row) + } + if child.IsBlock() { + buf.WriteRune('\n') + newlined = true + } + } + return strings.TrimSpace(buf.String()) +} + +func (be *BlockquoteEntity) String() string { + return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity) +} diff --git a/tui/messages/html/break.go b/tui/messages/html/break.go new file mode 100644 index 0000000..f702a76 --- /dev/null +++ b/tui/messages/html/break.go @@ -0,0 +1,54 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "go.mau.fi/mauview" +) + +type BreakEntity struct { + *BaseEntity +} + +func NewBreakEntity() *BreakEntity { + return &BreakEntity{&BaseEntity{ + Tag: "br", + Block: true, + }} +} + +// AdjustStyle changes the style of this text entity. +func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) + return be +} + +func (be *BreakEntity) Clone() Entity { + return NewBreakEntity() +} + +func (be *BreakEntity) PlainText() string { + return "\n" +} + +func (be *BreakEntity) String() string { + return "&html.BreakEntity{},\n" +} + +func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) { + // No-op, the logic happens in containers +} diff --git a/tui/messages/html/codeblock.go b/tui/messages/html/codeblock.go new file mode 100644 index 0000000..01071a8 --- /dev/null +++ b/tui/messages/html/codeblock.go @@ -0,0 +1,59 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +type CodeBlockEntity struct { + *ContainerEntity + Background tcell.Style +} + +func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity { + return &CodeBlockEntity{ + ContainerEntity: &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "pre", + Block: true, + }, + Children: children, + }, + Background: background, + } +} + +func (ce *CodeBlockEntity) Clone() Entity { + return &CodeBlockEntity{ + ContainerEntity: ce.ContainerEntity.Clone().(*ContainerEntity), + Background: ce.Background, + } +} + +func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) { + screen.Fill(' ', ce.Background) + ce.ContainerEntity.Draw(screen, ctx) +} + +func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + if reason != AdjustStyleReasonNormal { + ce.ContainerEntity.AdjustStyle(fn, reason) + } + return ce +} diff --git a/tui/messages/html/colormap.go b/tui/messages/html/colormap.go new file mode 100644 index 0000000..305309c --- /dev/null +++ b/tui/messages/html/colormap.go @@ -0,0 +1,156 @@ +// From https://github.com/golang/image/blob/master/colornames/colornames.go +package html + +import ( + "image/color" +) + +var colorMap = map[string]color.RGBA{ + "aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255) + "antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215) + "aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) + "aquamarine": {0x7f, 0xff, 0xd4, 0xff}, // rgb(127, 255, 212) + "azure": {0xf0, 0xff, 0xff, 0xff}, // rgb(240, 255, 255) + "beige": {0xf5, 0xf5, 0xdc, 0xff}, // rgb(245, 245, 220) + "bisque": {0xff, 0xe4, 0xc4, 0xff}, // rgb(255, 228, 196) + "black": {0x00, 0x00, 0x00, 0xff}, // rgb(0, 0, 0) + "blanchedalmond": {0xff, 0xeb, 0xcd, 0xff}, // rgb(255, 235, 205) + "blue": {0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255) + "blueviolet": {0x8a, 0x2b, 0xe2, 0xff}, // rgb(138, 43, 226) + "brown": {0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42) + "burlywood": {0xde, 0xb8, 0x87, 0xff}, // rgb(222, 184, 135) + "cadetblue": {0x5f, 0x9e, 0xa0, 0xff}, // rgb(95, 158, 160) + "chartreuse": {0x7f, 0xff, 0x00, 0xff}, // rgb(127, 255, 0) + "chocolate": {0xd2, 0x69, 0x1e, 0xff}, // rgb(210, 105, 30) + "coral": {0xff, 0x7f, 0x50, 0xff}, // rgb(255, 127, 80) + "cornflowerblue": {0x64, 0x95, 0xed, 0xff}, // rgb(100, 149, 237) + "cornsilk": {0xff, 0xf8, 0xdc, 0xff}, // rgb(255, 248, 220) + "crimson": {0xdc, 0x14, 0x3c, 0xff}, // rgb(220, 20, 60) + "cyan": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) + "darkblue": {0x00, 0x00, 0x8b, 0xff}, // rgb(0, 0, 139) + "darkcyan": {0x00, 0x8b, 0x8b, 0xff}, // rgb(0, 139, 139) + "darkgoldenrod": {0xb8, 0x86, 0x0b, 0xff}, // rgb(184, 134, 11) + "darkgray": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) + "darkgreen": {0x00, 0x64, 0x00, 0xff}, // rgb(0, 100, 0) + "darkgrey": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) + "darkkhaki": {0xbd, 0xb7, 0x6b, 0xff}, // rgb(189, 183, 107) + "darkmagenta": {0x8b, 0x00, 0x8b, 0xff}, // rgb(139, 0, 139) + "darkolivegreen": {0x55, 0x6b, 0x2f, 0xff}, // rgb(85, 107, 47) + "darkorange": {0xff, 0x8c, 0x00, 0xff}, // rgb(255, 140, 0) + "darkorchid": {0x99, 0x32, 0xcc, 0xff}, // rgb(153, 50, 204) + "darkred": {0x8b, 0x00, 0x00, 0xff}, // rgb(139, 0, 0) + "darksalmon": {0xe9, 0x96, 0x7a, 0xff}, // rgb(233, 150, 122) + "darkseagreen": {0x8f, 0xbc, 0x8f, 0xff}, // rgb(143, 188, 143) + "darkslateblue": {0x48, 0x3d, 0x8b, 0xff}, // rgb(72, 61, 139) + "darkslategray": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) + "darkslategrey": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) + "darkturquoise": {0x00, 0xce, 0xd1, 0xff}, // rgb(0, 206, 209) + "darkviolet": {0x94, 0x00, 0xd3, 0xff}, // rgb(148, 0, 211) + "deeppink": {0xff, 0x14, 0x93, 0xff}, // rgb(255, 20, 147) + "deepskyblue": {0x00, 0xbf, 0xff, 0xff}, // rgb(0, 191, 255) + "dimgray": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) + "dimgrey": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) + "dodgerblue": {0x1e, 0x90, 0xff, 0xff}, // rgb(30, 144, 255) + "firebrick": {0xb2, 0x22, 0x22, 0xff}, // rgb(178, 34, 34) + "floralwhite": {0xff, 0xfa, 0xf0, 0xff}, // rgb(255, 250, 240) + "forestgreen": {0x22, 0x8b, 0x22, 0xff}, // rgb(34, 139, 34) + "fuchsia": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) + "gainsboro": {0xdc, 0xdc, 0xdc, 0xff}, // rgb(220, 220, 220) + "ghostwhite": {0xf8, 0xf8, 0xff, 0xff}, // rgb(248, 248, 255) + "gold": {0xff, 0xd7, 0x00, 0xff}, // rgb(255, 215, 0) + "goldenrod": {0xda, 0xa5, 0x20, 0xff}, // rgb(218, 165, 32) + "gray": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) + "green": {0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0) + "greenyellow": {0xad, 0xff, 0x2f, 0xff}, // rgb(173, 255, 47) + "grey": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) + "honeydew": {0xf0, 0xff, 0xf0, 0xff}, // rgb(240, 255, 240) + "hotpink": {0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180) + "indianred": {0xcd, 0x5c, 0x5c, 0xff}, // rgb(205, 92, 92) + "indigo": {0x4b, 0x00, 0x82, 0xff}, // rgb(75, 0, 130) + "ivory": {0xff, 0xff, 0xf0, 0xff}, // rgb(255, 255, 240) + "khaki": {0xf0, 0xe6, 0x8c, 0xff}, // rgb(240, 230, 140) + "lavender": {0xe6, 0xe6, 0xfa, 0xff}, // rgb(230, 230, 250) + "lavenderblush": {0xff, 0xf0, 0xf5, 0xff}, // rgb(255, 240, 245) + "lawngreen": {0x7c, 0xfc, 0x00, 0xff}, // rgb(124, 252, 0) + "lemonchiffon": {0xff, 0xfa, 0xcd, 0xff}, // rgb(255, 250, 205) + "lightblue": {0xad, 0xd8, 0xe6, 0xff}, // rgb(173, 216, 230) + "lightcoral": {0xf0, 0x80, 0x80, 0xff}, // rgb(240, 128, 128) + "lightcyan": {0xe0, 0xff, 0xff, 0xff}, // rgb(224, 255, 255) + "lightgoldenrodyellow": {0xfa, 0xfa, 0xd2, 0xff}, // rgb(250, 250, 210) + "lightgray": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) + "lightgreen": {0x90, 0xee, 0x90, 0xff}, // rgb(144, 238, 144) + "lightgrey": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) + "lightpink": {0xff, 0xb6, 0xc1, 0xff}, // rgb(255, 182, 193) + "lightsalmon": {0xff, 0xa0, 0x7a, 0xff}, // rgb(255, 160, 122) + "lightseagreen": {0x20, 0xb2, 0xaa, 0xff}, // rgb(32, 178, 170) + "lightskyblue": {0x87, 0xce, 0xfa, 0xff}, // rgb(135, 206, 250) + "lightslategray": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) + "lightslategrey": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) + "lightsteelblue": {0xb0, 0xc4, 0xde, 0xff}, // rgb(176, 196, 222) + "lightyellow": {0xff, 0xff, 0xe0, 0xff}, // rgb(255, 255, 224) + "lime": {0x00, 0xff, 0x00, 0xff}, // rgb(0, 255, 0) + "limegreen": {0x32, 0xcd, 0x32, 0xff}, // rgb(50, 205, 50) + "linen": {0xfa, 0xf0, 0xe6, 0xff}, // rgb(250, 240, 230) + "magenta": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) + "maroon": {0x80, 0x00, 0x00, 0xff}, // rgb(128, 0, 0) + "mediumaquamarine": {0x66, 0xcd, 0xaa, 0xff}, // rgb(102, 205, 170) + "mediumblue": {0x00, 0x00, 0xcd, 0xff}, // rgb(0, 0, 205) + "mediumorchid": {0xba, 0x55, 0xd3, 0xff}, // rgb(186, 85, 211) + "mediumpurple": {0x93, 0x70, 0xdb, 0xff}, // rgb(147, 112, 219) + "mediumseagreen": {0x3c, 0xb3, 0x71, 0xff}, // rgb(60, 179, 113) + "mediumslateblue": {0x7b, 0x68, 0xee, 0xff}, // rgb(123, 104, 238) + "mediumspringgreen": {0x00, 0xfa, 0x9a, 0xff}, // rgb(0, 250, 154) + "mediumturquoise": {0x48, 0xd1, 0xcc, 0xff}, // rgb(72, 209, 204) + "mediumvioletred": {0xc7, 0x15, 0x85, 0xff}, // rgb(199, 21, 133) + "midnightblue": {0x19, 0x19, 0x70, 0xff}, // rgb(25, 25, 112) + "mintcream": {0xf5, 0xff, 0xfa, 0xff}, // rgb(245, 255, 250) + "mistyrose": {0xff, 0xe4, 0xe1, 0xff}, // rgb(255, 228, 225) + "moccasin": {0xff, 0xe4, 0xb5, 0xff}, // rgb(255, 228, 181) + "navajowhite": {0xff, 0xde, 0xad, 0xff}, // rgb(255, 222, 173) + "navy": {0x00, 0x00, 0x80, 0xff}, // rgb(0, 0, 128) + "oldlace": {0xfd, 0xf5, 0xe6, 0xff}, // rgb(253, 245, 230) + "olive": {0x80, 0x80, 0x00, 0xff}, // rgb(128, 128, 0) + "olivedrab": {0x6b, 0x8e, 0x23, 0xff}, // rgb(107, 142, 35) + "orange": {0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0) + "orangered": {0xff, 0x45, 0x00, 0xff}, // rgb(255, 69, 0) + "orchid": {0xda, 0x70, 0xd6, 0xff}, // rgb(218, 112, 214) + "palegoldenrod": {0xee, 0xe8, 0xaa, 0xff}, // rgb(238, 232, 170) + "palegreen": {0x98, 0xfb, 0x98, 0xff}, // rgb(152, 251, 152) + "paleturquoise": {0xaf, 0xee, 0xee, 0xff}, // rgb(175, 238, 238) + "palevioletred": {0xdb, 0x70, 0x93, 0xff}, // rgb(219, 112, 147) + "papayawhip": {0xff, 0xef, 0xd5, 0xff}, // rgb(255, 239, 213) + "peachpuff": {0xff, 0xda, 0xb9, 0xff}, // rgb(255, 218, 185) + "peru": {0xcd, 0x85, 0x3f, 0xff}, // rgb(205, 133, 63) + "pink": {0xff, 0xc0, 0xcb, 0xff}, // rgb(255, 192, 203) + "plum": {0xdd, 0xa0, 0xdd, 0xff}, // rgb(221, 160, 221) + "powderblue": {0xb0, 0xe0, 0xe6, 0xff}, // rgb(176, 224, 230) + "purple": {0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128) + "red": {0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0) + "rosybrown": {0xbc, 0x8f, 0x8f, 0xff}, // rgb(188, 143, 143) + "royalblue": {0x41, 0x69, 0xe1, 0xff}, // rgb(65, 105, 225) + "saddlebrown": {0x8b, 0x45, 0x13, 0xff}, // rgb(139, 69, 19) + "salmon": {0xfa, 0x80, 0x72, 0xff}, // rgb(250, 128, 114) + "sandybrown": {0xf4, 0xa4, 0x60, 0xff}, // rgb(244, 164, 96) + "seagreen": {0x2e, 0x8b, 0x57, 0xff}, // rgb(46, 139, 87) + "seashell": {0xff, 0xf5, 0xee, 0xff}, // rgb(255, 245, 238) + "sienna": {0xa0, 0x52, 0x2d, 0xff}, // rgb(160, 82, 45) + "silver": {0xc0, 0xc0, 0xc0, 0xff}, // rgb(192, 192, 192) + "skyblue": {0x87, 0xce, 0xeb, 0xff}, // rgb(135, 206, 235) + "slateblue": {0x6a, 0x5a, 0xcd, 0xff}, // rgb(106, 90, 205) + "slategray": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) + "slategrey": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) + "snow": {0xff, 0xfa, 0xfa, 0xff}, // rgb(255, 250, 250) + "springgreen": {0x00, 0xff, 0x7f, 0xff}, // rgb(0, 255, 127) + "steelblue": {0x46, 0x82, 0xb4, 0xff}, // rgb(70, 130, 180) + "tan": {0xd2, 0xb4, 0x8c, 0xff}, // rgb(210, 180, 140) + "teal": {0x00, 0x80, 0x80, 0xff}, // rgb(0, 128, 128) + "thistle": {0xd8, 0xbf, 0xd8, 0xff}, // rgb(216, 191, 216) + "tomato": {0xff, 0x63, 0x47, 0xff}, // rgb(255, 99, 71) + "turquoise": {0x40, 0xe0, 0xd0, 0xff}, // rgb(64, 224, 208) + "violet": {0xee, 0x82, 0xee, 0xff}, // rgb(238, 130, 238) + "wheat": {0xf5, 0xde, 0xb3, 0xff}, // rgb(245, 222, 179) + "white": {0xff, 0xff, 0xff, 0xff}, // rgb(255, 255, 255) + "whitesmoke": {0xf5, 0xf5, 0xf5, 0xff}, // rgb(245, 245, 245) + "yellow": {0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0) + "yellowgreen": {0x9a, 0xcd, 0x32, 0xff}, // rgb(154, 205, 50) +} diff --git a/tui/messages/html/container.go b/tui/messages/html/container.go new file mode 100644 index 0000000..17e7aa9 --- /dev/null +++ b/tui/messages/html/container.go @@ -0,0 +1,148 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "strings" + + "go.mau.fi/mauview" +) + +type ContainerEntity struct { + *BaseEntity + + // The children of this container entity. + Children []Entity + // Number of cells to indent children. + Indent int +} + +func (ce *ContainerEntity) IsEmpty() bool { + return len(ce.Children) == 0 +} + +// PlainText returns the plaintext content in this entity and all its children. +func (ce *ContainerEntity) PlainText() string { + if len(ce.Children) == 0 { + return "" + } + var buf strings.Builder + newlined := false + for _, child := range ce.Children { + text := child.PlainText() + if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined { + buf.WriteRune('\n') + } + newlined = false + buf.WriteString(text) + if child.IsBlock() { + if !strings.HasSuffix(text, "\n") { + buf.WriteRune('\n') + } + newlined = true + } + } + return strings.TrimSpace(buf.String()) +} + +// AdjustStyle recursively changes the style of this entity and all its children. +func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + for _, child := range ce.Children { + child.AdjustStyle(fn, reason) + } + ce.Style = fn(ce.Style) + return ce +} + +// Clone creates a deep copy of this base entity. +func (ce *ContainerEntity) Clone() Entity { + children := make([]Entity, len(ce.Children)) + for i, child := range ce.Children { + children[i] = child.Clone() + } + return &ContainerEntity{ + BaseEntity: ce.BaseEntity.Clone().(*BaseEntity), + Children: children, + Indent: ce.Indent, + } +} + +// String returns a textual representation of this BaseEntity struct. +func (ce *ContainerEntity) String() string { + if len(ce.Children) == 0 { + return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent) + } + var buf strings.Builder + _, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent) + for _, child := range ce.Children { + buf.WriteString("\n ") + buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n ")) + } + buf.WriteString("\n]},") + return buf.String() +} + +// Draw draws this entity onto the given mauview Screen. +func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) { + if len(ce.Children) == 0 { + return + } + width, _ := screen.Size() + prevBreak := false + proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style} + for i, entity := range ce.Children { + if i != 0 && entity.getStartX() == 0 { + proxyScreen.OffsetY++ + } + proxyScreen.Height = entity.Height() + entity.Draw(proxyScreen, ctx) + proxyScreen.SetStyle(ce.Style) + proxyScreen.OffsetY += entity.Height() - 1 + _, isBreak := entity.(*BreakEntity) + if prevBreak && isBreak { + proxyScreen.OffsetY++ + } + prevBreak = isBreak + } +} + +// CalculateBuffer prepares this entity and all its children for rendering with the given parameters +func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { + ce.BaseEntity.CalculateBuffer(width, startX, ctx) + if len(ce.Children) > 0 { + ce.height = 0 + childStartX := ce.startX + prevBreak := false + for _, entity := range ce.Children { + if entity.IsBlock() || childStartX == 0 || ce.height == 0 { + ce.height++ + } + childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx) + ce.height += entity.Height() - 1 + _, isBreak := entity.(*BreakEntity) + if prevBreak && isBreak { + ce.height++ + } + prevBreak = isBreak + } + if !ce.Block { + return childStartX + } + } + return ce.startX +} diff --git a/tui/messages/html/entity.go b/tui/messages/html/entity.go new file mode 100644 index 0000000..3c4260a --- /dev/null +++ b/tui/messages/html/entity.go @@ -0,0 +1,63 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +// AdjustStyleFunc is a lambda function type to edit an existing tcell Style. +type AdjustStyleFunc func(tcell.Style) tcell.Style + +type AdjustStyleReason int + +const ( + AdjustStyleReasonNormal AdjustStyleReason = iota + AdjustStyleReasonHideSpoiler +) + +type DrawContext struct { + IsSelected bool + BareMessages bool +} + +type Entity interface { + // AdjustStyle recursively changes the style of the entity and all its children. + AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity + // Draw draws the entity onto the given mauview Screen. + Draw(screen mauview.Screen, ctx DrawContext) + // IsBlock returns whether or not it's a block-type entity. + IsBlock() bool + // GetTag returns the HTML tag of the entity. + GetTag() string + // PlainText returns the plaintext content in the entity and all its children. + PlainText() string + // String returns a string representation of the entity struct. + String() string + // Clone creates a deep copy of the entity. + Clone() Entity + + // Height returns the render height of the entity. + Height() int + // CalculateBuffer prepares the entity and all its children for rendering with the given parameters + CalculateBuffer(width, startX int, ctx DrawContext) int + + getStartX() int + + IsEmpty() bool +} diff --git a/tui/messages/html/horizontalline.go b/tui/messages/html/horizontalline.go new file mode 100644 index 0000000..ff12709 --- /dev/null +++ b/tui/messages/html/horizontalline.go @@ -0,0 +1,61 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "strings" + + "go.mau.fi/mauview" +) + +type HorizontalLineEntity struct { + *BaseEntity +} + +const HorizontalLineChar = '━' + +func NewHorizontalLineEntity() *HorizontalLineEntity { + return &HorizontalLineEntity{&BaseEntity{ + Tag: "hr", + Block: true, + DefaultHeight: 1, + }} +} + +func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) + return he +} + +func (he *HorizontalLineEntity) Clone() Entity { + return NewHorizontalLineEntity() +} + +func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) { + width, _ := screen.Size() + for x := 0; x < width; x++ { + screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style) + } +} + +func (he *HorizontalLineEntity) PlainText() string { + return strings.Repeat(string(HorizontalLineChar), 5) +} + +func (he *HorizontalLineEntity) String() string { + return "&html.HorizontalLineEntity{},\n" +} diff --git a/tui/messages/html/list.go b/tui/messages/html/list.go new file mode 100644 index 0000000..04f1414 --- /dev/null +++ b/tui/messages/html/list.go @@ -0,0 +1,123 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "strings" + + "go.mau.fi/mauview" + + "maunium.net/go/gomuks/tui/widget" + "maunium.net/go/mautrix/format" +) + +type ListEntity struct { + *ContainerEntity + Ordered bool + Start int +} + +func NewListEntity(ordered bool, start int, children []Entity) *ListEntity { + entity := &ListEntity{ + ContainerEntity: &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "ul", + Block: true, + }, + Indent: 2, + Children: children, + }, + Ordered: ordered, + Start: start, + } + if ordered { + entity.Tag = "ol" + entity.Indent += format.Digits(start + len(children) - 1) + } + return entity +} + +func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) + le.ContainerEntity.AdjustStyle(fn, reason) + return le +} + +func (le *ListEntity) Clone() Entity { + return &ListEntity{ + ContainerEntity: le.ContainerEntity.Clone().(*ContainerEntity), + Ordered: le.Ordered, + Start: le.Start, + } +} + +func (le *ListEntity) paddingFor(number int) string { + padding := le.Indent - 2 - format.Digits(number) + if padding <= 0 { + return "" + } + return strings.Repeat(" ", padding) +} + +func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) { + width, _ := screen.Size() + + proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style} + for i, entity := range le.Children { + proxyScreen.Height = entity.Height() + if le.Ordered { + number := le.Start + i + line := fmt.Sprintf("%d. %s", number, le.paddingFor(number)) + widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style) + } else { + screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style) + } + entity.Draw(proxyScreen, ctx) + proxyScreen.SetStyle(le.Style) + proxyScreen.OffsetY += entity.Height() + } +} + +func (le *ListEntity) PlainText() string { + if len(le.Children) == 0 { + return "" + } + var buf strings.Builder + for i, child := range le.Children { + indent := strings.Repeat(" ", le.Indent) + if le.Ordered { + number := le.Start + i + _, _ = fmt.Fprintf(&buf, "%d. %s", number, le.paddingFor(number)) + } else { + buf.WriteString("● ") + } + for j, row := range strings.Split(child.PlainText(), "\n") { + if j != 0 { + buf.WriteRune('\n') + buf.WriteString(indent) + } + buf.WriteString(row) + } + buf.WriteRune('\n') + } + return strings.TrimSpace(buf.String()) +} + +func (le *ListEntity) String() string { + return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity) +} diff --git a/tui/messages/html/parser.go b/tui/messages/html/parser.go new file mode 100644 index 0000000..5362f9c --- /dev/null +++ b/tui/messages/html/parser.go @@ -0,0 +1,555 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + /* + "github.com/alecthomas/chroma" + "github.com/alecthomas/chroma/lexers" + "github.com/alecthomas/chroma/styles" + */ + "github.com/lucasb-eyer/go-colorful" + "golang.org/x/net/html" + "mvdan.cc/xurls/v2" + + "github.com/gdamore/tcell/v2" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +/* + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/matrix/muksevt" + "maunium.net/go/gomuks/matrix/rooms" + "maunium.net/go/gomuks/ui/widget" +*/ +) + +type htmlParser struct { + prefs *config.UserPreferences + room *rooms.Room + evt *muksevt.Event + + preserveWhitespace bool + linkIDCounter int +} + +func AdjustStyleBold(style tcell.Style) tcell.Style { + return style.Bold(true) +} + +func AdjustStyleItalic(style tcell.Style) tcell.Style { + return style.Italic(true) +} + +func AdjustStyleUnderline(style tcell.Style) tcell.Style { + return style.Underline(true) +} + +func AdjustStyleStrikethrough(style tcell.Style) tcell.Style { + return style.StrikeThrough(true) +} + +func AdjustStyleTextColor(color tcell.Color) AdjustStyleFunc { + return func(style tcell.Style) tcell.Style { + return style.Foreground(color) + } +} + +func AdjustStyleBackgroundColor(color tcell.Color) AdjustStyleFunc { + return func(style tcell.Style) tcell.Style { + return style.Background(color) + } +} + +func AdjustStyleLink(url, id string) AdjustStyleFunc { + return func(style tcell.Style) tcell.Style { + return style.Url(url).UrlId(id) + } +} + +func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { + for _, attr := range node.Attr { + if attr.Key == attribute { + return attr.Val, true + } + } + return "", false +} + +func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string { + val, _ := parser.maybeGetAttribute(node, attribute) + return val +} + +func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool { + _, ok := parser.maybeGetAttribute(node, attribute) + return ok +} + +func (parser *htmlParser) listToEntity(node *html.Node) Entity { + children := parser.nodeToEntities(node.FirstChild) + ordered := node.Data == "ol" + start := 1 + if ordered { + if startRaw := parser.getAttribute(node, "start"); len(startRaw) > 0 { + var err error + start, err = strconv.Atoi(startRaw) + if err != nil { + start = 1 + } + } + } + listItems := children[:0] + for _, child := range children { + if child.GetTag() == "li" { + listItems = append(listItems, child) + } + } + return NewListEntity(ordered, start, listItems) +} + +func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity { + entity := &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: node.Data, + }, + Children: parser.nodeToEntities(node.FirstChild), + } + switch node.Data { + case "b", "strong": + entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) + case "i", "em": + entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal) + case "s", "del", "strike": + entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal) + case "u", "ins": + entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal) + case "code": + bgColor := tcell.ColorDarkSlateGray + fgColor := tcell.ColorWhite + entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) + entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) + case "font", "span": + fgColor, ok := parser.parseColor(node, "data-mx-color", "color") + if ok { + entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) + } + bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color") + if ok { + entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) + } + spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler") + if isSpoiler { + return NewSpoilerEntity(entity, spoilerReason) + } + } + return entity +} + +func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) { + hex := parser.getAttribute(node, mainName) + if len(hex) == 0 { + hex = parser.getAttribute(node, altName) + if len(hex) == 0 { + return + } + } + + cful, err := colorful.Hex(hex) + if err != nil { + color2, found := colorMap[strings.ToLower(hex)] + if !found { + return + } + cful, _ = colorful.MakeColor(color2) + } + + r, g, b := cful.RGB255() + return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true +} + +func (parser *htmlParser) headerToEntity(node *html.Node) Entity { + return (&ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: node.Data, + }, + Children: append( + []Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")}, + parser.nodeToEntities(node.FirstChild)..., + ), + }).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) +} + +func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity { + return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild)) +} + +func (parser *htmlParser) linkToEntity(node *html.Node) Entity { + sameURL := false + href := parser.getAttribute(node, "href") + + entity := &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "a", + }, + Children: parser.nodeToEntities(node.FirstChild), + } + + if len(href) == 0 { + return entity + } + + if len(entity.Children) == 1 { + entity, ok := entity.Children[0].(*TextEntity) + if ok && entity.Text == href { + sameURL = true + } + } + + matrixURI, _ := id.ParseMatrixURIOrMatrixToURL(href) + if matrixURI != nil && (matrixURI.Sigil1 == '@' || matrixURI.Sigil1 == '#') && matrixURI.Sigil2 == 0 { + text := NewTextEntity(matrixURI.PrimaryIdentifier()) + if matrixURI.Sigil1 == '@' { + if member := parser.room.GetMember(matrixURI.UserID()); member != nil { + text.Text = member.Displayname + text.Style = text.Style.Foreground(widget.GetHashColor(matrixURI.UserID())) + } + entity.Children = []Entity{text} + } else if matrixURI.Sigil1 == '#' { + entity.Children = []Entity{text} + } + } else if parser.prefs.EnableInlineURLs() { + linkID := fmt.Sprintf("%s-%d", parser.evt.ID, parser.linkIDCounter) + parser.linkIDCounter++ + entity.AdjustStyle(AdjustStyleLink(href, linkID), AdjustStyleReasonNormal) + } else if !sameURL && !parser.prefs.DisableShowURLs && !parser.hasAttribute(node, "data-mautrix-exclude-plaintext") { + entity.Children = append(entity.Children, NewTextEntity(fmt.Sprintf(" (%s)", href))) + } + return entity +} + +func (parser *htmlParser) imageToEntity(node *html.Node) Entity { + alt := parser.getAttribute(node, "alt") + if len(alt) == 0 { + alt = parser.getAttribute(node, "title") + if len(alt) == 0 { + alt = "[inline image]" + } + } + entity := &TextEntity{ + BaseEntity: &BaseEntity{ + Tag: "img", + }, + Text: alt, + } + // TODO add click action and underline on hover for inline images + return entity +} + +func colourToColor(colour chroma.Colour) tcell.Color { + if !colour.IsSet() { + return tcell.ColorDefault + } + return tcell.NewRGBColor(int32(colour.Red()), int32(colour.Green()), int32(colour.Blue())) +} + +func styleEntryToStyle(se chroma.StyleEntry) tcell.Style { + return tcell.StyleDefault. + Bold(se.Bold == chroma.Yes). + Italic(se.Italic == chroma.Yes). + Underline(se.Underline == chroma.Yes). + Foreground(colourToColor(se.Colour)). + Background(colourToColor(se.Background)) +} + +func tokenToTextEntity(style *chroma.Style, token *chroma.Token) *TextEntity { + return &TextEntity{ + BaseEntity: &BaseEntity{ + Tag: token.Type.String(), + Style: styleEntryToStyle(style.Get(token.Type)), + DefaultHeight: 1, + }, + Text: token.Value, + } +} + +func (parser *htmlParser) syntaxHighlight(text, language string) Entity { + lexer := lexers.Get(strings.ToLower(language)) + if lexer == nil { + lexer = lexers.Get("plaintext") + } + iter, err := lexer.Tokenise(nil, text) + if err != nil { + return nil + } + // TODO allow changing theme + style := styles.SolarizedDark + + tokens := iter.Tokens() + + var children []Entity + for _, token := range tokens { + lines := strings.SplitAfter(token.Value, "\n") + for _, line := range lines { + line_len := len(line) + if line_len == 0 { + continue + } + t := token.Clone() + + if line[line_len-1:] == "\n" { + t.Value = line[:line_len-1] + children = append(children, tokenToTextEntity(style, &t), NewBreakEntity()) + } else { + t.Value = line + children = append(children, tokenToTextEntity(style, &t)) + } + } + } + + return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background))) +} + +func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity { + lang := "plaintext" + // TODO allow disabling syntax highlighting + if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { + node = node.FirstChild + attr := parser.getAttribute(node, "class") + for _, class := range strings.Split(attr, " ") { + if strings.HasPrefix(class, "language-") { + lang = class[len("language-"):] + break + } + } + } + parser.preserveWhitespace = true + text := (&ContainerEntity{ + Children: parser.nodeToEntities(node.FirstChild), + }).PlainText() + parser.preserveWhitespace = false + return parser.syntaxHighlight(text, lang) +} + +func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity { + switch node.Data { + case "blockquote": + return parser.blockquoteToEntity(node) + case "ol", "ul": + return parser.listToEntity(node) + case "h1", "h2", "h3", "h4", "h5", "h6": + return parser.headerToEntity(node) + case "br": + return NewBreakEntity() + case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "font", "span", "code": + return parser.basicFormatToEntity(node) + case "a": + return parser.linkToEntity(node) + case "img": + return parser.imageToEntity(node) + case "pre": + return parser.codeblockToEntity(node) + case "hr": + return NewHorizontalLineEntity() + case "mx-reply": + return nil + default: + return &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: node.Data, + Block: parser.isBlockTag(node.Data), + }, + Children: parser.nodeToEntities(node.FirstChild), + } + } +} + +var spaces = regexp.MustCompile("\\s+") + +// textToHTMLEntity converts a plain text string into an HTML Entity while preserving newlines. +func textToHTMLEntity(text string) Entity { + if strings.Index(text, "\n") == -1 { + return NewTextEntity(text) + } + return &ContainerEntity{ + BaseEntity: &BaseEntity{Tag: "span"}, + Children: textToHTMLEntities(text), + } +} + +func textToHTMLEntities(text string) []Entity { + lines := strings.SplitAfter(text, "\n") + entities := make([]Entity, 0, len(lines)) + for _, line := range lines { + line_len := len(line) + if line_len == 0 { + continue + } + if line == "\n" { + entities = append(entities, NewBreakEntity()) + } else if line[line_len-1:] == "\n" { + entities = append(entities, NewTextEntity(line[:line_len-1]), NewBreakEntity()) + } else { + entities = append(entities, NewTextEntity(line)) + } + } + return entities +} + +func TextToEntity(text string, eventID id.EventID, linkify bool) Entity { + if len(text) == 0 { + return nil + } + if !linkify { + return textToHTMLEntity(text) + } + indices := xurls.Strict().FindAllStringIndex(text, -1) + if len(indices) == 0 { + return textToHTMLEntity(text) + } + ent := &ContainerEntity{ + BaseEntity: &BaseEntity{Tag: "span"}, + } + var lastEnd int + for i, item := range indices { + start, end := item[0], item[1] + if start > lastEnd { + ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:start])...) + } + link := text[start:end] + linkID := fmt.Sprintf("%s-%d", eventID, i) + ent.Children = append(ent.Children, NewTextEntity(link).AdjustStyle(AdjustStyleLink(link, linkID), AdjustStyleReasonNormal)) + lastEnd = end + } + if lastEnd < len(text) { + ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:])...) + } + return ent +} + +func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity { + switch node.Type { + case html.TextNode: + if !parser.preserveWhitespace { + node.Data = strings.ReplaceAll(node.Data, "\n", "") + node.Data = spaces.ReplaceAllLiteralString(node.Data, " ") + } + return TextToEntity(node.Data, parser.evt.ID, parser.prefs.EnableInlineURLs()) + case html.ElementNode: + parsed := parser.tagNodeToEntity(node) + if parsed != nil && !parsed.IsBlock() && parsed.IsEmpty() { + return nil + } + return parsed + case html.DocumentNode: + if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil { + return parser.singleNodeToEntity(node.FirstChild) + } + return &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "html", + Block: true, + }, + Children: parser.nodeToEntities(node.FirstChild), + } + default: + return nil + } +} + +func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) { + for ; node != nil; node = node.NextSibling { + if entity := parser.singleNodeToEntity(node); entity != nil { + entities = append(entities, entity) + } + } + return +} + +var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "pre", "blockquote", "div", "hr", "table"} + +func (parser *htmlParser) isBlockTag(tag string) bool { + for _, blockTag := range BlockTags { + if tag == blockTag { + return true + } + } + return false +} + +func (parser *htmlParser) Parse(htmlData string) Entity { + node, _ := html.Parse(strings.NewReader(htmlData)) + bodyNode := node.FirstChild.FirstChild + for bodyNode != nil && (bodyNode.Type != html.ElementNode || bodyNode.Data != "body") { + bodyNode = bodyNode.NextSibling + } + if bodyNode != nil { + return parser.singleNodeToEntity(bodyNode) + } + + return parser.singleNodeToEntity(node) +} + +const TabLength = 4 + +// Parse parses a HTML-formatted Matrix event into a UIMessage. +func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.MessageEventContent, evt *muksevt.Event, senderDisplayname string) Entity { + htmlData := content.FormattedBody + + if content.Format != event.FormatHTML { + htmlData = strings.Replace(html.EscapeString(content.Body), "\n", "
", -1) + } + htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1) + + parser := htmlParser{room: room, prefs: prefs, evt: evt} + root := parser.Parse(htmlData) + if root == nil { + return nil + } + beRoot, ok := root.(*ContainerEntity) + if ok { + beRoot.Block = false + if len(beRoot.Children) > 0 { + beChild, ok := beRoot.Children[0].(*ContainerEntity) + if ok && beChild.Tag == "p" { + // Hacky fix for m.emote + beChild.Block = false + } + } + } + + if content.MsgType == event.MsgEmote { + root = &ContainerEntity{ + BaseEntity: &BaseEntity{ + Tag: "emote", + }, + Children: []Entity{ + NewTextEntity("* "), + NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal), + NewTextEntity(" "), + root, + }, + } + } + + return root +} diff --git a/tui/messages/html/spoiler.go b/tui/messages/html/spoiler.go new file mode 100644 index 0000000..c325798 --- /dev/null +++ b/tui/messages/html/spoiler.go @@ -0,0 +1,120 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2022 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "strings" + + "go.mau.fi/mauview" + "github.com/gdamore/tcell/v2" +) + +type SpoilerEntity struct { + reason string + hidden *ContainerEntity + visible *ContainerEntity +} + +const SpoilerColor = tcell.ColorYellow + +func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity { + hidden := visible.Clone().(*ContainerEntity) + hidden.AdjustStyle(func(style tcell.Style) tcell.Style { + return style.Foreground(SpoilerColor).Background(SpoilerColor) + }, AdjustStyleReasonHideSpoiler) + if len(reason) > 0 { + reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason)) + hidden.Children = append([]Entity{reasonEnt}, hidden.Children...) + visible.Children = append([]Entity{reasonEnt}, visible.Children...) + } + return &SpoilerEntity{ + reason: reason, + hidden: hidden, + visible: visible, + } +} + +func (se *SpoilerEntity) Clone() Entity { + return &SpoilerEntity{ + reason: se.reason, + hidden: se.hidden.Clone().(*ContainerEntity), + visible: se.visible.Clone().(*ContainerEntity), + } +} + +func (se *SpoilerEntity) IsBlock() bool { + return false +} + +func (se *SpoilerEntity) GetTag() string { + return "span" +} + +func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) { + if ctx.IsSelected { + se.visible.Draw(screen, ctx) + } else { + se.hidden.Draw(screen, ctx) + } +} + +func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + if reason != AdjustStyleReasonHideSpoiler { + se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style { + return fn(style).Foreground(SpoilerColor).Background(SpoilerColor) + }, reason) + se.visible.AdjustStyle(fn, reason) + } + return se +} + +func (se *SpoilerEntity) PlainText() string { + if len(se.reason) > 0 { + return fmt.Sprintf("spoiler: %s", se.reason) + } else { + return "spoiler" + } +} + +func (se *SpoilerEntity) String() string { + var buf strings.Builder + _, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason) + buf.WriteString("\n visible=") + buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n ")) + buf.WriteString("\n hidden=") + buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n ")) + buf.WriteString("\n]},") + return buf.String() +} + +func (se *SpoilerEntity) Height() int { + return se.visible.Height() +} + +func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { + se.hidden.CalculateBuffer(width, startX, ctx) + return se.visible.CalculateBuffer(width, startX, ctx) +} + +func (se *SpoilerEntity) getStartX() int { + return se.visible.getStartX() +} + +func (se *SpoilerEntity) IsEmpty() bool { + return se.visible.IsEmpty() +} diff --git a/tui/messages/html/text.go b/tui/messages/html/text.go new file mode 100644 index 0000000..95a1ff5 --- /dev/null +++ b/tui/messages/html/text.go @@ -0,0 +1,156 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package html + +import ( + "fmt" + "regexp" + + "github.com/mattn/go-runewidth" + + "go.mau.fi/mauview" + + "maunium.net/go/gomuks/tui/widget" +) + +type TextEntity struct { + *BaseEntity + // Text in this entity. + Text string + + buffer []string +} + +// NewTextEntity creates a new text-only Entity. +func NewTextEntity(text string) *TextEntity { + return &TextEntity{ + BaseEntity: &BaseEntity{ + Tag: "text", + }, + Text: text, + } +} + +func (te *TextEntity) IsEmpty() bool { + return len(te.Text) == 0 +} + +func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { + te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) + return te +} + +func (te *TextEntity) Clone() Entity { + return &TextEntity{ + BaseEntity: te.BaseEntity.Clone().(*BaseEntity), + Text: te.Text, + } +} + +func (te *TextEntity) PlainText() string { + return te.Text +} + +func (te *TextEntity) String() string { + return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity) +} + +func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) { + width, _ := screen.Size() + x := te.startX + for y, line := range te.buffer { + widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, te.Style) + x = 0 + } +} + +func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { + te.BaseEntity.CalculateBuffer(width, startX, ctx) + if len(te.Text) == 0 { + return te.startX + } + te.height = 0 + te.prevWidth = width + if te.buffer == nil { + te.buffer = []string{} + } + bufPtr := 0 + text := te.Text + textStartX := te.startX + for { + // TODO add option no wrap and character wrap options + extract := runewidth.Truncate(text, width-textStartX, "") + extract, wordWrapped := trim(extract, text, ctx.BareMessages) + if !wordWrapped && textStartX > 0 { + if bufPtr < len(te.buffer) { + te.buffer[bufPtr] = "" + } else { + te.buffer = append(te.buffer, "") + } + bufPtr++ + textStartX = 0 + continue + } + if bufPtr < len(te.buffer) { + te.buffer[bufPtr] = extract + } else { + te.buffer = append(te.buffer, extract) + } + bufPtr++ + text = text[len(extract):] + if len(text) == 0 { + te.buffer = te.buffer[:bufPtr] + te.height += len(te.buffer) + // This entity is over, return the startX for the next entity + if te.Block { + // ...except if it's a block entity + return 0 + } + return textStartX + runewidth.StringWidth(extract) + } + textStartX = 0 + } +} + +var ( + boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) + bareBoundaryPattern = regexp.MustCompile(`(\s+)`) + spacePattern = regexp.MustCompile(`\s+`) +) + +func trim(extract, full string, bare bool) (string, bool) { + if len(extract) == len(full) { + return extract, true + } + if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 { + extract = full[:len(extract)+spaces[1]] + } + regex := boundaryPattern + if bare { + regex = bareBoundaryPattern + } + matches := regex.FindAllStringIndex(extract, -1) + if len(matches) > 0 { + if match := matches[len(matches)-1]; len(match) >= 2 { + if until := match[1]; until < len(extract) { + extract = extract[:until] + return extract, true + } + } + } + return extract, len(extract) > 0 && extract[len(extract)-1] == ' ' +} diff --git a/tui/messages/htmlmessage.go b/tui/messages/htmlmessage.go new file mode 100644 index 0000000..bca1672 --- /dev/null +++ b/tui/messages/htmlmessage.go @@ -0,0 +1,100 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "go.mau.fi/mauview" + "github.com/gdamore/tcell/v2" +/* + "maunium.net/go/gomuks/matrix/muksevt" + + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/tui/messages/html" +*/ +) + +type HTMLMessage struct { + Root html.Entity + TextColor tcell.Color +} + +func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage { + return newUIMessage(evt, displayname, &HTMLMessage{ + Root: root, + }) +} + +func (hw *HTMLMessage) Clone() MessageRenderer { + return &HTMLMessage{ + Root: hw.Root.Clone(), + } +} + +func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) { + if hw.TextColor != tcell.ColorDefault { + hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style { + fg, _, _ := style.Decompose() + if fg == tcell.ColorDefault { + return style.Foreground(hw.TextColor) + } + return style + }, html.AdjustStyleReasonNormal) + } + screen.Clear() + hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected}) +} + +func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool { + return false +} + +func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool { + return false +} + +func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool { + return false +} + +func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int, msg *UIMessage) { + if width < 2 { + return + } + // TODO account for bare messages in initial startX + startX := 0 + hw.TextColor = msg.TextColor() + hw.Root.CalculateBuffer(width, startX, html.DrawContext{ + IsSelected: msg.IsSelected, + BareMessages: preferences.BareMessageView, + }) +} + +func (hw *HTMLMessage) Height() int { + return hw.Root.Height() +} + +func (hw *HTMLMessage) PlainText() string { + return hw.Root.PlainText() +} + +func (hw *HTMLMessage) NotificationContent() string { + return hw.Root.PlainText() +} + +func (hw *HTMLMessage) String() string { + return hw.Root.String() +} diff --git a/tui/messages/parser.go b/tui/messages/parser.go new file mode 100644 index 0000000..3450d7a --- /dev/null +++ b/tui/messages/parser.go @@ -0,0 +1,324 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + +/* "maunium.net/go/gomuks/debug" + "maunium.net/go/gomuks/matrix/muksevt" + "maunium.net/go/gomuks/matrix/rooms" + "maunium.net/go/gomuks/tui/messages/html" + "maunium.net/go/gomuks/tui/messages/tstring" + "maunium.net/go/gomuks/tui/widget" +*/ +) + +func getCachedEvent(mainView ifc.MainView, roomID id.RoomID, eventID id.EventID) *UIMessage { + if roomView := mainView.GetRoom(roomID); roomView != nil { + if replyToIfcMsg := roomView.GetEvent(eventID); replyToIfcMsg != nil { + if replyToMsg, ok := replyToIfcMsg.(*UIMessage); ok && replyToMsg != nil { + return replyToMsg + } + } + } + return nil +} + +func ParseEvent(matrix ifc.MatrixContainer, mainView ifc.MainView, room *rooms.Room, evt *muksevt.Event) *UIMessage { + msg := directParseEvent(matrix, room, evt) + if msg == nil { + return nil + } + if content, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && len(content.GetReplyTo()) > 0 { + if replyToMsg := getCachedEvent(mainView, room.ID, content.GetReplyTo()); replyToMsg != nil { + msg.ReplyTo = replyToMsg.Clone() + } else if replyToEvt, _ := matrix.GetEvent(room, content.GetReplyTo()); replyToEvt != nil { + if replyToMsg = directParseEvent(matrix, room, replyToEvt); replyToMsg != nil { + msg.ReplyTo = replyToMsg + msg.ReplyTo.Reactions = nil + } else { + // TODO add unrenderable reply header + } + } else { + // TODO add unknown reply header + } + } + return msg +} + +func directParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event) *UIMessage { + displayname := string(evt.Sender) + member := room.GetMember(evt.Sender) + if member != nil { + displayname = member.Displayname + } + if evt.Unsigned.RedactedBecause != nil || evt.Type == event.EventRedaction { + return NewRedactedMessage(evt, displayname) + } + switch content := evt.Content.Parsed.(type) { + case *event.MessageEventContent: + if evt.Type == event.EventSticker { + content.MsgType = event.MsgImage + } + return ParseMessage(matrix, room, evt, displayname) + case *muksevt.BadEncryptedContent: + return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString(content.Reason, tcell.StyleDefault.Italic(true))) + case *muksevt.EncryptionUnsupportedContent: + return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString("gomuks not built with encryption support", tcell.StyleDefault.Italic(true))) + case *event.TopicEventContent, *event.RoomNameEventContent, *event.CanonicalAliasEventContent: + return ParseStateEvent(evt, displayname) + case *event.MemberEventContent: + return ParseMembershipEvent(room, evt) + default: + debug.Printf("Unknown event content type %T in directParseEvent", content) + return nil + } +} + +func findAltAliasDifference(newList, oldList []id.RoomAlias) (addedStr, removedStr tstring.TString) { + var addedList, removedList []tstring.TString +OldLoop: + for _, oldAlias := range oldList { + for _, newAlias := range newList { + if oldAlias == newAlias { + continue OldLoop + } + } + removedList = append(removedList, tstring.NewStyleTString(string(oldAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(oldAlias)).Underline(true))) + } +NewLoop: + for _, newAlias := range newList { + for _, oldAlias := range oldList { + if newAlias == oldAlias { + continue NewLoop + } + } + addedList = append(addedList, tstring.NewStyleTString(string(newAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(newAlias)).Underline(true))) + } + if len(addedList) == 1 { + addedStr = tstring.NewColorTString("added alternative address ", tcell.ColorGreen).AppendTString(addedList[0]) + } else if len(addedList) != 0 { + addedStr = tstring. + Join(addedList[:len(addedList)-1], ", "). + PrependColor("added alternative addresses ", tcell.ColorGreen). + AppendColor(" and ", tcell.ColorGreen). + AppendTString(addedList[len(addedList)-1]) + } + if len(removedList) == 1 { + removedStr = tstring.NewColorTString("removed alternative address ", tcell.ColorGreen).AppendTString(removedList[0]) + } else if len(removedList) != 0 { + removedStr = tstring. + Join(removedList[:len(removedList)-1], ", "). + PrependColor("removed alternative addresses ", tcell.ColorGreen). + AppendColor(" and ", tcell.ColorGreen). + AppendTString(removedList[len(removedList)-1]) + } + return +} + +func ParseStateEvent(evt *muksevt.Event, displayname string) *UIMessage { + text := tstring.NewColorTString(displayname, widget.GetHashColor(evt.Sender)).Append(" ") + switch content := evt.Content.Parsed.(type) { + case *event.TopicEventContent: + if len(content.Topic) == 0 { + text = text.AppendColor("removed the topic.", tcell.ColorGreen) + } else { + text = text.AppendColor("changed the topic to ", tcell.ColorGreen). + AppendStyle(content.Topic, tcell.StyleDefault.Underline(true)). + AppendColor(".", tcell.ColorGreen) + } + case *event.RoomNameEventContent: + if len(content.Name) == 0 { + text = text.AppendColor("removed the room name.", tcell.ColorGreen) + } else { + text = text.AppendColor("changed the room name to ", tcell.ColorGreen). + AppendStyle(content.Name, tcell.StyleDefault.Underline(true)). + AppendColor(".", tcell.ColorGreen) + } + case *event.CanonicalAliasEventContent: + prevContent := &event.CanonicalAliasEventContent{} + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent = evt.Unsigned.PrevContent.AsCanonicalAlias() + } + debug.Printf("%+v -> %+v", prevContent, content) + if len(content.Alias) == 0 && len(prevContent.Alias) != 0 { + text = text.AppendColor("removed the main address of the room", tcell.ColorGreen) + } else if content.Alias != prevContent.Alias { + text = text. + AppendColor("changed the main address of the room to ", tcell.ColorGreen). + AppendStyle(string(content.Alias), tcell.StyleDefault.Underline(true)) + } else { + added, removed := findAltAliasDifference(content.AltAliases, prevContent.AltAliases) + if len(added) > 0 { + if len(removed) > 0 { + text = text. + AppendTString(added). + AppendColor(" and ", tcell.ColorGreen). + AppendTString(removed) + } else { + text = text.AppendTString(added) + } + } else if len(removed) > 0 { + text = text.AppendTString(removed) + } else { + text = text.AppendColor("changed nothing", tcell.ColorGreen) + } + text = text.AppendColor(" for this room", tcell.ColorGreen) + } + } + return NewExpandedTextMessage(evt, displayname, text) +} + +func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event, displayname string) *UIMessage { + content := evt.Content.AsMessage() + if len(content.GetReplyTo()) > 0 { + content.RemoveReplyFallback() + } + if len(evt.Gomuks.Edits) > 0 { + newContent := evt.Gomuks.Edits[len(evt.Gomuks.Edits)-1].Content.AsMessage().NewContent + if newContent != nil { + content = newContent + } + } + switch content.MsgType { + case event.MsgText, event.MsgNotice, event.MsgEmote: + var htmlEntity html.Entity + if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { + htmlEntity = html.Parse(matrix.Preferences(), room, content, evt, displayname) + if htmlEntity == nil { + htmlEntity = html.NewTextEntity("Malformed message") + htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) + } + } else if len(content.Body) > 0 { + content.Body = strings.Replace(content.Body, "\t", " ", -1) + htmlEntity = html.TextToEntity(content.Body, evt.ID, matrix.Preferences().EnableInlineURLs()) + } else { + htmlEntity = html.NewTextEntity("Blank message") + htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) + } + return NewHTMLMessage(evt, displayname, htmlEntity) + case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: + msg := NewFileMessage(matrix, evt, displayname) + if !matrix.Preferences().DisableDownloads { + renderer := msg.Renderer.(*FileMessage) + renderer.DownloadPreview() + } + return msg + } + return nil +} + +func getMembershipChangeMessage(evt *muksevt.Event, content *event.MemberEventContent, prevMembership event.Membership, senderDisplayname, displayname, prevDisplayname string) (sender string, text tstring.TString) { + switch content.Membership { + case "invite": + sender = "---" + text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", senderDisplayname, displayname), tcell.ColorGreen) + text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) + text.Colorize(len(senderDisplayname)+len(" invited "), len(displayname), widget.GetHashColor(evt.StateKey)) + case "join": + sender = "-->" + if prevMembership == event.MembershipInvite { + text = tstring.NewColorTString(fmt.Sprintf("%s accepted the invite.", displayname), tcell.ColorGreen) + } else { + text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen) + } + text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) + case "leave": + sender = "<--" + if evt.Sender != id.UserID(*evt.StateKey) { + if prevMembership == event.MembershipBan { + text = tstring.NewColorTString(fmt.Sprintf("%s unbanned %s", senderDisplayname, displayname), tcell.ColorGreen) + text.Colorize(len(senderDisplayname)+len(" unbanned "), len(displayname), widget.GetHashColor(evt.StateKey)) + } else { + text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) + text.Colorize(len(senderDisplayname)+len(" kicked "), len(displayname), widget.GetHashColor(evt.StateKey)) + } + text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) + } else { + if displayname == *evt.StateKey { + displayname = prevDisplayname + } + if prevMembership == event.MembershipInvite { + text = tstring.NewColorTString(fmt.Sprintf("%s rejected the invite.", displayname), tcell.ColorRed) + } else { + text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed) + } + text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) + } + case "ban": + text = tstring.NewColorTString(fmt.Sprintf("%s banned %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) + text.Colorize(len(senderDisplayname)+len(" banned "), len(displayname), widget.GetHashColor(evt.StateKey)) + text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) + } + return +} + +func getMembershipEventContent(room *rooms.Room, evt *muksevt.Event) (sender string, text tstring.TString) { + member := room.GetMember(evt.Sender) + senderDisplayname := string(evt.Sender) + if member != nil { + senderDisplayname = member.Displayname + } + + content := evt.Content.AsMember() + displayname := content.Displayname + if len(displayname) == 0 { + displayname = *evt.StateKey + } + + prevMembership := event.MembershipLeave + prevDisplayname := *evt.StateKey + if evt.Unsigned.PrevContent != nil { + _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) + prevContent := evt.Unsigned.PrevContent.AsMember() + prevMembership = prevContent.Membership + prevDisplayname = prevContent.Displayname + if len(prevDisplayname) == 0 { + prevDisplayname = *evt.StateKey + } + } + + if content.Membership != prevMembership { + sender, text = getMembershipChangeMessage(evt, content, prevMembership, senderDisplayname, displayname, prevDisplayname) + } else if displayname != prevDisplayname { + sender = "---" + color := widget.GetHashColor(evt.StateKey) + text = tstring.NewBlankTString(). + AppendColor(prevDisplayname, color). + AppendColor(" changed their display name to ", tcell.ColorGreen). + AppendColor(displayname, color). + AppendColor(".", tcell.ColorGreen) + } + return +} + +func ParseMembershipEvent(room *rooms.Room, evt *muksevt.Event) *UIMessage { + displayname, text := getMembershipEventContent(room, evt) + if len(text) == 0 { + return nil + } + + return NewExpandedTextMessage(evt, displayname, text) +} diff --git a/tui/messages/redactedmessage.go b/tui/messages/redactedmessage.go new file mode 100644 index 0000000..497ede9 --- /dev/null +++ b/tui/messages/redactedmessage.go @@ -0,0 +1,66 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + /* "maunium.net/go/gomuks/matrix/muksevt" + + "maunium.net/go/gomuks/config" + */) + +type RedactedMessage struct{} + +func NewRedactedMessage(evt *muksevt.Event, displayname string) *UIMessage { + return newUIMessage(evt, displayname, &RedactedMessage{}) +} + +func (msg *RedactedMessage) Clone() MessageRenderer { + return &RedactedMessage{} +} + +func (msg *RedactedMessage) NotificationContent() string { + return "" +} + +func (msg *RedactedMessage) PlainText() string { + return "[redacted]" +} + +func (msg *RedactedMessage) String() string { + return "&messages.RedactedMessage{}" +} + +func (msg *RedactedMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { +} + +func (msg *RedactedMessage) Height() int { + return 1 +} + +const RedactionChar = '█' +const RedactionMaxWidth = 40 + +var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0)) + +func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) { + w, _ := screen.Size() + for x := 0; x < w && x < RedactionMaxWidth; x++ { + screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle) + } +} diff --git a/tui/messages/textbase.go b/tui/messages/textbase.go new file mode 100644 index 0000000..8f8da60 --- /dev/null +++ b/tui/messages/textbase.go @@ -0,0 +1,96 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package messages + +import ( + "fmt" + "regexp" + /* + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/tui/messages/tstring" + */) + +// Regular expressions used to split lines when calculating the buffer. +// +// From tview/textview.go +var ( + boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) + bareBoundaryPattern = regexp.MustCompile(`(\s+)`) + spacePattern = regexp.MustCompile(`\s+`) +) + +func matchBoundaryPattern(bare bool, extract tstring.TString) tstring.TString { + regex := boundaryPattern + if bare { + regex = bareBoundaryPattern + } + matches := regex.FindAllStringIndex(extract.String(), -1) + if len(matches) > 0 { + if match := matches[len(matches)-1]; len(match) >= 2 { + if until := match[1]; until < len(extract) { + extract = extract[:until] + } + } + } + return extract +} + +// CalculateBuffer generates the internal buffer for this message that consists +// of the text of this message split into lines at most as wide as the width +// parameter. +func calculateBufferWithText(prefs config.UserPreferences, text tstring.TString, width int, msg *UIMessage) []tstring.TString { + if width < 2 { + return nil + } + + var buffer []tstring.TString + + if prefs.BareMessageView { + newText := tstring.NewTString(msg.FormatTime()) + if len(msg.Sender()) > 0 { + newText = newText.AppendTString(tstring.NewColorTString(fmt.Sprintf(" <%s> ", msg.Sender()), msg.SenderColor())) + } else { + newText = newText.Append(" ") + } + newText = newText.AppendTString(text) + text = newText + } + + forcedLinebreaks := text.Split('\n') + newlines := 0 + for _, str := range forcedLinebreaks { + if len(str) == 0 && newlines < 1 { + buffer = append(buffer, tstring.TString{}) + newlines++ + } else { + newlines = 0 + } + // Adapted from tview/textview.go#reindexBuffer() + for len(str) > 0 { + extract := str.Truncate(width) + if len(extract) < len(str) { + if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 { + extract = str[:len(extract)+spaces[1]] + } + extract = matchBoundaryPattern(prefs.BareMessageView, extract) + } + buffer = append(buffer, extract) + str = str[len(extract):] + } + } + return buffer +} diff --git a/tui/messages/tstring/cell.go b/tui/messages/tstring/cell.go new file mode 100644 index 0000000..affa01c --- /dev/null +++ b/tui/messages/tstring/cell.go @@ -0,0 +1,53 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tstring + +import ( + "github.com/mattn/go-runewidth" + + "go.mau.fi/mauview" + "github.com/gdamore/tcell/v2" +) + +type Cell struct { + Char rune + Style tcell.Style +} + +func NewStyleCell(char rune, style tcell.Style) Cell { + return Cell{char, style} +} + +func NewColorCell(char rune, color tcell.Color) Cell { + return Cell{char, tcell.StyleDefault.Foreground(color)} +} + +func NewCell(char rune) Cell { + return Cell{char, tcell.StyleDefault} +} + +func (cell Cell) RuneWidth() int { + return runewidth.RuneWidth(cell.Char) +} + +func (cell Cell) Draw(screen mauview.Screen, x, y int) (chWidth int) { + chWidth = cell.RuneWidth() + for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ { + screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style) + } + return +} diff --git a/tui/messages/tstring/doc.go b/tui/messages/tstring/doc.go new file mode 100644 index 0000000..d03a1da --- /dev/null +++ b/tui/messages/tstring/doc.go @@ -0,0 +1,4 @@ +// Package tstring contains a string type that stores style data for each +// character, allowing it to be rendered to a tcell screen essentially +// unmodified. +package tstring diff --git a/tui/messages/tstring/string.go b/tui/messages/tstring/string.go new file mode 100644 index 0000000..ddba2da --- /dev/null +++ b/tui/messages/tstring/string.go @@ -0,0 +1,270 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tstring + +import ( + "strings" + "unicode" + + "github.com/mattn/go-runewidth" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +type TString []Cell + +func NewBlankTString() TString { + return make(TString, 0) +} + +func NewTString(str string) TString { + newStr := make(TString, len(str)) + for i, char := range str { + newStr[i] = NewCell(char) + } + return newStr +} + +func NewColorTString(str string, color tcell.Color) TString { + newStr := make(TString, len(str)) + for i, char := range str { + newStr[i] = NewColorCell(char, color) + } + return newStr +} + +func NewStyleTString(str string, style tcell.Style) TString { + newStr := make(TString, len(str)) + for i, char := range str { + newStr[i] = NewStyleCell(char, style) + } + return newStr +} + +func Join(strings []TString, separator string) TString { + if len(strings) == 0 { + return NewBlankTString() + } + + out := strings[0] + strings = strings[1:] + + if len(separator) == 0 { + return out.AppendTString(strings...) + } + + for _, str := range strings { + out = append(out, str.Prepend(separator)...) + } + return out +} + +func (str TString) Clone() TString { + newStr := make(TString, len(str)) + copy(newStr, str) + return newStr +} + +func (str TString) AppendTString(dataList ...TString) TString { + newStr := str + for _, data := range dataList { + newStr = append(newStr, data...) + } + return newStr +} + +func (str TString) PrependTString(data TString) TString { + return append(data, str...) +} + +func (str TString) Append(data string) TString { + return str.AppendCustom(data, func(r rune) Cell { + return NewCell(r) + }) +} + +func (str TString) TrimSpace() TString { + return str.Trim(unicode.IsSpace) +} + +func (str TString) Trim(fn func(rune) bool) TString { + return str.TrimLeft(fn).TrimRight(fn) +} + +func (str TString) TrimLeft(fn func(rune) bool) TString { + for index, cell := range str { + if !fn(cell.Char) { + return append(NewBlankTString(), str[index:]...) + } + } + return NewBlankTString() +} + +func (str TString) TrimRight(fn func(rune) bool) TString { + for i := len(str) - 1; i >= 0; i-- { + if !fn(str[i].Char) { + return append(NewBlankTString(), str[:i+1]...) + } + } + return NewBlankTString() +} + +func (str TString) AppendColor(data string, color tcell.Color) TString { + return str.AppendCustom(data, func(r rune) Cell { + return NewColorCell(r, color) + }) +} + +func (str TString) AppendStyle(data string, style tcell.Style) TString { + return str.AppendCustom(data, func(r rune) Cell { + return NewStyleCell(r, style) + }) +} + +func (str TString) AppendCustom(data string, cellCreator func(rune) Cell) TString { + newStr := make(TString, len(str)+len(data)) + copy(newStr, str) + for i, char := range data { + newStr[i+len(str)] = cellCreator(char) + } + return newStr +} + +func (str TString) Prepend(data string) TString { + return str.PrependCustom(data, func(r rune) Cell { + return NewCell(r) + }) +} + +func (str TString) PrependColor(data string, color tcell.Color) TString { + return str.PrependCustom(data, func(r rune) Cell { + return NewColorCell(r, color) + }) +} + +func (str TString) PrependStyle(data string, style tcell.Style) TString { + return str.PrependCustom(data, func(r rune) Cell { + return NewStyleCell(r, style) + }) +} + +func (str TString) PrependCustom(data string, cellCreator func(rune) Cell) TString { + newStr := make(TString, len(str)+len(data)) + copy(newStr[len(data):], str) + for i, char := range data { + newStr[i] = cellCreator(char) + } + return newStr +} + +func (str TString) Colorize(from, length int, color tcell.Color) { + str.AdjustStyle(from, length, func(style tcell.Style) tcell.Style { + return style.Foreground(color) + }) +} + +func (str TString) AdjustStyle(from, length int, fn func(tcell.Style) tcell.Style) { + for i := from; i < from+length; i++ { + str[i].Style = fn(str[i].Style) + } +} + +func (str TString) AdjustStyleFull(fn func(tcell.Style) tcell.Style) { + str.AdjustStyle(0, len(str), fn) +} + +func (str TString) Draw(screen mauview.Screen, x, y int) { + for _, cell := range str { + x += cell.Draw(screen, x, y) + } +} + +func (str TString) RuneWidth() (width int) { + for _, cell := range str { + width += runewidth.RuneWidth(cell.Char) + } + return width +} + +func (str TString) String() string { + var buf strings.Builder + for _, cell := range str { + buf.WriteRune(cell.Char) + } + return buf.String() +} + +// Truncate return string truncated with w cells +func (str TString) Truncate(w int) TString { + if str.RuneWidth() <= w { + return str[:] + } + width := 0 + i := 0 + for ; i < len(str); i++ { + cw := runewidth.RuneWidth(str[i].Char) + if width+cw > w { + break + } + width += cw + } + return str[0:i] +} + +func (str TString) IndexFrom(r rune, from int) int { + for i := from; i < len(str); i++ { + if str[i].Char == r { + return i + } + } + return -1 +} + +func (str TString) Index(r rune) int { + return str.IndexFrom(r, 0) +} + +func (str TString) Count(r rune) (counter int) { + index := 0 + for { + index = str.IndexFrom(r, index) + if index < 0 { + break + } + index++ + counter++ + } + return +} + +func (str TString) Split(sep rune) []TString { + a := make([]TString, str.Count(sep)+1) + i := 0 + orig := str + for { + m := orig.Index(sep) + if m < 0 { + break + } + a[i] = orig[:m] + orig = orig[m+1:] + i++ + } + a[i] = orig + return a[:i+1] +} diff --git a/tui/no-crypto-commands.go b/tui/no-crypto-commands.go new file mode 100644 index 0000000..f559f28 --- /dev/null +++ b/tui/no-crypto-commands.go @@ -0,0 +1,46 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build !cgo + +package tui + +func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { + return []string{}, "" +} + +func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { + return []string{}, "" +} + +func cmdNoCrypto(cmd *Command) { + cmd.Reply("This gomuks was built without encryption support") +} + +var ( + cmdDevices = cmdNoCrypto + cmdDevice = cmdNoCrypto + cmdVerifyDevice = cmdNoCrypto + cmdVerify = cmdNoCrypto + cmdUnverify = cmdNoCrypto + cmdBlacklist = cmdNoCrypto + cmdResetSession = cmdNoCrypto + cmdImportKeys = cmdNoCrypto + cmdExportKeys = cmdNoCrypto + cmdExportRoomKeys = cmdNoCrypto + cmdSSSS = cmdNoCrypto + cmdCrossSigning = cmdNoCrypto +) diff --git a/tui/password-modal.go b/tui/password-modal.go new file mode 100644 index 0000000..a86a2d1 --- /dev/null +++ b/tui/password-modal.go @@ -0,0 +1,143 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +type PasswordModal struct { + mauview.Component + + outputChan chan string + cancelChan chan struct{} + + form *mauview.Form + + text *mauview.TextField + confirmText *mauview.TextField + + input *mauview.InputField + confirmInput *mauview.InputField + + cancel *mauview.Button + submit *mauview.Button + + parent *MainView +} + +func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) { + pwm := NewPasswordModal(view, title, thing, placeholder, isNew) + view.ShowModal(pwm) + view.parent.App.Redraw() + return pwm.Wait() +} + +func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal { + if placeholder == "" { + placeholder = "correct horse battery staple" + } + if thing == "" { + thing = strings.ToLower(title) + } + pwm := &PasswordModal{ + parent: parent, + form: mauview.NewForm(), + outputChan: make(chan string, 1), + cancelChan: make(chan struct{}, 1), + } + + pwm.form. + SetColumns([]int{1, 20, 1, 20, 1}). + SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1}) + + width := 45 + height := 8 + + pwm.text = mauview.NewTextField() + if isNew { + pwm.text.SetText(fmt.Sprintf("Create a %s", thing)) + } else { + pwm.text.SetText(fmt.Sprintf("Enter the %s", thing)) + } + pwm.input = mauview.NewInputField(). + SetMaskCharacter('*'). + SetPlaceholder(placeholder) + pwm.form.AddComponent(pwm.text, 1, 1, 3, 1) + pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1) + + if isNew { + height += 3 + pwm.confirmInput = mauview.NewInputField(). + SetMaskCharacter('*'). + SetPlaceholder(placeholder). + SetChangedFunc(pwm.HandleChange) + pwm.input.SetChangedFunc(pwm.HandleChange) + pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing)) + + pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1) + pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1) + pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1) + } + + pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel) + pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit) + + pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1) + pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1) + + box := mauview.NewBox(pwm.form).SetTitle(title) + center := mauview.Center(box, width, height).SetAlwaysFocusChild(true) + center.Focus() + pwm.form.FocusNextItem() + pwm.Component = center + + return pwm +} + +func (pwm *PasswordModal) HandleChange(_ string) { + if pwm.input.GetText() == pwm.confirmInput.GetText() { + pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor) + } else { + pwm.submit.SetBackgroundColor(tcell.ColorDefault) + } +} + +func (pwm *PasswordModal) ClickCancel() { + pwm.parent.HideModal() + pwm.cancelChan <- struct{}{} +} + +func (pwm *PasswordModal) ClickSubmit() { + if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() { + pwm.parent.HideModal() + pwm.outputChan <- pwm.input.GetText() + } +} + +func (pwm *PasswordModal) Wait() (string, bool) { + select { + case result := <-pwm.outputChan: + return result, true + case <-pwm.cancelChan: + return "", false + } +} diff --git a/tui/rainbow.go b/tui/rainbow.go new file mode 100644 index 0000000..82c4b88 --- /dev/null +++ b/tui/rainbow.go @@ -0,0 +1,135 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2022 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "fmt" + "crypto/rand" + "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" +) + +func Rand(n int) (str string) { + b := make([]byte, n) + rand.Read(b) + str = fmt.Sprintf("%x", b) + return +} + +type extRainbow struct{} +type rainbowRenderer struct { + HardWraps bool + ColorID string +} + +var ExtensionRainbow = &extRainbow{} +var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: Rand(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/tui/room-list.go b/tui/room-list.go index f7136f9..3d54404 100644 --- a/tui/room-list.go +++ b/tui/room-list.go @@ -22,15 +22,12 @@ import ( "sort" "strings" - sync "github.com/sasha-s/go-deadlock" - + "github.com/gdamore/tcell/v2" "go.mau.fi/mauview" - "go.mau.fi/tcell" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/matrix/rooms" ) var tagOrder = map[string]int{ diff --git a/tui/room-view.go b/tui/room-view.go index 1cf05e0..6d33a05 100644 --- a/tui/room-view.go +++ b/tui/room-view.go @@ -23,29 +23,21 @@ import ( "time" "unicode" - "github.com/kyokomi/emoji/v2" "github.com/mattn/go-runewidth" "github.com/zyedidia/clipboard" + "github.com/gdamore/tcell/v2" "go.mau.fi/mauview" - "go.mau.fi/tcell" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/variationselector" - "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/lib/util" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/gomuks/tui/messages" + "maunium.net/go/gomuks/tui/widget" ) type RoomView struct { @@ -106,7 +98,6 @@ func NewRoomView(parent *MainView, room *rooms.Room) *RoomView { ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth}, parent: parent, - config: parent.config, } view.content = NewMessageView(view) view.Room.SetPreUnload(func() bool { @@ -338,6 +329,7 @@ func (view *RoomView) ClearAllContext() { func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool { msgView := view.MessageView() + //helpp kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), @@ -711,11 +703,11 @@ func (view *RoomView) CopyToClipboard(text string, register string) { err := clipboard.WriteAll(text, register) if err != nil { view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } } else { view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } } @@ -723,11 +715,11 @@ func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile path, err := view.parent.matrix.DownloadToDisk(url, file, filename) if err != nil { view.AddServiceMessage(fmt.Sprintf("Failed to download media: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() return } view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() if openFile { debug.Print("Opening file", path) open.Open(path) @@ -745,7 +737,7 @@ func (view *RoomView) Redact(eventID id.EventID, reason string) { } } view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } } @@ -775,7 +767,7 @@ func (view *RoomView) SendReaction(eventID id.EventID, reaction string) { } } view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } } @@ -816,7 +808,7 @@ func (view *RoomView) SendMessageMedia(path string) { evt, err := view.parent.matrix.PrepareMediaMessage(view.Room, path, rel) if err != nil { view.AddServiceMessage(fmt.Sprintf("Failed to upload media: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() return } view.addLocalEcho(evt) @@ -838,13 +830,13 @@ func (view *RoomView) addLocalEcho(evt *muksevt.Event) { } } view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err)) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } else { debug.Print("Event ID received:", eventID) msg.EventID = eventID msg.State = muksevt.StateDefault view.MessageView().setMessageID(msg) - view.parent.parent.Render() + view.parent.parent.App.Redraw() } } diff --git a/tui/syncing-modal.go b/tui/syncing-modal.go index 591b7e7..882209d 100644 --- a/tui/syncing-modal.go +++ b/tui/syncing-modal.go @@ -51,15 +51,15 @@ func (sm *SyncingModal) SetMessage(text string) { func (sm *SyncingModal) SetIndeterminate() { sm.progress.SetIndeterminate(true) - sm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond) - sm.parent.parent.app.Redraw() + sm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond) + sm.parent.parent.App.Redraw() } func (sm *SyncingModal) SetSteps(max int) { sm.progress.SetMax(max) sm.progress.SetIndeterminate(false) - sm.parent.parent.app.SetRedrawTicker(1 * time.Minute) - sm.parent.parent.Render() + sm.parent.parent.App.SetRedrawTicker(1 * time.Minute) + sm.parent.parent.App.Redraw() } func (sm *SyncingModal) Step() { diff --git a/tui/tag-room-list.go b/tui/tag-room-list.go new file mode 100644 index 0000000..521b38d --- /dev/null +++ b/tui/tag-room-list.go @@ -0,0 +1,329 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tui + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + + "maunium.net/go/gomuks/debug" + +) + +type OrderedRoom struct { + *rooms.Room + order float64 +} + +func NewOrderedRoom(order json.Number, room *rooms.Room) *OrderedRoom { + numOrder, err := order.Float64() + if err != nil { + numOrder = 0.5 + } + return &OrderedRoom{ + Room: room, + order: numOrder, + } +} + +func NewDefaultOrderedRoom(room *rooms.Room) *OrderedRoom { + return NewOrderedRoom("0.5", room) +} + +func (or *OrderedRoom) Draw(roomList *RoomList, screen mauview.Screen, x, y, lineWidth int, isSelected bool) { + style := tcell.StyleDefault. + Foreground(roomList.mainTextColor). + Bold(or.HasNewMessages()) + if isSelected { + style = style. + Foreground(roomList.selectedTextColor). + Background(roomList.selectedBackgroundColor) + } + + unreadCount := or.UnreadCount() + + widget.WriteLinePadded(screen, mauview.AlignLeft, or.GetTitle(), x, y, lineWidth, style) + + if unreadCount > 0 { + unreadMessageCount := "99+" + if unreadCount < 100 { + unreadMessageCount = strconv.Itoa(unreadCount) + } + if or.Highlighted() { + unreadMessageCount += "!" + } + unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount) + widget.WriteLine(screen, mauview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style) + lineWidth -= len(unreadMessageCount) + } +} + +type TagRoomList struct { + mauview.NoopEventHandler + // The list of rooms in the list, in reverse order + rooms []*OrderedRoom + // Maximum number of rooms to show + maxShown int + // The internal name of this tag + name string + // The displayname of this tag + displayname string + // The parent RoomList instance + parent *RoomList +} + +func NewTagRoomList(parent *RoomList, name string, rooms ...*OrderedRoom) *TagRoomList { + return &TagRoomList{ + maxShown: 10, + rooms: rooms, + name: name, + displayname: parent.GetTagDisplayName(name), + parent: parent, + } +} + +func (trl *TagRoomList) Visible() []*OrderedRoom { + return trl.rooms[len(trl.rooms)-trl.Length():] +} + +func (trl *TagRoomList) FirstVisible() *rooms.Room { + visible := trl.Visible() + if len(visible) > 0 { + return visible[len(visible)-1].Room + } + return nil +} + +func (trl *TagRoomList) LastVisible() *rooms.Room { + visible := trl.Visible() + if len(visible) > 0 { + return visible[0].Room + } + return nil +} + +func (trl *TagRoomList) All() []*OrderedRoom { + return trl.rooms +} + +func (trl *TagRoomList) Length() int { + if len(trl.rooms) < trl.maxShown { + return len(trl.rooms) + } + return trl.maxShown +} + +func (trl *TagRoomList) TotalLength() int { + return len(trl.rooms) +} + +func (trl *TagRoomList) IsEmpty() bool { + return len(trl.rooms) == 0 +} + +func (trl *TagRoomList) IsCollapsed() bool { + return trl.maxShown == 0 +} + +func (trl *TagRoomList) ToggleCollapse() { + if trl.IsCollapsed() { + trl.maxShown = 10 + } else { + trl.maxShown = 0 + } +} + +func (trl *TagRoomList) HasInvisibleRooms() bool { + return trl.maxShown < trl.TotalLength() +} + +func (trl *TagRoomList) HasVisibleRooms() bool { + return !trl.IsEmpty() && trl.maxShown > 0 +} + +const equalityThreshold = 1e-6 + +func almostEqual(a, b float64) bool { + return math.Abs(a-b) <= equalityThreshold +} + +// ShouldBeAfter returns if the first room should be after the second room in the room list. +// The manual order and last received message timestamp are considered. +func (trl *TagRoomList) ShouldBeAfter(room1 *OrderedRoom, room2 *OrderedRoom) bool { + // Lower order value = higher in list + return room1.order > room2.order || + // Equal order value and more recent message = higher in the list + (almostEqual(room1.order, room2.order) && room2.LastReceivedMessage.After(room1.LastReceivedMessage)) +} + +func (trl *TagRoomList) Insert(order json.Number, mxRoom *rooms.Room) { + room := NewOrderedRoom(order, mxRoom) + // The default insert index is the newly added slot. + // That index will be used if all other rooms in the list have the same LastReceivedMessage timestamp. + insertAt := len(trl.rooms) + // Find the spot where the new room should be put according to the last received message timestamps. + for i := 0; i < len(trl.rooms); i++ { + if trl.rooms[i].Room == mxRoom { + debug.Printf("Warning: tried to re-insert room %s into tag %s", mxRoom.ID, trl.name) + return + } else if trl.ShouldBeAfter(room, trl.rooms[i]) { + insertAt = i + break + } + } + trl.rooms = append(trl.rooms, nil) + copy(trl.rooms[insertAt+1:], trl.rooms[insertAt:len(trl.rooms)-1]) + trl.rooms[insertAt] = room +} + +func (trl *TagRoomList) Bump(mxRoom *rooms.Room) { + var roomBeingBumped *OrderedRoom + for i := 0; i < len(trl.rooms); i++ { + currentIndexRoom := trl.rooms[i] + if roomBeingBumped != nil { + if trl.ShouldBeAfter(roomBeingBumped, currentIndexRoom) { + // This room should be after the room being bumped, so insert the + // room being bumped here and return + trl.rooms[i-1] = roomBeingBumped + return + } + // Move older rooms back in the array + trl.rooms[i-1] = currentIndexRoom + } else if currentIndexRoom.Room == mxRoom { + roomBeingBumped = currentIndexRoom + } + } + if roomBeingBumped == nil { + debug.Print("Warning: couldn't find room", mxRoom.ID, mxRoom.NameCache, "to bump in tag", trl.name) + return + } + // If the room being bumped should be first in the list, it won't be inserted during the loop. + trl.rooms[len(trl.rooms)-1] = roomBeingBumped +} + +func (trl *TagRoomList) Remove(room *rooms.Room) { + trl.RemoveIndex(trl.Index(room)) +} + +func (trl *TagRoomList) RemoveIndex(index int) { + if index < 0 || index > len(trl.rooms) { + return + } + last := len(trl.rooms) - 1 + if index < last { + copy(trl.rooms[index:], trl.rooms[index+1:]) + } + trl.rooms[last] = nil + trl.rooms = trl.rooms[:last] +} + +func (trl *TagRoomList) Index(room *rooms.Room) int { + return trl.indexInList(trl.All(), room) +} + +func (trl *TagRoomList) IndexVisible(room *rooms.Room) int { + return trl.indexInList(trl.Visible(), room) +} + +func (trl *TagRoomList) indexInList(list []*OrderedRoom, room *rooms.Room) int { + for index, entry := range list { + if entry.Room == room { + return index + } + } + return -1 +} + +var TagDisplayNameStyle = tcell.StyleDefault.Underline(true).Bold(true) +var TagRoomCountStyle = tcell.StyleDefault.Italic(true) + +func (trl *TagRoomList) RenderHeight() int { + if len(trl.displayname) == 0 { + return 0 + } + + if trl.IsCollapsed() { + return 1 + } + height := 2 + trl.Length() + if trl.HasInvisibleRooms() || trl.maxShown > 10 { + height++ + } + return height +} + +func (trl *TagRoomList) DrawHeader(screen mauview.Screen) { + width, _ := screen.Size() + roomCount := strconv.Itoa(trl.TotalLength()) + + // Draw tag name + displayNameWidth := width - 1 - len(roomCount) + widget.WriteLine(screen, mauview.AlignLeft, trl.displayname, 0, 0, displayNameWidth, TagDisplayNameStyle) + + // Draw tag room count + roomCountX := len(trl.displayname) + 1 + roomCountWidth := width - 2 - len(trl.displayname) + widget.WriteLine(screen, mauview.AlignLeft, roomCount, roomCountX, 0, roomCountWidth, TagRoomCountStyle) +} + +func (trl *TagRoomList) Draw(screen mauview.Screen) { + if len(trl.displayname) == 0 { + return + } + + trl.DrawHeader(screen) + + width, height := screen.Size() + + items := trl.Visible() + + if trl.IsCollapsed() { + screen.SetCell(width-1, 0, tcell.StyleDefault, '▶') + return + } + screen.SetCell(width-1, 0, tcell.StyleDefault, '▼') + + y := 1 + for i := len(items) - 1; i >= 0; i-- { + if y >= height { + return + } + + item := items[i] + + lineWidth := width + isSelected := trl.name == trl.parent.selectedTag && item.Room == trl.parent.selected + item.Draw(trl.parent, screen, 0, y, lineWidth, isSelected) + y++ + } + hasLess := trl.maxShown > 10 + hasMore := trl.HasInvisibleRooms() + if (hasLess || hasMore) && y < height { + if hasMore { + widget.WriteLine(screen, mauview.AlignRight, "More ↓", 0, y, width, tcell.StyleDefault) + } + if hasLess { + widget.WriteLine(screen, mauview.AlignLeft, "↑ Less", 0, y, width, tcell.StyleDefault) + } + y++ + } +} diff --git a/tui/verification-modal.go b/tui/verification-modal.go new file mode 100644 index 0000000..ce86329 --- /dev/null +++ b/tui/verification-modal.go @@ -0,0 +1,252 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build cgo + +package tui + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" + + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + + "maunium.net/go/gomuks/debug" +) + +type EmojiView struct { + mauview.SimpleEventHandler + Data crypto.SASData +} + +func (e *EmojiView) Draw(screen mauview.Screen) { + if e.Data == nil { + return + } + switch e.Data.Type() { + case event.SASEmoji: + width := 10 + for i, emoji := range e.Data.(crypto.EmojiSASData) { + x := i*width + i + y := 0 + if i >= 4 { + x = (i-4)*width + i + y = 2 + } + mauview.Print(screen, string(emoji.Emoji), x, y, width, mauview.AlignCenter, tcell.ColorDefault) + mauview.Print(screen, emoji.Description, x, y+1, width, mauview.AlignCenter, tcell.ColorDefault) + } + case event.SASDecimal: + maxWidth := 43 + for i, number := range e.Data.(crypto.DecimalSASData) { + mauview.Print(screen, strconv.FormatUint(uint64(number), 10), 0, i, maxWidth, mauview.AlignCenter, tcell.ColorDefault) + } + } +} + +type VerificationModal struct { + mauview.Component + + device *crypto.DeviceIdentity + + container *mauview.Box + + waitingBar *mauview.ProgressBar + infoText *mauview.TextView + emojiText *EmojiView + inputBar *mauview.InputField + + progress int + progressMax int + stopWaiting chan struct{} + confirmChan chan bool + done bool + + parent *MainView +} + +func NewVerificationModal(mainView *MainView, device *crypto.DeviceIdentity, timeout time.Duration) *VerificationModal { + vm := &VerificationModal{ + parent: mainView, + device: device, + stopWaiting: make(chan struct{}), + confirmChan: make(chan bool), + done: false, + } + + vm.progressMax = int(timeout.Seconds()) + vm.progress = vm.progressMax + vm.waitingBar = mauview.NewProgressBar(). + SetMax(vm.progressMax). + SetProgress(vm.progress). + SetIndeterminate(false) + + vm.infoText = mauview.NewTextView() + vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto accept", device.UserID)) + + vm.emojiText = &EmojiView{} + + vm.inputBar = mauview.NewInputField(). + SetBackgroundColor(tcell.ColorDefault). + SetPlaceholderTextColor(tcell.ColorDefault) + + flex := mauview.NewFlex(). + SetDirection(mauview.FlexRow). + AddFixedComponent(vm.waitingBar, 1). + AddFixedComponent(vm.infoText, 4). + AddFixedComponent(vm.emojiText, 4). + AddFixedComponent(vm.inputBar, 1) + + vm.container = mauview.NewBox(flex). + SetBorder(true). + SetTitle("Interactive verification") + + vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true) + + go vm.decrementWaitingBar() + + return vm +} + +func (vm *VerificationModal) decrementWaitingBar() { + for { + select { + case <-time.Tick(time.Second): + if vm.progress <= 0 { + vm.waitingBar.SetIndeterminate(true) + vm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond) + return + } + vm.progress-- + vm.waitingBar.SetProgress(vm.progress) + vm.parent.parent.App.Redraw() + case <-vm.stopWaiting: + return + } + } +} + +func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod { + return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}} +} + +func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool { + vm.device = device + var typeName string + if data.Type() == event.SASDecimal { + typeName = "numbers" + } else if data.Type() == event.SASEmoji { + typeName = "emojis" + } else { + return false + } + vm.infoText.SetText(fmt.Sprintf( + "Check if the other device is showing the\n"+ + "same %s as below, then type \"yes\" to\n"+ + "accept, or \"no\" to reject", typeName)) + vm.inputBar. + SetTextColor(tcell.ColorDefault). + SetBackgroundColor(tcell.ColorDarkCyan). + SetPlaceholder("Type \"yes\" or \"no\""). + Focus() + vm.emojiText.Data = data + vm.parent.parent.App.Redraw() + vm.progress = vm.progressMax + confirm := <-vm.confirmChan + vm.progress = vm.progressMax + vm.emojiText.Data = nil + vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID)) + vm.parent.parent.App.Redraw() + return confirm +} + +func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) { + vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) + vm.parent.parent.app.SetRedrawTicker(1 * time.Minute) + if cancelledByUs { + vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason)) + } else { + vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason)) + } + vm.inputBar.SetPlaceholder("Press enter to close the dialog") + vm.stopWaiting <- struct{}{} + vm.done = true + vm.parent.parent.App.Redraw() +} + +func (vm *VerificationModal) OnSuccess() { + vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) + vm.parent.parent.App.SetRedrawTicker(1 * time.Minute) + vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID)) + vm.inputBar.SetPlaceholder("Press enter to close the dialog") + vm.stopWaiting <- struct{}{} + vm.done = true + vm.parent.parent.App.Redraw() + if vm.parent.config.SendToVerifiedOnly { + // Hacky way to make new group sessions after verified + vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID) + } +} + +func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool { + kb := config.Keybind{ + Key: event.Key(), + Ch: event.Rune(), + Mod: event.Modifiers(), + } + if vm.done { + if vm.parent.config.Keybindings.Modal[kb] == "cancel" || vm.parent.config.Keybindings.Modal[kb] == "confirm" { + vm.parent.HideModal() + return true + } + return false + } else if vm.emojiText.Data == nil { + debug.Print("Ignoring pre-emoji key event") + return false + } + if vm.parent.config.Keybindings.Modal[kb] == "confirm" { + text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText())) + if text == "yes" { + debug.Print("Confirming verification") + vm.confirmChan <- true + } else if text == "no" { + debug.Print("Rejecting verification") + vm.confirmChan <- false + } + vm.inputBar. + SetPlaceholder(""). + SetTextAndMoveCursor(""). + SetBackgroundColor(tcell.ColorDefault). + SetTextColor(tcell.ColorDefault) + return true + } else { + return vm.inputBar.OnKeyEvent(event) + } +} + +func (vm *VerificationModal) Focus() { + vm.container.Focus() +} + +func (vm *VerificationModal) Blur() { + vm.container.Blur() +} diff --git a/tui/view-main.go b/tui/view-main.go index c5d855c..50826f6 100644 --- a/tui/view-main.go +++ b/tui/view-main.go @@ -18,25 +18,29 @@ package tui import ( "bufio" + "context" "fmt" "os" "sync/atomic" "time" - - sync "github.com/sasha-s/go-deadlock" - + /* + sync "github.com/sasha-s/go-deadlock" + */ + "github.com/gdamore/tcell/v2" "go.mau.fi/mauview" - "go.mau.fi/tcell" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/lib/notification" - "maunium.net/go/gomuks/matrix/rooms" + + //find what to use "maunium.net/go/gomuks/gt/messages" "maunium.net/go/gomuks/gt/widget" + "maunium.net/go/gomuks/lib/notification" + + "maunium.net/go/gomuks/matrix/rooms" ) type MainView struct { @@ -54,20 +58,19 @@ type MainView struct { lastFocusTime time.Time - matrix ifc.MatrixContainer - gmx ifc.Gomuks parent *GomuksTUI } func (gt *GomuksTUI) NewMainView() mauview.Component { - + mainView := &MainView{ flex: mauview.NewFlex().SetDirection(mauview.FlexColumn), roomView: mauview.NewBox(nil).SetBorder(false), rooms: make(map[id.RoomID]*RoomView), - - matrix: gt.gmx.Matrix(), - gmx: gt.gmx, - config: gt.gmx.Config(), + /*crying face* + matrix: gt.Gomuks.Matrix(), + gmx: gt.Gomuks, + config: gt.Gomuks.Config(), + */ parent: gt, } mainView.roomList = NewRoomList(mainView) @@ -101,6 +104,7 @@ func (view *MainView) HideModal() { } func (view *MainView) Draw(screen mauview.Screen) { + //config does not exist if view.config.Preferences.HideRoomList { view.roomView.Draw(screen) } else { @@ -125,7 +129,8 @@ func (view *MainView) MarkRead(roomView *RoomView) { if len(msgList) > 0 { msg := msgList[len(msgList)-1] if roomView.Room.MarkRead(msg.ID()) { - view.matrix.MarkRead(roomView.Room.ID, msg.ID()) + //? receipt type + view.parent.Client.MarkRead(context.TODO(), roomView.Room.ID, msg.ID()) } } } @@ -133,7 +138,8 @@ func (view *MainView) MarkRead(roomView *RoomView) { func (view *MainView) InputChanged(roomView *RoomView, text string) { if !roomView.config.Preferences.DisableTypingNotifs { - view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/') + //fix args in this too + view.parent.Client.SetTyping(context.TODO(), roomView.Room.ID, len(text) > 0 && text[0] != '/') } } @@ -141,8 +147,8 @@ func (view *MainView) ShowBare(roomView *RoomView) { if roomView == nil { return } - _, height := view.parent.app.Screen().Size() - view.parent.app.Suspend(func() { + _, height := view.parent.App.Screen().Size() + view.parent.App.Suspend(func() { print("\033[2J\033[0;0H") // We don't know how much space there exactly is. Too few messages looks weird, // and too many messages shouldn't cause any problems, so we just show too many. @@ -155,75 +161,84 @@ func (view *MainView) ShowBare(roomView *RoomView) { }) } +/*//???? idk what it does func (view *MainView) OpenSyncingModal() ifc.SyncingModal { component, modal := NewSyncingModal(view) view.ShowModal(component) return modal } +*/ -func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool { - view.BumpFocus(view.currentRoom) +/* + //umm this is some keyboard magic which idk how to implement - if view.modal != nil { - return view.modal.OnKeyEvent(event) - } + func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool { + view.BumpFocus(view.currentRoom) + if view.modal != nil { + return view.modal.OnKeyEvent(event) + } + + kb := config.Keybind{ + Key: event.Key(), + Ch: event.Rune(), + Mod: event.Modifiers(), + } + switch view.config.Keybindings.Main[kb] { + case "next_room": + view.SwitchRoom(view.roomList.Next()) + case "prev_room": + view.SwitchRoom(view.roomList.Previous()) + case "search_rooms": + view.ShowModal(NewFuzzySearchModal(view, 42, 12)) + case "scroll_up": + msgView := view.currentRoom.MessageView() + msgView.AddScrollOffset(msgView.TotalHeight()) + case "scroll_down": + msgView := view.currentRoom.MessageView() + msgView.AddScrollOffset(-msgView.TotalHeight()) + case "add_newline": + return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift)) + case "next_active_room": + view.SwitchRoom(view.roomList.NextWithActivity()) + case "show_bare": + view.ShowBare(view.currentRoom) + default: + goto defaultHandler + } + return true - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - switch view.config.Keybindings.Main[kb] { - case "next_room": - view.SwitchRoom(view.roomList.Next()) - case "prev_room": - view.SwitchRoom(view.roomList.Previous()) - case "search_rooms": - view.ShowModal(NewFuzzySearchModal(view, 42, 12)) - case "scroll_up": - msgView := view.currentRoom.MessageView() - msgView.AddScrollOffset(msgView.TotalHeight()) - case "scroll_down": - msgView := view.currentRoom.MessageView() - msgView.AddScrollOffset(-msgView.TotalHeight()) - case "add_newline": - return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift)) - case "next_active_room": - view.SwitchRoom(view.roomList.NextWithActivity()) - case "show_bare": - view.ShowBare(view.currentRoom) - default: - goto defaultHandler - } - return true defaultHandler: - if view.config.Preferences.HideRoomList { - return view.roomView.OnKeyEvent(event) - } - return view.flex.OnKeyEvent(event) -} + if view.config.Preferences.HideRoomList { + return view.roomView.OnKeyEvent(event) + } + return view.flex.OnKeyEvent(event) + } +*/ const WheelScrollOffsetDiff = 3 -func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool { - if view.modal != nil { - return view.modal.OnMouseEvent(event) - } - if view.config.Preferences.HideRoomList { - return view.roomView.OnMouseEvent(event) - } - return view.flex.OnMouseEvent(event) -} +/* +//more :( key things -func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool { - if view.modal != nil { - return view.modal.OnPasteEvent(event) - } else if view.config.Preferences.HideRoomList { - return view.roomView.OnPasteEvent(event) + func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool { + if view.modal != nil { + return view.modal.OnMouseEvent(event) + } + if view.config.Preferences.HideRoomList { + return view.roomView.OnMouseEvent(event) + } + return view.flex.OnMouseEvent(event) } - return view.flex.OnPasteEvent(event) -} + func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool { + if view.modal != nil { + return view.modal.OnPasteEvent(event) + } else if view.config.Preferences.HideRoomList { + return view.roomView.OnPasteEvent(event) + } + return view.flex.OnPasteEvent(event) + } +*/ func (view *MainView) Focus() { if view.focused != nil { view.focused.Focus() @@ -260,7 +275,7 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) { view.flex.SetFocused(view.roomView) view.focused = view.roomView view.roomView.Focus() - view.parent.Render() + view.parent.App.Redraw() if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded { msgView.initialHistoryLoaded = true @@ -268,13 +283,14 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) { } if !room.MembersFetched { go func() { - err := view.matrix.FetchMembers(room) + //another args thing + err := view.parent.Client.GetRoomState(context.TODO()) if err != nil { debug.Print("Error fetching members:", err) return } roomView.UpdateUserList() - view.parent.Render() + view.parent.App.Redraw() }() } } @@ -289,6 +305,7 @@ func (view *MainView) addRoomPage(room *rooms.Room) *RoomView { return nil } +// ifc + what do i get? func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView { room, ok := view.getRoomView(roomID, true) if !ok { @@ -328,7 +345,7 @@ func (view *MainView) RemoveRoom(room *rooms.Room) { delete(view.rooms, room.ID) view.roomsLock.Unlock() - view.parent.Render() + view.parent.App.Redraw() } func (view *MainView) addRoom(room *rooms.Room) *RoomView { @@ -374,14 +391,14 @@ func (view *MainView) UpdateTags(room *rooms.Room) { if reselect { view.roomList.SetSelected(room.Tags()[0].Tag, room) } - view.parent.Render() + view.parent.App.Redraw() } func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) { roomView, ok := view.getRoomView(roomID, true) if ok { roomView.SetTyping(users) - view.parent.Render() + view.parent.App.Redraw() } } @@ -397,10 +414,11 @@ func (view *MainView) Bump(room *rooms.Room) { view.roomList.Bump(room) } +// another arg mess. Did not find what is userID func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) { view.Bump(room) gtMsg, ok := message.(*messages.UIMessage) - if ok && gtMsg.SenderID == view.config.UserID { + if ok && gtMsg.SenderID == view.UserID { return } // Whether or not the room where the message came is the currently shown room. @@ -413,9 +431,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, shoul // The message is not in the current room, show new message status in room list. room.AddUnread(message.ID(), should.Notify, should.Highlight) } else { - view.matrix.MarkRead(room.ID, message.ID()) + //args & also why not use the func in here + view.parent.Client.MarkRead(room.ID, message.ID()) } - + //config if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications { // Push rules say notify and the terminal is not focused, send desktop notification. shouldPlaySound := should.PlaySound && @@ -443,13 +462,13 @@ func (view *MainView) LoadHistory(roomID id.RoomID) { } defer atomic.StoreInt32(&msgView.loadingMessages, 0) // Update the "Loading more messages..." text - view.parent.Render() - + view.parent.App.Redraw() + //history????? history, newLoadPtr, err := view.matrix.GetHistory(roomView.Room, 50, msgView.historyLoadPtr) if err != nil { roomView.AddServiceMessage("Failed to fetch history") debug.Print("Failed to fetch history for", roomView.Room.ID, err) - view.parent.Render() + view.parent.App.Redraw() return } //debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr) @@ -457,5 +476,5 @@ func (view *MainView) LoadHistory(roomID id.RoomID) { for _, evt := range history { roomView.AddHistoryEvent(evt) } - view.parent.Render() + view.parent.App.Redraw() } diff --git a/tui/widget/border.go b/tui/widget/border.go new file mode 100644 index 0000000..111fe18 --- /dev/null +++ b/tui/widget/border.go @@ -0,0 +1,63 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package widget + +import ( + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +// Border is a simple tview widget that renders a horizontal or vertical bar. +// +// If the width of the box is 1, the bar will be vertical. +// If the height is 1, the bar will be horizontal. +// If the width nor the height are 1, nothing will be rendered. +type Border struct { + Style tcell.Style +} + +// NewBorder wraps a new tview Box into a new Border. +func NewBorder() *Border { + return &Border{ + Style: tcell.StyleDefault.Foreground(mauview.Styles.BorderColor), + } +} + +func (border *Border) Draw(screen mauview.Screen) { + width, height := screen.Size() + if width == 1 { + for borderY := 0; borderY < height; borderY++ { + screen.SetContent(0, borderY, mauview.Borders.Vertical, nil, border.Style) + } + } else if height == 1 { + for borderX := 0; borderX < width; borderX++ { + screen.SetContent(borderX, 0, mauview.Borders.Horizontal, nil, border.Style) + } + } +} + +func (border *Border) OnKeyEvent(event mauview.KeyEvent) bool { + return false +} + +func (border *Border) OnPasteEvent(event mauview.PasteEvent) bool { + return false +} + +func (border *Border) OnMouseEvent(event mauview.MouseEvent) bool { + return false +} diff --git a/tui/widget/color.go b/tui/widget/color.go new file mode 100644 index 0000000..3d7e627 --- /dev/null +++ b/tui/widget/color.go @@ -0,0 +1,224 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package widget + +import ( + "fmt" + "hash/fnv" + + "github.com/gdamore/tcell/v2" + + "maunium.net/go/mautrix/id" +) + +var colorNames = []string{ + "maroon", + "green", + "olive", + "navy", + "purple", + "teal", + "silver", + "gray", + "red", + "lime", + "yellow", + "blue", + "fuchsia", + "aqua", + "white", + "aliceblue", + "antiquewhite", + "aquamarine", + "azure", + "beige", + "bisque", + "blanchedalmond", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "greenyellow", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightsteelblue", + "lightyellow", + "limegreen", + "linen", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "oldlace", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "rebeccapurple", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "skyblue", + "slateblue", + "slategray", + "snow", + "springgreen", + "steelblue", + "tan", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "whitesmoke", + "yellowgreen", + "grey", + "dimgrey", + "darkgrey", + "darkslategrey", + "lightgrey", + "lightslategrey", + "slategrey", +} + +// GetHashColorName gets a color name for the given string based on its FNV-1 hash. +// +// The array of possible color names are the alphabetically ordered color +// names specified in tcell.ColorNames. +// +// The algorithm to get the color is as follows: +// +// colorNames[ FNV1(string) % len(colorNames) ] +// +// With the exception of the three special cases: +// +// --> = green +// <-- = red +// --- = yellow +func GetHashColorName(s string) string { + switch s { + case "-->": + return "green" + case "<--": + return "red" + case "---": + return "yellow" + default: + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return colorNames[h.Sum32()%uint32(len(colorNames))] + } +} + +// GetHashColor gets the tcell Color value for the given string. +// +// GetHashColor calls GetHashColorName() and gets the Color value from the tcell.ColorNames map. +func GetHashColor(val interface{}) tcell.Color { + switch str := val.(type) { + case string: + return tcell.ColorNames[GetHashColorName(str)] + case *string: + return tcell.ColorNames[GetHashColorName(*str)] + case id.UserID: + return tcell.ColorNames[GetHashColorName(string(str))] + default: + return tcell.ColorNames["red"] + } +} + +// AddColor adds tview color tags to the given string. +func AddColor(s, color string) string { + return fmt.Sprintf("[%s]%s[white]", color, s) +} diff --git a/tui/widget/doc.go b/tui/widget/doc.go new file mode 100644 index 0000000..03d2060 --- /dev/null +++ b/tui/widget/doc.go @@ -0,0 +1,2 @@ +// Package widget contains additional tview widgets. +package widget diff --git a/tui/widget/util.go b/tui/widget/util.go new file mode 100644 index 0000000..9c40955 --- /dev/null +++ b/tui/widget/util.go @@ -0,0 +1,73 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package widget + +import ( + "fmt" + "strconv" + + "github.com/mattn/go-runewidth" + + "github.com/gdamore/tcell/v2" + "go.mau.fi/mauview" +) + +func WriteLineSimple(screen mauview.Screen, line string, x, y int) { + WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault) +} + +func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) { + WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color)) +} + +func WriteLineColor(screen mauview.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) { + WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color)) +} + +func WriteLine(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { + offsetX := 0 + if align == mauview.AlignRight { + offsetX = maxWidth - runewidth.StringWidth(line) + } + if offsetX < 0 { + offsetX = 0 + } + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, style) + } + offsetX += chWidth + if offsetX >= maxWidth { + break + } + } +} + +func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { + padding := strconv.Itoa(maxWidth) + if align == mauview.AlignRight { + line = fmt.Sprintf("%"+padding+"s", line) + } else { + line = fmt.Sprintf("%-"+padding+"s", line) + } + WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style) +}