diff --git a/tui/command-processor.go b/tui/command-processor.go new file mode 100644 index 0000000..2e3dcf5 --- /dev/null +++ b/tui/command-processor.go @@ -0,0 +1,290 @@ +// 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/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 +} + +type Command struct { + gomuksPointerContainer + Handler *CommandProcessor + + Room *RoomView + Command string + OrigCommand string + Args []string + RawArgs string + OrigText string +} + +type CommandAutocomplete Command + +func (cmd *Command) Reply(message string, args ...interface{}) { + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + cmd.Room.AddServiceMessage(message) + cmd.UI.Render() +} + +type Alias struct { + NewCommand string +} + +func (alias *Alias) Process(cmd *Command) *Command { + cmd.Command = alias.NewCommand + return cmd +} + +type CommandHandler func(cmd *Command) +type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string) + +type CommandProcessor struct { + gomuksPointerContainer + + aliases map[string]*Alias + commands map[string]CommandHandler + + autocompleters map[string]CommandAutocompleter +} + +func NewCommandProcessor(parent *MainView) *CommandProcessor { + return &CommandProcessor{ + gomuksPointerContainer: gomuksPointerContainer{ + MainView: parent, + UI: parent.parent, + Matrix: parent.matrix, + Config: parent.config, + Gomuks: parent.gmx, + }, + aliases: map[string]*Alias{ + "part": {"leave"}, + "send": {"sendevent"}, + "msend": {"msendevent"}, + "state": {"setstate"}, + "mstate": {"msetstate"}, + "rb": {"rainbow"}, + "rbme": {"rainbowme"}, + "rbn": {"rainbownotice"}, + "myroomnick": {"roomnick"}, + "createroom": {"create"}, + "dm": {"pm"}, + "query": {"pm"}, + "r": {"reply"}, + "delete": {"redact"}, + "remove": {"redact"}, + "rm": {"redact"}, + "del": {"redact"}, + "e": {"edit"}, + "dl": {"download"}, + "o": {"open"}, + "4s": {"ssss"}, + "s4": {"ssss"}, + "cs": {"cross-signing"}, + "power": {"powerlevel"}, + "pl": {"powerlevel"}, + }, + autocompleters: map[string]CommandAutocompleter{ + "devices": autocompleteUser, + "device": autocompleteDevice, + "verify": autocompleteUser, + "verify-device": autocompleteDevice, + "unverify": autocompleteDevice, + "blacklist": autocompleteDevice, + "upload": autocompleteFile, + "download": autocompleteFile, + "open": autocompleteFile, + "import": autocompleteFile, + "export": autocompleteFile, + "export-room": autocompleteFile, + "toggle": autocompleteToggle, + "powerlevel": autocompletePowerLevel, + }, + commands: map[string]CommandHandler{ + "unknown-command": cmdUnknownCommand, + + "id": cmdID, + "help": cmdHelp, + "me": cmdMe, + "quit": cmdQuit, + "clearcache": cmdClearCache, + "leave": cmdLeave, + "create": cmdCreateRoom, + "pm": cmdPrivateMessage, + "join": cmdJoin, + "kick": cmdKick, + "ban": cmdBan, + "unban": cmdUnban, + "powerlevel": cmdPowerLevel, + "toggle": cmdToggle, + "logout": cmdLogout, + "accept": cmdAccept, + "reject": cmdReject, + "reply": cmdReply, + "redact": cmdRedact, + "react": cmdReact, + "edit": cmdEdit, + "external": cmdExternalEditor, + "download": cmdDownload, + "upload": cmdUpload, + "open": cmdOpen, + "copy": cmdCopy, + "sendevent": cmdSendEvent, + "msendevent": cmdMSendEvent, + "setstate": cmdSetState, + "msetstate": cmdMSetState, + "roomnick": cmdRoomNick, + "rainbow": cmdRainbow, + "rainbowme": cmdRainbowMe, + "notice": cmdNotice, + "alias": cmdAlias, + "tags": cmdTags, + "tag": cmdTag, + "untag": cmdUntag, + "invite": cmdInvite, + "hprof": cmdHeapProfile, + "cprof": cmdCPUProfile, + "trace": cmdTrace, + "panic": func(cmd *Command) { + panic("hello world") + }, + + "rainbownotice": cmdRainbowNotice, + + "fingerprint": cmdFingerprint, + "devices": cmdDevices, + "verify-device": cmdVerifyDevice, + "verify": cmdVerify, + "device": cmdDevice, + "unverify": cmdUnverify, + "blacklist": cmdBlacklist, + "reset-session": cmdResetSession, + "import": cmdImportKeys, + "export": cmdExportKeys, + "export-room": cmdExportRoomKeys, + "ssss": cmdSSSS, + "cross-signing": cmdCrossSigning, + }, + } +} + +func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Command { + if text[0] != '/' || len(text) < 2 { + return nil + } + text = text[1:] + split := strings.Fields(text) + command := split[0] + args := split[1:] + var rawArgs string + if len(text) > len(command)+1 { + rawArgs = text[len(command)+1:] + } + return &Command{ + gomuksPointerContainer: ch.gomuksPointerContainer, + Handler: ch, + + Room: roomView, + Command: strings.ToLower(command), + OrigCommand: command, + Args: args, + RawArgs: rawArgs, + OrigText: text, + } +} + +func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) { + var completions []string + if cursorOffset != runewidth.StringWidth(text) { + return completions, text, false + } + + var cmd *Command + if cmd = ch.ParseCommand(roomView, text); cmd == nil { + return completions, text, false + } else if alias, ok := ch.aliases[cmd.Command]; ok { + cmd = alias.Process(cmd) + } + + handler, ok := ch.autocompleters[cmd.Command] + if ok { + var newText string + completions, newText = handler((*CommandAutocomplete)(cmd)) + if newText != "" { + text = newText + } + } + return completions, text, ok +} + +func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) { + if word[0] != '/' { + return + } + word = word[1:] + for alias := range ch.aliases { + if alias == word { + return []string{"/" + alias} + } + if strings.HasPrefix(alias, word) { + completions = append(completions, "/"+alias) + } + } + for command := range ch.commands { + if command == word { + return []string{"/" + command} + } + if strings.HasPrefix(command, word) { + completions = append(completions, "/"+command) + } + } + return +} + +func (ch *CommandProcessor) HandleCommand(cmd *Command) { + defer debug.Recover() + if cmd == nil { + return + } + if alias, ok := ch.aliases[cmd.Command]; ok { + cmd = alias.Process(cmd) + } + if cmd == nil { + return + } + if handler, ok := ch.commands[cmd.Command]; ok { + handler(cmd) + return + } + cmdUnknownCommand(cmd) +} diff --git a/tui/member-list.go b/tui/member-list.go new file mode 100644 index 0000000..15d0e37 --- /dev/null +++ b/tui/member-list.go @@ -0,0 +1,128 @@ +// 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 ( + "math" + "sort" + "strings" + + "github.com/mattn/go-runewidth" + + "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 { + list roomMemberList +} + +func NewMemberList() *MemberList { + return &MemberList{} +} + +type memberListItem struct { + rooms.Member + PowerLevel int + Sigil rune + UserID id.UserID + Color tcell.Color +} + +type roomMemberList []*memberListItem + +func (rml roomMemberList) Len() int { + return len(rml) +} + +func (rml roomMemberList) Less(i, j int) bool { + if rml[i].PowerLevel != rml[j].PowerLevel { + return rml[i].PowerLevel > rml[j].PowerLevel + } + return strings.Compare(strings.ToLower(rml[i].Displayname), strings.ToLower(rml[j].Displayname)) < 0 +} + +func (rml roomMemberList) Swap(i, j int) { + rml[i], rml[j] = rml[j], rml[i] +} + +func (ml *MemberList) Update(data map[id.UserID]*rooms.Member, levels *event.PowerLevelsEventContent) *MemberList { + ml.list = make(roomMemberList, len(data)) + i := 0 + highestLevel := math.MinInt32 + count := 0 + for _, level := range levels.Users { + if level > highestLevel { + highestLevel = level + count = 1 + } else if level == highestLevel { + count++ + } + } + for userID, member := range data { + level := levels.GetUserLevel(userID) + sigil := ' ' + if level == highestLevel && count == 1 { + sigil = '~' + } else if level > levels.StateDefault() { + sigil = '&' + } else if level >= levels.Ban() { + sigil = '@' + } else if level >= levels.Kick() || level >= levels.Redact() { + sigil = '%' + } else if level > levels.UsersDefault { + sigil = '+' + } + ml.list[i] = &memberListItem{ + Member: *member, + UserID: userID, + PowerLevel: level, + Sigil: sigil, + Color: widget.GetHashColor(userID), + } + i++ + } + sort.Sort(ml.list) + return ml +} + +func (ml *MemberList) Draw(screen mauview.Screen) { + width, _ := screen.Size() + sigilStyle := tcell.StyleDefault.Background(tcell.ColorGreen).Foreground(tcell.ColorDefault) + for y, member := range ml.list { + if member.Sigil != ' ' { + screen.SetCell(0, y, sigilStyle, member.Sigil) + } + if member.Membership == "invite" { + widget.WriteLineSimpleColor(screen, member.Displayname, 2, y, member.Color) + screen.SetCell(1, y, tcell.StyleDefault, '(') + if sw := runewidth.StringWidth(member.Displayname); sw+2 < width { + screen.SetCell(sw+2, y, tcell.StyleDefault, ')') + } else { + screen.SetCell(width-1, y, tcell.StyleDefault, ')') + } + } else { + widget.WriteLineSimpleColor(screen, member.Displayname, 1, y, member.Color) + } + } +} diff --git a/tui/message-view.go b/tui/message-view.go new file mode 100644 index 0000000..189774e --- /dev/null +++ b/tui/message-view.go @@ -0,0 +1,682 @@ +// 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" + "math" + "strings" + "sync/atomic" + + "github.com/mattn/go-runewidth" + sync "github.com/sasha-s/go-deadlock" + + "go.mau.fi/mauview" + "go.mau.fi/tcell" + + "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 { + parent *RoomView + config *config.Config + + ScrollOffset int + MaxSenderWidth int + DateFormat string + TimestampFormat string + TimestampWidth int + + // Used for locking + loadingMessages int32 + historyLoadPtr uint64 + + _widestSender uint32 + _prevWidestSender uint32 + + _width uint32 + _height uint32 + _prevWidth uint32 + _prevHeight uint32 + + prevMsgCount int + prevPrefs config.UserPreferences + + messageIDLock sync.RWMutex + messageIDs map[id.EventID]*messages.UIMessage + messagesLock sync.RWMutex + messages []*messages.UIMessage + msgBufferLock sync.RWMutex + msgBuffer []*messages.UIMessage + selected *messages.UIMessage + + initialHistoryLoaded bool +} + +func NewMessageView(parent *RoomView) *MessageView { + return &MessageView{ + parent: parent, + config: parent.config, + + MaxSenderWidth: 15, + TimestampWidth: len(messages.TimeFormat), + ScrollOffset: 0, + + messages: make([]*messages.UIMessage, 0), + messageIDs: make(map[id.EventID]*messages.UIMessage), + msgBuffer: make([]*messages.UIMessage, 0), + + _widestSender: 5, + _prevWidestSender: 0, + + _width: 80, + _prevWidth: 0, + _prevHeight: 0, + prevMsgCount: -1, + } +} + +func (view *MessageView) Unload() { + debug.Print("Unloading message view", view.parent.Room.ID) + view.messagesLock.Lock() + view.msgBufferLock.Lock() + view.messageIDLock.Lock() + view.messageIDs = make(map[id.EventID]*messages.UIMessage) + view.msgBuffer = make([]*messages.UIMessage, 0) + view.messages = make([]*messages.UIMessage, 0) + view.initialHistoryLoaded = false + view.ScrollOffset = 0 + view._widestSender = 5 + view.prevMsgCount = -1 + view.historyLoadPtr = 0 + view.messagesLock.Unlock() + view.msgBufferLock.Unlock() + view.messageIDLock.Unlock() +} + +func (view *MessageView) updateWidestSender(sender string) { + if len(sender) > int(view._widestSender) { + if len(sender) > view.MaxSenderWidth { + atomic.StoreUint32(&view._widestSender, uint32(view.MaxSenderWidth)) + } else { + atomic.StoreUint32(&view._widestSender, uint32(len(sender))) + } + } +} + +type MessageDirection int + +const ( + AppendMessage MessageDirection = iota + PrependMessage + IgnoreMessage +) + +func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDirection) { + if ifcMessage == nil { + return + } + message, ok := ifcMessage.(*messages.UIMessage) + if !ok || message == nil { + debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().") + debug.PrintStack() + return + } + + var oldMsg *messages.UIMessage + if oldMsg = view.getMessageByID(message.EventID); oldMsg != nil { + view.replaceMessage(oldMsg, message) + direction = IgnoreMessage + } else if oldMsg = view.getMessageByID(id.EventID(message.TxnID)); oldMsg != nil { + view.replaceMessage(oldMsg, message) + view.deleteMessageID(id.EventID(message.TxnID)) + direction = IgnoreMessage + } + + view.updateWidestSender(message.Sender()) + + width := view.width() + bare := view.config.Preferences.BareMessageView + if !bare { + width -= view.widestSender() + SenderMessageGap + if !view.config.Preferences.HideTimestamp { + width -= view.TimestampWidth + TimestampSenderGap + } + } + message.CalculateBuffer(view.config.Preferences, width) + + makeDateChange := func(msg *messages.UIMessage) *messages.UIMessage { + dateChange := messages.NewDateChangeMessage( + fmt.Sprintf("Date changed to %s", msg.FormatDate())) + dateChange.CalculateBuffer(view.config.Preferences, width) + view.appendBuffer(dateChange) + return dateChange + } + + if direction == AppendMessage { + if view.ScrollOffset > 0 { + view.ScrollOffset += message.Height() + } + view.messagesLock.Lock() + if len(view.messages) > 0 && !view.messages[len(view.messages)-1].SameDate(message) { + view.messages = append(view.messages, makeDateChange(message), message) + } else { + view.messages = append(view.messages, message) + } + view.messagesLock.Unlock() + view.appendBuffer(message) + } else if direction == PrependMessage { + view.messagesLock.Lock() + if len(view.messages) > 0 && !view.messages[0].SameDate(message) { + view.messages = append([]*messages.UIMessage{message, makeDateChange(view.messages[0])}, view.messages...) + } else { + view.messages = append([]*messages.UIMessage{message}, view.messages...) + } + view.messagesLock.Unlock() + } else if oldMsg != nil { + view.replaceBuffer(oldMsg, message) + } else { + debug.Print("Unexpected AddMessage() call: Direction is not append or prepend, but message is new.") + debug.PrintStack() + } + + if len(message.ID()) > 0 { + view.setMessageID(message) + } +} + +func (view *MessageView) replaceMessage(original *messages.UIMessage, new *messages.UIMessage) { + if len(new.ID()) > 0 { + view.setMessageID(new) + } + view.messagesLock.Lock() + for index, msg := range view.messages { + if msg == original { + view.messages[index] = new + } + } + view.messagesLock.Unlock() +} + +func (view *MessageView) getMessageByID(id id.EventID) *messages.UIMessage { + if id == "" { + return nil + } + view.messageIDLock.RLock() + defer view.messageIDLock.RUnlock() + msg, ok := view.messageIDs[id] + if !ok { + return nil + } + return msg +} + +func (view *MessageView) deleteMessageID(id id.EventID) { + if id == "" { + return + } + view.messageIDLock.Lock() + delete(view.messageIDs, id) + view.messageIDLock.Unlock() +} + +func (view *MessageView) setMessageID(message *messages.UIMessage) { + if message.ID() == "" { + return + } + view.messageIDLock.Lock() + view.messageIDs[message.ID()] = message + view.messageIDLock.Unlock() +} + +func (view *MessageView) appendBuffer(message *messages.UIMessage) { + view.msgBufferLock.Lock() + view.appendBufferUnlocked(message) + view.msgBufferLock.Unlock() +} + +func (view *MessageView) appendBufferUnlocked(message *messages.UIMessage) { + for i := 0; i < message.Height(); i++ { + view.msgBuffer = append(view.msgBuffer, message) + } + view.prevMsgCount++ +} + +func (view *MessageView) replaceBuffer(original *messages.UIMessage, new *messages.UIMessage) { + start := -1 + end := -1 + view.msgBufferLock.RLock() + for index, meta := range view.msgBuffer { + if meta == original { + if start == -1 { + start = index + } + end = index + } else if start != -1 { + break + } + } + view.msgBufferLock.RUnlock() + + if start == -1 { + debug.Print("Called replaceBuffer() with message that was not in the buffer:", original) + //debug.PrintStack() + view.appendBuffer(new) + return + } + + if len(view.msgBuffer) > end { + end++ + } + + if new.Height() == 0 { + new.CalculateBuffer(view.prevPrefs, view.prevWidth()) + } + + view.msgBufferLock.Lock() + if new.Height() != end-start { + height := new.Height() + + newBuffer := make([]*messages.UIMessage, height+len(view.msgBuffer)-end) + for i := 0; i < height; i++ { + newBuffer[i] = new + } + for i := height; i < len(newBuffer); i++ { + newBuffer[i] = view.msgBuffer[end+(i-height)] + } + view.msgBuffer = append(view.msgBuffer[0:start], newBuffer...) + } else { + for i := start; i < end; i++ { + view.msgBuffer[i] = new + } + } + view.msgBufferLock.Unlock() +} + +func (view *MessageView) recalculateBuffers() { + prefs := view.config.Preferences + recalculateMessageBuffers := view.width() != view.prevWidth() || + view.widestSender() != view.prevWidestSender() || + view.prevPrefs.BareMessageView != prefs.BareMessageView || + view.prevPrefs.DisableImages != prefs.DisableImages + view.messagesLock.RLock() + view.msgBufferLock.Lock() + if recalculateMessageBuffers || len(view.messages) != view.prevMsgCount { + width := view.width() + if !prefs.BareMessageView { + width -= view.widestSender() + SenderMessageGap + if !prefs.HideTimestamp { + width -= view.TimestampWidth + TimestampSenderGap + } + } + view.msgBuffer = []*messages.UIMessage{} + view.prevMsgCount = 0 + for i, message := range view.messages { + if message == nil { + debug.Print("O.o found nil message at", i) + break + } + if recalculateMessageBuffers { + message.CalculateBuffer(prefs, width) + } + view.appendBufferUnlocked(message) + } + } + view.msgBufferLock.Unlock() + view.messagesLock.RUnlock() + view.updatePrevSize() + view.prevPrefs = prefs +} + +func (view *MessageView) SetSelected(message *messages.UIMessage) { + if view.selected != nil { + view.selected.IsSelected = false + } + if message != nil && (view.selected == message || message.IsService) { + view.selected = nil + } else { + view.selected = message + } + if view.selected != nil { + view.selected.IsSelected = true + } +} + +func (view *MessageView) handleMessageClick(message *messages.UIMessage, mod tcell.ModMask) bool { + if msg, ok := message.Renderer.(*messages.FileMessage); ok && mod > 0 && !msg.Thumbnail.IsEmpty() { + debug.Print("Opening thumbnail", msg.ThumbnailPath()) + open.Open(msg.ThumbnailPath()) + // No need to re-render + return false + } + view.SetSelected(message) + view.parent.OnSelect(view.selected) + return true +} + +func (view *MessageView) handleUsernameClick(message *messages.UIMessage, prevMessage *messages.UIMessage) bool { + // TODO this is needed if senders are hidden for messages from the same sender (see Draw method) + //if prevMessage != nil && prevMessage.SenderName == message.SenderName { + // return false + //} + + if message.SenderName == "---" || message.SenderName == "-->" || message.SenderName == "<--" || message.Type == event.MsgEmote { + return false + } + + sender := fmt.Sprintf("[%s](https://matrix.to/#/%s)", message.SenderName, message.SenderID) + + cursorPos := view.parent.input.GetCursorOffset() + text := view.parent.input.GetText() + var buf strings.Builder + if cursorPos == 0 { + buf.WriteString(sender) + buf.WriteRune(':') + buf.WriteRune(' ') + buf.WriteString(text) + } else { + textBefore := runewidth.Truncate(text, cursorPos, "") + textAfter := text[len(textBefore):] + buf.WriteString(textBefore) + buf.WriteString(sender) + buf.WriteRune(' ') + buf.WriteString(textAfter) + } + newText := buf.String() + view.parent.input.SetText(string(newText)) + view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text)) + return true +} + +func (view *MessageView) OnMouseEvent(event mauview.MouseEvent) bool { + if event.HasMotion() { + return false + } + switch event.Buttons() { + case tcell.WheelUp: + if view.IsAtTop() { + go view.parent.parent.LoadHistory(view.parent.Room.ID) + } else { + view.AddScrollOffset(WheelScrollOffsetDiff) + return true + } + case tcell.WheelDown: + view.AddScrollOffset(-WheelScrollOffsetDiff) + view.parent.parent.MarkRead(view.parent) + return true + case tcell.Button1: + x, y := event.Position() + line := view.TotalHeight() - view.ScrollOffset - view.Height() + y + if line < 0 || line >= view.TotalHeight() { + return false + } + + view.msgBufferLock.RLock() + message := view.msgBuffer[line] + var prevMessage *messages.UIMessage + if y != 0 && line > 0 { + prevMessage = view.msgBuffer[line-1] + } + view.msgBufferLock.RUnlock() + + usernameX := 0 + if !view.config.Preferences.HideTimestamp { + usernameX += view.TimestampWidth + TimestampSenderGap + } + messageX := usernameX + view.widestSender() + SenderMessageGap + + if x >= messageX { + return view.handleMessageClick(message, event.Modifiers()) + } else if x >= usernameX { + return view.handleUsernameClick(message, prevMessage) + } + } + return false +} + +const PaddingAtTop = 5 + +func (view *MessageView) AddScrollOffset(diff int) { + totalHeight := view.TotalHeight() + height := view.Height() + if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop { + view.ScrollOffset = totalHeight - height + PaddingAtTop + } else { + view.ScrollOffset += diff + } + + if view.ScrollOffset > totalHeight-height+PaddingAtTop { + view.ScrollOffset = totalHeight - height + PaddingAtTop + } + if view.ScrollOffset < 0 { + view.ScrollOffset = 0 + } +} + +func (view *MessageView) setSize(width, height int) { + atomic.StoreUint32(&view._width, uint32(width)) + atomic.StoreUint32(&view._height, uint32(height)) +} + +func (view *MessageView) updatePrevSize() { + atomic.StoreUint32(&view._prevWidth, atomic.LoadUint32(&view._width)) + atomic.StoreUint32(&view._prevHeight, atomic.LoadUint32(&view._height)) + atomic.StoreUint32(&view._prevWidestSender, atomic.LoadUint32(&view._widestSender)) +} + +func (view *MessageView) prevHeight() int { + return int(atomic.LoadUint32(&view._prevHeight)) +} + +func (view *MessageView) prevWidth() int { + return int(atomic.LoadUint32(&view._prevWidth)) +} + +func (view *MessageView) prevWidestSender() int { + return int(atomic.LoadUint32(&view._prevWidestSender)) +} + +func (view *MessageView) widestSender() int { + return int(atomic.LoadUint32(&view._widestSender)) +} + +func (view *MessageView) Height() int { + return int(atomic.LoadUint32(&view._height)) +} + +func (view *MessageView) width() int { + return int(atomic.LoadUint32(&view._width)) +} + +func (view *MessageView) TotalHeight() int { + view.msgBufferLock.RLock() + defer view.msgBufferLock.RUnlock() + return len(view.msgBuffer) +} + +func (view *MessageView) IsAtTop() bool { + return view.ScrollOffset >= view.TotalHeight()-view.Height()+PaddingAtTop +} + +const ( + TimestampSenderGap = 1 + SenderSeparatorGap = 1 + SenderMessageGap = 3 +) + +func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) { + char = '│' + style = tcell.StyleDefault + if scrollbarHere { + style = style.Foreground(tcell.ColorGreen) + } + if isTop { + if scrollbarHere { + char = '╥' + } else { + char = '┬' + } + } else if isBottom { + if scrollbarHere { + char = '╨' + } else { + char = '┴' + } + } else if scrollbarHere { + char = '║' + } + return +} + +func (view *MessageView) calculateScrollBar(height int) (scrollBarHeight, scrollBarPos int) { + viewportHeight := float64(height) + contentHeight := float64(view.TotalHeight()) + + scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight))) + + scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight)) + + return +} + +func (view *MessageView) getIndexOffset(screen mauview.Screen, height, messageX int) (indexOffset int) { + indexOffset = view.TotalHeight() - view.ScrollOffset - height + if indexOffset <= -PaddingAtTop { + message := "Scroll up to load more messages." + if atomic.LoadInt32(&view.loadingMessages) == 1 { + message = "Loading more messages..." + } + widget.WriteLineSimpleColor(screen, message, messageX, 0, tcell.ColorGreen) + } + return +} + +func (view *MessageView) CapturePlaintext(height int) string { + var buf strings.Builder + indexOffset := view.TotalHeight() - view.ScrollOffset - height + var prevMessage *messages.UIMessage + view.msgBufferLock.RLock() + for line := 0; line < height; line++ { + index := indexOffset + line + if index < 0 { + continue + } + + message := view.msgBuffer[index] + if message != prevMessage { + var sender string + if len(message.Sender()) > 0 { + sender = fmt.Sprintf(" <%s>", message.Sender()) + } else if message.Type == event.MsgEmote { + sender = fmt.Sprintf(" * %s", message.SenderName) + } + fmt.Fprintf(&buf, "%s%s %s\n", message.FormatTime(), sender, message.PlainText()) + prevMessage = message + } + } + view.msgBufferLock.RUnlock() + return buf.String() +} + +func (view *MessageView) Draw(screen mauview.Screen) { + view.setSize(screen.Size()) + view.recalculateBuffers() + + height := view.Height() + if view.TotalHeight() == 0 { + widget.WriteLineSimple(screen, "It's quite empty in here.", 0, height) + return + } + + usernameX := 0 + if !view.config.Preferences.HideTimestamp { + usernameX += view.TimestampWidth + TimestampSenderGap + } + messageX := usernameX + view.widestSender() + SenderMessageGap + + bareMode := view.config.Preferences.BareMessageView + if bareMode { + messageX = 0 + } + + indexOffset := view.getIndexOffset(screen, height, messageX) + + viewStart := 0 + if indexOffset < 0 { + viewStart = -indexOffset + } + + if !bareMode { + separatorX := usernameX + view.widestSender() + SenderSeparatorGap + scrollBarHeight, scrollBarPos := view.calculateScrollBar(height) + + for line := viewStart; line < height; line++ { + showScrollbar := line-viewStart >= scrollBarPos-scrollBarHeight && line-viewStart < scrollBarPos + isTop := line == viewStart && view.ScrollOffset+height >= view.TotalHeight() + isBottom := line == height-1 && view.ScrollOffset == 0 + + borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom) + + screen.SetContent(separatorX, line, borderChar, nil, borderStyle) + } + } + + var prevMsg *messages.UIMessage + view.msgBufferLock.RLock() + for line := viewStart; line < height && indexOffset+line < len(view.msgBuffer); { + index := indexOffset + line + + msg := view.msgBuffer[index] + 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 { + widget.WriteLineSimpleColor(screen, msg.FormatTime(), 0, line, msg.TimestampColor()) + } + // TODO hiding senders might not be that nice after all, maybe an option? (disabled for now) + //if !bareMode && (prevMsg == nil || meta.Sender() != prevMsg.Sender()) { + widget.WriteLineColor( + screen, mauview.AlignRight, msg.Sender(), + usernameX, line, view.widestSender(), + msg.SenderColor()) + //} + if msg.Edited { + // TODO add better indicator for edits + screen.SetCell(usernameX+view.widestSender(), line, tcell.StyleDefault.Foreground(tcell.ColorDarkRed), '*') + } + + for i := index - 1; i >= 0 && view.msgBuffer[i] == msg; i-- { + line-- + } + msg.Draw(mauview.NewProxyScreen(screen, messageX, line, view.width()-messageX, msg.Height())) + line += msg.Height() + + prevMsg = msg + } + view.msgBufferLock.RUnlock() +} diff --git a/tui/room-list.go b/tui/room-list.go new file mode 100644 index 0000000..f7136f9 --- /dev/null +++ b/tui/room-list.go @@ -0,0 +1,592 @@ +// 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 ( + "math" + "regexp" + "sort" + "strings" + + sync "github.com/sasha-s/go-deadlock" + + "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{ + "net.maunium.gomuks.fake.invite": 4, + "m.favourite": 3, + "net.maunium.gomuks.fake.direct": 2, + "": 1, + "m.lowpriority": -1, + "m.server_notice": -2, + "net.maunium.gomuks.fake.leave": -3, +} + +// TagNameList is a list of Matrix tag names where default names are sorted in a hardcoded way. +type TagNameList []string + +func (tnl TagNameList) Len() int { + return len(tnl) +} + +func (tnl TagNameList) Less(i, j int) bool { + orderI, _ := tagOrder[tnl[i]] + orderJ, _ := tagOrder[tnl[j]] + if orderI != orderJ { + return orderI > orderJ + } + return strings.Compare(tnl[i], tnl[j]) > 0 +} + +func (tnl TagNameList) Swap(i, j int) { + tnl[i], tnl[j] = tnl[j], tnl[i] +} + +type RoomList struct { + sync.RWMutex + + parent *MainView + + // The list of tags in display order. + tags TagNameList + // The list of rooms, in reverse order. + items map[string]*TagRoomList + // The selected room. + selected *rooms.Room + selectedTag string + + scrollOffset int + height int + width int + + // The item main text color. + mainTextColor tcell.Color + // The text color for selected items. + selectedTextColor tcell.Color + // The background color for selected items. + selectedBackgroundColor tcell.Color +} + +func NewRoomList(parent *MainView) *RoomList { + list := &RoomList{ + parent: parent, + + items: make(map[string]*TagRoomList), + tags: []string{}, + + scrollOffset: 0, + + mainTextColor: tcell.ColorDefault, + selectedTextColor: tcell.ColorWhite, + selectedBackgroundColor: tcell.ColorDarkGreen, + } + for _, tag := range list.tags { + list.items[tag] = NewTagRoomList(list, tag) + } + return list +} + +func (list *RoomList) Contains(roomID id.RoomID) bool { + list.RLock() + defer list.RUnlock() + for _, trl := range list.items { + for _, room := range trl.All() { + if room.ID == roomID { + return true + } + } + } + return false +} + +func (list *RoomList) Add(room *rooms.Room) { + if room.IsReplaced() { + debug.Print(room.ID, "is replaced by", room.ReplacedBy(), "-> not adding to room list") + return + } + debug.Print("Adding room to list", room.ID, room.GetTitle(), room.IsDirect, room.ReplacedBy(), room.Tags()) + for _, tag := range room.Tags() { + list.AddToTag(tag, room) + } +} + +func (list *RoomList) checkTag(tag string) { + index := list.indexTag(tag) + + trl, ok := list.items[tag] + + if ok && trl.IsEmpty() { + delete(list.items, tag) + ok = false + } + + if ok && index == -1 { + list.tags = append(list.tags, tag) + sort.Sort(list.tags) + } else if !ok && index != -1 { + list.tags = append(list.tags[0:index], list.tags[index+1:]...) + } +} + +func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) { + list.Lock() + defer list.Unlock() + trl, ok := list.items[tag.Tag] + if !ok { + list.items[tag.Tag] = NewTagRoomList(list, tag.Tag, NewOrderedRoom(tag.Order, room)) + } else { + trl.Insert(tag.Order, room) + } + list.checkTag(tag.Tag) +} + +func (list *RoomList) Remove(room *rooms.Room) { + for _, tag := range list.tags { + list.RemoveFromTag(tag, room) + } +} + +func (list *RoomList) RemoveFromTag(tag string, room *rooms.Room) { + list.Lock() + defer list.Unlock() + trl, ok := list.items[tag] + if !ok { + return + } + + index := trl.Index(room) + if index == -1 { + return + } + + trl.RemoveIndex(index) + + if trl.IsEmpty() { + // delete(list.items, tag) + } + + if room == list.selected { + if index > 0 { + list.selected = trl.All()[index-1].Room + } else if trl.Length() > 0 { + list.selected = trl.Visible()[0].Room + } else if len(list.items) > 0 { + for _, tag := range list.tags { + moreItems := list.items[tag] + if moreItems.Length() > 0 { + list.selected = moreItems.Visible()[0].Room + list.selectedTag = tag + } + } + } else { + list.selected = nil + list.selectedTag = "" + } + } + list.checkTag(tag) +} + +func (list *RoomList) Bump(room *rooms.Room) { + list.RLock() + defer list.RUnlock() + for _, tag := range room.Tags() { + trl, ok := list.items[tag.Tag] + if !ok { + return + } + trl.Bump(room) + } +} + +func (list *RoomList) Clear() { + list.Lock() + defer list.Unlock() + list.items = make(map[string]*TagRoomList) + list.tags = []string{} + for _, tag := range list.tags { + list.items[tag] = NewTagRoomList(list, tag) + } + list.selected = nil + list.selectedTag = "" +} + +func (list *RoomList) SetSelected(tag string, room *rooms.Room) { + list.selected = room + list.selectedTag = tag + pos := list.index(tag, room) + if pos <= list.scrollOffset { + list.scrollOffset = pos - 1 + } else if pos >= list.scrollOffset+list.height { + list.scrollOffset = pos - list.height + 1 + } + if list.scrollOffset < 0 { + list.scrollOffset = 0 + } + debug.Print("Selecting", room.GetTitle(), "in", list.GetTagDisplayName(tag)) +} + +func (list *RoomList) HasSelected() bool { + return list.selected != nil +} + +func (list *RoomList) Selected() (string, *rooms.Room) { + return list.selectedTag, list.selected +} + +func (list *RoomList) SelectedRoom() *rooms.Room { + return list.selected +} + +func (list *RoomList) AddScrollOffset(offset int) { + list.scrollOffset += offset + contentHeight := list.ContentHeight() + if list.scrollOffset > contentHeight-list.height { + list.scrollOffset = contentHeight - list.height + } + if list.scrollOffset < 0 { + list.scrollOffset = 0 + } +} + +func (list *RoomList) First() (string, *rooms.Room) { + list.RLock() + defer list.RUnlock() + return list.first() +} + +func (list *RoomList) first() (string, *rooms.Room) { + for _, tag := range list.tags { + trl := list.items[tag] + if trl.HasVisibleRooms() { + return tag, trl.FirstVisible() + } + } + return "", nil +} + +func (list *RoomList) Last() (string, *rooms.Room) { + list.RLock() + defer list.RUnlock() + return list.last() +} + +func (list *RoomList) last() (string, *rooms.Room) { + for tagIndex := len(list.tags) - 1; tagIndex >= 0; tagIndex-- { + tag := list.tags[tagIndex] + trl := list.items[tag] + if trl.HasVisibleRooms() { + return tag, trl.LastVisible() + } + } + return "", nil +} + +func (list *RoomList) indexTag(tag string) int { + for index, entry := range list.tags { + if tag == entry { + return index + } + } + return -1 +} + +func (list *RoomList) Previous() (string, *rooms.Room) { + list.RLock() + defer list.RUnlock() + if len(list.items) == 0 { + return "", nil + } else if list.selected == nil { + return list.first() + } + + trl := list.items[list.selectedTag] + index := trl.IndexVisible(list.selected) + indexInvisible := trl.Index(list.selected) + if index == -1 && indexInvisible >= 0 { + num := trl.TotalLength() - indexInvisible + trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) + index = trl.IndexVisible(list.selected) + } + + if index == trl.Length()-1 { + tagIndex := list.indexTag(list.selectedTag) + tagIndex-- + for ; tagIndex >= 0; tagIndex-- { + prevTag := list.tags[tagIndex] + prevTRL := list.items[prevTag] + if prevTRL.HasVisibleRooms() { + return prevTag, prevTRL.LastVisible() + } + } + return list.last() + } else if index >= 0 { + return list.selectedTag, trl.Visible()[index+1].Room + } + return list.first() +} + +func (list *RoomList) Next() (string, *rooms.Room) { + list.RLock() + defer list.RUnlock() + if len(list.items) == 0 { + return "", nil + } else if list.selected == nil { + return list.first() + } + + trl := list.items[list.selectedTag] + index := trl.IndexVisible(list.selected) + indexInvisible := trl.Index(list.selected) + if index == -1 && indexInvisible >= 0 { + num := trl.TotalLength() - indexInvisible + 1 + trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) + index = trl.IndexVisible(list.selected) + } + + if index == 0 { + tagIndex := list.indexTag(list.selectedTag) + tagIndex++ + for ; tagIndex < len(list.tags); tagIndex++ { + nextTag := list.tags[tagIndex] + nextTRL := list.items[nextTag] + if nextTRL.HasVisibleRooms() { + return nextTag, nextTRL.FirstVisible() + } + } + return list.first() + } else if index > 0 { + return list.selectedTag, trl.Visible()[index-1].Room + } + return list.last() +} + +// NextWithActivity Returns next room with activity. +// +// Sorted by (in priority): +// +// - Highlights +// - Messages +// - Other traffic (joins, parts, etc) +// +// TODO: Sorting. Now just finds first room with new messages. +func (list *RoomList) NextWithActivity() (string, *rooms.Room) { + list.RLock() + defer list.RUnlock() + for tag, trl := range list.items { + for _, room := range trl.All() { + if room.HasNewMessages() { + return tag, room.Room + } + } + } + // No room with activity found + return "", nil +} + +func (list *RoomList) index(tag string, room *rooms.Room) int { + tagIndex := list.indexTag(tag) + if tagIndex == -1 { + return -1 + } + + trl, ok := list.items[tag] + localIndex := -1 + if ok { + localIndex = trl.IndexVisible(room) + } + if localIndex == -1 { + return -1 + } + localIndex = trl.Length() - 1 - localIndex + + // Tag header + localIndex++ + + if tagIndex > 0 { + for i := 0; i < tagIndex; i++ { + prevTag := list.tags[i] + + prevTRL := list.items[prevTag] + localIndex += prevTRL.RenderHeight() + } + } + + return localIndex +} + +func (list *RoomList) ContentHeight() (height int) { + list.RLock() + for _, tag := range list.tags { + height += list.items[tag].RenderHeight() + } + list.RUnlock() + return +} + +func (list *RoomList) OnKeyEvent(_ mauview.KeyEvent) bool { + return false +} + +func (list *RoomList) OnPasteEvent(_ mauview.PasteEvent) bool { + return false +} + +func (list *RoomList) OnMouseEvent(event mauview.MouseEvent) bool { + if event.HasMotion() { + return false + } + switch event.Buttons() { + case tcell.WheelUp: + list.AddScrollOffset(-WheelScrollOffsetDiff) + return true + case tcell.WheelDown: + list.AddScrollOffset(WheelScrollOffsetDiff) + return true + case tcell.Button1: + x, y := event.Position() + return list.clickRoom(y, x, event.Modifiers() == tcell.ModCtrl) + } + return false +} + +func (list *RoomList) Focus() { + +} + +func (list *RoomList) Blur() { + +} + +func (list *RoomList) clickRoom(line, column int, mod bool) bool { + line += list.scrollOffset + if line < 0 { + return false + } + list.RLock() + for _, tag := range list.tags { + trl := list.items[tag] + if line--; line == -1 { + trl.ToggleCollapse() + list.RUnlock() + return true + } + + if trl.IsCollapsed() { + continue + } + + if line < 0 { + break + } else if line < trl.Length() { + switchToRoom := trl.Visible()[trl.Length()-1-line].Room + list.RUnlock() + list.parent.SwitchRoom(tag, switchToRoom) + return true + } + + // Tag items + line -= trl.Length() + + hasMore := trl.HasInvisibleRooms() + hasLess := trl.maxShown > 10 + if hasMore || hasLess { + if line--; line == -1 { + diff := 10 + if mod { + diff = 100 + } + if column <= 6 && hasLess { + trl.maxShown -= diff + } else if column >= list.width-6 && hasMore { + trl.maxShown += diff + } + if trl.maxShown < 10 { + trl.maxShown = 10 + } + list.RUnlock() + return true + } + } + // Tag footer + line-- + } + list.RUnlock() + return false +} + +var nsRegex = regexp.MustCompile("^[a-z]+\\.[a-z]+(?:\\.[a-z]+)*$") + +func (list *RoomList) GetTagDisplayName(tag string) string { + switch { + case len(tag) == 0: + return "Rooms" + case tag == "m.favourite": + return "Favorites" + case tag == "m.lowpriority": + return "Low Priority" + case tag == "m.server_notice": + return "System Alerts" + case tag == "net.maunium.gomuks.fake.direct": + return "People" + case tag == "net.maunium.gomuks.fake.invite": + return "Invites" + case tag == "net.maunium.gomuks.fake.leave": + return "Historical" + case strings.HasPrefix(tag, "u."): + return tag[len("u."):] + case !nsRegex.MatchString(tag): + return tag + default: + return "" + } +} + +// Draw draws this primitive onto the screen. +func (list *RoomList) Draw(screen mauview.Screen) { + list.width, list.height = screen.Size() + y := 0 + yLimit := y + list.height + y -= list.scrollOffset + + // Draw the list items. + list.RLock() + for _, tag := range list.tags { + trl := list.items[tag] + tagDisplayName := list.GetTagDisplayName(tag) + if trl == nil || len(tagDisplayName) == 0 { + continue + } + + renderHeight := trl.RenderHeight() + if y+renderHeight >= yLimit { + renderHeight = yLimit - y + } + trl.Draw(mauview.NewProxyScreen(screen, 0, y, list.width, renderHeight)) + y += renderHeight + if y >= yLimit { + break + } + } + list.RUnlock() +} diff --git a/tui/room-view.go b/tui/room-view.go new file mode 100644 index 0000000..1cf05e0 --- /dev/null +++ b/tui/room-view.go @@ -0,0 +1,937 @@ +// 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" + "strings" + "time" + "unicode" + + "github.com/kyokomi/emoji/v2" + "github.com/mattn/go-runewidth" + "github.com/zyedidia/clipboard" + + "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" +) + +type RoomView struct { + topic *mauview.TextView + content *MessageView + status *mauview.TextField + userList *MemberList + ulBorder *widget.Border + input *mauview.InputArea + Room *rooms.Room + + topicScreen *mauview.ProxyScreen + contentScreen *mauview.ProxyScreen + statusScreen *mauview.ProxyScreen + inputScreen *mauview.ProxyScreen + ulBorderScreen *mauview.ProxyScreen + ulScreen *mauview.ProxyScreen + + userListLoaded bool + + prevScreen mauview.Screen + + parent *MainView + config *config.Config + + typing []string + + selecting bool + selectReason SelectReason + selectContent string + + replying *muksevt.Event + + editing *muksevt.Event + editMoveText string + + completions struct { + list []string + textCache string + time time.Time + } +} + +func NewRoomView(parent *MainView, room *rooms.Room) *RoomView { + view := &RoomView{ + topic: mauview.NewTextView(), + status: mauview.NewTextField(), + userList: NewMemberList(), + ulBorder: widget.NewBorder(), + input: mauview.NewInputArea(), + Room: room, + + topicScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: 0, Height: TopicBarHeight}, + contentScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: StatusBarHeight}, + statusScreen: &mauview.ProxyScreen{OffsetX: 0, Height: StatusBarHeight}, + inputScreen: &mauview.ProxyScreen{OffsetX: 0}, + ulBorderScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListBorderWidth}, + ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth}, + + parent: parent, + config: parent.config, + } + view.content = NewMessageView(view) + view.Room.SetPreUnload(func() bool { + if view.parent.currentRoom == view { + return false + } + view.content.Unload() + return true + }) + view.Room.SetPostLoad(view.loadTyping) + + view.input. + SetTextColor(tcell.ColorDefault). + SetBackgroundColor(tcell.ColorDefault). + SetPlaceholder("Send a message..."). + SetPlaceholderTextColor(tcell.ColorGray). + SetTabCompleteFunc(view.InputTabComplete). + SetPressKeyUpAtStartFunc(view.EditPrevious). + SetPressKeyDownAtEndFunc(view.EditNext) + + if room.Encrypted { + view.input.SetPlaceholder("Send an encrypted message...") + } + + view.topic. + SetTextColor(tcell.ColorWhite). + SetBackgroundColor(tcell.ColorDarkGreen) + + view.status.SetBackgroundColor(tcell.ColorDimGray) + + return view +} + +func (view *RoomView) SetInputChangedFunc(fn func(room *RoomView, text string)) *RoomView { + view.input.SetChangedFunc(func(text string) { + fn(view, text) + }) + return view +} + +func (view *RoomView) SetInputText(newText string) *RoomView { + view.input.SetTextAndMoveCursor(newText) + return view +} + +func (view *RoomView) GetInputText() string { + return view.input.GetText() +} + +func (view *RoomView) Focus() { + view.input.Focus() +} + +func (view *RoomView) Blur() { + view.StopSelecting() + view.input.Blur() +} + +func (view *RoomView) StartSelecting(reason SelectReason, content string) { + view.selecting = true + view.selectReason = reason + view.selectContent = content + msgView := view.MessageView() + if msgView.selected != nil { + view.OnSelect(msgView.selected) + } else { + view.input.Blur() + view.SelectPrevious() + } +} + +func (view *RoomView) StopSelecting() { + view.selecting = false + view.selectContent = "" + view.MessageView().SetSelected(nil) +} + +func (view *RoomView) OnSelect(message *messages.UIMessage) { + if !view.selecting || message == nil { + return + } + switch view.selectReason { + case SelectReply: + view.replying = message.Event + if len(view.selectContent) > 0 { + go view.SendMessage(event.MsgText, view.selectContent) + } + case SelectEdit: + view.SetEditing(message.Event) + case SelectReact: + go view.SendReaction(message.EventID, view.selectContent) + case SelectRedact: + go view.Redact(message.EventID, view.selectContent) + case SelectDownload, SelectOpen: + msg, ok := message.Renderer.(*messages.FileMessage) + if ok { + path := "" + if len(view.selectContent) > 0 { + path = view.selectContent + } else if view.selectReason == SelectDownload { + path = msg.Body + } + go view.Download(msg.URL, msg.File, path, view.selectReason == SelectOpen) + } + case SelectCopy: + go view.CopyToClipboard(message.Renderer.PlainText(), view.selectContent) + } + view.selecting = false + view.selectContent = "" + view.MessageView().SetSelected(nil) + view.input.Focus() +} + +func (view *RoomView) GetStatus() string { + var buf strings.Builder + + if view.editing != nil { + buf.WriteString("Editing message - ") + } else if view.replying != nil { + buf.WriteString("Replying to ") + buf.WriteString(string(view.replying.Sender)) + buf.WriteString(" - ") + } else if view.selecting { + buf.WriteString("Selecting message to ") + buf.WriteString(string(view.selectReason)) + buf.WriteString(" - ") + } + + if len(view.completions.list) > 0 { + if view.completions.textCache != view.input.GetText() || view.completions.time.Add(10*time.Second).Before(time.Now()) { + view.completions.list = []string{} + } else { + buf.WriteString(strings.Join(view.completions.list, ", ")) + buf.WriteString(" - ") + } + } + + if len(view.typing) == 1 { + buf.WriteString("Typing: " + string(view.typing[0])) + buf.WriteString(" - ") + } else if len(view.typing) > 1 { + buf.WriteString("Typing: ") + for i, userID := range view.typing { + if i == len(view.typing)-1 { + buf.WriteString(" and ") + } else if i > 0 { + buf.WriteString(", ") + } + buf.WriteString(string(userID)) + } + buf.WriteString(" - ") + } + + return strings.TrimSuffix(buf.String(), " - ") +} + +// Constants defining the size of the room view grid. +const ( + UserListBorderWidth = 1 + UserListWidth = 20 + StaticHorizontalSpace = UserListBorderWidth + UserListWidth + + TopicBarHeight = 1 + StatusBarHeight = 1 + + MaxInputHeight = 5 +) + +func (view *RoomView) Draw(screen mauview.Screen) { + width, height := screen.Size() + if width <= 0 || height <= 0 { + return + } + + if view.prevScreen != screen { + view.topicScreen.Parent = screen + view.contentScreen.Parent = screen + view.statusScreen.Parent = screen + view.inputScreen.Parent = screen + view.ulBorderScreen.Parent = screen + view.ulScreen.Parent = screen + view.prevScreen = screen + } + + view.input.PrepareDraw(width) + inputHeight := view.input.GetTextHeight() + if inputHeight > MaxInputHeight { + inputHeight = MaxInputHeight + } else if inputHeight < 1 { + inputHeight = 1 + } + contentHeight := height - inputHeight - TopicBarHeight - StatusBarHeight + contentWidth := width - StaticHorizontalSpace + if view.config.Preferences.HideUserList { + contentWidth = width + } + + view.topicScreen.Width = width + view.contentScreen.Width = contentWidth + view.contentScreen.Height = contentHeight + view.statusScreen.OffsetY = view.contentScreen.YEnd() + view.statusScreen.Width = width + view.inputScreen.Width = width + view.inputScreen.OffsetY = view.statusScreen.YEnd() + view.inputScreen.Height = inputHeight + view.ulBorderScreen.OffsetX = view.contentScreen.XEnd() + view.ulBorderScreen.Height = contentHeight + view.ulScreen.OffsetX = view.ulBorderScreen.XEnd() + view.ulScreen.Height = contentHeight + + // Draw everything + view.topic.Draw(view.topicScreen) + view.content.Draw(view.contentScreen) + view.status.SetText(view.GetStatus()) + view.status.Draw(view.statusScreen) + view.input.Draw(view.inputScreen) + if !view.config.Preferences.HideUserList { + view.ulBorder.Draw(view.ulBorderScreen) + view.userList.Draw(view.ulScreen) + } +} + +func (view *RoomView) ClearAllContext() { + view.SetEditing(nil) + view.StopSelecting() + view.replying = nil + view.input.Focus() +} + +func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool { + msgView := view.MessageView() + kb := config.Keybind{ + Key: event.Key(), + Ch: event.Rune(), + Mod: event.Modifiers(), + } + + if view.selecting { + switch view.config.Keybindings.Visual[kb] { + case "clear": + view.ClearAllContext() + case "select_prev": + view.SelectPrevious() + case "select_next": + view.SelectNext() + case "confirm": + view.OnSelect(msgView.selected) + default: + return false + } + return true + } + + switch view.config.Keybindings.Room[kb] { + case "clear": + view.ClearAllContext() + return true + case "scroll_up": + if msgView.IsAtTop() { + go view.parent.LoadHistory(view.Room.ID) + } + msgView.AddScrollOffset(+msgView.Height() / 2) + return true + case "scroll_down": + msgView.AddScrollOffset(-msgView.Height() / 2) + return true + case "send": + view.InputSubmit(view.input.GetText()) + return true + } + return view.input.OnKeyEvent(event) +} + +func (view *RoomView) OnPasteEvent(event mauview.PasteEvent) bool { + return view.input.OnPasteEvent(event) +} + +func (view *RoomView) OnMouseEvent(event mauview.MouseEvent) bool { + switch { + case view.contentScreen.IsInArea(event.Position()): + return view.content.OnMouseEvent(view.contentScreen.OffsetMouseEvent(event)) + case view.topicScreen.IsInArea(event.Position()): + return view.topic.OnMouseEvent(view.topicScreen.OffsetMouseEvent(event)) + case view.inputScreen.IsInArea(event.Position()): + return view.input.OnMouseEvent(view.inputScreen.OffsetMouseEvent(event)) + } + return false +} + +func (view *RoomView) SetCompletions(completions []string) { + view.completions.list = completions + view.completions.textCache = view.input.GetText() + view.completions.time = time.Now() +} + +func (view *RoomView) loadTyping() { + for index, user := range view.typing { + member := view.Room.GetMember(id.UserID(user)) + if member != nil { + view.typing[index] = member.Displayname + } + } +} + +func (view *RoomView) SetTyping(users []id.UserID) { + view.typing = make([]string, len(users)) + for i, user := range users { + view.typing[i] = string(user) + } + if view.Room.Loaded() { + view.loadTyping() + } +} + +var editHTMLParser = &format.HTMLParser{ + PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { + if len(eventID) > 0 { + return fmt.Sprintf(`[%s](https://matrix.to/#/%s/%s)`, displayname, mxid, eventID) + } else { + return fmt.Sprintf(`[%s](https://matrix.to/#/%s)`, displayname, mxid) + } + }, + Newline: "\n", + HorizontalLine: "\n---\n", +} + +func (view *RoomView) SetEditing(evt *muksevt.Event) { + if evt == nil { + view.editing = nil + view.SetInputText(view.editMoveText) + view.editMoveText = "" + } else { + if view.editing == nil { + view.editMoveText = view.GetInputText() + } + view.editing = evt + // replying should never be non-nil when SetEditing, but do this just to be safe + view.replying = nil + msgContent := view.editing.Content.AsMessage() + if len(view.editing.Gomuks.Edits) > 0 { + // This feels kind of dangerous, but I think it works + msgContent = view.editing.Gomuks.Edits[len(view.editing.Gomuks.Edits)-1].Content.AsMessage().NewContent + } + text := msgContent.Body + if len(msgContent.FormattedBody) > 0 && (!view.config.Preferences.DisableMarkdown || !view.config.Preferences.DisableHTML) { + if view.config.Preferences.DisableMarkdown { + text = msgContent.FormattedBody + } else { + text = editHTMLParser.Parse(msgContent.FormattedBody, make(format.Context)) + } + } + if msgContent.MsgType == event.MsgEmote { + text = "/me " + text + } + view.input.SetText(text) + } + view.status.SetText(view.GetStatus()) + view.input.SetCursorOffset(-1) +} + +type findFilter func(evt *muksevt.Event) bool + +func (view *RoomView) filterOwnOnly(evt *muksevt.Event) bool { + return evt.Sender == view.parent.matrix.Client().UserID && evt.Type == event.EventMessage +} + +func (view *RoomView) filterMediaOnly(evt *muksevt.Event) bool { + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + return ok && (content.MsgType == event.MsgFile || + content.MsgType == event.MsgImage || + content.MsgType == event.MsgAudio || + content.MsgType == event.MsgVideo) +} + +func (view *RoomView) findMessage(current *muksevt.Event, forward bool, allow findFilter) *messages.UIMessage { + currentFound := current == nil + msgs := view.MessageView().messages + for i := 0; i < len(msgs); i++ { + index := i + if !forward { + index = len(msgs) - i - 1 + } + evt := msgs[index] + if evt.EventID == "" || string(evt.EventID) == evt.TxnID || evt.IsService { + continue + } else if currentFound { + if allow == nil || allow(evt.Event) { + return evt + } + } else if evt.EventID == current.ID { + currentFound = true + } + } + return nil +} + +func (view *RoomView) EditNext() { + if view.editing == nil { + return + } + foundMsg := view.findMessage(view.editing, true, view.filterOwnOnly) + view.SetEditing(foundMsg.GetEvent()) +} + +func (view *RoomView) EditPrevious() { + if view.replying != nil { + return + } + foundMsg := view.findMessage(view.editing, false, view.filterOwnOnly) + if foundMsg != nil { + view.SetEditing(foundMsg.GetEvent()) + } +} + +func (view *RoomView) SelectNext() { + msgView := view.MessageView() + if msgView.selected == nil { + return + } + var filter findFilter + if view.selectReason == SelectDownload || view.selectReason == SelectOpen { + filter = view.filterMediaOnly + } + foundMsg := view.findMessage(msgView.selected.GetEvent(), true, filter) + if foundMsg != nil { + msgView.SetSelected(foundMsg) + // TODO scroll selected message into view + } +} + +func (view *RoomView) SelectPrevious() { + msgView := view.MessageView() + var filter findFilter + if view.selectReason == SelectDownload || view.selectReason == SelectOpen { + filter = view.filterMediaOnly + } + foundMsg := view.findMessage(msgView.selected.GetEvent(), false, filter) + if foundMsg != nil { + msgView.SetSelected(foundMsg) + // TODO scroll selected message into view + } +} + +type completion struct { + displayName string + id string +} + +func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) { + textWithoutPrefix := strings.TrimPrefix(existingText, "@") + for userID, user := range view.Room.GetMembers() { + if user.Displayname == textWithoutPrefix || string(userID) == existingText { + // Exact match, return that. + return []completion{{user.Displayname, string(userID)}} + } + + if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) { + completions = append(completions, completion{user.Displayname, string(userID)}) + } + } + return +} + +func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) { + for _, room := range view.parent.rooms { + alias := string(room.Room.GetCanonicalAlias()) + if alias == existingText { + // Exact match, return that. + return []completion{{alias, string(room.Room.ID)}} + } + if strings.HasPrefix(alias, existingText) { + completions = append(completions, completion{alias, string(room.Room.ID)}) + continue + } + } + return +} + +func (view *RoomView) AutocompleteEmoji(word string) (completions []string) { + if word[0] != ':' { + return + } + var valueCompletion1 string + var manyValues bool + for name, value := range emoji.CodeMap() { + if name == word { + return []string{value} + } else if strings.HasPrefix(name, word) { + completions = append(completions, name) + if valueCompletion1 == "" { + valueCompletion1 = value + } else if valueCompletion1 != value { + manyValues = true + } + } + } + if !manyValues && len(completions) > 0 { + return []string{emoji.CodeMap()[completions[0]]} + } + return +} + +func findWordToTabComplete(text string) string { + output := "" + runes := []rune(text) + for i := len(runes) - 1; i >= 0; i-- { + if unicode.IsSpace(runes[i]) { + break + } + output = string(runes[i]) + output + } + return output +} + +var ( + mentionMarkdown = "[%[1]s](https://matrix.to/#/%[2]s)" + mentionHTML = `%[1]s` + mentionPlaintext = "%[1]s" +) + +func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) { + if len(word) == 0 { + return []string{}, "" + } + + completions := view.AutocompleteUser(word) + completions = append(completions, view.AutocompleteRoom(word)...) + + if len(completions) == 1 { + completion := completions[0] + template := mentionMarkdown + if view.config.Preferences.DisableMarkdown { + if view.config.Preferences.DisableHTML { + template = mentionPlaintext + } else { + template = mentionHTML + } + } + strCompletion = fmt.Sprintf(template, completion.displayName, completion.id) + if startIndex == 0 && completion.id[0] == '@' { + strCompletion = strCompletion + ":" + } + } else if len(completions) > 1 { + for _, completion := range completions { + strCompletions = append(strCompletions, completion.displayName) + } + } + + strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...) + strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...) + + return +} + +func (view *RoomView) InputTabComplete(text string, cursorOffset int) { + if len(text) == 0 { + return + } + + str := runewidth.Truncate(text, cursorOffset, "") + word := findWordToTabComplete(str) + startIndex := len(str) - len(word) + + var strCompletion string + + strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset) + if !ok { + strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex) + } + + if len(strCompletions) > 0 { + strCompletion = util.LongestCommonPrefix(strCompletions) + sort.Sort(sort.StringSlice(strCompletions)) + } + if len(strCompletion) > 0 && len(strCompletions) < 2 { + strCompletion += " " + strCompletions = []string{} + } + + if len(strCompletion) > 0 && newText == text { + newText = str[0:startIndex] + strCompletion + text[len(str):] + } + + view.input.SetTextAndMoveCursor(newText) + view.SetCompletions(strCompletions) +} + +func (view *RoomView) InputSubmit(text string) { + if len(text) == 0 { + return + } else if cmd := view.parent.cmdProcessor.ParseCommand(view, text); cmd != nil { + go view.parent.cmdProcessor.HandleCommand(cmd) + } else { + go view.SendMessage(event.MsgText, text) + } + view.editMoveText = "" + view.SetInputText("") +} + +func (view *RoomView) CopyToClipboard(text string, register string) { + if register == "clipboard" || register == "primary" { + err := clipboard.WriteAll(text, register) + if err != nil { + view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err)) + view.parent.parent.Render() + } + } else { + view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register)) + view.parent.parent.Render() + } +} + +func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile, filename string, openFile bool) { + 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() + return + } + view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path)) + view.parent.parent.Render() + if openFile { + debug.Print("Opening file", path) + open.Open(path) + } +} + +func (view *RoomView) Redact(eventID id.EventID, reason string) { + defer debug.Recover() + err := view.parent.matrix.Redact(view.Room.ID, eventID, reason) + if err != nil { + if httpErr, ok := err.(mautrix.HTTPError); ok { + err = httpErr + if respErr := httpErr.RespError; respErr != nil { + err = respErr + } + } + view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err)) + view.parent.parent.Render() + } +} + +func (view *RoomView) SendReaction(eventID id.EventID, reaction string) { + defer debug.Recover() + if !view.config.Preferences.DisableEmojis { + reaction = emoji.Sprint(reaction) + } + reaction = variationselector.Add(strings.TrimSpace(reaction)) + debug.Print("Reacting to", eventID, "in", view.Room.ID, "with", reaction) + eventID, err := view.parent.matrix.SendEvent(&muksevt.Event{ + Event: &event.Event{ + Type: event.EventReaction, + RoomID: view.Room.ID, + Content: event.Content{Parsed: &event.ReactionEventContent{RelatesTo: event.RelatesTo{ + Type: event.RelAnnotation, + EventID: eventID, + Key: reaction, + }}}, + }, + }) + if err != nil { + if httpErr, ok := err.(mautrix.HTTPError); ok { + err = httpErr + if respErr := httpErr.RespError; respErr != nil { + err = respErr + } + } + view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err)) + view.parent.parent.Render() + } +} + +func (view *RoomView) SendMessage(msgtype event.MessageType, text string) { + view.SendMessageHTML(msgtype, text, "") +} + +func (view *RoomView) getRelationForNewEvent() *ifc.Relation { + if view.editing != nil { + return &ifc.Relation{ + Type: event.RelReplace, + Event: view.editing, + } + } else if view.replying != nil { + return &ifc.Relation{ + Type: event.RelReply, + Event: view.replying, + } + } + return nil +} + +func (view *RoomView) SendMessageHTML(msgtype event.MessageType, text, html string) { + defer debug.Recover() + debug.Print("Sending message", msgtype, text, "to", view.Room.ID) + if !view.config.Preferences.DisableEmojis { + text = emoji.Sprint(text) + } + rel := view.getRelationForNewEvent() + evt := view.parent.matrix.PrepareMarkdownMessage(view.Room.ID, msgtype, text, html, rel) + view.addLocalEcho(evt) +} + +func (view *RoomView) SendMessageMedia(path string) { + defer debug.Recover() + debug.Print("Sending media at", path, "to", view.Room.ID) + rel := view.getRelationForNewEvent() + 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() + return + } + view.addLocalEcho(evt) +} + +func (view *RoomView) addLocalEcho(evt *muksevt.Event) { + msg := view.parseEvent(evt.SomewhatDangerousCopy()) + view.content.AddMessage(msg, AppendMessage) + view.ClearAllContext() + view.status.SetText(view.GetStatus()) + eventID, err := view.parent.matrix.SendEvent(evt) + if err != nil { + msg.State = muksevt.StateSendFail + // Show shorter version if available + if httpErr, ok := err.(mautrix.HTTPError); ok { + err = httpErr + if respErr := httpErr.RespError; respErr != nil { + err = respErr + } + } + view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err)) + view.parent.parent.Render() + } else { + debug.Print("Event ID received:", eventID) + msg.EventID = eventID + msg.State = muksevt.StateDefault + view.MessageView().setMessageID(msg) + view.parent.parent.Render() + } +} + +func (view *RoomView) MessageView() *MessageView { + return view.content +} + +func (view *RoomView) MxRoom() *rooms.Room { + return view.Room +} + +func (view *RoomView) Update() { + topicStr := strings.TrimSpace(strings.ReplaceAll(view.Room.GetTopic(), "\n", " ")) + if view.config.Preferences.HideRoomList { + if len(topicStr) > 0 { + topicStr = fmt.Sprintf("%s - %s", view.Room.GetTitle(), topicStr) + } else { + topicStr = view.Room.GetTitle() + } + topicStr = strings.TrimSpace(topicStr) + } + view.topic.SetText(topicStr) + if !view.userListLoaded { + view.UpdateUserList() + } +} + +func (view *RoomView) UpdateUserList() { + pls := &event.PowerLevelsEventContent{} + if plEvent := view.Room.GetStateEvent(event.StatePowerLevels, ""); plEvent != nil { + pls = plEvent.Content.AsPowerLevels() + } + view.userList.Update(view.Room.GetMembers(), pls) + view.userListLoaded = true +} + +func (view *RoomView) AddServiceMessage(text string) { + view.content.AddMessage(messages.NewServiceMessage(text), AppendMessage) +} + +func (view *RoomView) parseEvent(evt *muksevt.Event) *messages.UIMessage { + return messages.ParseEvent(view.parent.matrix, view.parent, view.Room, evt) +} + +func (view *RoomView) AddHistoryEvent(evt *muksevt.Event) { + if msg := view.parseEvent(evt); msg != nil { + view.content.AddMessage(msg, PrependMessage) + } +} + +func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message { + if msg := view.parseEvent(evt); msg != nil { + view.content.AddMessage(msg, AppendMessage) + return msg + } + return nil +} + +func (view *RoomView) AddRedaction(redactedEvt *muksevt.Event) { + view.AddEvent(redactedEvt) +} + +func (view *RoomView) AddEdit(evt *muksevt.Event) { + if msg := view.parseEvent(evt); msg != nil { + view.content.AddMessage(msg, IgnoreMessage) + } +} + +func (view *RoomView) AddReaction(evt *muksevt.Event, key string) { + msgView := view.MessageView() + msg := msgView.getMessageByID(evt.ID) + if msg == nil { + // Message not in view, nothing to do + return + } + heightChanged := len(msg.Reactions) == 0 + msg.AddReaction(key) + if heightChanged { + // Replace buffer to update height of message + msgView.replaceBuffer(msg, msg) + } +} + +func (view *RoomView) GetEvent(eventID id.EventID) ifc.Message { + message, ok := view.content.messageIDs[eventID] + if !ok { + return nil + } + return message +} diff --git a/tui/syncing-modal.go b/tui/syncing-modal.go new file mode 100644 index 0000000..591b7e7 --- /dev/null +++ b/tui/syncing-modal.go @@ -0,0 +1,71 @@ +// 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 ( + "time" + + "go.mau.fi/mauview" +) + +type SyncingModal struct { + parent *MainView + text *mauview.TextView + progress *mauview.ProgressBar +} + +func NewSyncingModal(parent *MainView) (mauview.Component, *SyncingModal) { + sm := &SyncingModal{ + parent: parent, + progress: mauview.NewProgressBar(), + text: mauview.NewTextView(), + } + return mauview.Center( + mauview.NewBox( + mauview.NewFlex(). + SetDirection(mauview.FlexRow). + AddFixedComponent(sm.progress, 1). + AddFixedComponent(mauview.Center(sm.text, 40, 1), 1)). + SetTitle("Synchronizing"), + 42, 4). + SetAlwaysFocusChild(true), sm +} + +func (sm *SyncingModal) SetMessage(text string) { + sm.text.SetText(text) +} + +func (sm *SyncingModal) SetIndeterminate() { + sm.progress.SetIndeterminate(true) + 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() +} + +func (sm *SyncingModal) Step() { + sm.progress.Increment(1) +} + +func (sm *SyncingModal) Close() { + sm.parent.HideModal() +} diff --git a/tui/tui.go b/tui/tui.go index 7454979..5f65538 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -1,5 +1,5 @@ // gomuks - A Matrix client written in Go. -// Copyright (C) 2024 Tulir Asokan +// Copyright (C) 2025 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 @@ -30,8 +30,8 @@ type View string // Allowed views in GomuksTUI type GomuksTUI struct { *gomuks.Gomuks - App *mauview.Application - + App *mauview.Application + mainView *MainView loginView *LoginView } @@ -57,7 +57,11 @@ func init() { func (gt *GomuksTUI) Run() { gt.App = mauview.NewApplication() - gt.App.SetRoot(gt.NewLoginView()) + if !gt.Client.IsLoggedIn() { + gt.App.SetRoot(gt.NewLoginView()) + } else { + gt.App.SetRoot(gt.NewMainView()) + } err := gt.App.Start() if err != nil { panic(err) diff --git a/tui/view-login.go b/tui/view-login.go index 0b5622a..41e263a 100644 --- a/tui/view-login.go +++ b/tui/view-login.go @@ -1,3 +1,19 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 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 ( @@ -104,6 +120,7 @@ func (view *LoginView) Error(err string) { view.parent.App.Redraw() } func (view *LoginView) actuallyLogin(ctx context.Context, hs, mxid, password string) { + view.loading = true view.loginButton.SetText("Logging in...") err := view.parent.Client.LoginPassword(ctx, hs, mxid, password) diff --git a/tui/view-main.go b/tui/view-main.go new file mode 100644 index 0000000..c5d855c --- /dev/null +++ b/tui/view-main.go @@ -0,0 +1,461 @@ +// 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 ( + "bufio" + "fmt" + "os" + "sync/atomic" + "time" + + sync "github.com/sasha-s/go-deadlock" + + "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" + "maunium.net/go/gomuks/gt/messages" + "maunium.net/go/gomuks/gt/widget" +) + +type MainView struct { + flex *mauview.Flex + + roomList *RoomList + roomView *mauview.Box + currentRoom *RoomView + rooms map[id.RoomID]*RoomView + roomsLock sync.RWMutex + cmdProcessor *CommandProcessor + focused mauview.Focusable + + modal mauview.Component + + lastFocusTime time.Time + + matrix ifc.MatrixContainer + gmx ifc.Gomuks + parent *GomuksTUI +} + +func (gt *GomuksTUI) NewMainView() mauview.Component { + + 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(), + parent: gt, + } + mainView.roomList = NewRoomList(mainView) + mainView.cmdProcessor = NewCommandProcessor(mainView) + + mainView.flex. + AddFixedComponent(mainView.roomList, 25). + AddFixedComponent(widget.NewBorder(), 1). + AddProportionalComponent(mainView.roomView, 1) + mainView.BumpFocus(nil) + + gt.mainView = mainView + + return mainView +} + +func (view *MainView) ShowModal(modal mauview.Component) { + view.modal = modal + var ok bool + view.focused, ok = modal.(mauview.Focusable) + if !ok { + view.focused = nil + } else { + view.focused.Focus() + } +} + +func (view *MainView) HideModal() { + view.modal = nil + view.focused = view.roomView +} + +func (view *MainView) Draw(screen mauview.Screen) { + if view.config.Preferences.HideRoomList { + view.roomView.Draw(screen) + } else { + view.flex.Draw(screen) + } + + if view.modal != nil { + view.modal.Draw(screen) + } +} + +func (view *MainView) BumpFocus(roomView *RoomView) { + if roomView != nil { + view.lastFocusTime = time.Now() + view.MarkRead(roomView) + } +} + +func (view *MainView) MarkRead(roomView *RoomView) { + if roomView != nil && roomView.Room.HasNewMessages() && roomView.MessageView().ScrollOffset == 0 { + msgList := roomView.MessageView().messages + if len(msgList) > 0 { + msg := msgList[len(msgList)-1] + if roomView.Room.MarkRead(msg.ID()) { + view.matrix.MarkRead(roomView.Room.ID, msg.ID()) + } + } + } +} + +func (view *MainView) InputChanged(roomView *RoomView, text string) { + if !roomView.config.Preferences.DisableTypingNotifs { + view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/') + } +} + +func (view *MainView) ShowBare(roomView *RoomView) { + if roomView == nil { + return + } + _, 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. + height *= 2 + fmt.Println(roomView.MessageView().CapturePlaintext(height)) + fmt.Println("Press enter to return to normal mode.") + reader := bufio.NewReader(os.Stdin) + _, _, _ = reader.ReadRune() + print("\033[2J\033[0;0H") + }) +} + +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) + + 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 +defaultHandler: + 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) +} + +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() + } +} + +func (view *MainView) Blur() { + if view.focused != nil { + view.focused.Blur() + } +} + +func (view *MainView) SwitchRoom(tag string, room *rooms.Room) { + view.switchRoom(tag, room, true) +} + +func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) { + if room == nil { + return + } + room.Load() + + roomView, ok := view.getRoomView(room.ID, lock) + if !ok { + debug.Print("Tried to switch to room with nonexistent roomView!") + debug.Print(tag, room) + return + } + roomView.Update() + view.roomView.SetInnerComponent(roomView) + view.currentRoom = roomView + view.MarkRead(roomView) + view.roomList.SetSelected(tag, room) + view.flex.SetFocused(view.roomView) + view.focused = view.roomView + view.roomView.Focus() + view.parent.Render() + + if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded { + msgView.initialHistoryLoaded = true + go view.LoadHistory(room.ID) + } + if !room.MembersFetched { + go func() { + err := view.matrix.FetchMembers(room) + if err != nil { + debug.Print("Error fetching members:", err) + return + } + roomView.UpdateUserList() + view.parent.Render() + }() + } +} + +func (view *MainView) addRoomPage(room *rooms.Room) *RoomView { + if _, ok := view.rooms[room.ID]; !ok { + roomView := NewRoomView(view, room). + SetInputChangedFunc(view.InputChanged) + view.rooms[room.ID] = roomView + return roomView + } + return nil +} + +func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView { + room, ok := view.getRoomView(roomID, true) + if !ok { + return view.addRoom(view.matrix.GetOrCreateRoom(roomID)) + } + return room +} + +func (view *MainView) getRoomView(roomID id.RoomID, lock bool) (room *RoomView, ok bool) { + if lock { + view.roomsLock.RLock() + room, ok = view.rooms[roomID] + view.roomsLock.RUnlock() + } else { + room, ok = view.rooms[roomID] + } + return room, ok +} + +func (view *MainView) AddRoom(room *rooms.Room) { + view.addRoom(room) +} + +func (view *MainView) RemoveRoom(room *rooms.Room) { + view.roomsLock.Lock() + _, ok := view.getRoomView(room.ID, false) + if !ok { + view.roomsLock.Unlock() + debug.Print("Remove aborted (not found)", room.ID, room.GetTitle()) + return + } + debug.Print("Removing", room.ID, room.GetTitle()) + + view.roomList.Remove(room) + t, r := view.roomList.Selected() + view.switchRoom(t, r, false) + delete(view.rooms, room.ID) + view.roomsLock.Unlock() + + view.parent.Render() +} + +func (view *MainView) addRoom(room *rooms.Room) *RoomView { + if view.roomList.Contains(room.ID) { + debug.Print("Add aborted (room exists)", room.ID, room.GetTitle()) + return nil + } + debug.Print("Adding", room.ID, room.GetTitle()) + view.roomList.Add(room) + view.roomsLock.Lock() + roomView := view.addRoomPage(room) + if !view.roomList.HasSelected() { + t, r := view.roomList.First() + view.switchRoom(t, r, false) + } + view.roomsLock.Unlock() + return roomView +} + +func (view *MainView) SetRooms(rooms *rooms.RoomCache) { + view.roomList.Clear() + view.roomsLock.Lock() + view.rooms = make(map[id.RoomID]*RoomView) + for _, room := range rooms.Map { + if room.HasLeft { + continue + } + view.roomList.Add(room) + view.addRoomPage(room) + } + t, r := view.roomList.First() + view.switchRoom(t, r, false) + view.roomsLock.Unlock() +} + +func (view *MainView) UpdateTags(room *rooms.Room) { + if !view.roomList.Contains(room.ID) { + return + } + reselect := view.roomList.selected == room + view.roomList.Remove(room) + view.roomList.Add(room) + if reselect { + view.roomList.SetSelected(room.Tags()[0].Tag, room) + } + view.parent.Render() +} + +func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) { + roomView, ok := view.getRoomView(roomID, true) + if ok { + roomView.SetTyping(users) + view.parent.Render() + } +} + +func sendNotification(room *rooms.Room, sender, text string, critical, sound bool) { + if room.GetTitle() != sender { + sender = fmt.Sprintf("%s (%s)", sender, room.GetTitle()) + } + debug.Printf("Sending notification with body \"%s\" from %s in room ID %s (critical=%v, sound=%v)", text, sender, room.ID, critical, sound) + notification.Send(sender, text, critical, sound) +} + +func (view *MainView) Bump(room *rooms.Room) { + view.roomList.Bump(room) +} + +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 { + return + } + // Whether or not the room where the message came is the currently shown room. + isCurrent := room == view.roomList.SelectedRoom() + // Whether or not the terminal window is focused. + recentlyFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime) + isFocused := time.Now().Add(-5 * time.Second).Before(view.lastFocusTime) + + if !isCurrent || !isFocused { + // 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()) + } + + if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications { + // Push rules say notify and the terminal is not focused, send desktop notification. + shouldPlaySound := should.PlaySound && + should.SoundName == "default" && + view.config.NotifySound + sendNotification(room, message.NotificationSenderName(), message.NotificationContent(), should.Highlight, shouldPlaySound) + } + + // TODO this should probably happen somewhere else + // (actually it's probably completely broken now) + message.SetIsHighlight(should.Highlight) +} + +func (view *MainView) LoadHistory(roomID id.RoomID) { + defer debug.Recover() + roomView, ok := view.getRoomView(roomID, true) + if !ok { + return + } + msgView := roomView.MessageView() + + if !atomic.CompareAndSwapInt32(&msgView.loadingMessages, 0, 1) { + // Locked + return + } + defer atomic.StoreInt32(&msgView.loadingMessages, 0) + // Update the "Loading more messages..." text + view.parent.Render() + + 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() + return + } + //debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr) + msgView.historyLoadPtr = newLoadPtr + for _, evt := range history { + roomView.AddHistoryEvent(evt) + } + view.parent.Render() +}