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()
+}