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._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() {

View file

@ -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,12 +40,35 @@ export class RoomTileViewModel extends ViewModel {
}
open() {
if (!this._isOpen) {
this._isOpen = true;
this._wasUnreadWhenOpening = this._room.isUnread;
this.emitChange("isOpen");
this._emitOpen(this._room, this);
}
}
compare(other) {
const myRoom = this._room;
const theirRoom = other._room;
if (isSortedAsUnread(this) !== isSortedAsUnread(other)) {
if (isSortedAsUnread(this)) {
return -1;
}
return 1;
}
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) {
@ -52,11 +76,17 @@ export class RoomTileViewModel extends ViewModel {
}
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;
}
}

View file

@ -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);

View file

@ -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",

View file

@ -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) {

View file

@ -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)?

View file

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

View file

@ -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;
}

View file

@ -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;

View file

@ -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),
])
]);
}