diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 7e8591af..1d2f3725 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -131,6 +131,22 @@ export class RoomViewModel extends ViewModel { get avatarTitle() { return this.name; } + + get canLeave() { + return this._room.isJoined; + } + + leaveRoom() { + this._room.leave(); + } + + get canForget() { + return this._room.isArchived; + } + + forgetRoom() { + this._room.forget(); + } async _sendMessage(message) { if (!this._room.isArchived && message) { @@ -262,7 +278,6 @@ export class RoomViewModel extends ViewModel { } } - get composerViewModel() { return this._composerVM; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 3cf1df65..38771c87 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -85,6 +85,7 @@ export class Session { }); } this._createRoomEncryption = this._createRoomEncryption.bind(this); + this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsSessionBackup = new ObservableValue(false); } @@ -407,6 +408,7 @@ export class Session { storage: this._storage, emitCollectionChange: () => {}, releaseCallback: () => this._activeArchivedRooms.delete(roomId), + forgetCallback: this._forgetArchivedRoom, hsApi: this._hsApi, mediaRepository: this._mediaRepository, user: this._user, @@ -555,6 +557,13 @@ export class Session { } } + _forgetArchivedRoom(roomId) { + const statusObservable = this._observedRoomStatus.get(roomId); + if (statusObservable) { + statusObservable.set(statusObservable.get().withoutArchived()); + } + } + /** @internal */ get syncToken() { return this._syncInfo?.token; diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 77e02d76..a3fe1bc9 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -193,6 +193,10 @@ export class HomeServerApi { leave(roomId, options = null) { return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options); } + + forget(roomId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/forget`, null, null, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 553f5a60..a87bd774 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -24,6 +24,7 @@ export class ArchivedRoom extends BaseRoom { // archived rooms are reference counted, // as they are not kept in memory when not needed this._releaseCallback = options.releaseCallback; + this._forgetCallback = options.forgetCallback; this._retentionCount = 1; /** Some details from our own member event when being kicked or banned. @@ -126,8 +127,40 @@ export class ArchivedRoom extends BaseRoom { return true; } - forget() { + forget(log = null) { + return this._platform.logger.wrapOrRun(log, "forget room", async log => { + log.set("id", this.id); + await this._hsApi.forget(this.id, {log}).response(); + const storeNames = this._storage.storeNames; + const txn = await this._storage.readWriteTxn([ + storeNames.roomState, + storeNames.archivedRoomSummary, + storeNames.roomMembers, + storeNames.timelineEvents, + storeNames.timelineFragments, + storeNames.pendingEvents, + storeNames.inboundGroupSessions, + storeNames.groupSessionDecryptions, + storeNames.operations, + ]); + txn.roomState.removeAllForRoom(this.id); + txn.archivedRoomSummary.remove(this.id); + txn.roomMembers.removeAllForRoom(this.id); + txn.timelineEvents.removeAllForRoom(this.id); + txn.timelineFragments.removeAllForRoom(this.id); + txn.pendingEvents.removeAllForRoom(this.id); + txn.inboundGroupSessions.removeAllForRoom(this.id); + txn.groupSessionDecryptions.removeAllForRoom(this.id); + await txn.operations.removeAllForScope(this.id); + + await txn.complete(); + + this._retentionCount = 0; + this._releaseCallback(); + + this._forgetCallback(this.id); + }); } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index ff13c0f4..93c580a3 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -367,6 +367,13 @@ export class Room extends BaseRoom { } } + leave(log = null) { + return this._platform.logger.wrapOrRun(log, "leave room", async log => { + log.set("id", this.id); + await this._hsApi.leave(this.id, {log}).response(); + }); + } + /* called by BaseRoom to pass pendingEvents when opening the timeline */ _getPendingEvents() { return this._sendQueue.pendingEvents; diff --git a/src/matrix/room/RoomStatus.js b/src/matrix/room/RoomStatus.js index b03b3177..884103e2 100644 --- a/src/matrix/room/RoomStatus.js +++ b/src/matrix/room/RoomStatus.js @@ -42,6 +42,16 @@ export class RoomStatus { return RoomStatus.none; } } + + withoutArchived() { + if (!this.archived) { + return this; + } else if (this.invited) { + return RoomStatus.invited; + } else { + return RoomStatus.none; + } + } } RoomStatus.joined = new RoomStatus(true, false, false); diff --git a/src/matrix/storage/idb/QueryTarget.js b/src/matrix/storage/idb/QueryTarget.js index c0c8ed2c..348e67f9 100644 --- a/src/matrix/storage/idb/QueryTarget.js +++ b/src/matrix/storage/idb/QueryTarget.js @@ -113,10 +113,18 @@ export class QueryTarget { return maxKey; } + + async iterateValues(range, callback) { + const cursor = this._target.openCursor(range, "next"); + await iterateCursor(cursor, (value, key, cur) => { + return {done: callback(value, key, cur)}; + }); + } + async iterateKeys(range, callback) { const cursor = this._target.openKeyCursor(range, "next"); - await iterateCursor(cursor, (_, key) => { - return {done: callback(key)}; + await iterateCursor(cursor, (_, key, cur) => { + return {done: callback(key, cur)}; }); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index aeb85a4b..3af31d8d 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -2,6 +2,7 @@ import {iterateCursor, reqAsPromise} from "./utils.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {SessionStore} from "./stores/SessionStore.js"; +import {encodeScopeTypeKey} from "./stores/OperationStore.js"; // FUNCTIONS SHOULD ONLY BE APPENDED!! // the index in the array is the database version @@ -14,6 +15,7 @@ export const schema = [ createAccountDataStore, createInviteStore, createArchivedRoomSummaryStore, + migrateOperationScopeIndex, ]; // TODO: how to deal with git merge conflicts of this array? @@ -114,4 +116,23 @@ function createInviteStore(db) { // v8 function createArchivedRoomSummaryStore(db) { db.createObjectStore("archivedRoomSummary", {keyPath: "summary.roomId"}); +} + +// v9 +async function migrateOperationScopeIndex(db, txn) { + try { + const operations = txn.objectStore("operations"); + operations.deleteIndex("byTypeAndScope"); + await iterateCursor(operations.openCursor(), (op, key, cur) => { + const {typeScopeKey} = op; + delete op.typeScopeKey; + const [type, scope] = typeScopeKey.split("|"); + op.scopeTypeKey = encodeScopeTypeKey(scope, type); + cur.update(op); + }); + operations.createIndex("byScopeAndType", "scopeTypeKey", {unique: false}); + } catch (err) { + txn.abort(); + console.error("could not migrate operations", err.stack); + } } \ No newline at end of file diff --git a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js index 8f8df3e7..df3c6fbe 100644 --- a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js +++ b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MIN_UNICODE, MAX_UNICODE} from "./common.js"; + function encodeKey(roomId, sessionId, messageIndex) { return `${roomId}|${sessionId}|${messageIndex}`; } @@ -31,4 +33,12 @@ export class GroupSessionDecryptionStore { decryption.key = encodeKey(roomId, sessionId, messageIndex); this._store.put(decryption); } + + removeAllForRoom(roomId) { + const range = IDBKeyRange.bound( + encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), + encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) + ); + this._store.delete(range); + } } diff --git a/src/matrix/storage/idb/stores/InboundGroupSessionStore.js b/src/matrix/storage/idb/stores/InboundGroupSessionStore.js index d05c67ff..928f7572 100644 --- a/src/matrix/storage/idb/stores/InboundGroupSessionStore.js +++ b/src/matrix/storage/idb/stores/InboundGroupSessionStore.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MIN_UNICODE, MAX_UNICODE} from "./common.js"; + function encodeKey(roomId, senderKey, sessionId) { return `${roomId}|${senderKey}|${sessionId}`; } @@ -37,4 +39,12 @@ export class InboundGroupSessionStore { session.key = encodeKey(session.roomId, session.senderKey, session.sessionId); this._store.put(session); } + + removeAllForRoom(roomId) { + const range = IDBKeyRange.bound( + encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), + encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) + ); + this._store.delete(range); + } } diff --git a/src/matrix/storage/idb/stores/OperationStore.js b/src/matrix/storage/idb/stores/OperationStore.js index 47207cc4..09e4552f 100644 --- a/src/matrix/storage/idb/stores/OperationStore.js +++ b/src/matrix/storage/idb/stores/OperationStore.js @@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import {MIN_UNICODE, MAX_UNICODE} from "./common.js"; -function encodeTypeScopeKey(type, scope) { - return `${type}|${scope}`; +export function encodeScopeTypeKey(scope, type) { + return `${scope}|${type}`; } export class OperationStore { @@ -28,10 +29,10 @@ export class OperationStore { } async getAllByTypeAndScope(type, scope) { - const key = encodeTypeScopeKey(type, scope); + const key = encodeScopeTypeKey(scope, type); const results = []; - await this._store.index("byTypeAndScope").iterateWhile(key, value => { - if (value.typeScopeKey !== key) { + await this._store.index("byScopeAndType").iterateWhile(key, value => { + if (value.scopeTypeKey !== key) { return false; } results.push(value); @@ -41,7 +42,7 @@ export class OperationStore { } add(operation) { - operation.typeScopeKey = encodeTypeScopeKey(operation.type, operation.scope); + operation.scopeTypeKey = encodeScopeTypeKey(operation.scope, operation.type); this._store.add(operation); } @@ -52,4 +53,16 @@ export class OperationStore { remove(id) { this._store.delete(id); } + + async removeAllForScope(scope) { + const range = IDBKeyRange.bound( + encodeScopeTypeKey(scope, MIN_UNICODE), + encodeScopeTypeKey(scope, MAX_UNICODE) + ); + const index = this._store.index("byScopeAndType"); + await index.iterateValues(range, (_, __, cur) => { + cur.delete(); + return true; + }); + } } diff --git a/src/matrix/storage/idb/stores/PendingEventStore.js b/src/matrix/storage/idb/stores/PendingEventStore.js index 3659299f..a6727ec5 100644 --- a/src/matrix/storage/idb/stores/PendingEventStore.js +++ b/src/matrix/storage/idb/stores/PendingEventStore.js @@ -68,4 +68,11 @@ export class PendingEventStore { getAll() { return this._eventStore.selectAll(); } + + removeAllForRoom(roomId) { + const minKey = encodeKey(roomId, KeyLimits.minStorageKey); + const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey); + const range = IDBKeyRange.bound(minKey, maxKey); + this._eventStore.delete(range); + } } diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.js b/src/matrix/storage/idb/stores/TimelineEventStore.js index 32468b21..9c40aad2 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.js +++ b/src/matrix/storage/idb/stores/TimelineEventStore.js @@ -257,4 +257,11 @@ export class TimelineEventStore { getByEventId(roomId, eventId) { return this._timelineStore.index("byEventId").get(encodeEventIdKey(roomId, eventId)); } + + removeAllForRoom(roomId) { + const minKey = encodeKey(roomId, KeyLimits.minStorageKey, KeyLimits.minStorageKey); + const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey, KeyLimits.maxStorageKey); + const range = IDBKeyRange.bound(minKey, maxKey); + this._timelineStore.delete(range); + } } diff --git a/src/matrix/storage/idb/stores/TimelineFragmentStore.js b/src/matrix/storage/idb/stores/TimelineFragmentStore.js index 8f11cb3e..10eaede1 100644 --- a/src/matrix/storage/idb/stores/TimelineFragmentStore.js +++ b/src/matrix/storage/idb/stores/TimelineFragmentStore.js @@ -72,4 +72,8 @@ export class TimelineFragmentStore { get(roomId, fragmentId) { return this._store.get(encodeKey(roomId, fragmentId)); } + + removeAllForRoom(roomId) { + this._store.delete(this._allRange(roomId)); + } } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 6f253329..00fdefd0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -443,6 +443,11 @@ a { font-size: 14rem; } +.RoomHeader .room-options { + font-weight: bold; + font-size: 1.5rem; +} + .RoomView_error { color: red; } diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index 51b53e6f..b927b44b 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -44,6 +44,14 @@ export class Popup { this._trackingTemplateView.addSubView(this); } + /** + @param {DOMElement} + @param {string} arrangement.relativeTo: whether top/left or bottom/right is used to position + @param {string} arrangement.align: how much of the popup axis size (start: 0, end: width or center: width/2) + is taken into account when positioning relative to the target + @param {number} arrangement.before extra padding to shift the final positioning with + @param {number} arrangement.after extra padding to shift the final positioning with + */ showRelativeTo(target, arrangement) { this._target = target; this._arrangement = arrangement; @@ -102,6 +110,9 @@ export class Popup { } _applyArrangementAxis(axis, {relativeTo, align, before, after}) { + // TODO: using {relativeTo: "end", align: "start"} to align the right edge of the popup + // with the right side of the target doens't make sense here, we'd expect align: "right"? + // see RoomView if (relativeTo === "end") { let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target); if (align === "end") { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index db3f68da..e2ef0de7 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -16,6 +16,8 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; +import {Popup} from "../../general/Popup.js"; +import {Menu} from "../../general/Menu.js"; import {TimelineList} from "./TimelineList.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; @@ -23,6 +25,11 @@ import {RoomArchivedView} from "./RoomArchivedView.js"; import {AvatarView} from "../../avatar.js"; export class RoomView extends TemplateView { + constructor(options) { + super(options); + this._optionsPopup = null; + } + render(t, vm) { let bottomView; if (vm.composerViewModel.kind === "composer") { @@ -37,6 +44,10 @@ export class RoomView extends TemplateView { t.div({className: "room-description"}, [ t.h2(vm => vm.name), ]), + t.button({ + className: "button-utility room-options", + onClick: evt => this._toggleOptionsMenu(evt) + }, "⋮") ]), t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_error"}, vm => vm.error), @@ -49,4 +60,36 @@ export class RoomView extends TemplateView { ]) ]); } + + _toggleOptionsMenu(evt) { + if (this._optionsPopup && this._optionsPopup.isOpen) { + this._optionsPopup.close(); + } else { + const vm = this.value; + const options = []; + if (vm.canLeave) { + options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom())); + } + if (vm.canForget) { + options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom())); + } + if (!options.length) { + return; + } + this._optionsPopup = new Popup(new Menu(options)); + this._optionsPopup.trackInTemplateView(this); + this._optionsPopup.showRelativeTo(evt.target, { + horizontal: { + relativeTo: "end", + align: "start", + after: 0 + }, + vertical: { + relativeTo: "start", + align: "start", + after: 40 + 4 + } + }); + } + } }