Merge pull request #346 from vector-im/bwindels/leave-room

Leave and forget room
This commit is contained in:
Bruno Windels 2021-05-12 15:31:31 +00:00 committed by GitHub
commit 2ccd0c8def
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 227 additions and 10 deletions

View file

@ -132,6 +132,22 @@ export class RoomViewModel extends ViewModel {
return this.name; 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) { async _sendMessage(message) {
if (!this._room.isArchived && message) { if (!this._room.isArchived && message) {
try { try {
@ -262,7 +278,6 @@ export class RoomViewModel extends ViewModel {
} }
} }
get composerViewModel() { get composerViewModel() {
return this._composerVM; return this._composerVM;
} }

View file

@ -85,6 +85,7 @@ export class Session {
}); });
} }
this._createRoomEncryption = this._createRoomEncryption.bind(this); this._createRoomEncryption = this._createRoomEncryption.bind(this);
this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this);
this.needsSessionBackup = new ObservableValue(false); this.needsSessionBackup = new ObservableValue(false);
} }
@ -407,6 +408,7 @@ export class Session {
storage: this._storage, storage: this._storage,
emitCollectionChange: () => {}, emitCollectionChange: () => {},
releaseCallback: () => this._activeArchivedRooms.delete(roomId), releaseCallback: () => this._activeArchivedRooms.delete(roomId),
forgetCallback: this._forgetArchivedRoom,
hsApi: this._hsApi, hsApi: this._hsApi,
mediaRepository: this._mediaRepository, mediaRepository: this._mediaRepository,
user: this._user, 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 */ /** @internal */
get syncToken() { get syncToken() {
return this._syncInfo?.token; return this._syncInfo?.token;

View file

@ -193,6 +193,10 @@ export class HomeServerApi {
leave(roomId, options = null) { leave(roomId, options = null) {
return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options); 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"; import {Request as MockRequest} from "../../mocks/Request.js";

View file

@ -24,6 +24,7 @@ export class ArchivedRoom extends BaseRoom {
// archived rooms are reference counted, // archived rooms are reference counted,
// as they are not kept in memory when not needed // as they are not kept in memory when not needed
this._releaseCallback = options.releaseCallback; this._releaseCallback = options.releaseCallback;
this._forgetCallback = options.forgetCallback;
this._retentionCount = 1; this._retentionCount = 1;
/** /**
Some details from our own member event when being kicked or banned. Some details from our own member event when being kicked or banned.
@ -126,8 +127,40 @@ export class ArchivedRoom extends BaseRoom {
return true; 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);
});
} }
} }

View file

@ -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 */ /* called by BaseRoom to pass pendingEvents when opening the timeline */
_getPendingEvents() { _getPendingEvents() {
return this._sendQueue.pendingEvents; return this._sendQueue.pendingEvents;

View file

@ -42,6 +42,16 @@ export class RoomStatus {
return RoomStatus.none; 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); RoomStatus.joined = new RoomStatus(true, false, false);

View file

@ -113,10 +113,18 @@ export class QueryTarget {
return maxKey; 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) { async iterateKeys(range, callback) {
const cursor = this._target.openKeyCursor(range, "next"); const cursor = this._target.openKeyCursor(range, "next");
await iterateCursor(cursor, (_, key) => { await iterateCursor(cursor, (_, key, cur) => {
return {done: callback(key)}; return {done: callback(key, cur)};
}); });
} }

View file

@ -2,6 +2,7 @@ import {iterateCursor, reqAsPromise} from "./utils.js";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js";
import {SessionStore} from "./stores/SessionStore.js"; import {SessionStore} from "./stores/SessionStore.js";
import {encodeScopeTypeKey} from "./stores/OperationStore.js";
// FUNCTIONS SHOULD ONLY BE APPENDED!! // FUNCTIONS SHOULD ONLY BE APPENDED!!
// the index in the array is the database version // the index in the array is the database version
@ -14,6 +15,7 @@ export const schema = [
createAccountDataStore, createAccountDataStore,
createInviteStore, createInviteStore,
createArchivedRoomSummaryStore, createArchivedRoomSummaryStore,
migrateOperationScopeIndex,
]; ];
// TODO: how to deal with git merge conflicts of this array? // TODO: how to deal with git merge conflicts of this array?
@ -115,3 +117,22 @@ function createInviteStore(db) {
function createArchivedRoomSummaryStore(db) { function createArchivedRoomSummaryStore(db) {
db.createObjectStore("archivedRoomSummary", {keyPath: "summary.roomId"}); 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);
}
}

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MIN_UNICODE, MAX_UNICODE} from "./common.js";
function encodeKey(roomId, sessionId, messageIndex) { function encodeKey(roomId, sessionId, messageIndex) {
return `${roomId}|${sessionId}|${messageIndex}`; return `${roomId}|${sessionId}|${messageIndex}`;
} }
@ -31,4 +33,12 @@ export class GroupSessionDecryptionStore {
decryption.key = encodeKey(roomId, sessionId, messageIndex); decryption.key = encodeKey(roomId, sessionId, messageIndex);
this._store.put(decryption); 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);
}
} }

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MIN_UNICODE, MAX_UNICODE} from "./common.js";
function encodeKey(roomId, senderKey, sessionId) { function encodeKey(roomId, senderKey, sessionId) {
return `${roomId}|${senderKey}|${sessionId}`; return `${roomId}|${senderKey}|${sessionId}`;
} }
@ -37,4 +39,12 @@ export class InboundGroupSessionStore {
session.key = encodeKey(session.roomId, session.senderKey, session.sessionId); session.key = encodeKey(session.roomId, session.senderKey, session.sessionId);
this._store.put(session); 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);
}
} }

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MIN_UNICODE, MAX_UNICODE} from "./common.js";
function encodeTypeScopeKey(type, scope) { export function encodeScopeTypeKey(scope, type) {
return `${type}|${scope}`; return `${scope}|${type}`;
} }
export class OperationStore { export class OperationStore {
@ -28,10 +29,10 @@ export class OperationStore {
} }
async getAllByTypeAndScope(type, scope) { async getAllByTypeAndScope(type, scope) {
const key = encodeTypeScopeKey(type, scope); const key = encodeScopeTypeKey(scope, type);
const results = []; const results = [];
await this._store.index("byTypeAndScope").iterateWhile(key, value => { await this._store.index("byScopeAndType").iterateWhile(key, value => {
if (value.typeScopeKey !== key) { if (value.scopeTypeKey !== key) {
return false; return false;
} }
results.push(value); results.push(value);
@ -41,7 +42,7 @@ export class OperationStore {
} }
add(operation) { add(operation) {
operation.typeScopeKey = encodeTypeScopeKey(operation.type, operation.scope); operation.scopeTypeKey = encodeScopeTypeKey(operation.scope, operation.type);
this._store.add(operation); this._store.add(operation);
} }
@ -52,4 +53,16 @@ export class OperationStore {
remove(id) { remove(id) {
this._store.delete(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;
});
}
} }

View file

@ -68,4 +68,11 @@ export class PendingEventStore {
getAll() { getAll() {
return this._eventStore.selectAll(); 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);
}
} }

View file

@ -257,4 +257,11 @@ export class TimelineEventStore {
getByEventId(roomId, eventId) { getByEventId(roomId, eventId) {
return this._timelineStore.index("byEventId").get(encodeEventIdKey(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);
}
} }

View file

@ -72,4 +72,8 @@ export class TimelineFragmentStore {
get(roomId, fragmentId) { get(roomId, fragmentId) {
return this._store.get(encodeKey(roomId, fragmentId)); return this._store.get(encodeKey(roomId, fragmentId));
} }
removeAllForRoom(roomId) {
this._store.delete(this._allRange(roomId));
}
} }

View file

@ -443,6 +443,11 @@ a {
font-size: 14rem; font-size: 14rem;
} }
.RoomHeader .room-options {
font-weight: bold;
font-size: 1.5rem;
}
.RoomView_error { .RoomView_error {
color: red; color: red;
} }

View file

@ -44,6 +44,14 @@ export class Popup {
this._trackingTemplateView.addSubView(this); 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) { showRelativeTo(target, arrangement) {
this._target = target; this._target = target;
this._arrangement = arrangement; this._arrangement = arrangement;
@ -102,6 +110,9 @@ export class Popup {
} }
_applyArrangementAxis(axis, {relativeTo, align, before, after}) { _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") { if (relativeTo === "end") {
let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target); let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target);
if (align === "end") { if (align === "end") {

View file

@ -16,6 +16,8 @@ limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js";
import {TimelineList} from "./TimelineList.js"; import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
@ -23,6 +25,11 @@ import {RoomArchivedView} from "./RoomArchivedView.js";
import {AvatarView} from "../../avatar.js"; import {AvatarView} from "../../avatar.js";
export class RoomView extends TemplateView { export class RoomView extends TemplateView {
constructor(options) {
super(options);
this._optionsPopup = null;
}
render(t, vm) { render(t, vm) {
let bottomView; let bottomView;
if (vm.composerViewModel.kind === "composer") { if (vm.composerViewModel.kind === "composer") {
@ -37,6 +44,10 @@ export class RoomView extends TemplateView {
t.div({className: "room-description"}, [ t.div({className: "room-description"}, [
t.h2(vm => vm.name), 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_body"}, [
t.div({className: "RoomView_error"}, vm => vm.error), 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
}
});
}
}
} }