Merge pull request #61 from vector-im/bwindels/roomlistsorting

Add unread state, badges, highlight state and sorting to room list
This commit is contained in:
Bruno Windels 2020-08-21 14:01:41 +00:00 committed by GitHub
commit 5930097f84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 242 additions and 43 deletions

View File

@ -32,6 +32,7 @@ export class RoomViewModel extends ViewModel {
this._sendError = null; this._sendError = null;
this._closeCallback = closeCallback; this._closeCallback = closeCallback;
this._composerVM = new ComposerViewModel(this); this._composerVM = new ComposerViewModel(this);
this._clearUnreadTimout = null;
} }
async load() { async load() {
@ -49,6 +50,15 @@ export class RoomViewModel extends ViewModel {
this._timelineError = err; this._timelineError = err;
this.emitChange("error"); 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() { dispose() {
@ -57,6 +67,10 @@ export class RoomViewModel extends ViewModel {
// will stop the timeline from delivering updates on entries // will stop the timeline from delivering updates on entries
this._timeline.close(); this._timeline.close();
} }
if (this._clearUnreadTimout) {
this._clearUnreadTimout.abort();
this._clearUnreadTimout = null;
}
} }
close() { close() {

View File

@ -17,17 +17,18 @@ limitations under the License.
import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
function isSortedAsUnread(vm) {
return vm.isUnread || (vm.isOpen && vm._wasUnreadWhenOpening);
}
export class RoomTileViewModel extends ViewModel { 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) { constructor(options) {
super(options); super(options);
const {room, emitOpen} = options; const {room, emitOpen} = options;
this._room = room; this._room = room;
this._emitOpen = emitOpen; this._emitOpen = emitOpen;
this._isOpen = false; this._isOpen = false;
this._wasUnreadWhenOpening = false;
} }
// called by parent for now (later should integrate with router) // called by parent for now (later should integrate with router)
@ -39,24 +40,53 @@ export class RoomTileViewModel extends ViewModel {
} }
open() { open() {
this._isOpen = true; if (!this._isOpen) {
this.emitChange("isOpen"); this._isOpen = true;
this._emitOpen(this._room, this); this._wasUnreadWhenOpening = this._room.isUnread;
this.emitChange("isOpen");
this._emitOpen(this._room, this);
}
} }
compare(other) { compare(other) {
// sort alphabetically const myRoom = this._room;
const nameCmp = this._room.name.localeCompare(other._room.name); const theirRoom = other._room;
if (nameCmp === 0) {
return this._room.id.localeCompare(other._room.id); 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() { get isOpen() {
return this._isOpen; return this._isOpen;
} }
get isUnread() {
return this._room.isUnread;
}
get name() { get name() {
return this._room.name; return this._room.name;
} }
@ -80,4 +110,12 @@ export class RoomTileViewModel extends ViewModel {
get avatarTitle() { get avatarTitle() {
return this.name; return this.name;
} }
get badgeCount() {
return this._room.notificationCount;
}
get isHighlighted() {
return this._room.highlightCount !== 0;
}
} }

View File

@ -142,7 +142,7 @@ export class Sync {
room = this._session.createRoom(roomId); room = this._session.createRoom(roomId);
} }
console.log(` * applying sync response to room ${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}); roomChanges.push({room, changes});
}); });
await Promise.all(promises); await Promise.all(promises);

View File

@ -136,6 +136,11 @@ export class HomeServerApi {
return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); 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) { passwordLogin(username, password, options = null) {
return this._post("/login", null, { return this._post("/login", null, {
"type": "m.login.password", "type": "m.login.password",

View File

@ -31,7 +31,7 @@ export class Room extends EventEmitter {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._hsApi = hsApi; this._hsApi = hsApi;
this._summary = new RoomSummary(roomId); this._summary = new RoomSummary(roomId, user.id);
this._fragmentIdComparer = new FragmentIdComparer([]); this._fragmentIdComparer = new FragmentIdComparer([]);
this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer}); this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer});
this._emitCollectionChange = emitCollectionChange; this._emitCollectionChange = emitCollectionChange;
@ -42,8 +42,13 @@ export class Room extends EventEmitter {
} }
/** @package */ /** @package */
async writeSync(roomResponse, membership, txn) { async writeSync(roomResponse, membership, isInitialSync, txn) {
const summaryChanges = this._summary.writeSync(roomResponse, membership, 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); const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
let removedPendingEvents; let removedPendingEvents;
if (roomResponse.timeline && roomResponse.timeline.events) { if (roomResponse.timeline && roomResponse.timeline.events) {
@ -184,6 +189,64 @@ export class Room extends EventEmitter {
return this._summary.avatarUrl; 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 */ /** @public */
async openTimeline() { async openTimeline() {
if (this._timeline) { if (this._timeline) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
function applySyncResponse(data, roomResponse, membership) { function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
if (roomResponse.summary) { if (roomResponse.summary) {
data = updateSummary(data, roomResponse.summary); data = updateSummary(data, roomResponse.summary);
} }
@ -24,7 +24,7 @@ function applySyncResponse(data, roomResponse, membership) {
} }
// state comes before timeline // state comes before timeline
if (roomResponse.state) { if (roomResponse.state) {
data = roomResponse.state.events.reduce(processEvent, data); data = roomResponse.state.events.reduce(processStateEvent, data);
} }
if (roomResponse.timeline) { if (roomResponse.timeline) {
const {timeline} = roomResponse; const {timeline} = roomResponse;
@ -32,45 +32,43 @@ function applySyncResponse(data, roomResponse, membership) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.lastPaginationToken = timeline.prev_batch; 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; const unreadNotifications = roomResponse.unread_notifications;
if (unreadNotifications) { if (unreadNotifications) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.highlightCount = unreadNotifications.highlight_count; data.highlightCount = unreadNotifications.highlight_count || 0;
data.notificationCount = unreadNotifications.notification_count; data.notificationCount = unreadNotifications.notification_count;
} }
return data; return data;
} }
function processEvent(data, event) { function processStateEvent(data, event) {
if (event.type === "m.room.encryption") { if (event.type === "m.room.encryption") {
if (!data.isEncrypted) { if (!data.isEncrypted) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.isEncrypted = true; data.isEncrypted = true;
} }
} } else if (event.type === "m.room.name") {
if (event.type === "m.room.name") {
const newName = event.content?.name; const newName = event.content?.name;
if (newName !== data.name) { if (newName !== data.name) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.name = newName; data.name = newName;
} }
} if (event.type === "m.room.avatar") { } else if (event.type === "m.room.avatar") {
const newUrl = event.content?.url; const newUrl = event.content?.url;
if (newUrl !== data.avatarUrl) { if (newUrl !== data.avatarUrl) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.avatarUrl = newUrl; 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") { } else if (event.type === "m.room.canonical_alias") {
const content = event.content; const content = event.content;
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
@ -80,6 +78,23 @@ function processEvent(data, event) {
return data; 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) { function updateSummary(data, summary) {
const heroes = summary["m.heroes"]; const heroes = summary["m.heroes"];
const inviteCount = summary["m.joined_member_count"]; const inviteCount = summary["m.joined_member_count"];
@ -105,10 +120,10 @@ class SummaryData {
this.roomId = copy ? copy.roomId : roomId; this.roomId = copy ? copy.roomId : roomId;
this.name = copy ? copy.name : null; this.name = copy ? copy.name : null;
this.lastMessageBody = copy ? copy.lastMessageBody : null; this.lastMessageBody = copy ? copy.lastMessageBody : null;
this.unreadCount = copy ? copy.unreadCount : null; this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
this.mentionCount = copy ? copy.mentionCount : null; this.isUnread = copy ? copy.isUnread : false;
this.isEncrypted = copy ? copy.isEncrypted : null; this.isEncrypted = copy ? copy.isEncrypted : false;
this.isDirectMessage = copy ? copy.isDirectMessage : null; this.isDirectMessage = copy ? copy.isDirectMessage : false;
this.membership = copy ? copy.membership : null; this.membership = copy ? copy.membership : null;
this.inviteCount = copy ? copy.inviteCount : 0; this.inviteCount = copy ? copy.inviteCount : 0;
this.joinCount = copy ? copy.joinCount : 0; this.joinCount = copy ? copy.joinCount : 0;
@ -138,7 +153,8 @@ class SummaryData {
} }
export class RoomSummary { export class RoomSummary {
constructor(roomId) { constructor(roomId, ownUserId) {
this._ownUserId = ownUserId;
this._data = new SummaryData(null, roomId); this._data = new SummaryData(null, roomId);
} }
@ -158,10 +174,26 @@ export class RoomSummary {
return this._data.roomId; return this._data.roomId;
} }
get isUnread() {
return this._data.isUnread;
}
get notificationCount() {
return this._data.notificationCount;
}
get highlightCount() {
return this._data.highlightCount;
}
get lastMessage() { get lastMessage() {
return this._data.lastMessageBody; return this._data.lastMessageBody;
} }
get lastMessageTimestamp() {
return this._data.lastMessageTimestamp;
}
get inviteCount() { get inviteCount() {
return this._data.inviteCount; return this._data.inviteCount;
} }
@ -182,6 +214,15 @@ export class RoomSummary {
return this._data.lastPaginationToken; 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) { writeHasFetchedMembers(value, txn) {
const data = new SummaryData(this._data); const data = new SummaryData(this._data);
data.hasFetchedMembers = value; data.hasFetchedMembers = value;
@ -189,11 +230,15 @@ export class RoomSummary {
return data; return data;
} }
writeSync(roomResponse, membership, txn) { writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
// clear cloned flag, so cloneIfNeeded makes a copy and // clear cloned flag, so cloneIfNeeded makes a copy and
// this._data is not modified if any field is changed. // this._data is not modified if any field is changed.
this._data.cloned = false; 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) { if (data !== this._data) {
// need to think here how we want to persist // need to think here how we want to persist
// things like unread status (as read marker, or unread count)? // things like unread status (as read marker, or unread count)?

View File

@ -209,6 +209,10 @@ export class SyncWriter {
afterSync(newLiveKey) { afterSync(newLiveKey) {
this._lastLiveKey = newLiveKey; this._lastLiveKey = newLiveKey;
} }
get lastMessageKey() {
return this._lastLiveKey;
}
} }
//import MemoryStorage from "../storage/memory/MemoryStorage.js"; //import MemoryStorage from "../storage/memory/MemoryStorage.js";

View File

@ -35,10 +35,12 @@ limitations under the License.
margin: 0; margin: 0;
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
display: flex;
} }
.LeftPanel .description > * { .LeftPanel .description > .name {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1;
} }

View File

@ -164,8 +164,30 @@ button.styled {
margin-right: 10px; margin-right: 10px;
} }
.LeftPanel .description .last-message { .LeftPanel .description {
font-size: 0.8em; 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 { a {
@ -301,6 +323,9 @@ ul.Timeline > li.continuation .profile {
display: none; display: none;
} }
ul.Timeline > li.continuation time {
display: none;
}
.message-container { .message-container {
padding: 1px 10px 0px 10px; padding: 1px 10px 0px 10px;

View File

@ -21,7 +21,10 @@ export class RoomTile extends TemplateView {
render(t, vm) { render(t, vm) {
return t.li({"className": {"active": vm => vm.isOpen}}, [ return t.li({"className": {"active": vm => vm.isOpen}}, [
renderAvatar(t, vm, 32), 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),
])
]); ]);
} }