diff --git a/config/keybindings.yaml b/config/keybindings.yaml index 7301aa6..96324a9 100644 --- a/config/keybindings.yaml +++ b/config/keybindings.yaml @@ -26,6 +26,7 @@ roster: 'Alt+Backspace': clear 'q': quit 'Enter': enter + 'z': toggle_split modal: 'Tab': select_next diff --git a/ui/commands.go b/ui/commands.go index 6ad0d14..e352ff8 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -913,12 +913,13 @@ func cmdEscape(cmd *Command) { cmd.Reply("/escape can only be used in the modern display mode") return } - if cmd.MainView.rosterView.selected == nil || !cmd.MainView.rosterView.focused { + if cmd.MainView.rosterView.room == nil || !cmd.MainView.rosterView.focused { cmd.Reply("/escape is used to exit from an open room (no room opened)") return } cmd.MainView.rosterView.focused = false - cmd.MainView.rosterView.selected = nil + cmd.MainView.rosterView.split = nil + cmd.MainView.rosterView.room = nil cmd.UI.Render() } diff --git a/ui/view-roster.go b/ui/view-roster.go index c400ae5..cb92c7a 100644 --- a/ui/view-roster.go +++ b/ui/view-roster.go @@ -17,6 +17,7 @@ package ui import ( + "strings" "time" sync "github.com/sasha-s/go-deadlock" @@ -30,29 +31,149 @@ import ( "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 - selected *rooms.Room - rooms []*rooms.Room - scrollOffset int + split *split + room *rooms.Room + + splits []*split + splitLookup map[string]*split height, width int - focused bool + //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, - rooms: make([]*rooms.Room, 0), + 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 @@ -61,35 +182,40 @@ func (rstr *RosterView) Add(room *rooms.Room) { rstr.Lock() defer rstr.Unlock() - insertAt := len(rstr.rooms) - for i := 0; i < len(rstr.rooms); i++ { - if rstr.rooms[i] == room { + 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(rstr.rooms[i].LastReceivedMessage) { + } else if room.LastReceivedMessage.After(splt.rooms[i].LastReceivedMessage) { insertAt = i break } } - rstr.rooms = append(rstr.rooms, nil) - copy(rstr.rooms[insertAt+1:], rstr.rooms[insertAt:len(rstr.rooms)-1]) - rstr.rooms[insertAt] = room + 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) { - index := rstr.index(room) - if index < 0 || index > len(rstr.rooms) { - return - } - rstr.Lock() defer rstr.Unlock() - last := len(rstr.rooms) - 1 - if index < last { - copy(rstr.rooms[index:], rstr.rooms[index+1:]) + splt, index := rstr.index(room) + if index < 0 || index > len(splt.rooms) { + return } - rstr.rooms[last] = nil - rstr.rooms = rstr.rooms[:last] + + 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) { @@ -97,16 +223,23 @@ func (rstr *RosterView) Bump(room *rooms.Room) { rstr.Add(room) } -func (rstr *RosterView) index(room *rooms.Room) int { - rstr.Lock() - defer rstr.Unlock() +func (rstr *RosterView) index(room *rooms.Room) (*split, int) { + if room == nil { + return nil, -1 + } - for index, entry := range rstr.rooms { + splt := rstr.splitForRoom(room, false) + if splt == nil { + return nil, -1 + } + + for index, entry := range splt.rooms { if entry == room { - return index + return splt, index } } - return -1 + + return nil, -1 } func (rstr *RosterView) getMostRecentMessage(room *rooms.Room) (string, bool) { @@ -128,39 +261,83 @@ func (rstr *RosterView) getMostRecentMessage(room *rooms.Room) (string, bool) { return "It's quite empty in here.", false } -func (rstr *RosterView) First() *rooms.Room { - rstr.Lock() - defer rstr.Unlock() - return rstr.rooms[0] +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() *rooms.Room { +func (rstr *RosterView) Last() (*split, *rooms.Room) { rstr.Lock() defer rstr.Unlock() - return rstr.rooms[len(rstr.rooms)-1] + + 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() { - if index := rstr.index(rstr.selected); index == -1 { - rstr.selected = rstr.First() - rstr.scrollOffset = 0 - } else if index < len(rstr.rooms)-1 { - rstr.Lock() - defer rstr.Unlock() - rstr.selected = rstr.rooms[index+1] - if rstr.VisualScrollHeight(rstr.scrollOffset, index+2) >= rstr.height { - rstr.scrollOffset++ + 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() { - if index := rstr.index(rstr.selected); index > 0 { - rstr.Lock() - defer rstr.Unlock() - rstr.selected = rstr.rooms[index-1] - if index == rstr.scrollOffset { - rstr.scrollOffset-- + 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 + } } } } @@ -176,13 +353,13 @@ func (rstr *RosterView) RoomsOnScreen() int { return (rstr.height - 3) / 2 } -func (rstr *RosterView) IndexOfLastVisibleRoom() int { - return rstr.scrollOffset + rstr.RoomsOnScreen() -} +//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.selected.ID, true); ok { + if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { roomView.Update() roomView.Draw(screen) return @@ -207,60 +384,76 @@ func (rstr *RosterView) Draw(screen mauview.Screen) { widget.NewBorder().Draw(mauview.NewProxyScreen(screen, 2, 3, rstr.width-5, 1)) y := 4 - for _, room := range rstr.rooms[rstr.scrollOffset:] { - if room.IsReplaced() { + for _, splt := range rstr.splits { + + if len(splt.rooms) == 0 { continue } - renderHeight := 2 - if y+renderHeight >= rstr.height { - renderHeight = rstr.height - y + 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 } - isSelected := room == rstr.selected - - 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") + for _, room := range splt.rooms { + if room.IsReplaced() { + continue } - } - lastMessage, received := rstr.getMostRecentMessage(room) - msgStyle := style.Foreground(tcell.ColorGray).Italic(!received) - startingX := 2 + renderHeight := 2 + if y+renderHeight >= rstr.height { + renderHeight = rstr.height - y + } - if isSelected { - lastMessage = " " + lastMessage - msgStyle = msgStyle.Background(tcell.ColorWhite).Italic(true) - startingX += 2 + isSelected := room == rstr.room - widget.WriteLine(screen, mauview.AlignLeft, string(tcell.RuneDiamond)+" ", 2, y, 4, style) - } + style := tcell.StyleDefault. + Foreground(tcell.ColorDefault). + Bold(room.HasNewMessages()) + if isSelected { + style = style. + Foreground(tcell.ColorBlack). + Background(tcell.ColorWhite). + Italic(true) + } - 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) + 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") + } + } - y += renderHeight - if y >= rstr.height { - break + 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 + } } } } @@ -275,9 +468,10 @@ func (rstr *RosterView) OnKeyEvent(event mauview.KeyEvent) bool { if rstr.focused { if rstr.parent.config.Keybindings.Roster[kb] == "clear" { rstr.focused = false - rstr.selected = nil + rstr.split = nil + rstr.room = nil } else { - if roomView, ok := rstr.parent.getRoomView(rstr.selected.ID, true); ok { + if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { return roomView.OnKeyEvent(event) } } @@ -289,22 +483,31 @@ func (rstr *RosterView) OnKeyEvent(event mauview.KeyEvent) bool { case "prev_room": rstr.ScrollPrev() case "top": - rstr.selected = rstr.First() - rstr.scrollOffset = 0 + rstr.Lock() + defer rstr.Unlock() + rstr.split, rstr.room = rstr.first() + //rstr.scrollOffset = 0 case "bottom": - rstr.selected = rstr.Last() + rstr.split, rstr.room = rstr.Last() - if i := len(rstr.rooms) - rstr.RoomsOnScreen(); i < 0 { - rstr.scrollOffset = 0 + if i := len(rstr.splits) - rstr.RoomsOnScreen(); i < 0 { + //rstr.scrollOffset = 0 } else { - rstr.scrollOffset = i + //rstr.scrollOffset = i } case "clear": - rstr.selected = nil + rstr.split = nil + rstr.room = nil case "quit": rstr.parent.gmx.Stop(true) case "enter": - rstr.focused = rstr.selected != nil + 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 } @@ -313,7 +516,7 @@ func (rstr *RosterView) OnKeyEvent(event mauview.KeyEvent) bool { func (rstr *RosterView) OnMouseEvent(event mauview.MouseEvent) bool { if rstr.focused { - if roomView, ok := rstr.parent.getRoomView(rstr.selected.ID, true); ok { + if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok { return roomView.OnMouseEvent(event) } } @@ -329,19 +532,19 @@ func (rstr *RosterView) OnMouseEvent(event mauview.MouseEvent) bool { 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 - } + //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