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() { diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 56d0345f..9fa7df0f 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -17,17 +17,18 @@ 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 - // parent for all RoomTileViewModels - // emitUpdate is ObservableMap/ObservableList update mechanism constructor(options) { super(options); const {room, emitOpen} = options; this._room = room; this._emitOpen = emitOpen; this._isOpen = false; + this._wasUnreadWhenOpening = false; } // called by parent for now (later should integrate with router) @@ -39,24 +40,53 @@ export class RoomTileViewModel extends ViewModel { } open() { - this._isOpen = true; - 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) { - // 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 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); + if (nameCmp === 0) { + return this._room.id.localeCompare(other._room.id); + } + return nameCmp; + } + return timeDiff; } get isOpen() { return this._isOpen; } + get isUnread() { + return this._room.isUnread; + } + get name() { return this._room.name; } @@ -80,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/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/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 5ac77009..7d482201 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; @@ -42,8 +42,13 @@ 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 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) { @@ -184,6 +189,64 @@ 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; + } + + get highlightCount() { + return this._summary.highlightCount; + } + + 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 || this.notificationCount) { + 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); + + 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; + } + } + } + } + /** @public */ async openTimeline() { if (this._timeline) { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index bef9424c..3f13bd1e 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, isTimelineOpen, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } @@ -24,7 +24,7 @@ function applySyncResponse(data, roomResponse, membership) { } // state comes before timeline if (roomResponse.state) { - data = roomResponse.state.events.reduce(processEvent, data); + data = roomResponse.state.events.reduce(processStateEvent, data); } if (roomResponse.timeline) { const {timeline} = roomResponse; @@ -32,45 +32,43 @@ 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) => { + if (typeof event.state_key === "string") { + return processStateEvent(data, event); + } else { + return processTimelineEvent(data, event, + isInitialSync, isTimelineOpen, ownUserId); + } + }, data); } 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; } return data; } -function processEvent(data, event) { +function processStateEvent(data, event) { if (event.type === "m.room.encryption") { if (!data.isEncrypted) { 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(); data.avatarUrl = newUrl; } - } else if (event.type === "m.room.message") { - 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") { const content = event.content; data = data.cloneIfNeeded(); @@ -80,6 +78,23 @@ function processEvent(data, event) { 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) { + data.isUnread = true; + } + const {content} = event; + const body = content?.body; + const msgtype = content?.msgtype; + if (msgtype === "m.text") { + data.lastMessageBody = body; + } + } + return data; +} + function updateSummary(data, summary) { const heroes = summary["m.heroes"]; const inviteCount = summary["m.joined_member_count"]; @@ -105,10 +120,10 @@ 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.isEncrypted = copy ? copy.isEncrypted : null; - this.isDirectMessage = copy ? copy.isDirectMessage : null; + this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : 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; @@ -138,7 +153,8 @@ class SummaryData { } export class RoomSummary { - constructor(roomId) { + constructor(roomId, ownUserId) { + this._ownUserId = ownUserId; this._data = new SummaryData(null, roomId); } @@ -158,10 +174,26 @@ export class RoomSummary { return this._data.roomId; } + get isUnread() { + return this._data.isUnread; + } + + get notificationCount() { + return this._data.notificationCount; + } + + get highlightCount() { + return this._data.highlightCount; + } + get lastMessage() { return this._data.lastMessageBody; } + get lastMessageTimestamp() { + return this._data.lastMessageTimestamp; + } + get inviteCount() { return this._data.inviteCount; } @@ -182,6 +214,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; @@ -189,11 +230,15 @@ export class RoomSummary { return data; } - writeSync(roomResponse, membership, 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); + 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)? 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"; diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index 9400da4d..cde12111 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -35,10 +35,12 @@ limitations under the License. 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 82754140..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 { @@ -301,6 +323,9 @@ ul.Timeline > li.continuation .profile { display: none; } +ul.Timeline > li.continuation time { + display: none; +} .message-container { padding: 1px 10px 0px 10px; diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/RoomTile.js index cde53b81..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"}, 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), + ]) ]); }