From 53720f56df826b050bc0c5d98a094c9e447eca3a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:55:25 +0200 Subject: [PATCH 01/21] some cleanup --- src/matrix/room/RoomSummary.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index bef9424c..a674d9e2 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -50,14 +50,13 @@ function processEvent(data, event) { data = data.cloneIfNeeded(); data.isEncrypted = true; } - } - if (event.type === "m.room.name") { + } else if (event.type === "m.room.name") { const newName = event.content?.name; if (newName !== data.name) { data = data.cloneIfNeeded(); data.name = newName; } - } if (event.type === "m.room.avatar") { + } else if (event.type === "m.room.avatar") { const newUrl = event.content?.url; if (newUrl !== data.avatarUrl) { data = data.cloneIfNeeded(); From 4419b3366e5294ccc581e064ff5aa18b58fefbf8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:55:47 +0200 Subject: [PATCH 02/21] store isUnread and lastMessageTimestamp --- src/matrix/room/RoomSummary.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index a674d9e2..bb74656f 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -63,11 +63,13 @@ function processEvent(data, event) { data.avatarUrl = newUrl; } } else if (event.type === "m.room.message") { + data = data.cloneIfNeeded(); + data.lastMessageTimestamp = event.origin_server_ts; + data.isUnread = true; const {content} = event; const body = content?.body; const msgtype = content?.msgtype; if (msgtype === "m.text") { - data = data.cloneIfNeeded(); data.lastMessageBody = body; } } else if (event.type === "m.room.canonical_alias") { @@ -104,8 +106,8 @@ class SummaryData { this.roomId = copy ? copy.roomId : roomId; this.name = copy ? copy.name : null; this.lastMessageBody = copy ? copy.lastMessageBody : null; - this.unreadCount = copy ? copy.unreadCount : null; - this.mentionCount = copy ? copy.mentionCount : null; + this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null; + this.isUnread = copy ? copy.isUnread : null; this.isEncrypted = copy ? copy.isEncrypted : null; this.isDirectMessage = copy ? copy.isDirectMessage : null; this.membership = copy ? copy.membership : null; @@ -157,10 +159,18 @@ export class RoomSummary { return this._data.roomId; } + get isUnread() { + return this._data.isUnread; + } + get lastMessage() { return this._data.lastMessageBody; } + get lastMessageTimestamp() { + return this._data.lastMessageTimestamp; + } + get inviteCount() { return this._data.inviteCount; } From 739d74bf9c0b024265a108607e3fcfea1e174c33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:56:10 +0200 Subject: [PATCH 03/21] add method to clear unread state --- src/matrix/room/Room.js | 17 +++++++++++++++++ src/matrix/room/RoomSummary.js | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 5ac77009..75978b27 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -184,6 +184,23 @@ export class Room extends EventEmitter { return this._summary.avatarUrl; } + async clearUnread() { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + ]); + let data; + try { + data = this._summary.writeClearUnread(txn); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + this._summary.applyChanges(data); + this.emit("change"); + this._emitCollectionChange(this); + } + /** @public */ async openTimeline() { if (this._timeline) { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index bb74656f..399d869b 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -191,6 +191,15 @@ export class RoomSummary { return this._data.lastPaginationToken; } + writeClearUnread(txn) { + const data = new SummaryData(this._data); + data.isUnread = false; + data.notificationCount = 0; + data.highlightCount = 0; + txn.roomSummary.set(data.serialize()); + return data; + } + writeHasFetchedMembers(value, txn) { const data = new SummaryData(this._data); data.hasFetchedMembers = value; From 7458465ef61cad33000c87755b57d5a2cfc836b3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:56:45 +0200 Subject: [PATCH 04/21] expose props on Room --- src/matrix/room/Room.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 75978b27..9d543e7d 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -184,6 +184,18 @@ export class Room extends EventEmitter { return this._summary.avatarUrl; } + get lastMessageTimestamp() { + return this._summary.lastMessageTimestamp; + } + + get isUnread() { + return this._summary.isUnread; + } + + get notificationCount() { + return this._summary.notificationCount; + } + async clearUnread() { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, From 89392434ade79f2aeb2f6f95bbd79915770efa02 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:57:38 +0200 Subject: [PATCH 05/21] render unread rooms as bold --- src/domain/session/roomlist/RoomTileViewModel.js | 4 ++++ src/ui/web/css/left-panel.css | 4 ++++ src/ui/web/session/RoomTile.js | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 56d0345f..41b7ca99 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -57,6 +57,10 @@ export class RoomTileViewModel extends ViewModel { return this._isOpen; } + get isUnread() { + return this._room.isUnread; + } + get name() { return this._room.name; } diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index 9400da4d..320bb899 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -31,6 +31,10 @@ limitations under the License. align-items: center; } +.LeftPanel .name.unread { + font-weight: 600; +} + .LeftPanel div.description { margin: 0; flex: 1 1 0; diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/RoomTile.js index cde53b81..798e7f91 100644 --- a/src/ui/web/session/RoomTile.js +++ b/src/ui/web/session/RoomTile.js @@ -21,7 +21,7 @@ export class RoomTile extends TemplateView { render(t, vm) { return t.li({"className": {"active": vm => vm.isOpen}}, [ renderAvatar(t, vm, 32), - t.div({className: "description"}, t.div({className: "name"}, vm => vm.name)) + t.div({className: "description"}, t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name)) ]); } From dbf5e59d874af384c1399af3166c6bcadd7d8b92 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:57:49 +0200 Subject: [PATCH 06/21] clear unread state 2s after opening the room --- src/domain/session/room/RoomViewModel.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 86edf027..b5c05be7 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -32,6 +32,7 @@ export class RoomViewModel extends ViewModel { this._sendError = null; this._closeCallback = closeCallback; this._composerVM = new ComposerViewModel(this); + this._clearUnreadTimout = null; } async load() { @@ -49,6 +50,15 @@ export class RoomViewModel extends ViewModel { this._timelineError = err; this.emitChange("error"); } + this._clearUnreadTimout = this.clock.createTimeout(2000); + try { + await this._clearUnreadTimout.elapsed(); + await this._room.clearUnread(); + } catch (err) { + if (err.name !== "AbortError") { + throw err; + } + } } dispose() { @@ -57,6 +67,10 @@ export class RoomViewModel extends ViewModel { // will stop the timeline from delivering updates on entries this._timeline.close(); } + if (this._clearUnreadTimout) { + this._clearUnreadTimout.abort(); + this._clearUnreadTimout = null; + } } close() { From eae5bc4230a162fd8f7e37835b8bf989c30cb939 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 11:58:05 +0200 Subject: [PATCH 07/21] sort unread rooms first, then on last message timestamp, then alphabet. --- .../session/roomlist/RoomTileViewModel.js | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 41b7ca99..d79b4dbe 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -17,6 +17,10 @@ limitations under the License. import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; +function isSortedAsUnread(vm) { + return vm.isUnread || (vm.isOpen && vm._wasUnreadWhenOpening); +} + export class RoomTileViewModel extends ViewModel { // we use callbacks to parent VM instead of emit because // it would be annoying to keep track of subscriptions in @@ -28,6 +32,7 @@ export class RoomTileViewModel extends ViewModel { this._room = room; this._emitOpen = emitOpen; this._isOpen = false; + this._wasUnreadWhenOpening = false; } // called by parent for now (later should integrate with router) @@ -40,17 +45,32 @@ export class RoomTileViewModel extends ViewModel { open() { this._isOpen = true; + this._wasUnreadWhenOpening = this._room.isUnread; this.emitChange("isOpen"); this._emitOpen(this._room, this); } compare(other) { - // sort alphabetically - const nameCmp = this._room.name.localeCompare(other._room.name); - if (nameCmp === 0) { - return this._room.id.localeCompare(other._room.id); + const myRoom = this._room; + const theirRoom = other._room; + + if (isSortedAsUnread(this) !== isSortedAsUnread(other)) { + if (isSortedAsUnread(this)) { + return -1; + } + return 1; } - return nameCmp; + + const timeDiff = theirRoom.lastMessageTimestamp - myRoom.lastMessageTimestamp; + if (timeDiff === 0) { + // sort alphabetically + const nameCmp = this._room.name.localeCompare(other._room.name); + if (nameCmp === 0) { + return this._room.id.localeCompare(other._room.id); + } + return nameCmp; + } + return timeDiff; } get isOpen() { From 4fb3010676ae639dd6fc132bea0df5116dc5447b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 13:45:38 +0200 Subject: [PATCH 08/21] only set unread for incremental syncs --- src/matrix/Sync.js | 2 +- src/matrix/room/Room.js | 4 ++-- src/matrix/room/RoomSummary.js | 20 +++++++++++++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 4b4acd9d..e6de7146 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -142,7 +142,7 @@ export class Sync { room = this._session.createRoom(roomId); } console.log(` * applying sync response to room ${roomId} ...`); - const changes = await room.writeSync(roomResponse, membership, syncTxn); + const changes = await room.writeSync(roomResponse, membership, isInitialSync, syncTxn); roomChanges.push({room, changes}); }); await Promise.all(promises); diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 9d543e7d..d1da35e4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -42,8 +42,8 @@ export class Room extends EventEmitter { } /** @package */ - async writeSync(roomResponse, membership, txn) { - const summaryChanges = this._summary.writeSync(roomResponse, membership, txn); + async writeSync(roomResponse, membership, isInitialSync, txn) { + const summaryChanges = this._summary.writeSync(roomResponse, membership, isInitialSync, txn); const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn); let removedPendingEvents; if (roomResponse.timeline && roomResponse.timeline.events) { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 399d869b..d149e1cc 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -function applySyncResponse(data, roomResponse, membership) { +function applySyncResponse(data, roomResponse, membership, isInitialSync) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } @@ -24,7 +24,9 @@ function applySyncResponse(data, roomResponse, membership) { } // state comes before timeline if (roomResponse.state) { - data = roomResponse.state.events.reduce(processEvent, data); + data = roomResponse.state.events.reduce((data, event) => { + return processEvent(data, event, isInitialSync); + }, data); } if (roomResponse.timeline) { const {timeline} = roomResponse; @@ -32,7 +34,9 @@ function applySyncResponse(data, roomResponse, membership) { data = data.cloneIfNeeded(); data.lastPaginationToken = timeline.prev_batch; } - data = timeline.events.reduce(processEvent, data); + data = timeline.events.reduce((data, event) => { + return processEvent(data, event, isInitialSync); + }, data); } const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { @@ -44,7 +48,7 @@ function applySyncResponse(data, roomResponse, membership) { return data; } -function processEvent(data, event) { +function processEvent(data, event, isInitialSync) { if (event.type === "m.room.encryption") { if (!data.isEncrypted) { data = data.cloneIfNeeded(); @@ -65,7 +69,9 @@ function processEvent(data, event) { } else if (event.type === "m.room.message") { data = data.cloneIfNeeded(); data.lastMessageTimestamp = event.origin_server_ts; - data.isUnread = true; + if (!isInitialSync) { + data.isUnread = true; + } const {content} = event; const body = content?.body; const msgtype = content?.msgtype; @@ -207,11 +213,11 @@ export class RoomSummary { return data; } - writeSync(roomResponse, membership, txn) { + writeSync(roomResponse, membership, isInitialSync, txn) { // clear cloned flag, so cloneIfNeeded makes a copy and // this._data is not modified if any field is changed. this._data.cloned = false; - const data = applySyncResponse(this._data, roomResponse, membership); + const data = applySyncResponse(this._data, roomResponse, membership, isInitialSync); if (data !== this._data) { // need to think here how we want to persist // things like unread status (as read marker, or unread count)? From 5d1bc61f61c17c1a6bb38c679602424d2b5c9768 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 13:45:55 +0200 Subject: [PATCH 09/21] don't open a room when already open --- src/domain/session/roomlist/RoomTileViewModel.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index d79b4dbe..13a01309 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -44,10 +44,12 @@ export class RoomTileViewModel extends ViewModel { } open() { - this._isOpen = true; - this._wasUnreadWhenOpening = this._room.isUnread; - this.emitChange("isOpen"); - this._emitOpen(this._room, this); + if (!this._isOpen) { + this._isOpen = true; + this._wasUnreadWhenOpening = this._room.isUnread; + this.emitChange("isOpen"); + this._emitOpen(this._room, this); + } } compare(other) { From 4969009b2b2f5f51f25e96c7d5421de1b4f1c886 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:10:53 +0200 Subject: [PATCH 10/21] default should be false, so comparison in the sorter is stable --- src/matrix/room/RoomSummary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index d149e1cc..6159d3e2 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -113,9 +113,9 @@ class SummaryData { this.name = copy ? copy.name : null; this.lastMessageBody = copy ? copy.lastMessageBody : null; this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null; - this.isUnread = copy ? copy.isUnread : null; this.isEncrypted = copy ? copy.isEncrypted : null; this.isDirectMessage = copy ? copy.isDirectMessage : null; + this.isUnread = copy ? copy.isUnread : false; this.membership = copy ? copy.membership : null; this.inviteCount = copy ? copy.inviteCount : 0; this.joinCount = copy ? copy.joinCount : 0; From 00e20d20885afb51c98797281f566abc7b98fca2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:11:26 +0200 Subject: [PATCH 11/21] take null timestamps into account --- src/domain/session/roomlist/RoomTileViewModel.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 13a01309..03a9f0a3 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -62,8 +62,16 @@ export class RoomTileViewModel extends ViewModel { } return 1; } - - const timeDiff = theirRoom.lastMessageTimestamp - myRoom.lastMessageTimestamp; + const myTimestamp = myRoom.lastMessageTimestamp; + const theirTimestamp = theirRoom.lastMessageTimestamp; + // rooms with a timestamp come before rooms without one + if ((myTimestamp === null) !== (theirTimestamp === null)) { + if (theirTimestamp === null) { + return -1; + } + return 1; + } + const timeDiff = theirTimestamp - myTimestamp; if (timeDiff === 0) { // sort alphabetically const nameCmp = this._room.name.localeCompare(other._room.name); From 2742162c8e18a0d266995d5614542fc025965298 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:11:42 +0200 Subject: [PATCH 12/21] only clear unread if needed --- src/matrix/room/Room.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index d1da35e4..d3f426a4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -197,20 +197,22 @@ export class Room extends EventEmitter { } async clearUnread() { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.roomSummary, - ]); - let data; - try { - data = this._summary.writeClearUnread(txn); - } catch (err) { - txn.abort(); - throw err; + if (this.isUnread) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + ]); + let data; + try { + data = this._summary.writeClearUnread(txn); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + this._summary.applyChanges(data); + this.emit("change"); + this._emitCollectionChange(this); } - await txn.complete(); - this._summary.applyChanges(data); - this.emit("change"); - this._emitCollectionChange(this); } /** @public */ From 879c4ff9513ff39bbb2d5b8b6456b51369981a8d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:11:53 +0200 Subject: [PATCH 13/21] default for all flags should be false --- src/matrix/room/RoomSummary.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 6159d3e2..6d7361ee 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -113,9 +113,9 @@ class SummaryData { this.name = copy ? copy.name : null; this.lastMessageBody = copy ? copy.lastMessageBody : null; this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null; - this.isEncrypted = copy ? copy.isEncrypted : null; - this.isDirectMessage = copy ? copy.isDirectMessage : null; this.isUnread = copy ? copy.isUnread : false; + this.isEncrypted = copy ? copy.isEncrypted : false; + this.isDirectMessage = copy ? copy.isDirectMessage : false; this.membership = copy ? copy.membership : null; this.inviteCount = copy ? copy.inviteCount : 0; this.joinCount = copy ? copy.joinCount : 0; From d3ea8c747ab41da9bbaea93a57bcfa7a42559e52 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:26:51 +0200 Subject: [PATCH 14/21] ignore own messages for unread state, and don't set unread while open --- src/matrix/room/Room.js | 9 +++++++-- src/matrix/room/RoomSummary.js | 25 +++++++++++++++++-------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index d3f426a4..760b7f08 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -31,7 +31,7 @@ export class Room extends EventEmitter { this._roomId = roomId; this._storage = storage; this._hsApi = hsApi; - this._summary = new RoomSummary(roomId); + this._summary = new RoomSummary(roomId, user.id); this._fragmentIdComparer = new FragmentIdComparer([]); this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer}); this._emitCollectionChange = emitCollectionChange; @@ -43,7 +43,12 @@ export class Room extends EventEmitter { /** @package */ async writeSync(roomResponse, membership, isInitialSync, txn) { - const summaryChanges = this._summary.writeSync(roomResponse, membership, isInitialSync, txn); + const isTimelineOpen = !!this._timeline; + const summaryChanges = this._summary.writeSync( + roomResponse, + membership, + isInitialSync, isTimelineOpen, + txn); const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn); let removedPendingEvents; if (roomResponse.timeline && roomResponse.timeline.events) { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 6d7361ee..5903b0e8 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -function applySyncResponse(data, roomResponse, membership, isInitialSync) { +function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } @@ -25,7 +25,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync) { // state comes before timeline if (roomResponse.state) { data = roomResponse.state.events.reduce((data, event) => { - return processEvent(data, event, isInitialSync); + return processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId); }, data); } if (roomResponse.timeline) { @@ -35,7 +35,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync) { data.lastPaginationToken = timeline.prev_batch; } data = timeline.events.reduce((data, event) => { - return processEvent(data, event, isInitialSync); + return processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId); }, data); } const unreadNotifications = roomResponse.unread_notifications; @@ -48,7 +48,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync) { return data; } -function processEvent(data, event, isInitialSync) { +function processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) { if (event.type === "m.room.encryption") { if (!data.isEncrypted) { data = data.cloneIfNeeded(); @@ -69,7 +69,7 @@ function processEvent(data, event, isInitialSync) { } else if (event.type === "m.room.message") { data = data.cloneIfNeeded(); data.lastMessageTimestamp = event.origin_server_ts; - if (!isInitialSync) { + if (!isInitialSync && event.sender !== ownUserId && !isTimelineOpen) { data.isUnread = true; } const {content} = event; @@ -145,7 +145,8 @@ class SummaryData { } export class RoomSummary { - constructor(roomId) { + constructor(roomId, ownUserId) { + this._ownUserId = ownUserId; this._data = new SummaryData(null, roomId); } @@ -169,6 +170,10 @@ export class RoomSummary { return this._data.isUnread; } + get notificationCount() { + return this._data.notificationCount; + } + get lastMessage() { return this._data.lastMessageBody; } @@ -213,11 +218,15 @@ export class RoomSummary { return data; } - writeSync(roomResponse, membership, isInitialSync, txn) { + writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) { // clear cloned flag, so cloneIfNeeded makes a copy and // this._data is not modified if any field is changed. this._data.cloned = false; - const data = applySyncResponse(this._data, roomResponse, membership, isInitialSync); + const data = applySyncResponse( + this._data, roomResponse, + membership, + isInitialSync, isTimelineOpen, + this._ownUserId); if (data !== this._data) { // need to think here how we want to persist // things like unread status (as read marker, or unread count)? From 5837aed346249818dd924e79a5758775ec4a9b24 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:27:16 +0200 Subject: [PATCH 15/21] remove obsolete comment --- src/domain/session/roomlist/RoomTileViewModel.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 03a9f0a3..a12218a1 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -22,10 +22,6 @@ function isSortedAsUnread(vm) { } export class RoomTileViewModel extends ViewModel { - // we use callbacks to parent VM instead of emit because - // it would be annoying to keep track of subscriptions in - // parent for all RoomTileViewModels - // emitUpdate is ObservableMap/ObservableList update mechanism constructor(options) { super(options); const {room, emitOpen} = options; From 1a61752aceeee5f71acb21738b79f38d892c719a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:35:23 +0200 Subject: [PATCH 16/21] process state events separately from timeline events --- src/matrix/room/RoomSummary.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 5903b0e8..c97034e4 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -24,9 +24,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime } // state comes before timeline if (roomResponse.state) { - data = roomResponse.state.events.reduce((data, event) => { - return processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId); - }, data); + data = roomResponse.state.events.reduce(processStateEvent, data); } if (roomResponse.timeline) { const {timeline} = roomResponse; @@ -35,7 +33,12 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime data.lastPaginationToken = timeline.prev_batch; } data = timeline.events.reduce((data, event) => { - return processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId); + if (typeof event.state_key === "string") { + return processStateEvent(data, event); + } else { + return processTimelineEvent(data, event, + isInitialSync, isTimelineOpen, ownUserId); + } }, data); } const unreadNotifications = roomResponse.unread_notifications; @@ -48,7 +51,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime return data; } -function processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) { +function processStateEvent(data, event) { if (event.type === "m.room.encryption") { if (!data.isEncrypted) { data = data.cloneIfNeeded(); @@ -66,7 +69,17 @@ function processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) { data = data.cloneIfNeeded(); data.avatarUrl = newUrl; } - } else if (event.type === "m.room.message") { + } else if (event.type === "m.room.canonical_alias") { + const content = event.content; + data = data.cloneIfNeeded(); + data.canonicalAlias = content.alias; + data.altAliases = content.alt_aliases; + } + return data; +} + +function processTimelineEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) { + if (event.type === "m.room.message") { data = data.cloneIfNeeded(); data.lastMessageTimestamp = event.origin_server_ts; if (!isInitialSync && event.sender !== ownUserId && !isTimelineOpen) { @@ -78,11 +91,6 @@ function processEvent(data, event, isInitialSync, isTimelineOpen, ownUserId) { if (msgtype === "m.text") { data.lastMessageBody = body; } - } else if (event.type === "m.room.canonical_alias") { - const content = event.content; - data = data.cloneIfNeeded(); - data.canonicalAlias = content.alias; - data.altAliases = content.alt_aliases; } return data; } From 9b16119e7b5bc7d327e2903509e7d775a1922735 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 14:37:57 +0200 Subject: [PATCH 17/21] don't show time on continued messages --- src/ui/web/css/themes/element/theme.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index 82754140..21a020dc 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -301,6 +301,9 @@ ul.Timeline > li.continuation .profile { display: none; } +ul.Timeline > li.continuation time { + display: none; +} .message-container { padding: 1px 10px 0px 10px; From 2bfbb41ee7d093457565bcd49a3d8a146eca702e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 15:16:57 +0200 Subject: [PATCH 18/21] send receipt to server when clearing unread state so notif count clears --- src/matrix/net/HomeServerApi.js | 5 +++++ src/matrix/room/Room.js | 17 ++++++++++++++++- .../room/timeline/persistence/SyncWriter.js | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index ba4adb7f..234c8bc3 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -136,6 +136,11 @@ export class HomeServerApi { return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); } + receipt(roomId, receiptType, eventId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/receipt/${encodeURIComponent(receiptType)}/${encodeURIComponent(eventId)}`, + {}, {}, options); + } + passwordLogin(username, password, options = null) { return this._post("/login", null, { "type": "m.login.password", diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 760b7f08..8797e7a5 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -201,8 +201,23 @@ export class Room extends EventEmitter { return this._summary.notificationCount; } + async _getLastEventId() { + const lastKey = this._syncWriter.lastMessageKey; + if (lastKey) { + const txn = await this._storage.readTxn([ + this._storage.storeNames.timelineEvents, + ]); + const eventEntry = await txn.timelineEvents.get(this._roomId, lastKey); + return eventEntry?.event?.event_id; + } + } + async clearUnread() { - if (this.isUnread) { + if (this.isUnread || this.notificationCount) { + const lastEventId = await this._getLastEventId(); + if (lastEventId) { + await this._hsApi.receipt(this._roomId, "m.read", lastEventId); + } const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, ]); diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 5cbd6779..7dd53129 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -209,6 +209,10 @@ export class SyncWriter { afterSync(newLiveKey) { this._lastLiveKey = newLiveKey; } + + get lastMessageKey() { + return this._lastLiveKey; + } } //import MemoryStorage from "../storage/memory/MemoryStorage.js"; From 0d8ff34c5554a283c7e54c9ea16c26fba88ab46e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 15:23:25 +0200 Subject: [PATCH 19/21] don't fail to clear unread state when offline also update UI before network request --- src/matrix/room/Room.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8797e7a5..158299c3 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -214,10 +214,6 @@ export class Room extends EventEmitter { async clearUnread() { if (this.isUnread || this.notificationCount) { - const lastEventId = await this._getLastEventId(); - if (lastEventId) { - await this._hsApi.receipt(this._roomId, "m.read", lastEventId); - } const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, ]); @@ -232,6 +228,18 @@ export class Room extends EventEmitter { this._summary.applyChanges(data); this.emit("change"); this._emitCollectionChange(this); + + try { + const lastEventId = await this._getLastEventId(); + if (lastEventId) { + await this._hsApi.receipt(this._roomId, "m.read", lastEventId); + } + } catch (err) { + // ignore ConnectionError + if (err.name !== "ConnectionError") { + throw err; + } + } } } From 831f4188f7351ecda99dc517f415f89bbec63371 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 15:50:32 +0200 Subject: [PATCH 20/21] also expose highlight count --- src/matrix/room/Room.js | 4 ++++ src/matrix/room/RoomSummary.js | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 158299c3..7d482201 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -200,6 +200,10 @@ export class Room extends EventEmitter { get notificationCount() { return this._summary.notificationCount; } + + get highlightCount() { + return this._summary.highlightCount; + } async _getLastEventId() { const lastKey = this._syncWriter.lastMessageKey; diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index c97034e4..3f13bd1e 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -44,7 +44,7 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { data = data.cloneIfNeeded(); - data.highlightCount = unreadNotifications.highlight_count; + data.highlightCount = unreadNotifications.highlight_count || 0; data.notificationCount = unreadNotifications.notification_count; } @@ -182,6 +182,10 @@ export class RoomSummary { return this._data.notificationCount; } + get highlightCount() { + return this._data.highlightCount; + } + get lastMessage() { return this._data.lastMessageBody; } From f55101096882ab8e4538d600f4ce5b49bbc566ca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Aug 2020 15:50:47 +0200 Subject: [PATCH 21/21] render badge on room --- .../session/roomlist/RoomTileViewModel.js | 8 ++++++ src/ui/web/css/left-panel.css | 8 +++--- src/ui/web/css/themes/element/theme.css | 26 +++++++++++++++++-- src/ui/web/session/RoomTile.js | 5 +++- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index a12218a1..9fa7df0f 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -110,4 +110,12 @@ export class RoomTileViewModel extends ViewModel { get avatarTitle() { return this.name; } + + get badgeCount() { + return this._room.notificationCount; + } + + get isHighlighted() { + return this._room.highlightCount !== 0; + } } diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index 320bb899..cde12111 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -31,18 +31,16 @@ limitations under the License. align-items: center; } -.LeftPanel .name.unread { - font-weight: 600; -} - .LeftPanel div.description { margin: 0; flex: 1 1 0; min-width: 0; + display: flex; } -.LeftPanel .description > * { +.LeftPanel .description > .name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + flex: 1; } diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index 21a020dc..bf81bcd0 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -164,8 +164,30 @@ button.styled { margin-right: 10px; } -.LeftPanel .description .last-message { - font-size: 0.8em; +.LeftPanel .description { + align-items: baseline; +} + +.LeftPanel .name.unread { + font-weight: 600; +} + +.LeftPanel .badge { + min-width: 1.6rem; + height: 1.6rem; + border-radius: 1.6rem; + box-sizing: border-box; + padding: 0.1rem 0.3rem; + background-color: #61708b; + color: white; + font-weight: bold; + font-size: 1rem; + line-height: 1.4rem; + text-align: center; +} + +.LeftPanel .badge.highlighted { + background-color: #ff4b55; } a { diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/RoomTile.js index 798e7f91..0486bfe2 100644 --- a/src/ui/web/session/RoomTile.js +++ b/src/ui/web/session/RoomTile.js @@ -21,7 +21,10 @@ export class RoomTile extends TemplateView { render(t, vm) { return t.li({"className": {"active": vm => vm.isOpen}}, [ renderAvatar(t, vm, 32), - t.div({className: "description"}, t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name)) + t.div({className: "description"}, [ + t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), + t.div({className: {"badge": true, highlighted: vm => vm.isHighlighted, hidden: vm => !vm.badgeCount}}, vm => vm.badgeCount), + ]) ]); }