diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 92df93c5..b5e6e2d9 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.1.1", + "version": "0.1.2", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index da5304ed..90da4bff 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -18,6 +18,7 @@ import {SimpleTile} from "./SimpleTile.js"; import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; + export class BaseMessageTile extends SimpleTile { constructor(entry, options) { super(entry, options); @@ -44,6 +45,10 @@ export class BaseMessageTile extends SimpleTile { return `https://matrix.to/#/${encodeURIComponent(this._room.id)}/${encodeURIComponent(this._entry.id)}`; } + copyPermalink() { + this.platform.copyPlaintext(this.permaLink); + } + get senderProfileLink() { return `https://matrix.to/#/${encodeURIComponent(this.sender)}`; } diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts index 5d67bafe..d08a3988 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -183,6 +183,16 @@ export class Member { return this.callDeviceMembership.device_id; } + /** @internal, to emulate deviceKey properties when calling formatToDeviceMessagesPayload */ + get user_id(): string { + return this.userId; + } + + /** @internal, to emulate deviceKey properties when calling formatToDeviceMessagesPayload */ + get device_id(): string { + return this.deviceId; + } + /** session id of the member */ get sessionId(): string { return this.callDeviceMembership.session_id; diff --git a/src/matrix/common.js b/src/matrix/common.js index 7cd72ae1..489846bb 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -33,11 +33,11 @@ export function isTxnId(txnId) { } export function formatToDeviceMessagesPayload(messages) { - const messagesByUser = groupBy(messages, message => message.device.userId); + const messagesByUser = groupBy(messages, message => message.device.user_id); const payload = { messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => { userMap[userId] = messages.reduce((deviceMap, message) => { - deviceMap[message.device.deviceId] = message.content; + deviceMap[message.device.device_id] = message.content; return deviceMap; }, {}); return userMap; diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index bd0defac..241ee83c 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -353,7 +353,7 @@ export class RoomEncryption { this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); - const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); + const userIds = Array.from(devices.reduce((set, device) => set.add(device.user_id), new Set())); let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]); let operation; @@ -431,8 +431,8 @@ export class RoomEncryption { await log.wrap("send", log => this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi, log)); if (missingDevices.length) { await log.wrap("missingDevices", async log => { - log.set("devices", missingDevices.map(d => d.deviceId)); - const unsentUserIds = operation.userIds.filter(userId => missingDevices.some(d => d.userId === userId)); + log.set("devices", missingDevices.map(d => d.device_id)); + const unsentUserIds = operation.userIds.filter(userId => missingDevices.some(d => d.user_id === userId)); log.set("unsentUserIds", unsentUserIds); operation.userIds = unsentUserIds; // first remove the users that we've sent the keys already from the operation, @@ -459,11 +459,11 @@ export class RoomEncryption { // TODO: make this use _sendMessagesToDevices async _sendSharedMessageToDevices(type, message, devices, hsApi, log) { - const devicesByUser = groupBy(devices, device => device.userId); + const devicesByUser = groupBy(devices, device => device.user_id); const payload = { messages: Array.from(devicesByUser.entries()).reduce((userMap, [userId, devices]) => { userMap[userId] = devices.reduce((deviceMap, device) => { - deviceMap[device.deviceId] = message; + deviceMap[device.device_id] = message; return deviceMap; }, {}); return userMap; diff --git a/src/matrix/room/PowerLevels.js b/src/matrix/room/PowerLevels.js index 76e062ef..aefbfaf1 100644 --- a/src/matrix/room/PowerLevels.js +++ b/src/matrix/room/PowerLevels.js @@ -16,6 +16,9 @@ limitations under the License. export const EVENT_TYPE = "m.room.power_levels"; +// See https://spec.matrix.org/latest/client-server-api/#mroompower_levels +const STATE_DEFAULT_POWER_LEVEL = 50; + export class PowerLevels { constructor({powerLevelEvent, createEvent, ownUserId, membership}) { this._plEvent = powerLevelEvent; @@ -66,11 +69,11 @@ export class PowerLevels { /** @param {string} action either "invite", "kick", "ban" or "redact". */ _getActionLevel(action) { - const level = this._plEvent?.content[action]; + const level = this._plEvent?.content?.[action]; if (typeof level === "number") { return level; } else { - return 50; + return STATE_DEFAULT_POWER_LEVEL; } } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 50ec60a5..e4986cc7 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -43,6 +43,7 @@ import {MediaDevicesWrapper} from "./dom/MediaDevices"; import {DOMWebRTC} from "./dom/WebRTC"; import {ThemeLoader} from "./theming/ThemeLoader"; import {TimeFormatter} from "./dom/TimeFormatter"; +import {copyPlaintext} from "./dom/utils"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -283,6 +284,10 @@ export class Platform { } } + async copyPlaintext(text) { + return await copyPlaintext(text); + } + restart() { document.location.reload(); } diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js index 19fd8c59..21eed5ff 100644 --- a/src/platform/web/dom/ImageHandle.js +++ b/src/platform/web/dom/ImageHandle.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BlobHandle} from "./BlobHandle.js"; -import {domEventAsPromise} from "./utils.js"; +import {domEventAsPromise} from "./utils"; export class ImageHandle { static async fromBlob(blob) { diff --git a/src/platform/web/dom/utils.js b/src/platform/web/dom/utils.js deleted file mode 100644 index 43a26640..00000000 --- a/src/platform/web/dom/utils.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2020 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 function domEventAsPromise(element, successEvent) { - return new Promise((resolve, reject) => { - let detach; - const handleError = evt => { - detach(); - reject(evt.target.error); - }; - const handleSuccess = () => { - detach(); - resolve(); - }; - detach = () => { - element.removeEventListener(successEvent, handleSuccess); - element.removeEventListener("error", handleError); - }; - element.addEventListener(successEvent, handleSuccess); - element.addEventListener("error", handleError); - }); -} diff --git a/src/platform/web/dom/utils.ts b/src/platform/web/dom/utils.ts new file mode 100644 index 00000000..8013b491 --- /dev/null +++ b/src/platform/web/dom/utils.ts @@ -0,0 +1,79 @@ +/* +Copyright 2020 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 function domEventAsPromise(element: HTMLElement, successEvent: string): Promise { + return new Promise((resolve, reject) => { + let detach; + const handleError = evt => { + detach(); + reject(evt.target.error); + }; + const handleSuccess = () => { + detach(); + resolve(); + }; + detach = () => { + element.removeEventListener(successEvent, handleSuccess); + element.removeEventListener("error", handleError); + }; + element.addEventListener(successEvent, handleSuccess); + element.addEventListener("error", handleError); + }); +} + +// Copies the given text to clipboard and returns a boolean of whether the action was +// successful +export async function copyPlaintext(text: string): Promise { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + + const selection = document.getSelection(); + if (!selection) { + console.error('copyPlaintext: Unable to copy text to clipboard in fallback mode because `selection` was null/undefined'); + return false; + } + + const range = document.createRange(); + // range.selectNodeContents(textArea); + range.selectNode(textArea); + selection.removeAllRanges(); + selection.addRange(range); + + const successful = document.execCommand("copy"); + selection.removeAllRanges(); + document.body.removeChild(textArea); + if(!successful) { + console.error('copyPlaintext: Unable to copy text to clipboard in fallback mode because the `copy` command is unsupported or disabled'); + } + return successful; + } + } catch (err) { + console.error("copyPlaintext: Ran into an error", err); + } + return false; +} diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index d998e826..84a4a1ad 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -126,6 +126,7 @@ export class BaseMessageView extends TemplateView { } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } + options.push(Menu.option(vm.i18n`Copy matrix.to permalink`, () => vm.copyPermalink())); return options; } diff --git a/src/platform/web/ui/session/room/timeline/VideoView.js b/src/platform/web/ui/session/room/timeline/VideoView.js index 340cae6d..9b092ed0 100644 --- a/src/platform/web/ui/session/room/timeline/VideoView.js +++ b/src/platform/web/ui/session/room/timeline/VideoView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseMediaView} from "./BaseMediaView.js"; -import {domEventAsPromise} from "../../../../dom/utils.js"; +import {domEventAsPromise} from "../../../../dom/utils"; export class VideoView extends BaseMediaView { renderMedia(t) {