mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
copied files to start working on them
This commit is contained in:
parent
b7d9a914e1
commit
dd3245eb90
9 changed files with 3186 additions and 4 deletions
290
tui/command-processor.go
Normal file
290
tui/command-processor.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
128
tui/member-list.go
Normal file
128
tui/member-list.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
682
tui/message-view.go
Normal file
682
tui/message-view.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
592
tui/room-list.go
Normal file
592
tui/room-list.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
937
tui/room-view.go
Normal file
937
tui/room-view.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 = `<a href="https://matrix.to/#/%[2]s">%[1]s</a>`
|
||||||
|
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
|
||||||
|
}
|
71
tui/syncing-modal.go
Normal file
71
tui/syncing-modal.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
12
tui/tui.go
12
tui/tui.go
|
@ -1,5 +1,5 @@
|
||||||
// gomuks - A Matrix client written in Go.
|
// 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
|
// 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
|
// 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
|
// Allowed views in GomuksTUI
|
||||||
type GomuksTUI struct {
|
type GomuksTUI struct {
|
||||||
*gomuks.Gomuks
|
*gomuks.Gomuks
|
||||||
App *mauview.Application
|
App *mauview.Application
|
||||||
|
mainView *MainView
|
||||||
loginView *LoginView
|
loginView *LoginView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +57,11 @@ func init() {
|
||||||
|
|
||||||
func (gt *GomuksTUI) Run() {
|
func (gt *GomuksTUI) Run() {
|
||||||
gt.App = mauview.NewApplication()
|
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()
|
err := gt.App.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -104,6 +120,7 @@ func (view *LoginView) Error(err string) {
|
||||||
view.parent.App.Redraw()
|
view.parent.App.Redraw()
|
||||||
}
|
}
|
||||||
func (view *LoginView) actuallyLogin(ctx context.Context, hs, mxid, password string) {
|
func (view *LoginView) actuallyLogin(ctx context.Context, hs, mxid, password string) {
|
||||||
|
|
||||||
view.loading = true
|
view.loading = true
|
||||||
view.loginButton.SetText("Logging in...")
|
view.loginButton.SetText("Logging in...")
|
||||||
err := view.parent.Client.LoginPassword(ctx, hs, mxid, password)
|
err := view.parent.Client.LoginPassword(ctx, hs, mxid, password)
|
||||||
|
|
461
tui/view-main.go
Normal file
461
tui/view-main.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue