From 8a3fbc24ab430443b89dfa45e726ab96ad3ea1ec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 16 May 2018 20:09:09 +0300 Subject: Handle m.direct and m.receipt events Fixes #12 Fixes #45 --- interface/matrix.go | 1 + interface/ui.go | 2 +- matrix/matrix.go | 92 ++++++++++++++++++++++++++++++++++++++++++++--- matrix/rooms/room.go | 86 ++++++++++++++++++++++++++++++++++++++------ matrix/rooms/room_test.go | 22 ++++++++---- matrix/sync.go | 4 +-- ui/room-list.go | 24 ++++++------- ui/view-main.go | 90 +++++++++++++++++++--------------------------- 8 files changed, 229 insertions(+), 92 deletions(-) diff --git a/interface/matrix.go b/interface/matrix.go index 6430686..8b450a9 100644 --- a/interface/matrix.go +++ b/interface/matrix.go @@ -35,6 +35,7 @@ type MatrixContainer interface { SendMessage(roomID, msgtype, message string) (string, error) SendMarkdownMessage(roomID, msgtype, message string) (string, error) SendTyping(roomID string, typing bool) + MarkRead(roomID, eventID string) JoinRoom(roomID, server string) (*rooms.Room, error) LeaveRoom(roomID string) error diff --git a/interface/ui.go b/interface/ui.go index b69f048..6023953 100644 --- a/interface/ui.go +++ b/interface/ui.go @@ -46,7 +46,7 @@ type MainView interface { SetRooms(rooms map[string]*rooms.Room) SaveAllHistory() - UpdateTags(room *rooms.Room, newTags []rooms.RoomTag) + UpdateTags(room *rooms.Room) SetTyping(roomID string, users []string) ParseEvent(roomView RoomView, evt *gomatrix.Event) Message diff --git a/matrix/matrix.go b/matrix/matrix.go index 7fbef5f..92fcabe 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -177,7 +177,9 @@ func (c *Container) OnLogin() { c.syncer = NewGomuksSyncer(c.config.Session) c.syncer.OnEventType("m.room.message", c.HandleMessage) c.syncer.OnEventType("m.room.member", c.HandleMembership) + c.syncer.OnEventType("m.receipt", c.HandleReadReceipt) c.syncer.OnEventType("m.typing", c.HandleTyping) + c.syncer.OnEventType("m.direct", c.HandleDirectChatInfo) c.syncer.OnEventType("m.push_rules", c.HandlePushRules) c.syncer.OnEventType("m.tag", c.HandleTag) c.syncer.InitDoneCallback = func() { @@ -228,7 +230,7 @@ func (c *Container) Start() { // HandleMessage is the event handler for the m.room.message timeline event. func (c *Container) HandleMessage(source EventSource, evt *gomatrix.Event) { - if source & EventSourceLeave != 0 { + if source&EventSourceLeave != 0 { return } mainView := c.ui.MainView() @@ -253,6 +255,82 @@ func (c *Container) HandleMessage(source EventSource, evt *gomatrix.Event) { } } +func (c *Container) parseReadReceipt(evt *gomatrix.Event) (largestTimestampEvent string) { + var largestTimestamp int64 + for eventID, rawContent := range evt.Content { + content, ok := rawContent.(map[string]interface{}) + if !ok { + continue + } + + mRead, ok := content["m.read"].(map[string]interface{}) + if !ok { + continue + } + + myInfo, ok := mRead[c.config.Session.UserID].(map[string]interface{}) + if !ok { + continue + } + + ts, ok := myInfo["ts"].(float64) + if int64(ts) > largestTimestamp { + largestTimestamp = int64(ts) + largestTimestampEvent = eventID + } + } + return +} + +func (c *Container) HandleReadReceipt(source EventSource, evt *gomatrix.Event) { + if source&EventSourceLeave != 0 { + return + } + + lastReadEvent := c.parseReadReceipt(evt) + if len(lastReadEvent) == 0 { + return + } + + room := c.GetRoom(evt.RoomID) + room.MarkRead(lastReadEvent) + c.ui.Render() +} + +func (c *Container) parseDirectChatInfo(evt *gomatrix.Event) (map[*rooms.Room]bool){ + directChats := make(map[*rooms.Room]bool) + for _, rawRoomIDList := range evt.Content { + roomIDList, ok := rawRoomIDList.([]interface{}) + if !ok { + continue + } + + for _, rawRoomID := range roomIDList { + roomID, ok := rawRoomID.(string) + if !ok { + continue + } + + room := c.GetRoom(roomID) + if room != nil && !room.HasLeft { + directChats[room] = true + } + } + } + return directChats +} + +func (c *Container) HandleDirectChatInfo(source EventSource, evt *gomatrix.Event) { + directChats := c.parseDirectChatInfo(evt) + for _, room := range c.config.Session.Rooms { + shouldBeDirect := directChats[room] + if shouldBeDirect != room.IsDirect { + room.IsDirect = shouldBeDirect + c.ui.MainView().UpdateTags(room) + } + } +} + // HandlePushRules is the event handler for the m.push_rules account data event. func (c *Container) HandlePushRules(source EventSource, evt *gomatrix.Event) { debug.Print("Received updated push rules") @@ -285,7 +363,8 @@ func (c *Container) HandleTag(source EventSource, evt *gomatrix.Event) { } mainView := c.ui.MainView() - mainView.UpdateTags(room, newTags) + room.RawTags = newTags + mainView.UpdateTags(room) } func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) { @@ -314,8 +393,8 @@ func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) { // HandleMembership is the event handler for the m.room.member state event. func (c *Container) HandleMembership(source EventSource, evt *gomatrix.Event) { - isLeave := source & EventSourceLeave != 0 - isTimeline := source & EventSourceTimeline != 0 + isLeave := source&EventSourceLeave != 0 + isTimeline := source&EventSourceTimeline != 0 isNonTimelineLeave := isLeave && !isTimeline if !c.config.Session.InitialSyncDone && isNonTimelineLeave { return @@ -356,6 +435,11 @@ func (c *Container) HandleTyping(source EventSource, evt *gomatrix.Event) { c.ui.MainView().SetTyping(evt.RoomID, strUsers) } +func (c *Container) MarkRead(roomID, eventID string) { + urlPath := c.client.BuildURL("rooms", roomID, "receipt", "m.read", eventID) + c.client.MakeRequest("POST", urlPath, struct{}{}, nil) +} + // SendMessage sends a message with the given text to the given room. func (c *Container) SendMessage(roomID, msgtype, text string) (string, error) { defer debug.Recover() diff --git a/matrix/rooms/room.go b/matrix/rooms/room.go index 40303be..17bf21b 100644 --- a/matrix/rooms/room.go +++ b/matrix/rooms/room.go @@ -43,6 +43,12 @@ type RoomTag struct { Order string } +type UnreadMessage struct { + EventID string + Counted bool + Highlight bool +} + // Room represents a single Matrix room. type Room struct { *gomatrix.Room @@ -57,13 +63,11 @@ type Room struct { SessionUserID string // The number of unread messages that were notified about. - UnreadMessages int - // Whether or not any of the unread messages were highlights. - Highlighted bool - // Whether or not the room contains any new messages. - // This can be true even when UnreadMessages is zero if there's - // a notificationless message like bot notices. - HasNewMessages bool + UnreadMessages []UnreadMessage + unreadCountCache *int + highlightCache *bool + // Whether or not this room is marked as a direct chat. + IsDirect bool // List of tags given to this room RawTags []RoomTag @@ -110,14 +114,74 @@ func (room *Room) UnlockHistory() { } // MarkRead clears the new message statuses on this room. -func (room *Room) MarkRead() { - room.UnreadMessages = 0 - room.Highlighted = false - room.HasNewMessages = false +func (room *Room) MarkRead(eventID string) { + readToIndex := -1 + for index, unreadMessage := range room.UnreadMessages { + if unreadMessage.EventID == eventID { + readToIndex = index + } + } + if readToIndex >= 0 { + room.UnreadMessages = room.UnreadMessages[readToIndex+1:] + room.highlightCache = nil + room.unreadCountCache = nil + } +} + +func (room *Room) UnreadCount() int { + if room.unreadCountCache == nil { + room.unreadCountCache = new(int) + for _, unreadMessage := range room.UnreadMessages { + if unreadMessage.Counted { + *room.unreadCountCache++ + } + } + } + return *room.unreadCountCache +} + +func (room *Room) Highlighted() bool { + if room.highlightCache == nil { + room.highlightCache = new(bool) + for _, unreadMessage := range room.UnreadMessages { + if unreadMessage.Highlight { + *room.highlightCache = true + break + } + } + } + return *room.highlightCache +} + +func (room *Room) HasNewMessages() bool { + return len(room.UnreadMessages) > 0 +} + +func (room *Room) AddUnread(eventID string, counted, highlight bool) { + room.UnreadMessages = append(room.UnreadMessages, UnreadMessage{ + EventID: eventID, + Counted: counted, + Highlight: highlight, + }) + if counted { + if room.unreadCountCache == nil { + room.unreadCountCache = new(int) + } + *room.unreadCountCache++ + } + if highlight { + if room.highlightCache == nil { + room.highlightCache = new(bool) + } + *room.highlightCache = true + } } func (room *Room) Tags() []RoomTag { if len(room.RawTags) == 0 { + if room.IsDirect { + return []RoomTag{{"net.maunium.gomuks.fake.direct", "0.5"}} + } return []RoomTag{{"", "0.5"}} } return room.RawTags diff --git a/matrix/rooms/room_test.go b/matrix/rooms/room_test.go index 258e57b..1bd47ff 100644 --- a/matrix/rooms/room_test.go +++ b/matrix/rooms/room_test.go @@ -215,11 +215,19 @@ func TestRoom_GetTitle_Members_GroupChat(t *testing.T) { func TestRoom_MarkRead(t *testing.T) { room := rooms.NewRoom("!test:maunium.net", "@tulir:maunium.net") - room.UnreadMessages = 123 - room.Highlighted = true - room.HasNewMessages = true - room.MarkRead() - assert.Zero(t, room.UnreadMessages) - assert.False(t, room.Highlighted) - assert.False(t, room.HasNewMessages) + + room.AddUnread("foo", true, false) + assert.Equal(t, 1, room.UnreadCount()) + assert.False(t, room.Highlighted()) + + room.AddUnread("bar", true, false) + assert.Equal(t, 2, room.UnreadCount()) + assert.False(t, room.Highlighted()) + + room.AddUnread("asd", false, true) + assert.Equal(t, 2, room.UnreadCount()) + assert.True(t, room.Highlighted()) + + room.MarkRead("") + assert.Empty(t, room.UnreadMessages) } diff --git a/matrix/sync.go b/matrix/sync.go index 93868c7..330a8c5 100644 --- a/matrix/sync.go +++ b/matrix/sync.go @@ -177,14 +177,14 @@ func (s *GomuksSyncer) GetFilterJSON(userID string) json.RawMessage { Limit: 50, }, Ephemeral: gomatrix.FilterPart{ - Types: []string{"m.typing"}, + Types: []string{"m.typing", "m.receipt"}, }, AccountData: gomatrix.FilterPart{ Types: []string{"m.tag"}, }, }, AccountData: gomatrix.FilterPart{ - Types: []string{"m.push_rules"}, + Types: []string{"m.push_rules", "m.direct"}, }, Presence: gomatrix.FilterPart{ Types: []string{}, diff --git a/ui/room-list.go b/ui/room-list.go index 613e587..6905d0e 100644 --- a/ui/room-list.go +++ b/ui/room-list.go @@ -265,6 +265,7 @@ func (list *RoomList) Contains(roomID string) bool { } func (list *RoomList) Add(room *rooms.Room) { + debug.Print("Adding room to list", room.ID, room.GetTitle(), room.IsDirect, room.Tags()) for _, tag := range room.Tags() { list.AddToTag(tag, room) } @@ -289,10 +290,6 @@ func (list *RoomList) CheckTag(tag string) { } func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) { - if tag.Tag == "" && len(room.GetMembers()) == 2 { - tag.Tag = "net.maunium.gomuks.fake.direct" - } - tagRoomList, ok := list.items[tag.Tag] if !ok { list.items[tag.Tag] = newTagRoomList(convertRoom(room)) @@ -304,8 +301,8 @@ func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) { } func (list *RoomList) Remove(room *rooms.Room) { - for _, tag := range room.Tags() { - list.RemoveFromTag(tag.Tag, room) + for _, tag := range list.tags { + list.RemoveFromTag(tag, room) } } @@ -707,21 +704,22 @@ func (list *RoomList) Draw(screen tcell.Screen) { if tag == list.selectedTag && item.Room == list.selected { style = style.Foreground(list.selectedTextColor).Background(list.selectedBackgroundColor) } - if item.HasNewMessages { + if item.HasNewMessages() { style = style.Bold(true) } - if item.UnreadMessages > 0 { + unreadCount := item.UnreadCount() + if unreadCount > 0 { unreadMessageCount := "99+" - if item.UnreadMessages < 100 { - unreadMessageCount = strconv.Itoa(item.UnreadMessages) + if unreadCount < 100 { + unreadMessageCount = strconv.Itoa(unreadCount) } - if item.Highlighted { + if item.Highlighted() { unreadMessageCount += "!" } unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount) - widget.WriteLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style) - lineWidth -= len(unreadMessageCount) + 1 + widget.WriteLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style) + lineWidth -= len(unreadMessageCount) } widget.WriteLinePadded(screen, tview.AlignLeft, text, x, y, lineWidth, style) diff --git a/ui/view-main.go b/ui/view-main.go index a83eaae..9f7c690 100644 --- a/ui/view-main.go +++ b/ui/view-main.go @@ -67,15 +67,25 @@ func (ui *GomuksUI) NewMainView() tview.Primitive { mainView.AddItem(mainView.roomList, 25, 0, false) mainView.AddItem(widget.NewBorder(), 1, 0, false) mainView.AddItem(mainView.roomView, 0, 1, true) - mainView.BumpFocus() + mainView.BumpFocus(nil) ui.mainView = mainView return mainView } -func (view *MainView) BumpFocus() { +func (view *MainView) BumpFocus(roomView *RoomView) { 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 + msg := msgList[len(msgList)-1] + roomView.Room.MarkRead(msg.ID()) + view.matrix.MarkRead(roomView.Room.ID, msg.ID()) + } } func (view *MainView) InputChanged(roomView *RoomView, text string) { @@ -182,7 +192,7 @@ func (view *MainView) HandleCommand(roomView *RoomView, command string, args []s } func (view *MainView) KeyEventHandler(roomView *RoomView, key *tcell.EventKey) *tcell.EventKey { - view.BumpFocus() + view.BumpFocus(roomView) k := key.Key() if key.Modifiers() == tcell.ModCtrl || key.Modifiers() == tcell.ModAlt { @@ -232,7 +242,7 @@ func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMo if event.Buttons() == tcell.ButtonNone || event.HasMotion() { return event } - view.BumpFocus() + view.BumpFocus(roomView) msgView := roomView.MessageView() x, y := event.Position() @@ -251,12 +261,8 @@ func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMo } case tcell.WheelDown: msgView.AddScrollOffset(-WheelScrollOffsetDiff) - view.parent.Render() - - if msgView.ScrollOffset == 0 { - roomView.Room.MarkRead() - } + view.MarkRead(roomView) default: if msgView.HandleClick(x-mx, y-my, event.Buttons()) { view.parent.Render() @@ -293,9 +299,12 @@ func (view *MainView) SwitchRoom(tag string, room *rooms.Room) { view.roomView.SwitchToPage(room.ID) roomView := view.rooms[room.ID] - if roomView.MessageView().ScrollOffset == 0 { - roomView.Room.MarkRead() + if roomView == nil { + debug.Print("Tried to switch to non-nil room with nil roomView!") + debug.Print(tag, room) + return } + view.MarkRead(roomView) view.roomList.SetSelected(tag, room) view.parent.app.SetFocus(view) view.parent.Render() @@ -353,10 +362,10 @@ func (view *MainView) GetRoom(roomID string) ifc.RoomView { func (view *MainView) AddRoom(room *rooms.Room) { if view.roomList.Contains(room.ID) { - debug.Print("Add aborted", room.ID) + debug.Print("Add aborted (room exists)", room.ID, room.GetTitle()) return } - debug.Print("Adding", room.ID) + debug.Print("Adding", room.ID, room.GetTitle()) view.roomList.Add(room) view.addRoomPage(room) if !view.roomList.HasSelected() { @@ -367,10 +376,10 @@ func (view *MainView) AddRoom(room *rooms.Room) { func (view *MainView) RemoveRoom(room *rooms.Room) { roomView := view.GetRoom(room.ID) if roomView == nil { - debug.Print("Remove aborted", room.ID) + debug.Print("Remove aborted (not found)", room.ID, room.GetTitle()) return } - debug.Print("Removing", room.ID) + debug.Print("Removing", room.ID, room.GetTitle()) view.roomList.Remove(room) view.SwitchRoom(view.roomList.Selected()) @@ -395,38 +404,12 @@ func (view *MainView) SetRooms(rooms map[string]*rooms.Room) { view.SwitchRoom(view.roomList.First()) } -func (view *MainView) UpdateTags(room *rooms.Room, newTags []rooms.RoomTag) { - if len(newTags) == 0 { - for _, tag := range room.RawTags { - view.roomList.RemoveFromTag(tag.Tag, room) - } - view.roomList.AddToTag(rooms.RoomTag{Tag: "", Order: "0.5"}, room) - } else if len(room.RawTags) == 0 { - view.roomList.RemoveFromTag("", room) - for _, tag := range newTags { - view.roomList.AddToTag(tag, room) - } - } else { - NewTags: - for _, newTag := range newTags { - for _, oldTag := range room.RawTags { - if newTag.Tag == oldTag.Tag { - continue NewTags - } - } - view.roomList.AddToTag(newTag, room) - } - OldTags: - for _, oldTag := range room.RawTags { - for _, newTag := range newTags { - if newTag.Tag == oldTag.Tag { - continue OldTags - } - } - view.roomList.RemoveFromTag(oldTag.Tag, room) - } +func (view *MainView) UpdateTags(room *rooms.Room) { + if !view.roomList.Contains(room.ID) { + return } - room.RawTags = newTags + view.roomList.Remove(room) + view.roomList.Add(room) } func (view *MainView) SetTyping(room string, users []string) { @@ -449,21 +432,20 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, shoul // 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. - isFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime) + recentlyFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime) + isFocused := time.Now().Add(-5 * time.Second).Before(view.lastFocusTime) // Whether or not the push rules say this message should be notified about. shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender() != view.config.Session.UserID - if !isCurrent { + if !isCurrent || !isFocused { // The message is not in the current room, show new message status in room list. - room.HasNewMessages = true - room.Highlighted = should.Highlight || room.Highlighted - if shouldNotify { - room.UnreadMessages++ - } + room.AddUnread(message.ID(), shouldNotify, should.Highlight) + } else { + view.matrix.MarkRead(room.ID, message.ID()) } - if shouldNotify && !isFocused { + if shouldNotify && !recentlyFocused { // Push rules say notify and the terminal is not focused, send desktop notification. shouldPlaySound := should.PlaySound && should.SoundName == "default" sendNotification(room, message.Sender(), message.NotificationContent(), should.Highlight, shouldPlaySound) -- cgit v1.2.3