forked from mystiq/hydrogen-web
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:
commit
5930097f84
10 changed files with 242 additions and 43 deletions
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)?
|
||||
|
|
|
@ -209,6 +209,10 @@ export class SyncWriter {
|
|||
afterSync(newLiveKey) {
|
||||
this._lastLiveKey = newLiveKey;
|
||||
}
|
||||
|
||||
get lastMessageKey() {
|
||||
return this._lastLiveKey;
|
||||
}
|
||||
}
|
||||
|
||||
//import MemoryStorage from "../storage/memory/MemoryStorage.js";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue