diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index a5dcee07..04ae7570 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -40,7 +40,7 @@ export class TimelineViewModel extends ViewModel { super(options); const {room, timeline, ownUserId} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, ownUserId}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline, ownUserId}))); } /** diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index ca15f207..8badc429 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -101,4 +101,8 @@ export class BaseMessageTile extends SimpleTile { redact(reason, log) { return this._room.sendRedaction(this._entry.id, reason, log); } + + get canRedact() { + return this._powerLevels.canRedactFromSender(this._entry.sender); + } } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 07529b51..b6c434df 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -123,4 +123,8 @@ export class SimpleTile extends ViewModel { get _room() { return this.getOption("room"); } + + get _powerLevels() { + return this.getOption("timeline").powerLevels; + } } diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js new file mode 100644 index 00000000..278c3aa4 --- /dev/null +++ b/src/matrix/room/timeline/PowerLevels.js @@ -0,0 +1,97 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +*/ + +export class PowerLevels { + constructor({powerLevelEvent, createEvent, ownUserId}) { + this._plEvent = powerLevelEvent; + this._createEvent = createEvent; + this._ownUserId = ownUserId; + } + + canRedactFromSender(userId) { + if (userId === this._ownUserId) { + return true; + } else { + return this.canRedact; + } + } + + get canRedact() { + return this._getUserLevel(this._ownUserId) >= this._getActionLevel("redact"); + } + + _getUserLevel(userId) { + if (this._plEvent) { + let userLevel = this._plEvent.content?.users?.[userId]; + if (typeof userLevel !== "number") { + userLevel = this._plEvent.content?.users_default; + } + if (typeof userLevel === "number") { + return userLevel; + } else { + return 0; + } + } else if (this._createEvent) { + if (userId === this._createEvent.content?.creator) { + return 100; + } + } + return 0; + } + + /** @param {string} action either "invite", "kick", "ban" or "redact". */ + _getActionLevel(action) { + const level = this._plEvent?.content[action]; + if (typeof level === "number") { + return level; + } else { + return 50; + } + } +} + +export function tests() { + const alice = "@alice:hs.tld"; + const bob = "@bob:hs.tld"; + const createEvent = {content: {creator: alice}}; + const powerLevelEvent = {content: { + redact: 50, + users: { + [alice]: 50 + }, + users_default: 0 + }}; + + return { + "redact somebody else event with power level event": assert => { + const pl1 = new PowerLevels({powerLevelEvent, ownUserId: alice}); + assert.equal(pl1.canRedact, true); + const pl2 = new PowerLevels({powerLevelEvent, ownUserId: bob}); + assert.equal(pl2.canRedact, false); + }, + "redact somebody else event with create event": assert => { + const pl1 = new PowerLevels({createEvent, ownUserId: alice}); + assert.equal(pl1.canRedact, true); + const pl2 = new PowerLevels({createEvent, ownUserId: bob}); + assert.equal(pl2.canRedact, false); + }, + "redact own event": assert => { + const pl = new PowerLevels({ownUserId: alice}); + assert.equal(pl.canRedactFromSender(alice), true); + assert.equal(pl.canRedactFromSender(bob), false); + }, + } +} \ No newline at end of file diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 6614e58b..ce34bbf8 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -21,6 +21,7 @@ import {Direction} from "./Direction.js"; import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; +import {PowerLevels} from "./PowerLevels.js"; export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { @@ -40,11 +41,15 @@ export class Timeline { }); this._readerRequest = null; this._allEntries = null; + this._powerLevels = null; } /** @package */ async load(user, membership, log) { - const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat(this._storage.storeNames.roomMembers)); + const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat( + this._storage.storeNames.roomMembers, + this._storage.storeNames.roomState + )); const memberData = await txn.roomMembers.get(this._roomId, user.id); if (memberData) { this._ownMember = new RoomMember(memberData); @@ -66,6 +71,22 @@ export class Timeline { } finally { this._disposables.disposeTracked(readerRequest); } + this._powerLevels = await this._loadPowerLevels(txn); + } + + async _loadPowerLevels(txn) { + const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); + if (powerLevelsState) { + return new PowerLevels({ + powerLevelEvent: powerLevelsState.event, + ownUserId: this._ownMember.userId + }); + } + const createState = await txn.roomState.get(this._roomId, "m.room.create", ""); + return new PowerLevels({ + createEvent: createState.event, + ownUserId: this._ownMember.userId + }); } _setupEntries(timelineEntries) { @@ -199,7 +220,12 @@ export class Timeline { } } + /** @internal */ enableEncryption(decryptEntries) { this._timelineReader.enableEncryption(decryptEntries); } + + get powerLevels() { + return this._powerLevels; + } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 20ef6942..e4c0c894 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -17,21 +17,26 @@ limitations under the License. import {MAX_UNICODE} from "./common.js"; +function encodeKey(roomId, eventType, stateKey) { + return `${roomId}|${eventType}|${stateKey}`; +} + export class RoomStateStore { constructor(idbStore) { this._roomStateStore = idbStore; } - async getAllForType(type) { + getAllForType(roomId, type) { throw new Error("unimplemented"); } - async get(type, stateKey) { - throw new Error("unimplemented"); + get(roomId, type, stateKey) { + const key = encodeKey(roomId, type, stateKey); + return this._roomStateStore.get(key); } - async set(roomId, event) { - const key = `${roomId}|${event.type}|${event.state_key}`; + set(roomId, event) { + const key = encodeKey(roomId, event.type, event.state_key); const entry = {roomId, event, key}; return this._roomStateStore.put(entry); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index f15709aa..c9857638 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -94,7 +94,7 @@ export class BaseMessageView extends TemplateView { const options = []; if (vm.isPending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); - } else if (vm.shape !== "redacted") { + } else if (vm.shape !== "redacted" && vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } return options;