// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "strings" "time" sync "github.com/sasha-s/go-deadlock" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/widget" "maunium.net/go/mautrix/event" ) const beeperBridgeSuffix = ":beeper.local" type split struct { name, tag string collapsed bool rooms []*rooms.Room } func (splt *split) title(selected bool) string { char := "▼" if splt.collapsed { if selected { char = "▷" } else { char = "▶" } } return splt.name + " " + char } type RosterView struct { mauview.Component sync.RWMutex split *split room *rooms.Room splits []*split splitLookup map[string]*split height, width int //scrollOffset int focused bool parent *MainView } func NewRosterView(mainView *MainView) *RosterView { splts := make([]*split, 0) splts = append(splts, &split{ name: "Favorites", tag: "m.favourite", rooms: make([]*rooms.Room, 0), }) splts = append(splts, &split{ name: "Inbox", tag: "", rooms: make([]*rooms.Room, 0), }) splts = append(splts, &split{ name: "Low Priority", tag: "m.lowpriority", collapsed: true, rooms: make([]*rooms.Room, 0), }) rstr := &RosterView{ parent: mainView, splits: splts, splitLookup: make(map[string]*split, 0), } for _, splt := range rstr.splits { rstr.splitLookup[splt.tag] = splt } return rstr } // splitForRoom returns the corresponding split for a given room. func (rstr *RosterView) splitForRoom(room *rooms.Room, create bool) *split { if room == nil { return nil } if strings.HasSuffix(room.ID.String(), beeperBridgeSuffix) { splt, sortByTag := rstr.splitForDiscordAndSlackRooms(room, create) if !sortByTag { return splt } } for _, tag := range room.Tags() { if splt, ok := rstr.splitLookup[tag.Tag]; ok { return splt } } return nil } // splitForDiscordAndSlackRooms returns the corresponding split for // passed bridged rooms from the Discord and Slack networks. If the room // is not bridged, or is not from Discord or Slack, it returns (nil, true). // If the split does not yet exist, it is created. func (rstr *RosterView) splitForDiscordAndSlackRooms(room *rooms.Room, create bool) (*split, bool) { bridgeEvent := room.MostRecentStateEventOfType(event.StateBridge) if bridgeEvent == nil { return nil, true } if _, server, err := bridgeEvent.Sender.Parse(); err != nil || server != beeperBridgeSuffix[1:] { return nil, true } content := bridgeEvent.Content bridge := content.AsBridge() if bridge.Protocol.DisplayName != "Discord" && bridge.Protocol.DisplayName != "Slack" { return nil, true } if bridge.Network == nil { // Need to check account data for "show in inbox" settings, which // govern the display of DMs. if _, ok := content.Raw["com.beeper.room_type"]; ok && bridge.Protocol.DisplayName == "Discord" { bridge.Network = &event.BridgeInfoSection{ ID: "discord-dms", DisplayName: "Discord DMs", } } else { return nil, true } } if splt, ok := rstr.splitLookup[bridge.Network.ID]; ok { return splt, false } if create { splt := &split{ name: bridge.Network.DisplayName, tag: bridge.Network.ID, collapsed: true, rooms: make([]*rooms.Room, 0), } rstr.splits = append(rstr.splits, splt) rstr.splitLookup[splt.tag] = splt return splt, false } return nil, true } func (rstr *RosterView) Add(room *rooms.Room) { if room.IsReplaced() { return } rstr.Lock() defer rstr.Unlock() splt := rstr.splitForRoom(room, true) if splt == nil { return } insertAt := len(splt.rooms) for i := 0; i < len(splt.rooms); i++ { if splt.rooms[i] == room { return } else if room.LastReceivedMessage.After(splt.rooms[i].LastReceivedMessage) { insertAt = i break } } splt.rooms = append(splt.rooms, nil) copy(splt.rooms[insertAt+1:], splt.rooms[insertAt:len(splt.rooms)-1]) splt.rooms[insertAt] = room } func (rstr *RosterView) Remove(room *rooms.Room) { rstr.Lock() defer rstr.Unlock() splt, index := rstr.index(room) if index < 0 || index > len(splt.rooms) { return } last := len(splt.rooms) - 1 if index < last { copy(splt.rooms[index:], splt.rooms[index+1:]) } splt.rooms[last] = nil splt.rooms = splt.rooms[:last] } func (rstr *RosterView) Bump(room *rooms.Room) { rstr.Remove(room) rstr.Add(room) } func (rstr *RosterView) index(room *rooms.Room) (*split, int) { if room == nil { return nil, -1 } splt := rstr.splitForRoom(room, false) if splt == nil { return nil, -1 } for index, entry := range splt.rooms { if entry == room { return splt, index } } return nil, -1 } func (rstr *RosterView) getMostRecentMessage(room *rooms.Room) (string, bool) { roomView, _ := rstr.parent.getRoomView(room.ID, true) if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded { msgView.initialHistoryLoaded = true go rstr.parent.LoadHistory(room.ID) } if len(roomView.content.messages) > 0 { for index := len(roomView.content.messages) - 1; index >= 0; index-- { if roomView.content.messages[index].Type == event.MsgText { return roomView.content.messages[index].PlainText(), true } } } return "It's quite empty in here.", false } func (rstr *RosterView) first() (*split, *rooms.Room) { for _, splt := range rstr.splits { if !splt.collapsed && len(splt.rooms) > 0 { return splt, splt.rooms[0] } } return rstr.splits[0], rstr.splits[0].rooms[0] } func (rstr *RosterView) Last() (*split, *rooms.Room) { rstr.Lock() defer rstr.Unlock() for index := len(rstr.splits) - 1; index >= 0; index-- { if rstr.splits[index].collapsed || len(rstr.splits[index].rooms) == 0 { continue } splt := rstr.splits[index] return splt, splt.rooms[len(splt.rooms)-1] } return rstr.splits[len(rstr.splits)-1], rstr.splits[len(rstr.splits)-1].rooms[0] } func (rstr *RosterView) ScrollNext() { rstr.Lock() defer rstr.Unlock() if splt, index := rstr.index(rstr.room); splt == nil || index == -1 { rstr.split, rstr.room = rstr.first() //rstr.scrollOffset = 0 } else if index < len(splt.rooms)-1 && !splt.collapsed { rstr.room = splt.rooms[index+1] //if rstr.VisualScrollHeight(rstr.scrollOffset, index+2) >= rstr.height { // rstr.scrollOffset++ //} } else { idx := -1 for i, s := range rstr.splits { if s == rstr.split { idx = i } } for i := idx + 1; i < len(rstr.splits); i++ { if len(rstr.splits[i].rooms) > 0 { rstr.split = rstr.splits[i] rstr.room = rstr.splits[i].rooms[0] return } } } } func (rstr *RosterView) ScrollPrev() { rstr.Lock() defer rstr.Unlock() if splt, index := rstr.index(rstr.room); splt == nil || index == -1 { return } else if index > 0 && !splt.collapsed { rstr.room = splt.rooms[index-1] //if index == rstr.scrollOffset { // rstr.scrollOffset-- //} } else { for idx := len(rstr.splits) - 1; idx > 0; idx-- { if rstr.splits[idx] == rstr.split { rstr.split = rstr.splits[idx-1] if len(rstr.split.rooms) > 0 { if rstr.split.collapsed { rstr.room = rstr.split.rooms[0] } else { rstr.room = rstr.split.rooms[len(rstr.split.rooms)-1] } } return } } } } func (rstr *RosterView) VisualScrollHeight(start, end int) int { if start < 0 || start > end { return -1 } return 3 + (2 * (end - start)) } func (rstr *RosterView) RoomsOnScreen() int { return (rstr.height - 3) / 2 } //func (rstr *RosterView) IndexOfLastVisibleRoom() int { // return rstr.scrollOffset + rstr.RoomsOnScreen() //} func (rstr *RosterView) Draw(screen mauview.Screen) { if rstr.focused { if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { roomView.Update() roomView.Draw(screen) return } } rstr.width, rstr.height = screen.Size() titleStyle := tcell.StyleDefault.Foreground(tcell.ColorDefault).Bold(true) mainStyle := titleStyle.Bold(false) now := time.Now() tm := now.Format("15:04") tmX := rstr.width - 3 - len(tm) // first line widget.WriteLine(screen, mauview.AlignLeft, "GOMUKS", 2, 1, tmX, titleStyle) widget.WriteLine(screen, mauview.AlignLeft, tm, tmX, 1, 2+len(tm), titleStyle) // second line widget.WriteLine(screen, mauview.AlignRight, now.Format("Mon, Jan 02"), 0, 2, rstr.width-3, mainStyle) // third line widget.NewBorder().Draw(mauview.NewProxyScreen(screen, 2, 3, rstr.width-5, 1)) y := 4 for _, splt := range rstr.splits { if len(splt.rooms) == 0 { continue } name := splt.title(splt == rstr.split) halfWidth := (rstr.width - 5 - len(name)) / 2 widget.WriteLineColor(screen, mauview.AlignCenter, name, halfWidth, y, halfWidth, tcell.ColorGray) y++ if splt.collapsed { continue } for _, room := range splt.rooms { if room.IsReplaced() { continue } renderHeight := 2 if y+renderHeight >= rstr.height { renderHeight = rstr.height - y } isSelected := room == rstr.room style := tcell.StyleDefault. Foreground(tcell.ColorDefault). Bold(room.HasNewMessages()) if isSelected { style = style. Foreground(tcell.ColorBlack). Background(tcell.ColorWhite). Italic(true) } timestamp := room.LastReceivedMessage tm := timestamp.Format("15:04") now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) if timestamp.Before(today) { if timestamp.Before(today.AddDate(0, 0, -6)) { tm = timestamp.Format("2006-01-02") } else { tm = timestamp.Format("Monday") } } lastMessage, received := rstr.getMostRecentMessage(room) msgStyle := style.Foreground(tcell.ColorGray).Italic(!received) startingX := 2 if isSelected { lastMessage = " " + lastMessage msgStyle = msgStyle.Background(tcell.ColorWhite).Italic(true) startingX += 2 widget.WriteLine(screen, mauview.AlignLeft, string(tcell.RuneDiamond)+" ", 2, y, 4, style) } tmX := rstr.width - 3 - len(tm) widget.WriteLinePadded(screen, mauview.AlignLeft, room.GetTitle(), startingX, y, tmX, style) widget.WriteLine(screen, mauview.AlignLeft, tm, tmX, y, startingX+len(tm), style) widget.WriteLinePadded(screen, mauview.AlignLeft, lastMessage, 2, y+1, rstr.width-5, msgStyle) y += renderHeight if y >= rstr.height { break } } } } func (rstr *RosterView) OnKeyEvent(event mauview.KeyEvent) bool { kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } if rstr.focused { if rstr.parent.config.Keybindings.Roster[kb] == "clear" { rstr.focused = false rstr.split = nil rstr.room = nil } else { if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { return roomView.OnKeyEvent(event) } } } switch rstr.parent.config.Keybindings.Roster[kb] { case "next_room": rstr.ScrollNext() case "prev_room": rstr.ScrollPrev() case "top": rstr.Lock() defer rstr.Unlock() rstr.split, rstr.room = rstr.first() //rstr.scrollOffset = 0 case "bottom": rstr.split, rstr.room = rstr.Last() if i := len(rstr.splits) - rstr.RoomsOnScreen(); i < 0 { //rstr.scrollOffset = 0 } else { //rstr.scrollOffset = i } case "clear": rstr.split = nil rstr.room = nil case "quit": rstr.parent.gmx.Stop(true) case "enter": if rstr.split != nil && !rstr.split.collapsed { rstr.focused = rstr.room != nil } case "toggle_split": if rstr.split != nil { rstr.split.collapsed = !rstr.split.collapsed } default: return false } return true } func (rstr *RosterView) OnMouseEvent(event mauview.MouseEvent) bool { if rstr.focused { if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { return roomView.OnMouseEvent(event) } } if event.HasMotion() { return false } switch event.Buttons() { case tcell.WheelUp: rstr.ScrollPrev() return true case tcell.WheelDown: rstr.ScrollNext() return true //case tcell.Button1: // _, y := event.Position() // if y <= 3 || y > rstr.VisualScrollHeight(rstr.scrollOffset, rstr.IndexOfLastVisibleRoom()) { // return false // } else { // index := rstr.scrollOffset + y/2 - 2 // if index > len(rstr.rooms)-1 { // return false // } // rstr.selected = rstr.rooms[index] // rstr.focused = true // return true // } } return false }