mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-17 14:05:04 +08:00
Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/15255
Conflicts: src/components/views/settings/ProfileSettings.js
This commit is contained in:
commit
55a18b8c2d
@ -79,6 +79,7 @@
|
||||
"linkifyjs": "^2.1.9",
|
||||
"lodash": "^4.17.19",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^0.1.0-beta.2",
|
||||
"minimist": "^1.2.5",
|
||||
"pako": "^1.0.11",
|
||||
"parse5": "^5.1.1",
|
||||
|
@ -133,6 +133,10 @@ limitations under the License.
|
||||
.mx_RoomDirectory_topic {
|
||||
cursor: initial;
|
||||
color: $light-fg-color;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mx_RoomDirectory_alias {
|
||||
|
@ -78,10 +78,6 @@ $MiniAppTileHeight: 200px;
|
||||
font-size: $font-12px;
|
||||
}
|
||||
|
||||
.mx_AddWidget_button_full_width {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.mx_SetAppURLDialog_input {
|
||||
border-radius: 3px;
|
||||
border: 1px solid $input-border-color;
|
||||
@ -92,7 +88,6 @@ $MiniAppTileHeight: 200px;
|
||||
}
|
||||
|
||||
.mx_AppTile {
|
||||
max-width: 960px;
|
||||
width: 50%;
|
||||
border: 5px solid $widget-menu-bar-bg-color;
|
||||
border-radius: 4px;
|
||||
@ -105,7 +100,6 @@ $MiniAppTileHeight: 200px;
|
||||
}
|
||||
|
||||
.mx_AppTileFullWidth {
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@ -116,7 +110,6 @@ $MiniAppTileHeight: 200px;
|
||||
}
|
||||
|
||||
.mx_AppTile_mini {
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
.mx_AvatarSetting_avatar {
|
||||
width: 90px;
|
||||
min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
|
||||
height: 90px;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
|
@ -59,7 +59,7 @@ limitations under the License.
|
||||
display: flex;
|
||||
direction: row;
|
||||
|
||||
img {
|
||||
img, .mx_BaseAvatar_initial {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
|
@ -82,6 +82,7 @@ function urlForColor(color) {
|
||||
const colorToDataURLCache = new Map();
|
||||
|
||||
export function defaultAvatarUrlForString(s) {
|
||||
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
|
||||
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
|
||||
let total = 0;
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
|
@ -75,7 +75,8 @@ import {base32} from "rfc4648";
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
import WidgetStore from "./stores/WidgetStore";
|
||||
import ActiveWidgetStore from "./stores/ActiveWidgetStore";
|
||||
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
|
||||
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
||||
|
||||
// until we ts-ify the js-sdk voip code
|
||||
type Call = any;
|
||||
@ -503,10 +504,10 @@ export default class CallHandler {
|
||||
|
||||
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
||||
jitsiWidgets.forEach(w => {
|
||||
const messaging = ActiveWidgetStore.getWidgetMessaging(w.id);
|
||||
const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
|
||||
if (!messaging) return; // more "should never happen" words
|
||||
|
||||
messaging.hangup();
|
||||
messaging.transport.send(ElementWidgetActions.HangupCall, {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,275 +0,0 @@
|
||||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 Travis Ralston
|
||||
Copyright 2019 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.
|
||||
*/
|
||||
|
||||
import URL from 'url';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
|
||||
import ActiveWidgetStore from './stores/ActiveWidgetStore';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {Capability} from "./widgets/WidgetApi";
|
||||
import {objectClone} from "./utils/objects";
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.2'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
'0.0.1',
|
||||
'0.0.2',
|
||||
];
|
||||
const INBOUND_API_NAME = 'fromWidget';
|
||||
|
||||
// Listen for and handle incoming requests using the 'fromWidget' postMessage
|
||||
// API and initiate responses
|
||||
export default class FromWidgetPostMessageApi {
|
||||
constructor() {
|
||||
this.widgetMessagingEndpoints = [];
|
||||
this.widgetListeners = {}; // {action: func[]}
|
||||
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener for a given action
|
||||
* @param {string} action The action to listen for.
|
||||
* @param {Function} callbackFn A callback function to be called when the action is
|
||||
* encountered. Called with two parameters: the interesting request information and
|
||||
* the raw event received from the postMessage API. The raw event is meant to be used
|
||||
* for sendResponse and similar functions.
|
||||
*/
|
||||
addListener(action, callbackFn) {
|
||||
if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
|
||||
this.widgetListeners[action].push(callbackFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a listener for a given action.
|
||||
* @param {string} action The action that was subscribed to.
|
||||
* @param {Function} callbackFn The original callback function that was used to subscribe
|
||||
* to updates.
|
||||
*/
|
||||
removeListener(action, callbackFn) {
|
||||
if (!this.widgetListeners[action]) return;
|
||||
|
||||
const idx = this.widgetListeners[action].indexOf(callbackFn);
|
||||
if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a widget endpoint for trusted postMessage communication
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
*/
|
||||
addEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
|
||||
if (this.widgetMessagingEndpoints.some(function(ep) {
|
||||
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
|
||||
})) {
|
||||
// Message endpoint already registered
|
||||
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
|
||||
return;
|
||||
} else {
|
||||
console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
|
||||
this.widgetMessagingEndpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-register a widget endpoint from trusted communication sources
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
* @return {boolean} True if endpoint was successfully removed
|
||||
*/
|
||||
removeEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Remove widget messaging endpoint - Invalid origin');
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
|
||||
const length = this.widgetMessagingEndpoints.length;
|
||||
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints
|
||||
.filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin);
|
||||
return (length > this.widgetMessagingEndpoints.length);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle widget postMessage events
|
||||
* Messages are only handled where a valid, registered messaging endpoints
|
||||
* @param {Event} event Event to handle
|
||||
* @return {undefined}
|
||||
*/
|
||||
onPostMessage(event) {
|
||||
if (!event.origin) { // Handle chrome
|
||||
event.origin = event.originalEvent.origin;
|
||||
}
|
||||
|
||||
// Event origin is empty string if undefined
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!this.trustedEndpoint(event.origin) ||
|
||||
event.data.api !== INBOUND_API_NAME ||
|
||||
!event.data.widgetId
|
||||
) {
|
||||
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||
}
|
||||
|
||||
// Call any listeners we have registered
|
||||
if (this.widgetListeners[event.data.action]) {
|
||||
for (const fn of this.widgetListeners[event.data.action]) {
|
||||
fn(event.data, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Although the requestId is required, we don't use it. We'll be nice and process the message
|
||||
// if the property is missing, but with a warning for widget developers.
|
||||
if (!event.data.requestId) {
|
||||
console.warn("fromWidget action '" + event.data.action + "' does not have a requestId");
|
||||
}
|
||||
|
||||
const action = event.data.action;
|
||||
const widgetId = event.data.widgetId;
|
||||
if (action === 'content_loaded') {
|
||||
console.log('Widget reported content loaded for', widgetId);
|
||||
dis.dispatch({
|
||||
action: 'widget_content_loaded',
|
||||
widgetId: widgetId,
|
||||
});
|
||||
this.sendResponse(event, {success: true});
|
||||
} else if (action === 'supported_api_versions') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
|
||||
});
|
||||
} else if (action === 'api_version') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
version: WIDGET_API_VERSION,
|
||||
});
|
||||
} else if (action === 'm.sticker') {
|
||||
// console.warn('Got sticker message from widget', widgetId);
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId});
|
||||
} else if (action === 'integration_manager_open') {
|
||||
// Close the stickerpicker
|
||||
dis.dispatch({action: 'stickerpicker_close'});
|
||||
// Open the integration manager
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
const integType = (data && data.integType) ? data.integType : null;
|
||||
const integId = (data && data.integId) ? data.integId : null;
|
||||
|
||||
// TODO: Open the right integration manager for the widget
|
||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
} else {
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
}
|
||||
} else if (action === 'set_always_on_screen') {
|
||||
// This is a new message: there is no reason to support the deprecated widgetData here
|
||||
const data = event.data.data;
|
||||
const val = data.value;
|
||||
|
||||
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
|
||||
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
|
||||
}
|
||||
} else if (action === 'get_openid') {
|
||||
// Handled by caller
|
||||
} else {
|
||||
console.warn('Widget postMessage event unhandled');
|
||||
this.sendError(event, {message: 'The postMessage was unhandled'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message origin is registered as trusted
|
||||
* @param {string} origin PostMessage origin to check
|
||||
* @return {boolean} True if trusted
|
||||
*/
|
||||
trustedEndpoint(origin) {
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.widgetMessagingEndpoints.some((endpoint) => {
|
||||
// TODO / FIXME -- Should this also check the widgetId?
|
||||
return endpoint.endpointUrl === origin;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a postmessage response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {Object} res Response data
|
||||
*/
|
||||
sendResponse(event, res) {
|
||||
const data = objectClone(event.data);
|
||||
data.response = res;
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {string} msg Error message
|
||||
* @param {Error} nestedError Nested error event (optional)
|
||||
*/
|
||||
sendError(event, msg, nestedError) {
|
||||
console.error('Action:' + event.data.action + ' failed with message: ' + msg);
|
||||
const data = objectClone(event.data);
|
||||
data.response = {
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
};
|
||||
if (nestedError) {
|
||||
data.response.error._error = nestedError;
|
||||
}
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
}
|
@ -186,6 +186,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
|
||||
console.log("Logged in with token");
|
||||
return _clearStorage().then(() => {
|
||||
_persistCredentialsToLocalStorage(creds);
|
||||
// remember that we just logged in
|
||||
sessionStorage.setItem("mx_fresh_login", true);
|
||||
return true;
|
||||
});
|
||||
}).catch((err) => {
|
||||
@ -312,6 +314,9 @@ async function _restoreFromLocalStorage(opts) {
|
||||
console.log("No pickle key available");
|
||||
}
|
||||
|
||||
const freshLogin = sessionStorage.getItem("mx_fresh_login");
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
|
||||
console.log(`Restoring session for ${userId}`);
|
||||
await _doSetLoggedIn({
|
||||
userId: userId,
|
||||
@ -321,6 +326,7 @@ async function _restoreFromLocalStorage(opts) {
|
||||
identityServerUrl: isUrl,
|
||||
guest: isGuest,
|
||||
pickleKey: pickleKey,
|
||||
freshLogin: freshLogin,
|
||||
}, false);
|
||||
return true;
|
||||
} else {
|
||||
@ -364,6 +370,7 @@ async function _handleLoadSessionFailure(e) {
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export async function setLoggedIn(credentials) {
|
||||
credentials.freshLogin = true;
|
||||
stopMatrixClient();
|
||||
const pickleKey = credentials.userId && credentials.deviceId
|
||||
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
||||
@ -429,6 +436,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
||||
" guest: " + credentials.guest +
|
||||
" hs: " + credentials.homeserverUrl +
|
||||
" softLogout: " + softLogout,
|
||||
" freshLogin: " + credentials.freshLogin,
|
||||
);
|
||||
|
||||
// This is dispatched to indicate that the user is still in the process of logging in
|
||||
@ -462,10 +470,28 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
||||
|
||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
|
||||
// If we just logged in, try to rehydrate a device instead of using a
|
||||
// new device. If it succeeds, we'll get a new device ID, so make sure
|
||||
// we persist that ID to localStorage
|
||||
const newDeviceId = await client.rehydrateDevice();
|
||||
if (newDeviceId) {
|
||||
credentials.deviceId = newDeviceId;
|
||||
}
|
||||
|
||||
delete credentials.freshLogin;
|
||||
}
|
||||
|
||||
if (localStorage) {
|
||||
try {
|
||||
_persistCredentialsToLocalStorage(credentials);
|
||||
|
||||
// make sure we don't think that it's a fresh login any more
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
|
||||
// The user registered as a PWLU (PassWord-Less User), the generated password
|
||||
// is cached here such that the user can change it at a later time.
|
||||
if (credentials.password) {
|
||||
@ -482,12 +508,10 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
||||
console.warn("No local storage available: can't persist session!");
|
||||
}
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
||||
dis.dispatch({ action: 'on_logged_in' });
|
||||
|
||||
await startMatrixClient(/*startSyncing=*/!softLogout);
|
||||
return MatrixClientPeg.get();
|
||||
return client;
|
||||
}
|
||||
|
||||
function _showStorageEvictedDialog() {
|
||||
|
@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
|
||||
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
||||
import * as StorageManager from './utils/StorageManager';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
import { crossSigningCallbacks } from './SecurityManager';
|
||||
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
|
||||
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||
|
||||
export interface IMatrixClientCreds {
|
||||
@ -42,6 +42,7 @@ export interface IMatrixClientCreds {
|
||||
accessToken: string;
|
||||
guest: boolean;
|
||||
pickleKey?: string;
|
||||
freshLogin?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Move this to the js-sdk
|
||||
@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
||||
this.matrixClient.setCryptoTrustCrossSignedDevices(
|
||||
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
|
||||
);
|
||||
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
|
||||
StorageManager.setCryptoInitialised(true);
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
|
||||
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
// only meant to act as a cache to avoid prompting the user multiple times
|
||||
@ -31,8 +32,13 @@ import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreK
|
||||
// single secret storage operation, as it will clear the cached keys once the
|
||||
// operation ends.
|
||||
let secretStorageKeys = {};
|
||||
let secretStorageKeyInfo = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
let nonInteractive = false;
|
||||
|
||||
let dehydrationCache = {};
|
||||
|
||||
function isCachingAllowed() {
|
||||
return secretStorageBeingAccessed;
|
||||
}
|
||||
@ -66,6 +72,20 @@ async function confirmToDismiss() {
|
||||
return !sure;
|
||||
}
|
||||
|
||||
function makeInputToKey(keyInfo) {
|
||||
return async ({ passphrase, recoveryKey }) => {
|
||||
if (passphrase) {
|
||||
return deriveKey(
|
||||
passphrase,
|
||||
keyInfo.passphrase.salt,
|
||||
keyInfo.passphrase.iterations,
|
||||
);
|
||||
} else {
|
||||
return decodeRecoveryKey(recoveryKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||
const keyInfoEntries = Object.entries(keyInfos);
|
||||
if (keyInfoEntries.length > 1) {
|
||||
@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||
return [keyId, secretStorageKeys[keyId]];
|
||||
}
|
||||
|
||||
const inputToKey = async ({ passphrase, recoveryKey }) => {
|
||||
if (passphrase) {
|
||||
return deriveKey(
|
||||
passphrase,
|
||||
keyInfo.passphrase.salt,
|
||||
keyInfo.passphrase.iterations,
|
||||
);
|
||||
} else {
|
||||
return decodeRecoveryKey(recoveryKey);
|
||||
if (dehydrationCache.key) {
|
||||
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
|
||||
cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
|
||||
return [keyId, dehydrationCache.key];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error("Could not unlock non-interactively");
|
||||
}
|
||||
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// Save to cache to avoid future prompts in the current session
|
||||
cacheSecretStorageKey(keyId, key);
|
||||
cacheSecretStorageKey(keyId, key, keyInfo);
|
||||
|
||||
return [keyId, key];
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(keyId, key) {
|
||||
export async function getDehydrationKey(keyInfo, checkFunc) {
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
{
|
||||
keyInfo,
|
||||
checkPrivateKey: async (input) => {
|
||||
const key = await inputToKey(input);
|
||||
try {
|
||||
checkFunc(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
/* className= */ null,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason) => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [input] = await finished;
|
||||
if (!input) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// need to copy the key because rehydration (unpickling) will clobber it
|
||||
dehydrationCache = {key: new Uint8Array(key), keyInfo};
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(keyId, key, keyInfo) {
|
||||
if (isCachingAllowed()) {
|
||||
secretStorageKeys[keyId] = key;
|
||||
secretStorageKeyInfo[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,6 +239,7 @@ export const crossSigningCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
onSecretRequested,
|
||||
getDehydrationKey,
|
||||
};
|
||||
|
||||
export async function promptForBackupPassphrase() {
|
||||
@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
||||
await cli.bootstrapSecretStorage({
|
||||
getKeyBackupPassphrase: promptForBackupPassphrase,
|
||||
});
|
||||
|
||||
const keyId = Object.keys(secretStorageKeys)[0];
|
||||
if (keyId && SettingsStore.getValue("feature_dehydration")) {
|
||||
const dehydrationKeyInfo =
|
||||
secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase
|
||||
? {passphrase: secretStorageKeyInfo[keyId].passphrase}
|
||||
: {};
|
||||
console.log("Setting dehydration key");
|
||||
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
|
||||
} else {
|
||||
console.log("Not setting dehydration key: no SSSS key found");
|
||||
}
|
||||
}
|
||||
|
||||
// `return await` needed here to ensure `finally` block runs after the
|
||||
@ -272,6 +348,57 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
||||
secretStorageBeingAccessed = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this function name is a bit of a mouthful
|
||||
export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
|
||||
const key = dehydrationCache.key;
|
||||
let restoringBackup = false;
|
||||
if (key && await client.isSecretStorageReady()) {
|
||||
console.log("Trying to set up cross-signing using dehydration key");
|
||||
secretStorageBeingAccessed = true;
|
||||
nonInteractive = true;
|
||||
try {
|
||||
await client.checkOwnCrossSigningTrust();
|
||||
|
||||
// we also need to set a new dehydrated device to replace the
|
||||
// device we rehydrated
|
||||
const dehydrationKeyInfo =
|
||||
dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase
|
||||
? {passphrase: dehydrationCache.keyInfo.passphrase}
|
||||
: {};
|
||||
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
|
||||
|
||||
// and restore from backup
|
||||
const backupInfo = await client.getKeyBackupVersion();
|
||||
if (backupInfo) {
|
||||
restoringBackup = true;
|
||||
// don't await, because this can take a long time
|
||||
client.restoreKeyBackupWithSecretStorage(backupInfo)
|
||||
.finally(() => {
|
||||
secretStorageBeingAccessed = false;
|
||||
nonInteractive = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dehydrationCache = {};
|
||||
// the secret storage cache is needed for restoring from backup, so
|
||||
// don't clear it yet if we're restoring from backup
|
||||
if (!restoringBackup) {
|
||||
secretStorageBeingAccessed = false;
|
||||
nonInteractive = false;
|
||||
if (!isCachingAllowed()) {
|
||||
secretStorageKeys = {};
|
||||
secretStorageKeyInfo = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,84 +0,0 @@
|
||||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
// const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
// Initiate requests using the "toWidget" postMessage API and handle responses
|
||||
// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
|
||||
// response field
|
||||
export default class ToWidgetPostMessageApi {
|
||||
constructor(timeoutMs) {
|
||||
this._timeoutMs = timeoutMs || 5000; // default to 5s timer
|
||||
this._counter = 0;
|
||||
this._requestMap = {
|
||||
// $ID: {resolve, reject}
|
||||
};
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
onPostMessage(ev) {
|
||||
// THIS IS ALL UNSAFE EXECUTION.
|
||||
// We do not verify who the sender of `ev` is!
|
||||
const payload = ev.data;
|
||||
// NOTE: Workaround for running in a mobile WebView where a
|
||||
// postMessage immediately triggers this callback even though it is
|
||||
// not the response.
|
||||
if (payload.response === undefined) {
|
||||
return;
|
||||
}
|
||||
const promise = this._requestMap[payload.requestId];
|
||||
if (!promise) {
|
||||
return;
|
||||
}
|
||||
delete this._requestMap[payload.requestId];
|
||||
promise.resolve(payload);
|
||||
}
|
||||
|
||||
// Initiate outbound requests (toWidget)
|
||||
exec(action, targetWindow, targetOrigin) {
|
||||
targetWindow = targetWindow || window.parent; // default to parent window
|
||||
targetOrigin = targetOrigin || "*";
|
||||
this._counter += 1;
|
||||
action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requestMap[action.requestId] = {resolve, reject};
|
||||
targetWindow.postMessage(action, targetOrigin);
|
||||
|
||||
if (this._timeoutMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (!this._requestMap[action.requestId]) {
|
||||
return;
|
||||
}
|
||||
console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
|
||||
this._requestMap);
|
||||
this._requestMap[action.requestId].reject(new Error("Timed out"));
|
||||
delete this._requestMap[action.requestId];
|
||||
}, this._timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,223 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2019 Travis Ralston
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
|
||||
* spec. details / documentation.
|
||||
*/
|
||||
|
||||
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
|
||||
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
|
||||
import Modal from "./Modal";
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import {KnownWidgetActions} from "./widgets/WidgetApi";
|
||||
|
||||
if (!global.mxFromWidgetMessaging) {
|
||||
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
|
||||
global.mxFromWidgetMessaging.start();
|
||||
}
|
||||
if (!global.mxToWidgetMessaging) {
|
||||
global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
|
||||
global.mxToWidgetMessaging.start();
|
||||
}
|
||||
|
||||
const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
export default class WidgetMessaging {
|
||||
/**
|
||||
* @param {string} widgetId The widget's ID
|
||||
* @param {string} wurl The raw URL of the widget as in the event (the 'wURL')
|
||||
* @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL
|
||||
* or a different URL of the clients choosing if it is using its own impl).
|
||||
* @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget
|
||||
* @param {object} target Where widget messages should be sent (eg. the iframe object)
|
||||
*/
|
||||
constructor(widgetId, wurl, renderedUrl, isUserWidget, target) {
|
||||
this.widgetId = widgetId;
|
||||
this.wurl = wurl;
|
||||
this.renderedUrl = renderedUrl;
|
||||
this.isUserWidget = isUserWidget;
|
||||
this.target = target;
|
||||
this.fromWidget = global.mxFromWidgetMessaging;
|
||||
this.toWidget = global.mxToWidgetMessaging;
|
||||
this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
|
||||
this.start();
|
||||
}
|
||||
|
||||
messageToWidget(action) {
|
||||
action.widgetId = this.widgetId; // Required to be sent for all outbound requests
|
||||
|
||||
return this.toWidget.exec(action, this.target).then((data) => {
|
||||
// Check for errors and reject if found
|
||||
if (data.response === undefined) { // null is valid
|
||||
throw new Error("Missing 'response' field");
|
||||
}
|
||||
if (data.response && data.response.error) {
|
||||
const err = data.response.error;
|
||||
const msg = String(err.message ? err.message : "An error was returned");
|
||||
if (err._error) {
|
||||
console.error(err._error);
|
||||
}
|
||||
// Potential XSS attack if 'msg' is not appropriately sanitized,
|
||||
// as it is untrusted input by our parent window (which we assume is Element).
|
||||
// We can't aggressively sanitize [A-z0-9] since it might be a translation.
|
||||
throw new Error(msg);
|
||||
}
|
||||
// Return the response field for the request
|
||||
return data.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that the client is ready to handle further widget requests.
|
||||
* @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
|
||||
*/
|
||||
flagReadyToContinue() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.ClientReady,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget that it should terminate now.
|
||||
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
|
||||
*/
|
||||
terminate() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.Terminate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the widget to hang up on its call.
|
||||
* @returns {Promise<*>} Resolves when the widget has acknowledged the message.
|
||||
*/
|
||||
hangup() {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: KnownWidgetActions.Hangup,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a screenshot from a widget
|
||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||
*/
|
||||
getScreenshot() {
|
||||
console.log('Requesting screenshot for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "screenshot",
|
||||
})
|
||||
.catch((error) => new Error("Failed to get screenshot: " + error.message))
|
||||
.then((response) => response.screenshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request capabilities required by the widget
|
||||
* @return {Promise} To be resolved with an array of requested widget capabilities
|
||||
*/
|
||||
getCapabilities() {
|
||||
console.log('Requesting capabilities for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "capabilities",
|
||||
}).then((response) => {
|
||||
console.log('Got capabilities for', this.widgetId, response.capabilities);
|
||||
return response.capabilities;
|
||||
});
|
||||
}
|
||||
|
||||
sendVisibility(visible) {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "visibility",
|
||||
visible,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to send visibility: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl);
|
||||
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl);
|
||||
this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
|
||||
}
|
||||
|
||||
async _onOpenIdRequest(ev, rawEv) {
|
||||
if (ev.widgetId !== this.widgetId) return; // not interesting
|
||||
|
||||
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget);
|
||||
|
||||
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
|
||||
this.fromWidget.sendResponse(rawEv, {state: "blocked"});
|
||||
return;
|
||||
}
|
||||
if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
|
||||
const responseBody = {state: "allowed"};
|
||||
const credentials = await MatrixClientPeg.get().getOpenIdToken();
|
||||
Object.assign(responseBody, credentials);
|
||||
this.fromWidget.sendResponse(rawEv, responseBody);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm that we received the request
|
||||
this.fromWidget.sendResponse(rawEv, {state: "request"});
|
||||
|
||||
// Actually ask for permission to send the user's data
|
||||
Modal.createTrackedDialog("OpenID widget permissions", '',
|
||||
WidgetOpenIDPermissionsDialog, {
|
||||
widgetUrl: this.wurl,
|
||||
widgetId: this.widgetId,
|
||||
isUserWidget: this.isUserWidget,
|
||||
|
||||
onFinished: async (confirm) => {
|
||||
const responseBody = {
|
||||
// Legacy (early draft) fields
|
||||
success: confirm,
|
||||
|
||||
// New style MSC1960 fields
|
||||
state: confirm ? "allowed" : "blocked",
|
||||
original_request_id: ev.requestId, // eslint-disable-line camelcase
|
||||
};
|
||||
if (confirm) {
|
||||
const credentials = await MatrixClientPeg.get().getOpenIdToken();
|
||||
Object.assign(responseBody, credentials);
|
||||
}
|
||||
this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "openid_credentials",
|
||||
data: responseBody,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send OpenID credentials: ", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Represents mapping of widget instance to URLs for trusted postMessage communication.
|
||||
*/
|
||||
export default class WidgetMessageEndpoint {
|
||||
/**
|
||||
* Mapping of widget instance to URL for trusted postMessage communication.
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin.
|
||||
*/
|
||||
constructor(widgetId, endpointUrl) {
|
||||
if (!widgetId) {
|
||||
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
if (!endpointUrl) {
|
||||
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
this.widgetId = widgetId;
|
||||
this.endpointUrl = endpointUrl;
|
||||
}
|
||||
}
|
@ -1019,7 +1019,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
if (communityId) {
|
||||
// double check the user will have permission to associate this room with the community
|
||||
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
|
||||
if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) {
|
||||
Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
|
||||
title: _t("Cannot create rooms in this community"),
|
||||
description: _t("You do not have permission to create rooms in this community."),
|
||||
|
@ -202,13 +202,19 @@ export default class RightPanel extends React.Component {
|
||||
dis.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
} else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
this.state.verificationRequest && this.state.verificationRequest.pending
|
||||
) {
|
||||
// When the user clicks close on the encryption panel cancel the pending request first if any
|
||||
this.state.verificationRequest.cancel();
|
||||
} else {
|
||||
// Otherwise we have got our user from RoomViewStore which means we're being shown
|
||||
// within a room/group, so go back to the member panel if we were in the encryption panel,
|
||||
// or the member list if we were in the member panel... phew.
|
||||
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
|
||||
member: isEncryptionPhase ? this.state.member : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -35,7 +35,7 @@ import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 160;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
function track(action) {
|
||||
Analytics.trackEvent('RoomDirectory', action);
|
||||
@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component {
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
// Additional truncation based on line numbers is done via CSS,
|
||||
// but to ensure that the DOM is not polluted with a huge string
|
||||
// we give it a hard limit before rendering.
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
|
@ -1820,7 +1820,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
let aux = null;
|
||||
let previewBar;
|
||||
let hideCancel = false;
|
||||
let forceHideRightPanel = false;
|
||||
if (this.state.forwardingEvent) {
|
||||
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
|
||||
} else if (this.state.searching) {
|
||||
@ -1865,8 +1864,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
{ previewBar }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
forceHideRightPanel = true;
|
||||
}
|
||||
} else if (hiddenHighlightCount > 0) {
|
||||
aux = (
|
||||
@ -2069,7 +2066,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
});
|
||||
|
||||
const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel;
|
||||
const showRightPanel = this.state.room && this.state.showRightPanel;
|
||||
const rightPanel = showRightPanel
|
||||
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
||||
: null;
|
||||
|
@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import BaseAvatar from "./BaseAvatar";
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
|
||||
member: RoomMember;
|
||||
fallbackUserId?: string;
|
||||
width: number;
|
||||
|
@ -18,11 +18,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import url from 'url';
|
||||
import qs from 'qs';
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import WidgetMessaging from '../../../WidgetMessaging';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import classNames from 'classnames';
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement from "./PersistedElement";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import {Capability} from "../../../widgets/WidgetApi";
|
||||
import {sleep} from "../../../utils/promise";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
||||
const ENABLE_REACT_PERF = false;
|
||||
|
||||
/**
|
||||
* Does template substitution on a URL (or any string). Variables will be
|
||||
* passed through encodeURIComponent.
|
||||
* @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
|
||||
* @param {Object} variables The key/value pairs to replace the template
|
||||
* variables with. E.g. { '$bar': 'baz' }.
|
||||
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
|
||||
*/
|
||||
function uriFromTemplate(uriTemplate, variables) {
|
||||
let out = uriTemplate;
|
||||
for (const [key, val] of Object.entries(variables)) {
|
||||
out = out.replace(
|
||||
'$' + key, encodeURIComponent(val),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
|
||||
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
|
||||
export default class AppTile extends React.Component {
|
||||
constructor(props) {
|
||||
@ -72,11 +49,13 @@ export default class AppTile extends React.Component {
|
||||
|
||||
// The key used for PersistedElement
|
||||
this._persistKey = 'widget_' + this.props.app.id;
|
||||
this._sgWidget = new StopGapWidget(this.props);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
this.iframe = null; // ref to the iframe (callback style)
|
||||
|
||||
this.state = this._getNewState(props);
|
||||
|
||||
this._onAction = this._onAction.bind(this);
|
||||
this._onLoaded = this._onLoaded.bind(this);
|
||||
this._onEditClick = this._onEditClick.bind(this);
|
||||
this._onDeleteClick = this._onDeleteClick.bind(this);
|
||||
this._onRevokeClicked = this._onRevokeClicked.bind(this);
|
||||
@ -89,7 +68,6 @@ export default class AppTile extends React.Component {
|
||||
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
|
||||
|
||||
this._contextMenuButton = createRef();
|
||||
this._appFrame = createRef();
|
||||
this._menu_bar = createRef();
|
||||
}
|
||||
|
||||
@ -108,12 +86,10 @@ export default class AppTile extends React.Component {
|
||||
return !!currentlyAllowedWidgets[newProps.app.eventId];
|
||||
};
|
||||
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
return {
|
||||
initialising: true, // True while we are mangling the widget URL
|
||||
// True while the iframe content is loading
|
||||
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
|
||||
widgetUrl: this._addWurlParams(newProps.app.url),
|
||||
// Assume that widget has permission to load if we are the user who
|
||||
// added it to the room, or if explicitly granted by the user
|
||||
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
|
||||
@ -124,43 +100,6 @@ export default class AppTile extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the widget support a given capability
|
||||
* @param {string} capability Capability to check for
|
||||
* @return {Boolean} True if capability supported
|
||||
*/
|
||||
_hasCapability(capability) {
|
||||
return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add widget instance specific parameters to pass in wUrl
|
||||
* Properties passed to widget instance:
|
||||
* - widgetId
|
||||
* - origin / parent URL
|
||||
* @param {string} urlString Url string to modify
|
||||
* @return {string}
|
||||
* Url string with parameters appended.
|
||||
* If url can not be parsed, it is returned unmodified.
|
||||
*/
|
||||
_addWurlParams(urlString) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
parsed.searchParams.set('widgetId', this.props.app.id);
|
||||
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
|
||||
|
||||
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
|
||||
// in HTTP, but URL parsers encode them anyways.
|
||||
return parsed.toString().replace(/%24/g, '$');
|
||||
} catch (e) {
|
||||
console.error("Failed to add widget URL params:", e);
|
||||
return urlString;
|
||||
}
|
||||
}
|
||||
|
||||
isMixedContent() {
|
||||
const parentContentProtocol = window.location.protocol;
|
||||
const u = url.parse(this.props.app.url);
|
||||
@ -176,7 +115,7 @@ export default class AppTile extends React.Component {
|
||||
componentDidMount() {
|
||||
// Only fetch IM token on mount if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
// Widget action listeners
|
||||
@ -190,93 +129,44 @@ export default class AppTile extends React.Component {
|
||||
// if it's not remaining on screen, get rid of the PersistedElement container
|
||||
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
}
|
||||
|
||||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Generify the name of this function. It's not just scalar tokens.
|
||||
/**
|
||||
* Adds a scalar token to the widget URL, if required
|
||||
* Component initialisation is only complete when this function has resolved
|
||||
*/
|
||||
setScalarToken() {
|
||||
if (!WidgetUtils.isScalarUrl(this.props.app.url)) {
|
||||
console.warn('Widget does not match integration manager, refusing to set auth token', url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
_resetWidget(newProps) {
|
||||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
this._sgWidget = new StopGapWidget(newProps);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (!managers.hasManager()) {
|
||||
console.warn("No integration manager - not setting scalar token", url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Pick the right manager for the widget
|
||||
|
||||
const defaultManager = managers.getPrimaryManager();
|
||||
if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
|
||||
console.warn('Unknown integration manager, refusing to set auth token', url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the token before loading the iframe as we need it to mangle the URL
|
||||
if (!this._scalarClient) {
|
||||
this._scalarClient = defaultManager.getScalarClient();
|
||||
}
|
||||
this._scalarClient.getScalarToken().then((token) => {
|
||||
// Append scalar_token as a query param if not already present
|
||||
this._scalarClient.scalarToken = token;
|
||||
const u = url.parse(this._addWurlParams(this.props.app.url));
|
||||
const params = qs.parse(u.query);
|
||||
if (!params.scalar_token) {
|
||||
params.scalar_token = encodeURIComponent(token);
|
||||
// u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
|
||||
u.search = undefined;
|
||||
u.query = params;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: u.format(),
|
||||
initialising: false,
|
||||
});
|
||||
|
||||
// Fetch page title from remote content if not already set
|
||||
if (!this.state.widgetPageTitle && params.url) {
|
||||
this._fetchWidgetTitle(params.url);
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Failed to get scalar_token", err);
|
||||
this.setState({
|
||||
error: err.message,
|
||||
initialising: false,
|
||||
});
|
||||
_startWidget() {
|
||||
this._sgWidget.prepare().then(() => {
|
||||
this.setState({initialising: false});
|
||||
});
|
||||
}
|
||||
|
||||
_iframeRefChange = (ref) => {
|
||||
this.iframe = ref;
|
||||
if (ref) {
|
||||
this._sgWidget.start(ref);
|
||||
} else {
|
||||
this._resetWidget(this.props);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||
if (nextProps.app.url !== this.props.app.url) {
|
||||
this._getNewState(nextProps);
|
||||
// Fetch IM token for new URL if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,9 +177,9 @@ export default class AppTile extends React.Component {
|
||||
loading: true,
|
||||
});
|
||||
}
|
||||
// Fetch IM token now that we're showing if we already have permission to load
|
||||
// Start the widget now that we're showing if we already have permission to load
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}
|
||||
}
|
||||
|
||||
@ -319,7 +209,14 @@ export default class AppTile extends React.Component {
|
||||
}
|
||||
|
||||
_onSnapshotClick() {
|
||||
WidgetUtils.snapshotWidget(this.props.app);
|
||||
this._sgWidget.widgetApi.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -327,35 +224,24 @@ export default class AppTile extends React.Component {
|
||||
* @private
|
||||
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
||||
*/
|
||||
_endWidgetActions() {
|
||||
let terminationPromise;
|
||||
|
||||
if (this._hasCapability(Capability.ReceiveTerminate)) {
|
||||
// Wait for widget to terminate within a timeout
|
||||
const timeout = 2000;
|
||||
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
|
||||
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
|
||||
} else {
|
||||
terminationPromise = Promise.resolve();
|
||||
async _endWidgetActions() { // widget migration dev note: async to maintain signature
|
||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
||||
// its hold on the webcam. Without this, the widget holds a media
|
||||
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
|
||||
if (this.iframe) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Element instance is located.
|
||||
this.iframe.src = 'about:blank';
|
||||
}
|
||||
|
||||
return terminationPromise.finally(() => {
|
||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
||||
// its hold on the webcam. Without this, the widget holds a media
|
||||
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
|
||||
if (this._appFrame.current) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Element instance is located.
|
||||
this._appFrame.current.src = 'about:blank';
|
||||
}
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
});
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
/* If user has permission to modify widgets, delete the widget,
|
||||
@ -409,73 +295,18 @@ export default class AppTile extends React.Component {
|
||||
this._revokeWidgetPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when widget iframe has finished loading
|
||||
*/
|
||||
_onLoaded() {
|
||||
// Destroy the old widget messaging before starting it back up again. Some widgets
|
||||
// have startup routines that run when they are loaded, so we just need to reinitialize
|
||||
// the messaging for them.
|
||||
ActiveWidgetStore.delWidgetMessaging(this.props.app.id);
|
||||
this._setupWidgetMessaging();
|
||||
|
||||
ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId);
|
||||
_onWidgetReady = () => {
|
||||
this.setState({loading: false});
|
||||
}
|
||||
|
||||
_setupWidgetMessaging() {
|
||||
// FIXME: There's probably no reason to do this here: it should probably be done entirely
|
||||
// in ActiveWidgetStore.
|
||||
const widgetMessaging = new WidgetMessaging(
|
||||
this.props.app.id,
|
||||
this.props.app.url,
|
||||
this._getRenderedUrl(),
|
||||
this.props.userWidget,
|
||||
this._appFrame.current.contentWindow,
|
||||
);
|
||||
ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging);
|
||||
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
|
||||
console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities);
|
||||
requestedCapabilities = requestedCapabilities || [];
|
||||
|
||||
// Allow whitelisted capabilities
|
||||
let requestedWhitelistCapabilies = [];
|
||||
|
||||
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
|
||||
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
|
||||
return this.indexOf(e)>=0;
|
||||
}, this.props.whitelistCapabilities);
|
||||
|
||||
if (requestedWhitelistCapabilies.length > 0 ) {
|
||||
console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` +
|
||||
requestedWhitelistCapabilies,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO -- Add UI to warn about and optionally allow requested capabilities
|
||||
|
||||
ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies);
|
||||
|
||||
if (this.props.onCapabilityRequest) {
|
||||
this.props.onCapabilityRequest(requestedCapabilities);
|
||||
}
|
||||
|
||||
// We only tell Jitsi widgets that we're ready because they're realistically the only ones
|
||||
// using this custom extension to the widget API.
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
widgetMessaging.flagReadyToContinue();
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
|
||||
});
|
||||
}
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
|
||||
}
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
if (payload.widgetId === this.props.app.id) {
|
||||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
if (this._hasCapability('m.sticker')) {
|
||||
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
|
||||
dis.dispatch({action: 'post_sticker_message', data: payload.data});
|
||||
} else {
|
||||
console.warn('Ignoring sticker message. Invalid capability');
|
||||
@ -493,20 +324,6 @@ export default class AppTile extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote content title on AppTile
|
||||
* @param {string} url Url to check for title
|
||||
*/
|
||||
_fetchWidgetTitle(url) {
|
||||
this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
|
||||
if (widgetPageTitle) {
|
||||
this.setState({widgetPageTitle: widgetPageTitle});
|
||||
}
|
||||
}, (err) =>{
|
||||
console.error("Failed to get page title", err);
|
||||
});
|
||||
}
|
||||
|
||||
_grantWidgetPermission() {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Granting permission for widget to load: " + this.props.app.eventId);
|
||||
@ -516,7 +333,7 @@ export default class AppTile extends React.Component {
|
||||
this.setState({hasPermissionToLoad: true});
|
||||
|
||||
// Fetch a token for the integration manager, now that we're allowed to
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
@ -535,6 +352,7 @@ export default class AppTile extends React.Component {
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
@ -572,40 +390,6 @@ export default class AppTile extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the widget template variables in a url with their values
|
||||
*
|
||||
* @param {string} u The URL with template variables
|
||||
* @param {string} widgetType The widget's type
|
||||
*
|
||||
* @returns {string} url with temlate variables replaced
|
||||
*/
|
||||
_templatedUrl(u, widgetType: string) {
|
||||
const targetData = {};
|
||||
if (WidgetType.JITSI.matches(widgetType)) {
|
||||
targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded
|
||||
}
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const myUser = MatrixClientPeg.get().getUser(myUserId);
|
||||
const vars = Object.assign(targetData, this.props.app.data, {
|
||||
'matrix_user_id': myUserId,
|
||||
'matrix_room_id': this.props.room.roomId,
|
||||
'matrix_display_name': myUser ? myUser.displayName : myUserId,
|
||||
'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '',
|
||||
|
||||
// TODO: Namespace themes through some standard
|
||||
'theme': SettingsStore.getValue("theme"),
|
||||
});
|
||||
|
||||
if (vars.conferenceId === undefined) {
|
||||
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
|
||||
const parsedUrl = new URL(this.props.app.url);
|
||||
vars.conferenceId = parsedUrl.searchParams.get("confId");
|
||||
}
|
||||
|
||||
return uriFromTemplate(u, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we're using a local version of the widget rather than loading the
|
||||
* actual widget URL
|
||||
@ -615,67 +399,11 @@ export default class AppTile extends React.Component {
|
||||
return WidgetType.JITSI.matches(this.props.app.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL used in the iframe
|
||||
* In cases where we supply our own UI for a widget, this is an internal
|
||||
* URL different to the one used if the widget is popped out to a separate
|
||||
* tab / browser
|
||||
*
|
||||
* @returns {string} url
|
||||
*/
|
||||
_getRenderedUrl() {
|
||||
let url;
|
||||
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
console.log("Replacing Jitsi widget URL with local wrapper");
|
||||
url = WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: true,
|
||||
auth: this.props.app.data ? this.props.app.data.auth : null,
|
||||
});
|
||||
url = this._addWurlParams(url);
|
||||
} else {
|
||||
url = this._getSafeUrl(this.state.widgetUrl);
|
||||
}
|
||||
return this._templatedUrl(url, this.props.app.type);
|
||||
}
|
||||
|
||||
_getPopoutUrl() {
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
return this._templatedUrl(
|
||||
WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: false,
|
||||
auth: this.props.app.data ? this.props.app.data.auth : null,
|
||||
}),
|
||||
this.props.app.type,
|
||||
);
|
||||
} else {
|
||||
// use app.url, not state.widgetUrl, because we want the one without
|
||||
// the wURL params for the popped-out version.
|
||||
return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type);
|
||||
}
|
||||
}
|
||||
|
||||
_getSafeUrl(u) {
|
||||
const parsedWidgetUrl = url.parse(u, true);
|
||||
if (ENABLE_REACT_PERF) {
|
||||
parsedWidgetUrl.search = null;
|
||||
parsedWidgetUrl.query.react_perf = true;
|
||||
}
|
||||
let safeWidgetUrl = '';
|
||||
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) {
|
||||
safeWidgetUrl = url.format(parsedWidgetUrl);
|
||||
}
|
||||
|
||||
// Replace all the dollar signs back to dollar signs as they don't affect HTTP at all.
|
||||
// We also need the dollar signs in-tact for variable substitution.
|
||||
return safeWidgetUrl.replace(/%24/g, '$');
|
||||
}
|
||||
|
||||
_getTileTitle() {
|
||||
const name = this.formatAppTileName();
|
||||
const titleSpacer = <span> - </span>;
|
||||
let title = '';
|
||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
|
||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
|
||||
title = this.state.widgetPageTitle;
|
||||
}
|
||||
|
||||
@ -698,9 +426,9 @@ export default class AppTile extends React.Component {
|
||||
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
|
||||
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
|
||||
this._endWidgetActions().then(() => {
|
||||
if (this._appFrame.current) {
|
||||
if (this.iframe) {
|
||||
// Reload iframe
|
||||
this._appFrame.current.src = this._getRenderedUrl();
|
||||
this.iframe.src = this._sgWidget.embedUrl;
|
||||
this.setState({});
|
||||
}
|
||||
});
|
||||
@ -708,13 +436,13 @@ export default class AppTile extends React.Component {
|
||||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click();
|
||||
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
|
||||
}
|
||||
|
||||
_onReloadWidgetClick() {
|
||||
// Reload iframe in this way to avoid cross-origin restrictions
|
||||
// eslint-disable-next-line no-self-assign
|
||||
this._appFrame.current.src = this._appFrame.current.src;
|
||||
this.iframe.src = this.iframe.src;
|
||||
}
|
||||
|
||||
_onContextMenuClick = () => {
|
||||
@ -760,7 +488,7 @@ export default class AppTile extends React.Component {
|
||||
<AppPermission
|
||||
roomId={this.props.room.roomId}
|
||||
creatorUserId={this.props.creatorUserId}
|
||||
url={this.state.widgetUrl}
|
||||
url={this._sgWidget.embedUrl}
|
||||
isRoomEncrypted={isEncrypted}
|
||||
onPermissionGranted={this._grantWidgetPermission}
|
||||
/>
|
||||
@ -785,11 +513,11 @@ export default class AppTile extends React.Component {
|
||||
{ this.state.loading && loadingElement }
|
||||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._appFrame}
|
||||
src={this._getRenderedUrl()}
|
||||
ref={this._iframeRefChange}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
onLoad={this._onLoaded} />
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// if the widget would be allowed to remain on screen, we must put it in
|
||||
@ -833,9 +561,10 @@ export default class AppTile extends React.Component {
|
||||
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
|
||||
|
||||
const canUserModify = this._canUserModify();
|
||||
const showEditButton = Boolean(this._scalarClient && canUserModify);
|
||||
const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
|
||||
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
|
||||
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
|
||||
const showPictureSnapshotButton = this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots)
|
||||
&& this.props.show;
|
||||
|
||||
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
|
||||
contextMenu = (
|
||||
@ -943,9 +672,6 @@ AppTile.propTypes = {
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
whitelistCapabilities: PropTypes.array,
|
||||
// Optional function to be called on widget capability request
|
||||
// Called with an array of the requested capabilities
|
||||
onCapabilityRequest: PropTypes.func,
|
||||
// Is this an instance of a user widget
|
||||
userWidget: PropTypes.bool,
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 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.
|
||||
@ -14,15 +14,41 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ReactChildren, useEffect} from 'react';
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {MatrixEvent, RoomMember} from "matrix-js-sdk";
|
||||
import {useStateToggle} from "../../../hooks/useStateToggle";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => {
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
events: MatrixEvent[];
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold?: number;
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded?: boolean,
|
||||
// The list of room members for which to show avatars next to the summary
|
||||
summaryMembers?: RoomMember[],
|
||||
// The text to show as the summary of this event list
|
||||
summaryText?: string,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: ReactChildren,
|
||||
// Called when the event list expansion is toggled
|
||||
onToggle?(): void;
|
||||
}
|
||||
|
||||
const EventListSummary: React.FC<IProps> = ({
|
||||
events,
|
||||
children,
|
||||
threshold = 3,
|
||||
onToggle,
|
||||
startExpanded,
|
||||
summaryMembers = [],
|
||||
summaryText,
|
||||
}) => {
|
||||
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
|
||||
|
||||
// Whenever expanded changes call onToggle
|
||||
@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
|
||||
);
|
||||
};
|
||||
|
||||
EventListSummary.propTypes = {
|
||||
// An array of member events to summarise
|
||||
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: PropTypes.arrayOf(PropTypes.element).isRequired,
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold: PropTypes.number,
|
||||
// Called when the event list expansion is toggled
|
||||
onToggle: PropTypes.func,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded: PropTypes.bool,
|
||||
|
||||
// The list of room members for which to show avatars next to the summary
|
||||
summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)),
|
||||
// The text to show as the summary of this event list
|
||||
summaryText: PropTypes.string,
|
||||
};
|
||||
|
||||
export default EventListSummary;
|
@ -16,32 +16,60 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { ReactChildren } from 'react';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixEvent} from "matrix-js-sdk";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import EventListSummary from "./EventListSummary";
|
||||
|
||||
export default class MemberEventListSummary extends React.Component {
|
||||
static propTypes = {
|
||||
// An array of member events to summarise
|
||||
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: PropTypes.array.isRequired,
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength: PropTypes.number,
|
||||
// The maximum number of avatars to display in the summary
|
||||
avatarsMaxLength: PropTypes.number,
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold: PropTypes.number,
|
||||
// Called when the MELS expansion is toggled
|
||||
onToggle: PropTypes.func,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded: PropTypes.bool,
|
||||
};
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
events: MatrixEvent[];
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength?: number;
|
||||
// The maximum number of avatars to display in the summary
|
||||
avatarsMaxLength?: number;
|
||||
// The minimum number of events needed to trigger summarisation
|
||||
threshold?: number,
|
||||
// Whether or not to begin with state.expanded=true
|
||||
startExpanded?: boolean,
|
||||
// An array of EventTiles to render when expanded
|
||||
children: ReactChildren;
|
||||
// Called when the MELS expansion is toggled
|
||||
onToggle?(): void,
|
||||
}
|
||||
|
||||
interface IUserEvents {
|
||||
// The original event
|
||||
mxEvent: MatrixEvent;
|
||||
// The display name of the user (if not, then user ID)
|
||||
displayName: string;
|
||||
// The original index of the event in this.props.events
|
||||
index: number;
|
||||
}
|
||||
|
||||
enum TransitionType {
|
||||
Joined = "joined",
|
||||
Left = "left",
|
||||
JoinedAndLeft = "joined_and_left",
|
||||
LeftAndJoined = "left_and_joined",
|
||||
InviteReject = "invite_reject",
|
||||
InviteWithdrawal = "invite_withdrawal",
|
||||
Invited = "invited",
|
||||
Banned = "banned",
|
||||
Unbanned = "unbanned",
|
||||
Kicked = "kicked",
|
||||
ChangedName = "changed_name",
|
||||
ChangedAvatar = "changed_avatar",
|
||||
NoChange = "no_change",
|
||||
}
|
||||
|
||||
const SEP = ",";
|
||||
|
||||
export default class MemberEventListSummary extends React.Component<IProps> {
|
||||
static defaultProps = {
|
||||
summaryLength: 1,
|
||||
threshold: 3,
|
||||
@ -62,30 +90,28 @@ export default class MemberEventListSummary extends React.Component {
|
||||
/**
|
||||
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
|
||||
* the sequences are ordered by `orderedTransitionSequences`.
|
||||
* @param {object[]} eventAggregates a map of transition sequence to array of user display names
|
||||
* @param {object} eventAggregates a map of transition sequence to array of user display names
|
||||
* or user IDs.
|
||||
* @param {string[]} orderedTransitionSequences an array which is some ordering of
|
||||
* `Object.keys(eventAggregates)`.
|
||||
* @returns {string} the textual summary of the aggregated events that occurred.
|
||||
*/
|
||||
_generateSummary(eventAggregates, orderedTransitionSequences) {
|
||||
private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
|
||||
const summaries = orderedTransitionSequences.map((transitions) => {
|
||||
const userNames = eventAggregates[transitions];
|
||||
const nameList = this._renderNameList(userNames);
|
||||
const nameList = this.renderNameList(userNames);
|
||||
|
||||
const splitTransitions = transitions.split(',');
|
||||
const splitTransitions = transitions.split(SEP) as TransitionType[];
|
||||
|
||||
// Some neighbouring transitions are common, so canonicalise some into "pair"
|
||||
// transitions
|
||||
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
|
||||
const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
|
||||
// Transform into consecutive repetitions of the same transition (like 5
|
||||
// consecutive 'joined_and_left's)
|
||||
const coalescedTransitions = this._coalesceRepeatedTransitions(
|
||||
canonicalTransitions,
|
||||
);
|
||||
const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
|
||||
|
||||
const descs = coalescedTransitions.map((t) => {
|
||||
return this._getDescriptionForTransition(
|
||||
return MemberEventListSummary.getDescriptionForTransition(
|
||||
t.transitionType, userNames.length, t.repeats,
|
||||
);
|
||||
});
|
||||
@ -108,7 +134,7 @@ export default class MemberEventListSummary extends React.Component {
|
||||
* more items in `users` than `this.props.summaryLength`, which is the number of names
|
||||
* included before "and [n] others".
|
||||
*/
|
||||
_renderNameList(users) {
|
||||
private renderNameList(users: string[]) {
|
||||
return formatCommaSeparatedList(users, this.props.summaryLength);
|
||||
}
|
||||
|
||||
@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component {
|
||||
* @param {string[]} transitions an array of transitions.
|
||||
* @returns {string[]} an array of transitions.
|
||||
*/
|
||||
_getCanonicalTransitions(transitions) {
|
||||
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
|
||||
const modMap = {
|
||||
'joined': {
|
||||
'after': 'left',
|
||||
'newTransition': 'joined_and_left',
|
||||
[TransitionType.Joined]: {
|
||||
after: TransitionType.Left,
|
||||
newTransition: TransitionType.JoinedAndLeft,
|
||||
},
|
||||
'left': {
|
||||
'after': 'joined',
|
||||
'newTransition': 'left_and_joined',
|
||||
[TransitionType.Left]: {
|
||||
after: TransitionType.Joined,
|
||||
newTransition: TransitionType.LeftAndJoined,
|
||||
},
|
||||
// $currentTransition : {
|
||||
// 'after' : $nextTransition,
|
||||
// 'newTransition' : 'new_transition_type',
|
||||
// },
|
||||
};
|
||||
const res = [];
|
||||
const res: TransitionType[] = [];
|
||||
|
||||
for (let i = 0; i < transitions.length; i++) {
|
||||
const t = transitions[i];
|
||||
@ -166,8 +192,12 @@ export default class MemberEventListSummary extends React.Component {
|
||||
* @param {string[]} transitions the array of transitions to transform.
|
||||
* @returns {object[]} an array of coalesced transitions.
|
||||
*/
|
||||
_coalesceRepeatedTransitions(transitions) {
|
||||
const res = [];
|
||||
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
|
||||
const res: {
|
||||
transitionType: TransitionType;
|
||||
repeats: number;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < transitions.length; i++) {
|
||||
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
|
||||
res[res.length - 1].repeats += 1;
|
||||
@ -189,7 +219,7 @@ export default class MemberEventListSummary extends React.Component {
|
||||
* @param {number} repeats the number of times the transition was repeated in a row.
|
||||
* @returns {string} the written Human Readable equivalent of the transition.
|
||||
*/
|
||||
_getDescriptionForTransition(t, userCount, repeats) {
|
||||
private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
|
||||
// The empty interpolations 'severalUsers' and 'oneUser'
|
||||
// are there only to show translators to non-English languages
|
||||
// that the verb is conjugated to plural or singular Subject.
|
||||
@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component {
|
||||
break;
|
||||
case "invite_reject":
|
||||
res = (userCount > 1)
|
||||
? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats })
|
||||
? _t("%(severalUsers)srejected their invitations %(count)s times", {
|
||||
severalUsers: "",
|
||||
count: repeats,
|
||||
})
|
||||
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
|
||||
break;
|
||||
case "invite_withdrawal":
|
||||
res = (userCount > 1)
|
||||
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats })
|
||||
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
|
||||
severalUsers: "",
|
||||
count: repeats,
|
||||
})
|
||||
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
|
||||
break;
|
||||
case "invited":
|
||||
@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component {
|
||||
return res;
|
||||
}
|
||||
|
||||
_getTransitionSequence(events) {
|
||||
return events.map(this._getTransition);
|
||||
private static getTransitionSequence(events: MatrixEvent[]) {
|
||||
return events.map(MemberEventListSummary.getTransition);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -277,60 +313,60 @@ export default class MemberEventListSummary extends React.Component {
|
||||
* @returns {string?} the transition type given to this event. This defaults to `null`
|
||||
* if a transition is not recognised.
|
||||
*/
|
||||
_getTransition(e) {
|
||||
private static getTransition(e: MatrixEvent): TransitionType {
|
||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||
// Handle 3pid invites the same as invites so they get bundled together
|
||||
if (!isValid3pidInvite(e.mxEvent)) {
|
||||
return 'invite_withdrawal';
|
||||
return TransitionType.InviteWithdrawal;
|
||||
}
|
||||
return 'invited';
|
||||
return TransitionType.Invited;
|
||||
}
|
||||
|
||||
switch (e.mxEvent.getContent().membership) {
|
||||
case 'invite': return 'invited';
|
||||
case 'ban': return 'banned';
|
||||
case 'invite': return TransitionType.Invited;
|
||||
case 'ban': return TransitionType.Banned;
|
||||
case 'join':
|
||||
if (e.mxEvent.getPrevContent().membership === 'join') {
|
||||
if (e.mxEvent.getContent().displayname !==
|
||||
e.mxEvent.getPrevContent().displayname) {
|
||||
return 'changed_name';
|
||||
return TransitionType.ChangedName;
|
||||
} else if (e.mxEvent.getContent().avatar_url !==
|
||||
e.mxEvent.getPrevContent().avatar_url) {
|
||||
return 'changed_avatar';
|
||||
return TransitionType.ChangedAvatar;
|
||||
}
|
||||
// console.log("MELS ignoring duplicate membership join event");
|
||||
return 'no_change';
|
||||
return TransitionType.NoChange;
|
||||
} else {
|
||||
return 'joined';
|
||||
return TransitionType.Joined;
|
||||
}
|
||||
case 'leave':
|
||||
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
|
||||
switch (e.mxEvent.getPrevContent().membership) {
|
||||
case 'invite': return 'invite_reject';
|
||||
default: return 'left';
|
||||
case 'invite': return TransitionType.InviteReject;
|
||||
default: return TransitionType.Left;
|
||||
}
|
||||
}
|
||||
switch (e.mxEvent.getPrevContent().membership) {
|
||||
case 'invite': return 'invite_withdrawal';
|
||||
case 'ban': return 'unbanned';
|
||||
case 'invite': return TransitionType.InviteWithdrawal;
|
||||
case 'ban': return TransitionType.Unbanned;
|
||||
// sender is not target and made the target leave, if not from invite/ban then this is a kick
|
||||
default: return 'kicked';
|
||||
default: return TransitionType.Kicked;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
_getAggregate(userEvents) {
|
||||
getAggregate(userEvents: Record<string, IUserEvents[]>) {
|
||||
// A map of aggregate type to arrays of display names. Each aggregate type
|
||||
// is a comma-delimited string of transitions, e.g. "joined,left,kicked".
|
||||
// The array of display names is the array of users who went through that
|
||||
// sequence during eventsToRender.
|
||||
const aggregate = {
|
||||
const aggregate: Record<string, string[]> = {
|
||||
// $aggregateType : []:string
|
||||
};
|
||||
// A map of aggregate types to the indices that order them (the index of
|
||||
// the first event for a given transition sequence)
|
||||
const aggregateIndices = {
|
||||
const aggregateIndices: Record<string, number> = {
|
||||
// $aggregateType : int
|
||||
};
|
||||
|
||||
@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component {
|
||||
const firstEvent = userEvents[userId][0];
|
||||
const displayName = firstEvent.displayName;
|
||||
|
||||
const seq = this._getTransitionSequence(userEvents[userId]);
|
||||
const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
|
||||
if (!aggregate[seq]) {
|
||||
aggregate[seq] = [];
|
||||
aggregateIndices[seq] = -1;
|
||||
@ -349,8 +385,9 @@ export default class MemberEventListSummary extends React.Component {
|
||||
aggregate[seq].push(displayName);
|
||||
|
||||
if (aggregateIndices[seq] === -1 ||
|
||||
firstEvent.index < aggregateIndices[seq]) {
|
||||
aggregateIndices[seq] = firstEvent.index;
|
||||
firstEvent.index < aggregateIndices[seq]
|
||||
) {
|
||||
aggregateIndices[seq] = firstEvent.index;
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component {
|
||||
render() {
|
||||
const eventsToRender = this.props.events;
|
||||
|
||||
// Map user IDs to an array of objects:
|
||||
const userEvents = {
|
||||
// $userId : [{
|
||||
// // The original event
|
||||
// mxEvent: e,
|
||||
// // The display name of the user (if not, then user ID)
|
||||
// displayName: e.target.name || userId,
|
||||
// // The original index of the event in this.props.events
|
||||
// index: index,
|
||||
// }]
|
||||
};
|
||||
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
|
||||
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
|
||||
const latestUserAvatarMember = new Map<string, RoomMember>();
|
||||
|
||||
const avatarMembers = [];
|
||||
// Object mapping user IDs to an array of IUserEvents
|
||||
const userEvents: Record<string, IUserEvents[]> = {};
|
||||
eventsToRender.forEach((e, index) => {
|
||||
const userId = e.getStateKey();
|
||||
// Initialise a user's events
|
||||
if (!userEvents[userId]) {
|
||||
userEvents[userId] = [];
|
||||
if (e.target) avatarMembers.push(e.target);
|
||||
}
|
||||
|
||||
if (e.target) {
|
||||
latestUserAvatarMember.set(userId, e.target);
|
||||
}
|
||||
|
||||
let displayName = userId;
|
||||
@ -399,21 +432,20 @@ export default class MemberEventListSummary extends React.Component {
|
||||
});
|
||||
});
|
||||
|
||||
const aggregate = this._getAggregate(userEvents);
|
||||
const aggregate = this.getAggregate(userEvents);
|
||||
|
||||
// Sort types by order of lowest event index within sequence
|
||||
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
|
||||
(seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2],
|
||||
(seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
|
||||
);
|
||||
|
||||
const EventListSummary = sdk.getComponent("views.elements.EventListSummary");
|
||||
return <EventListSummary
|
||||
events={this.props.events}
|
||||
threshold={this.props.threshold}
|
||||
onToggle={this.props.onToggle}
|
||||
startExpanded={this.props.startExpanded}
|
||||
children={this.props.children}
|
||||
summaryMembers={avatarMembers}
|
||||
summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||
summaryMembers={[...latestUserAvatarMember.values()]}
|
||||
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -14,32 +15,53 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {RefObject} from 'react';
|
||||
|
||||
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker";
|
||||
import * as sdk from '../../../index';
|
||||
import LazyRenderList from "../elements/LazyRenderList";
|
||||
import {DATA_BY_CATEGORY, IEmoji} from "../../../emoji";
|
||||
import Emoji from './Emoji';
|
||||
|
||||
const OVERFLOW_ROWS = 3;
|
||||
|
||||
class Category extends React.PureComponent {
|
||||
static propTypes = {
|
||||
emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
onMouseEnter: PropTypes.func.isRequired,
|
||||
onMouseLeave: PropTypes.func.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
selectedEmojis: PropTypes.instanceOf(Set),
|
||||
};
|
||||
export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent";
|
||||
|
||||
_renderEmojiRow = (rowIndex) => {
|
||||
export interface ICategory {
|
||||
id: CategoryKey;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
visible: boolean;
|
||||
ref: RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
id: string;
|
||||
name: string;
|
||||
emojis: IEmoji[];
|
||||
selectedEmojis: Set<string>;
|
||||
heightBefore: number;
|
||||
viewportHeight: number;
|
||||
scrollTop: number;
|
||||
onClick(emoji: IEmoji): void;
|
||||
onMouseEnter(emoji: IEmoji): void;
|
||||
onMouseLeave(emoji: IEmoji): void;
|
||||
}
|
||||
|
||||
class Category extends React.PureComponent<IProps> {
|
||||
private renderEmojiRow = (rowIndex: number) => {
|
||||
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
|
||||
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
|
||||
const Emoji = sdk.getComponent("emojipicker.Emoji");
|
||||
return (<div key={rowIndex}>{
|
||||
emojisForRow.map(emoji =>
|
||||
<Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
|
||||
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)
|
||||
emojisForRow.map(emoji => ((
|
||||
<Emoji
|
||||
key={emoji.hexcode}
|
||||
emoji={emoji}
|
||||
selectedEmojis={selectedEmojis}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
)))
|
||||
}</div>);
|
||||
};
|
||||
|
||||
@ -52,7 +74,6 @@ class Category extends React.PureComponent {
|
||||
for (let counter = 0; counter < rows.length; ++counter) {
|
||||
rows[counter] = counter;
|
||||
}
|
||||
const LazyRenderList = sdk.getComponent('elements.LazyRenderList');
|
||||
|
||||
const viewportTop = scrollTop;
|
||||
const viewportBottom = viewportTop + viewportHeight;
|
||||
@ -84,7 +105,7 @@ class Category extends React.PureComponent {
|
||||
height={localHeight}
|
||||
overflowItems={OVERFLOW_ROWS}
|
||||
overflowMargin={0}
|
||||
renderItem={this._renderEmojiRow}>
|
||||
renderItem={this.renderEmojiRow}>
|
||||
</LazyRenderList>
|
||||
</section>
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,18 +16,19 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
import {IEmoji} from "../../../emoji";
|
||||
|
||||
class Emoji extends React.PureComponent {
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func,
|
||||
emoji: PropTypes.object.isRequired,
|
||||
selectedEmojis: PropTypes.instanceOf(Set),
|
||||
};
|
||||
interface IProps {
|
||||
emoji: IEmoji;
|
||||
selectedEmojis?: Set<string>;
|
||||
onClick(emoji: IEmoji): void;
|
||||
onMouseEnter(emoji: IEmoji): void;
|
||||
onMouseLeave(emoji: IEmoji): void;
|
||||
}
|
||||
|
||||
class Emoji extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
|
||||
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,25 +16,43 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
import * as recent from '../../../emojipicker/recent';
|
||||
import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji";
|
||||
import {DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji} from "../../../emoji";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import Header from "./Header";
|
||||
import Search from "./Search";
|
||||
import Preview from "./Preview";
|
||||
import QuickReactions from "./QuickReactions";
|
||||
import Category, {ICategory, CategoryKey} from "./Category";
|
||||
|
||||
export const CATEGORY_HEADER_HEIGHT = 22;
|
||||
export const EMOJI_HEIGHT = 37;
|
||||
export const EMOJIS_PER_ROW = 8;
|
||||
|
||||
class EmojiPicker extends React.Component {
|
||||
static propTypes = {
|
||||
onChoose: PropTypes.func.isRequired,
|
||||
selectedEmojis: PropTypes.instanceOf(Set),
|
||||
showQuickReactions: PropTypes.bool,
|
||||
};
|
||||
interface IProps {
|
||||
selectedEmojis: Set<string>;
|
||||
showQuickReactions?: boolean;
|
||||
onChoose(unicode: string): boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
filter: string;
|
||||
previewEmoji?: IEmoji;
|
||||
scrollTop: number;
|
||||
// initial estimation of height, dialog is hardcoded to 450px height.
|
||||
// should be enough to never have blank rows of emojis as
|
||||
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
class EmojiPicker extends React.Component<IProps, IState> {
|
||||
private readonly recentlyUsed: IEmoji[];
|
||||
private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
|
||||
private readonly categories: ICategory[];
|
||||
|
||||
private bodyRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -42,9 +61,6 @@ class EmojiPicker extends React.Component {
|
||||
filter: "",
|
||||
previewEmoji: null,
|
||||
scrollTop: 0,
|
||||
// initial estimation of height, dialog is hardcoded to 450px height.
|
||||
// should be enough to never have blank rows of emojis as
|
||||
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
|
||||
viewportHeight: 280,
|
||||
};
|
||||
|
||||
@ -110,18 +126,9 @@ class EmojiPicker extends React.Component {
|
||||
visible: false,
|
||||
ref: React.createRef(),
|
||||
}];
|
||||
|
||||
this.bodyRef = React.createRef();
|
||||
|
||||
this.onChangeFilter = this.onChangeFilter.bind(this);
|
||||
this.onHoverEmoji = this.onHoverEmoji.bind(this);
|
||||
this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this);
|
||||
this.onClickEmoji = this.onClickEmoji.bind(this);
|
||||
this.scrollToCategory = this.scrollToCategory.bind(this);
|
||||
this.updateVisibility = this.updateVisibility.bind(this);
|
||||
}
|
||||
|
||||
onScroll = () => {
|
||||
private onScroll = () => {
|
||||
const body = this.bodyRef.current;
|
||||
this.setState({
|
||||
scrollTop: body.scrollTop,
|
||||
@ -130,7 +137,7 @@ class EmojiPicker extends React.Component {
|
||||
this.updateVisibility();
|
||||
};
|
||||
|
||||
updateVisibility() {
|
||||
private updateVisibility = () => {
|
||||
const body = this.bodyRef.current;
|
||||
const rect = body.getBoundingClientRect();
|
||||
for (const cat of this.categories) {
|
||||
@ -147,21 +154,21 @@ class EmojiPicker extends React.Component {
|
||||
// We update this here instead of through React to avoid re-render on scroll.
|
||||
if (cat.visible) {
|
||||
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
|
||||
cat.ref.current.setAttribute("aria-selected", true);
|
||||
cat.ref.current.setAttribute("tabindex", 0);
|
||||
cat.ref.current.setAttribute("aria-selected", "true");
|
||||
cat.ref.current.setAttribute("tabindex", "0");
|
||||
} else {
|
||||
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
|
||||
cat.ref.current.setAttribute("aria-selected", false);
|
||||
cat.ref.current.setAttribute("tabindex", -1);
|
||||
cat.ref.current.setAttribute("aria-selected", "false");
|
||||
cat.ref.current.setAttribute("tabindex", "-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollToCategory(category) {
|
||||
private scrollToCategory = (category: string) => {
|
||||
this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
onChangeFilter(filter) {
|
||||
private onChangeFilter = (filter: string) => {
|
||||
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
|
||||
for (const cat of this.categories) {
|
||||
let emojis;
|
||||
@ -181,27 +188,34 @@ class EmojiPicker extends React.Component {
|
||||
// Header underlines need to be updated, but updating requires knowing
|
||||
// where the categories are, so we wait for a tick.
|
||||
setTimeout(this.updateVisibility, 0);
|
||||
}
|
||||
};
|
||||
|
||||
onHoverEmoji(emoji) {
|
||||
private onEnterFilter = () => {
|
||||
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
|
||||
if (btn) {
|
||||
btn.click();
|
||||
}
|
||||
};
|
||||
|
||||
private onHoverEmoji = (emoji: IEmoji) => {
|
||||
this.setState({
|
||||
previewEmoji: emoji,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onHoverEmojiEnd(emoji) {
|
||||
private onHoverEmojiEnd = (emoji: IEmoji) => {
|
||||
this.setState({
|
||||
previewEmoji: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onClickEmoji(emoji) {
|
||||
private onClickEmoji = (emoji: IEmoji) => {
|
||||
if (this.props.onChoose(emoji.unicode) !== false) {
|
||||
recent.add(emoji.unicode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_categoryHeightForEmojiCount(count) {
|
||||
private static categoryHeightForEmojiCount(count: number) {
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
}
|
||||
@ -209,25 +223,37 @@ class EmojiPicker extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const Header = sdk.getComponent("emojipicker.Header");
|
||||
const Search = sdk.getComponent("emojipicker.Search");
|
||||
const Category = sdk.getComponent("emojipicker.Category");
|
||||
const Preview = sdk.getComponent("emojipicker.Preview");
|
||||
const QuickReactions = sdk.getComponent("emojipicker.QuickReactions");
|
||||
let heightBefore = 0;
|
||||
return (
|
||||
<div className="mx_EmojiPicker">
|
||||
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
|
||||
<Search query={this.state.filter} onChange={this.onChangeFilter} />
|
||||
<AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}>
|
||||
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
|
||||
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
|
||||
<AutoHideScrollbar
|
||||
className="mx_EmojiPicker_body"
|
||||
wrappedRef={ref => {
|
||||
// @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead
|
||||
this.bodyRef.current = ref
|
||||
}}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{this.categories.map(category => {
|
||||
const emojis = this.memoizedDataByCategory[category.id];
|
||||
const categoryElement = (<Category key={category.id} id={category.id} name={category.name}
|
||||
heightBefore={heightBefore} viewportHeight={this.state.viewportHeight}
|
||||
scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji}
|
||||
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
|
||||
selectedEmojis={this.props.selectedEmojis} />);
|
||||
const height = this._categoryHeightForEmojiCount(emojis.length);
|
||||
const categoryElement = ((
|
||||
<Category
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
heightBefore={heightBefore}
|
||||
viewportHeight={this.state.viewportHeight}
|
||||
scrollTop={this.state.scrollTop}
|
||||
emojis={emojis}
|
||||
onClick={this.onClickEmoji}
|
||||
onMouseEnter={this.onHoverEmoji}
|
||||
onMouseLeave={this.onHoverEmojiEnd}
|
||||
selectedEmojis={this.props.selectedEmojis}
|
||||
/>
|
||||
));
|
||||
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
|
||||
heightBefore += height;
|
||||
return categoryElement;
|
||||
})}
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,19 +16,19 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from "classnames";
|
||||
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {CategoryKey, ICategory} from "./Category";
|
||||
|
||||
class Header extends React.PureComponent {
|
||||
static propTypes = {
|
||||
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onAnchorClick: PropTypes.func.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
categories: ICategory[];
|
||||
onAnchorClick(id: CategoryKey): void
|
||||
}
|
||||
|
||||
findNearestEnabled(index, delta) {
|
||||
class Header extends React.PureComponent<IProps> {
|
||||
private findNearestEnabled(index: number, delta: number) {
|
||||
index += this.props.categories.length;
|
||||
const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories];
|
||||
|
||||
@ -37,12 +38,12 @@ class Header extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
changeCategoryRelative(delta) {
|
||||
private changeCategoryRelative(delta: number) {
|
||||
const current = this.props.categories.findIndex(c => c.visible);
|
||||
this.changeCategoryAbsolute(current + delta, delta);
|
||||
}
|
||||
|
||||
changeCategoryAbsolute(index, delta=1) {
|
||||
private changeCategoryAbsolute(index: number, delta=1) {
|
||||
const category = this.props.categories[this.findNearestEnabled(index, delta)];
|
||||
if (category) {
|
||||
this.props.onAnchorClick(category.id);
|
||||
@ -52,7 +53,7 @@ class Header extends React.PureComponent {
|
||||
|
||||
// Implements ARIA Tabs with Automatic Activation pattern
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
|
||||
onKeyDown = (ev) => {
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
@ -80,7 +81,12 @@ class Header extends React.PureComponent {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<nav className="mx_EmojiPicker_header" role="tablist" aria-label={_t("Categories")} onKeyDown={this.onKeyDown}>
|
||||
<nav
|
||||
className="mx_EmojiPicker_header"
|
||||
role="tablist"
|
||||
aria-label={_t("Categories")}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{this.props.categories.map(category => {
|
||||
const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
|
||||
mx_EmojiPicker_anchor_visible: category.visible,
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,19 +16,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Preview extends React.PureComponent {
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object,
|
||||
};
|
||||
import {IEmoji} from "../../../emoji";
|
||||
|
||||
interface IProps {
|
||||
emoji: IEmoji;
|
||||
}
|
||||
|
||||
class Preview extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const {
|
||||
unicode = "",
|
||||
annotation = "",
|
||||
shortcodes: [shortcode = ""],
|
||||
} = this.props.emoji || {};
|
||||
|
||||
return (
|
||||
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
|
||||
<div className="mx_EmojiPicker_preview_emoji">
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,11 +16,10 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {getEmojiFromUnicode} from "../../../emoji";
|
||||
import {getEmojiFromUnicode, IEmoji} from "../../../emoji";
|
||||
import Emoji from "./Emoji";
|
||||
|
||||
// We use the variation-selector Heart in Quick Reactions for some reason
|
||||
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
|
||||
@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
|
||||
return data;
|
||||
});
|
||||
|
||||
class QuickReactions extends React.Component {
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
selectedEmojis: PropTypes.instanceOf(Set),
|
||||
};
|
||||
interface IProps {
|
||||
selectedEmojis?: Set<string>;
|
||||
onClick(emoji: IEmoji): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover?: IEmoji;
|
||||
}
|
||||
|
||||
class QuickReactions extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: null,
|
||||
};
|
||||
this.onMouseEnter = this.onMouseEnter.bind(this);
|
||||
this.onMouseLeave = this.onMouseLeave.bind(this);
|
||||
}
|
||||
|
||||
onMouseEnter(emoji) {
|
||||
private onMouseEnter = (emoji: IEmoji) => {
|
||||
this.setState({
|
||||
hover: emoji,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave() {
|
||||
private onMouseLeave = () => {
|
||||
this.setState({
|
||||
hover: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const Emoji = sdk.getComponent("emojipicker.Emoji");
|
||||
|
||||
return (
|
||||
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
|
||||
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
|
||||
@ -72,10 +72,16 @@ class QuickReactions extends React.Component {
|
||||
}
|
||||
</h2>
|
||||
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
|
||||
{QUICK_REACTIONS.map(emoji => <Emoji
|
||||
key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick}
|
||||
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}
|
||||
selectedEmojis={this.props.selectedEmojis} />)}
|
||||
{QUICK_REACTIONS.map(emoji => ((
|
||||
<Emoji
|
||||
key={emoji.hexcode}
|
||||
emoji={emoji}
|
||||
onClick={this.props.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
selectedEmojis={this.props.selectedEmojis}
|
||||
/>
|
||||
)))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,26 +16,29 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from "prop-types";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import EmojiPicker from "./EmojiPicker";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
||||
class ReactionPicker extends React.Component {
|
||||
static propTypes = {
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
reactions: PropTypes.object,
|
||||
};
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
reactions: any; // TODO type this once js-sdk is more typescripted
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
selectedEmojis: Set<string>;
|
||||
}
|
||||
|
||||
class ReactionPicker extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
||||
};
|
||||
this.onChoose = this.onChoose.bind(this);
|
||||
this.onReactionsChange = this.onReactionsChange.bind(this);
|
||||
this.addListeners();
|
||||
}
|
||||
|
||||
@ -45,7 +49,7 @@ class ReactionPicker extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
addListeners() {
|
||||
private addListeners() {
|
||||
if (this.props.reactions) {
|
||||
this.props.reactions.on("Relations.add", this.onReactionsChange);
|
||||
this.props.reactions.on("Relations.remove", this.onReactionsChange);
|
||||
@ -55,22 +59,13 @@ class ReactionPicker extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.reactions) {
|
||||
this.props.reactions.removeListener(
|
||||
"Relations.add",
|
||||
this.onReactionsChange,
|
||||
);
|
||||
this.props.reactions.removeListener(
|
||||
"Relations.remove",
|
||||
this.onReactionsChange,
|
||||
);
|
||||
this.props.reactions.removeListener(
|
||||
"Relations.redaction",
|
||||
this.onReactionsChange,
|
||||
);
|
||||
this.props.reactions.removeListener("Relations.add", this.onReactionsChange);
|
||||
this.props.reactions.removeListener("Relations.remove", this.onReactionsChange);
|
||||
this.props.reactions.removeListener("Relations.redaction", this.onReactionsChange);
|
||||
}
|
||||
}
|
||||
|
||||
getReactions() {
|
||||
private getReactions() {
|
||||
if (!this.props.reactions) {
|
||||
return {};
|
||||
}
|
||||
@ -81,13 +76,13 @@ class ReactionPicker extends React.Component {
|
||||
.map(event => [event.getRelation().key, event.getId()]));
|
||||
}
|
||||
|
||||
onReactionsChange() {
|
||||
private onReactionsChange = () => {
|
||||
this.setState({
|
||||
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onChoose(reaction) {
|
||||
onChoose = (reaction: string) => {
|
||||
this.componentWillUnmount();
|
||||
this.props.onFinished();
|
||||
const myReactions = this.getReactions();
|
||||
@ -109,7 +104,7 @@ class ReactionPicker extends React.Component {
|
||||
dis.dispatch({action: "message_sent"});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return <EmojiPicker
|
@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
||||
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.
|
||||
@ -15,32 +16,41 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Key} from "../../../Keyboard";
|
||||
|
||||
class Search extends React.PureComponent {
|
||||
static propTypes = {
|
||||
query: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
query: string;
|
||||
onChange(value: string): void;
|
||||
onEnter(): void;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.inputRef = React.createRef();
|
||||
}
|
||||
class Search extends React.PureComponent<IProps> {
|
||||
private inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
componentDidMount() {
|
||||
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
|
||||
setTimeout(() => this.inputRef.current.focus(), 0);
|
||||
}
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (ev.key === Key.ENTER) {
|
||||
this.props.onEnter();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let rightButton;
|
||||
if (this.props.query) {
|
||||
rightButton = (
|
||||
<button onClick={() => this.props.onChange("")}
|
||||
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
|
||||
title={_t("Cancel search")} />
|
||||
<button
|
||||
onClick={() => this.props.onChange("")}
|
||||
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
|
||||
title={_t("Cancel search")}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
rightButton = <span className="mx_EmojiPicker_search_icon" />;
|
||||
@ -48,8 +58,15 @@ class Search extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className="mx_EmojiPicker_search">
|
||||
<input autoFocus type="text" placeholder="Search" value={this.props.query}
|
||||
onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={this.props.query}
|
||||
onChange={ev => this.props.onChange(ev.target.value)}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
{rightButton}
|
||||
</div>
|
||||
);
|
@ -25,10 +25,8 @@ export default class EncryptionEvent extends React.Component {
|
||||
|
||||
let body;
|
||||
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
|
||||
if (
|
||||
mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' &&
|
||||
MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId())
|
||||
) {
|
||||
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
|
||||
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">
|
||||
@ -38,6 +36,13 @@ export default class EncryptionEvent extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
</div>;
|
||||
} else if (isRoomEncrypted) {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">
|
||||
{_t("Ignored attempt to disable encryption")}
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
|
||||
|
@ -31,6 +31,7 @@ interface IProps {
|
||||
className?: string;
|
||||
withoutScrollContainer?: boolean;
|
||||
previousPhase?: RightPanelPhases;
|
||||
closeLabel?: string;
|
||||
onClose?(): void;
|
||||
}
|
||||
|
||||
@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
|
||||
};
|
||||
|
||||
const BaseCard: React.FC<IProps> = ({
|
||||
closeLabel,
|
||||
onClose,
|
||||
className,
|
||||
header,
|
||||
@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({
|
||||
|
||||
let closeButton;
|
||||
if (onClose) {
|
||||
closeButton = <AccessibleButton className="mx_BaseCard_close" onClick={onClose} title={_t("Close")} />;
|
||||
closeButton = <AccessibleButton
|
||||
className="mx_BaseCard_close"
|
||||
onClick={onClose}
|
||||
title={closeLabel || _t("Close")}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (!withoutScrollContainer) {
|
||||
|
@ -27,6 +27,9 @@ import * as sdk from "../../../index";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
||||
|
||||
// cancellation codes which constitute a key mismatch
|
||||
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
|
||||
@ -42,7 +45,14 @@ interface IProps {
|
||||
}
|
||||
|
||||
const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
const {verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted} = props;
|
||||
const {
|
||||
verificationRequest,
|
||||
verificationRequestPromise,
|
||||
member,
|
||||
onClose,
|
||||
layout,
|
||||
isRoomEncrypted,
|
||||
} = props;
|
||||
const [request, setRequest] = useState(verificationRequest);
|
||||
// state to show a spinner immediately after clicking "start verification",
|
||||
// before we have a request
|
||||
@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
}, [onClose, request]);
|
||||
useEventEmitter(request, "change", changeHandler);
|
||||
|
||||
const onCancel = useCallback(function() {
|
||||
if (request) {
|
||||
request.cancel();
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
let cancelButton: JSX.Element;
|
||||
if (layout !== "dialog" && request && request.pending) {
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
cancelButton = (<AccessibleButton
|
||||
className="mx_EncryptionPanel_cancel"
|
||||
onClick={onCancel}
|
||||
title={_t('Cancel')}
|
||||
></AccessibleButton>);
|
||||
}
|
||||
|
||||
const onStartVerification = useCallback(async () => {
|
||||
setRequesting(true);
|
||||
const cli = MatrixClientPeg.get();
|
||||
@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
|
||||
setRequest(verificationRequest_);
|
||||
setPhase(verificationRequest_.phase);
|
||||
}, [member.userId]);
|
||||
// Notify the RightPanelStore about this
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.EncryptionPanel,
|
||||
refireParams: { member, verificationRequest: verificationRequest_ },
|
||||
});
|
||||
}, [member]);
|
||||
|
||||
const requested =
|
||||
(!request && isRequesting) ||
|
||||
@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
member.userId === MatrixClientPeg.get().getUserId();
|
||||
if (!request || requested) {
|
||||
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
|
||||
return (<React.Fragment>
|
||||
{cancelButton}
|
||||
return (
|
||||
<EncryptionInfo
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
onStartVerification={onStartVerification}
|
||||
@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
waitingForOtherParty={requested && initiatedByMe}
|
||||
waitingForNetwork={requested && !initiatedByMe}
|
||||
inDialog={layout === "dialog"} />
|
||||
</React.Fragment>);
|
||||
);
|
||||
} else {
|
||||
return (<React.Fragment>
|
||||
{cancelButton}
|
||||
return (
|
||||
<VerificationPanel
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
layout={layout}
|
||||
@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
inDialog={layout === "dialog"}
|
||||
phase={phase}
|
||||
/>
|
||||
</React.Fragment>);
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -17,20 +17,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {Group, RoomMember, User, Room} from 'matrix-js-sdk';
|
||||
import {MatrixClient} from 'matrix-js-sdk/src/client';
|
||||
import {RoomMember} from 'matrix-js-sdk/src/models/room-member';
|
||||
import {User} from 'matrix-js-sdk/src/models/user';
|
||||
import {Room} from 'matrix-js-sdk/src/models/room';
|
||||
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {EventTimeline} from "matrix-js-sdk";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import MultiInviter from "../../../utils/MultiInviter";
|
||||
import GroupStore from "../../../stores/GroupStore";
|
||||
@ -41,13 +43,31 @@ import {textualPowerLevel} from '../../../Roles';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
||||
import EncryptionPanel from "./EncryptionPanel";
|
||||
import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
|
||||
import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification';
|
||||
import {useAsyncMemo} from '../../../hooks/useAsyncMemo';
|
||||
import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
|
||||
import BaseCard from "./BaseCard";
|
||||
import {E2EStatus} from "../../../utils/ShieldUtils";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import IconButton from "../elements/IconButton";
|
||||
import PowerSelector from "../elements/PowerSelector";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import PresenceLabel from "../rooms/PresenceLabel";
|
||||
import ShareDialog from "../dialogs/ShareDialog";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
|
||||
const _disambiguateDevices = (devices) => {
|
||||
interface IDevice {
|
||||
deviceId: string;
|
||||
ambiguous?: boolean;
|
||||
getDisplayName(): string;
|
||||
}
|
||||
|
||||
const disambiguateDevices = (devices: IDevice[]) => {
|
||||
const names = Object.create(null);
|
||||
for (let i = 0; i < devices.length; i++) {
|
||||
const name = devices[i].getDisplayName();
|
||||
@ -64,11 +84,11 @@ const _disambiguateDevices = (devices) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getE2EStatus = (cli, userId, devices) => {
|
||||
export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice[]): E2EStatus => {
|
||||
const isMe = userId === cli.getUserId();
|
||||
const userTrust = cli.checkUserTrust(userId);
|
||||
if (!userTrust.isCrossSigningVerified()) {
|
||||
return userTrust.wasCrossSigningVerified() ? "warning" : "normal";
|
||||
return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
|
||||
}
|
||||
|
||||
const anyDeviceUnverified = devices.some(device => {
|
||||
@ -81,10 +101,10 @@ export const getE2EStatus = (cli, userId, devices) => {
|
||||
const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
|
||||
return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified();
|
||||
});
|
||||
return anyDeviceUnverified ? "warning" : "verified";
|
||||
return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
|
||||
};
|
||||
|
||||
async function openDMForUser(matrixClient, userId) {
|
||||
async function openDMForUser(matrixClient: MatrixClient, userId: string) {
|
||||
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
|
||||
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
|
||||
const room = matrixClient.getRoom(roomId);
|
||||
@ -107,6 +127,7 @@ async function openDMForUser(matrixClient, userId) {
|
||||
|
||||
const createRoomOptions = {
|
||||
dmUserId: userId,
|
||||
encryption: undefined,
|
||||
};
|
||||
|
||||
if (privateShouldBeEncrypted()) {
|
||||
@ -122,10 +143,12 @@ async function openDMForUser(matrixClient, userId) {
|
||||
}
|
||||
}
|
||||
|
||||
createRoom(createRoomOptions);
|
||||
return createRoom(createRoomOptions);
|
||||
}
|
||||
|
||||
function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
|
||||
type SetUpdating = (updating: boolean) => void;
|
||||
|
||||
function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify: boolean, setUpdating: SetUpdating) {
|
||||
return useAsyncMemo(async () => {
|
||||
if (!canVerify) {
|
||||
return undefined;
|
||||
@ -142,7 +165,7 @@ function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
|
||||
}, [cli, member, canVerify], undefined);
|
||||
}
|
||||
|
||||
function DeviceItem({userId, device}) {
|
||||
function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const isMe = userId === cli.getUserId();
|
||||
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
|
||||
@ -169,8 +192,8 @@ function DeviceItem({userId, device}) {
|
||||
};
|
||||
|
||||
const deviceName = device.ambiguous ?
|
||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||
device.getDisplayName();
|
||||
let trustedLabel = null;
|
||||
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
||||
|
||||
@ -198,8 +221,7 @@ function DeviceItem({userId, device}) {
|
||||
}
|
||||
}
|
||||
|
||||
function DevicesSection({devices, userId, loading}) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userTrust = cli.checkUserTrust(userId);
|
||||
|
||||
@ -210,7 +232,7 @@ function DevicesSection({devices, userId, loading}) {
|
||||
return <Spinner />;
|
||||
}
|
||||
if (devices === null) {
|
||||
return _t("Unable to load session list");
|
||||
return <>{_t("Unable to load session list")}</>;
|
||||
}
|
||||
const isMe = userId === cli.getUserId();
|
||||
const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
|
||||
@ -285,7 +307,11 @@ function DevicesSection({devices, userId, loading}) {
|
||||
);
|
||||
}
|
||||
|
||||
const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||
const UserOptionsSection: React.FC<{
|
||||
member: RoomMember;
|
||||
isIgnored: boolean;
|
||||
canInvite: boolean;
|
||||
}> = ({member, isIgnored, canInvite}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
let ignoreButton = null;
|
||||
@ -296,7 +322,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||
const isMe = member.userId === cli.getUserId();
|
||||
|
||||
const onShareUserClick = () => {
|
||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
||||
Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
|
||||
target: member,
|
||||
});
|
||||
@ -318,7 +343,10 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||
};
|
||||
|
||||
ignoreButton = (
|
||||
<AccessibleButton onClick={onIgnoreToggle} className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}>
|
||||
<AccessibleButton
|
||||
onClick={onIgnoreToggle}
|
||||
className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}
|
||||
>
|
||||
{ isIgnored ? _t("Unignore") : _t("Ignore") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
@ -367,7 +395,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
|
||||
title: _t('Failed to invite'),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
@ -413,8 +440,7 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const _warnSelfDemote = async () => {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const warnSelfDemote = async () => {
|
||||
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
|
||||
title: _t("Demote yourself?"),
|
||||
description:
|
||||
@ -430,7 +456,7 @@ const _warnSelfDemote = async () => {
|
||||
return confirmed;
|
||||
};
|
||||
|
||||
const GenericAdminToolsContainer = ({children}) => {
|
||||
const GenericAdminToolsContainer: React.FC<{}> = ({children}) => {
|
||||
return (
|
||||
<div className="mx_UserInfo_container">
|
||||
<h3>{ _t("Admin Tools") }</h3>
|
||||
@ -441,7 +467,20 @@ const GenericAdminToolsContainer = ({children}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const _isMuted = (member, powerLevelContent) => {
|
||||
interface IPowerLevelsContent {
|
||||
events?: Record<string, number>;
|
||||
// eslint-disable-next-line camelcase
|
||||
users_default?: number;
|
||||
// eslint-disable-next-line camelcase
|
||||
events_default?: number;
|
||||
// eslint-disable-next-line camelcase
|
||||
state_default?: number;
|
||||
ban?: number;
|
||||
kick?: number;
|
||||
redact?: number;
|
||||
}
|
||||
|
||||
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => {
|
||||
if (!powerLevelContent || !member) return false;
|
||||
|
||||
const levelToSend = (
|
||||
@ -451,8 +490,8 @@ const _isMuted = (member, powerLevelContent) => {
|
||||
return member.powerLevel < levelToSend;
|
||||
};
|
||||
|
||||
export const useRoomPowerLevels = (cli, room) => {
|
||||
const [powerLevels, setPowerLevels] = useState({});
|
||||
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
|
||||
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!room) {
|
||||
@ -479,14 +518,19 @@ export const useRoomPowerLevels = (cli, room) => {
|
||||
return powerLevels;
|
||||
};
|
||||
|
||||
const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
|
||||
interface IBaseProps {
|
||||
member: RoomMember;
|
||||
startUpdating(): void;
|
||||
stopUpdating(): void;
|
||||
}
|
||||
|
||||
const RoomKickButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
// check if user can be kicked/disinvited
|
||||
if (member.membership !== "invite" && member.membership !== "join") return null;
|
||||
|
||||
const onKick = async () => {
|
||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
||||
const {finished} = Modal.createTrackedDialog(
|
||||
'Confirm User Action Dialog',
|
||||
'onKick',
|
||||
@ -509,7 +553,6 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
|
||||
// get out of sync if we force setState here!
|
||||
console.log("Kick success");
|
||||
}, function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Kick error: " + err);
|
||||
Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, {
|
||||
title: _t("Failed to kick"),
|
||||
@ -526,7 +569,7 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const RedactMessagesButton = ({member}) => {
|
||||
const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onRedactAllMessages = async () => {
|
||||
@ -554,7 +597,6 @@ const RedactMessagesButton = ({member}) => {
|
||||
const user = member.name;
|
||||
|
||||
if (count === 0) {
|
||||
const InfoDialog = sdk.getComponent("dialogs.InfoDialog");
|
||||
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
|
||||
title: _t("No recent messages by %(user)s found", {user}),
|
||||
description:
|
||||
@ -563,14 +605,14 @@ const RedactMessagesButton = ({member}) => {
|
||||
</div>,
|
||||
});
|
||||
} else {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
|
||||
title: _t("Remove recent messages by %(user)s", {user}),
|
||||
description:
|
||||
<div>
|
||||
<p>{ _t("You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?", {count, user}) }</p>
|
||||
<p>{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }</p>
|
||||
<p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
|
||||
"This cannot be undone. Do you wish to continue?", {count, user}) }</p>
|
||||
<p>{ _t("For a large amount of messages, this might take some time. " +
|
||||
"Please don't refresh your client in the meantime.") }</p>
|
||||
</div>,
|
||||
button: _t("Remove %(count)s messages", {count}),
|
||||
});
|
||||
@ -603,11 +645,10 @@ const RedactMessagesButton = ({member}) => {
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
||||
const BanToggleButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onBanOrUnban = async () => {
|
||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
||||
const {finished} = Modal.createTrackedDialog(
|
||||
'Confirm User Action Dialog',
|
||||
'onBanOrUnban',
|
||||
@ -636,7 +677,6 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
||||
// get out of sync if we force setState here!
|
||||
console.log("Ban success");
|
||||
}, function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Ban error: " + err);
|
||||
Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
@ -661,22 +701,26 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdating}) => {
|
||||
interface IBaseRoomProps extends IBaseProps {
|
||||
room: Room;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}
|
||||
|
||||
const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels, startUpdating, stopUpdating}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
// Don't show the mute/unmute option if the user is not in the room
|
||||
if (member.membership !== "join") return null;
|
||||
|
||||
const isMuted = _isMuted(member, powerLevels);
|
||||
const muted = isMuted(member, powerLevels);
|
||||
const onMuteToggle = async () => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
const roomId = member.roomId;
|
||||
const target = member.userId;
|
||||
|
||||
// if muting self, warn as it may be irreversible
|
||||
if (target === cli.getUserId()) {
|
||||
try {
|
||||
if (!(await _warnSelfDemote())) return;
|
||||
if (!(await warnSelfDemote())) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
return;
|
||||
@ -692,7 +736,7 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
|
||||
powerLevels.events_default
|
||||
);
|
||||
let level;
|
||||
if (isMuted) { // unmute
|
||||
if (muted) { // unmute
|
||||
level = levelToSend;
|
||||
} else { // mute
|
||||
level = levelToSend - 1;
|
||||
@ -718,16 +762,23 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
|
||||
};
|
||||
|
||||
const classes = classNames("mx_UserInfo_field", {
|
||||
mx_UserInfo_destructive: !isMuted,
|
||||
mx_UserInfo_destructive: !muted,
|
||||
});
|
||||
|
||||
const muteLabel = isMuted ? _t("Unmute") : _t("Mute");
|
||||
const muteLabel = muted ? _t("Unmute") : _t("Mute");
|
||||
return <AccessibleButton className={classes} onClick={onMuteToggle}>
|
||||
{ muteLabel }
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpdating, powerLevels}) => {
|
||||
const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||
room,
|
||||
children,
|
||||
member,
|
||||
startUpdating,
|
||||
stopUpdating,
|
||||
powerLevels,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
let kickButton;
|
||||
let banButton;
|
||||
@ -786,7 +837,18 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd
|
||||
return <div />;
|
||||
};
|
||||
|
||||
const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, stopUpdating}) => {
|
||||
interface GroupMember {
|
||||
userId: string;
|
||||
displayname?: string; // XXX: GroupMember objects are inconsistent :((
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
const GroupAdminToolsSection: React.FC<{
|
||||
groupId: string;
|
||||
groupMember: GroupMember;
|
||||
startUpdating(): void;
|
||||
stopUpdating(): void;
|
||||
}> = ({children, groupId, groupMember, startUpdating, stopUpdating}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [isPrivileged, setIsPrivileged] = useState(false);
|
||||
@ -814,8 +876,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||
}, [groupId, groupMember.userId]);
|
||||
|
||||
if (isPrivileged) {
|
||||
const _onKick = async () => {
|
||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
||||
const onKick = async () => {
|
||||
const {finished} = Modal.createDialog(ConfirmUserActionDialog, {
|
||||
matrixClient: cli,
|
||||
groupMember,
|
||||
@ -836,7 +897,6 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||
member: null,
|
||||
});
|
||||
}).catch((e) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: isInvited ?
|
||||
@ -850,7 +910,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||
};
|
||||
|
||||
const kickButton = (
|
||||
<AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={_onKick}>
|
||||
<AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}>
|
||||
{ isInvited ? _t('Disinvite') : _t('Remove from community') }
|
||||
</AccessibleButton>
|
||||
);
|
||||
@ -870,13 +930,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||
return <div />;
|
||||
};
|
||||
|
||||
const GroupMember = PropTypes.shape({
|
||||
userId: PropTypes.string.isRequired,
|
||||
displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :((
|
||||
avatarUrl: PropTypes.string,
|
||||
});
|
||||
|
||||
const useIsSynapseAdmin = (cli) => {
|
||||
const useIsSynapseAdmin = (cli: MatrixClient) => {
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
useEffect(() => {
|
||||
cli.isSynapseAdministrator().then((isAdmin) => {
|
||||
@ -888,14 +942,20 @@ const useIsSynapseAdmin = (cli) => {
|
||||
return isAdmin;
|
||||
};
|
||||
|
||||
const useHomeserverSupportsCrossSigning = (cli) => {
|
||||
return useAsyncMemo(async () => {
|
||||
const useHomeserverSupportsCrossSigning = (cli: MatrixClient) => {
|
||||
return useAsyncMemo<boolean>(async () => {
|
||||
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||
}, [cli], false);
|
||||
};
|
||||
|
||||
function useRoomPermissions(cli, room, user) {
|
||||
const [roomPermissions, setRoomPermissions] = useState({
|
||||
interface IRoomPermissions {
|
||||
modifyLevelMax: number;
|
||||
canEdit: boolean;
|
||||
canInvite: boolean;
|
||||
}
|
||||
|
||||
function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPermissions {
|
||||
const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
|
||||
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
|
||||
modifyLevelMax: -1,
|
||||
canEdit: false,
|
||||
@ -940,7 +1000,7 @@ function useRoomPermissions(cli, room, user) {
|
||||
updateRoomPermissions();
|
||||
return () => {
|
||||
setRoomPermissions({
|
||||
maximalPowerLevel: -1,
|
||||
modifyLevelMax: -1,
|
||||
canEdit: false,
|
||||
canInvite: false,
|
||||
});
|
||||
@ -950,14 +1010,18 @@ function useRoomPermissions(cli, room, user) {
|
||||
return roomPermissions;
|
||||
}
|
||||
|
||||
const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => {
|
||||
const PowerLevelSection: React.FC<{
|
||||
user: User;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}> = ({user, room, roomPermissions, powerLevels}) => {
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
if (isEditing) {
|
||||
return (<PowerLevelEditor
|
||||
user={user} room={room} roomPermissions={roomPermissions}
|
||||
onFinished={() => setEditing(false)} />);
|
||||
} else {
|
||||
const IconButton = sdk.getComponent('elements.IconButton');
|
||||
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
||||
const powerLevel = parseInt(user.powerLevel, 10);
|
||||
const modifyButton = roomPermissions.canEdit ?
|
||||
@ -975,7 +1039,12 @@ const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
const PowerLevelEditor: React.FC<{
|
||||
user: User;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
onFinished(): void;
|
||||
}> = ({user, room, roomPermissions, onFinished}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
@ -994,7 +1063,6 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
// get out of sync if we force setState here!
|
||||
console.log("Power change success");
|
||||
}, function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to change power level " + err);
|
||||
Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
@ -1025,12 +1093,10 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
}
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
if (myUserId === target) {
|
||||
try {
|
||||
if (!(await _warnSelfDemote())) return;
|
||||
if (!(await warnSelfDemote())) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
@ -1039,7 +1105,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
}
|
||||
|
||||
const myPower = powerLevelEvent.getContent().users[myUserId];
|
||||
if (parseInt(myPower) === parseInt(powerLevel)) {
|
||||
if (parseInt(myPower) === powerLevel) {
|
||||
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
@ -1062,12 +1128,9 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||
const IconButton = sdk.getComponent('elements.IconButton');
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
|
||||
<IconButton icon="check" onClick={changePowerLevel} />;
|
||||
|
||||
const PowerSelector = sdk.getComponent('elements.PowerSelector');
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<PowerSelector
|
||||
@ -1083,7 +1146,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useDevices = (userId) => {
|
||||
export const useDevices = (userId: string) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
|
||||
@ -1094,7 +1157,7 @@ export const useDevices = (userId) => {
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function _downloadDeviceList() {
|
||||
async function downloadDeviceList() {
|
||||
try {
|
||||
await cli.downloadKeys([userId], true);
|
||||
const devices = cli.getStoredDevicesForUser(userId);
|
||||
@ -1104,13 +1167,13 @@ export const useDevices = (userId) => {
|
||||
return;
|
||||
}
|
||||
|
||||
_disambiguateDevices(devices);
|
||||
disambiguateDevices(devices);
|
||||
setDevices(devices);
|
||||
} catch (err) {
|
||||
setDevices(null);
|
||||
}
|
||||
}
|
||||
_downloadDeviceList();
|
||||
downloadDeviceList();
|
||||
|
||||
// Handle being unmounted
|
||||
return () => {
|
||||
@ -1153,7 +1216,13 @@ export const useDevices = (userId) => {
|
||||
return devices;
|
||||
};
|
||||
|
||||
const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
const BasicUserInfo: React.FC<{
|
||||
room: Room;
|
||||
member: User | RoomMember;
|
||||
groupId: string;
|
||||
devices: IDevice[];
|
||||
isRoomEncrypted: boolean;
|
||||
}> = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const powerLevels = useRoomPowerLevels(cli, room);
|
||||
@ -1186,7 +1255,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
const roomPermissions = useRoomPermissions(cli, room, member);
|
||||
|
||||
const onSynapseDeactivate = useCallback(async () => {
|
||||
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
|
||||
const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
|
||||
title: _t("Deactivate user?"),
|
||||
description:
|
||||
@ -1207,7 +1275,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
console.error("Failed to deactivate user");
|
||||
console.error(err);
|
||||
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, {
|
||||
title: _t('Failed to deactivate user'),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
@ -1260,8 +1327,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
}
|
||||
|
||||
if (pendingUpdateCount > 0) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />;
|
||||
spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
|
||||
}
|
||||
|
||||
let memberDetails;
|
||||
@ -1324,7 +1390,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
// HACK: only show a spinner if the device section spinner is not shown,
|
||||
// to avoid showing a double spinner
|
||||
// We should ask for a design that includes all the different loading states here
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
verifyButton = <Spinner />;
|
||||
}
|
||||
}
|
||||
@ -1351,7 +1416,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
|
||||
{ securitySection }
|
||||
<UserOptionsSection
|
||||
devices={devices}
|
||||
canInvite={roomPermissions.canInvite}
|
||||
isIgnored={isIgnored}
|
||||
member={member} />
|
||||
@ -1362,7 +1426,12 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const UserInfoHeader = ({member, e2eStatus}) => {
|
||||
type Member = User | RoomMember | GroupMember;
|
||||
|
||||
const UserInfoHeader: React.FC<{
|
||||
member: Member;
|
||||
e2eStatus: E2EStatus;
|
||||
}> = ({member, e2eStatus}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onMemberAvatarClick = useCallback(() => {
|
||||
@ -1370,7 +1439,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||
if (!avatarUrl) return;
|
||||
|
||||
const httpUrl = cli.mxcUrlToHttp(avatarUrl);
|
||||
const ImageView = sdk.getComponent("elements.ImageView");
|
||||
const params = {
|
||||
src: httpUrl,
|
||||
name: member.name,
|
||||
@ -1379,7 +1447,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
}, [cli, member]);
|
||||
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const avatarElement = (
|
||||
<div className="mx_UserInfo_avatar">
|
||||
<div>
|
||||
@ -1421,10 +1488,13 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||
|
||||
let presenceLabel = null;
|
||||
if (showPresence) {
|
||||
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
|
||||
presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo}
|
||||
currentlyActive={presenceCurrentlyActive}
|
||||
presenceState={presenceState} />;
|
||||
presenceLabel = (
|
||||
<PresenceLabel
|
||||
activeAgo={presenceLastActiveAgo}
|
||||
currentlyActive={presenceCurrentlyActive}
|
||||
presenceState={presenceState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let statusLabel = null;
|
||||
@ -1461,7 +1531,32 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => {
|
||||
interface IProps {
|
||||
user: Member;
|
||||
groupId?: string;
|
||||
room?: Room;
|
||||
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> {
|
||||
user: Member;
|
||||
groupId: void;
|
||||
room: Room;
|
||||
phase: RightPanelPhases.EncryptionPanel;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
type Props = IProps | IPropsWithEncryptionPanel;
|
||||
|
||||
const UserInfo: React.FC<Props> = ({
|
||||
user,
|
||||
groupId,
|
||||
room,
|
||||
onClose,
|
||||
phase = RightPanelPhases.RoomMemberInfo,
|
||||
...props
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
// fetch latest room member if we have a room, so we don't show historical information, falling back to user
|
||||
@ -1485,7 +1580,7 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
|
||||
<BasicUserInfo
|
||||
room={room}
|
||||
member={member}
|
||||
groupId={groupId}
|
||||
groupId={groupId as string}
|
||||
devices={devices}
|
||||
isRoomEncrypted={isRoomEncrypted} />
|
||||
);
|
||||
@ -1493,7 +1588,12 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
|
||||
case RightPanelPhases.EncryptionPanel:
|
||||
classes.push("mx_UserInfo_smallAvatar");
|
||||
content = (
|
||||
<EncryptionPanel {...props} member={member} onClose={onClose} isRoomEncrypted={isRoomEncrypted} />
|
||||
<EncryptionPanel
|
||||
{...props as React.ComponentProps<typeof EncryptionPanel>}
|
||||
member={member}
|
||||
onClose={onClose}
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
@ -1504,23 +1604,24 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
|
||||
previousPhase = RightPanelPhases.RoomMemberList;
|
||||
}
|
||||
|
||||
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />;
|
||||
return <BaseCard className={classes.join(" ")} header={header} onClose={onClose} previousPhase={previousPhase}>
|
||||
let closeLabel = undefined;
|
||||
if (phase === RightPanelPhases.EncryptionPanel) {
|
||||
const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
|
||||
if (verificationRequest && verificationRequest.pending) {
|
||||
closeLabel = _t("Cancel");
|
||||
}
|
||||
}
|
||||
|
||||
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />;
|
||||
return <BaseCard
|
||||
className={classes.join(" ")}
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
closeLabel={closeLabel}
|
||||
previousPhase={previousPhase}
|
||||
>
|
||||
{ content }
|
||||
</BaseCard>;
|
||||
};
|
||||
|
||||
UserInfo.propTypes = {
|
||||
user: PropTypes.oneOfType([
|
||||
PropTypes.instanceOf(User),
|
||||
PropTypes.instanceOf(RoomMember),
|
||||
GroupMember,
|
||||
]).isRequired,
|
||||
group: PropTypes.instanceOf(Group),
|
||||
groupId: PropTypes.string,
|
||||
room: PropTypes.instanceOf(Room),
|
||||
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default UserInfo;
|
@ -29,16 +29,17 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
|
||||
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
|
||||
import {Capability} from "../../../widgets/WidgetApi";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import { MatrixCapabilities } from "matrix-widget-api";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
@ -77,9 +78,17 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
let snapshotButton;
|
||||
if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) {
|
||||
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
|
||||
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
|
||||
const onSnapshotClick = () => {
|
||||
WidgetUtils.snapshotWidget(app);
|
||||
widgetMessaging.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
|
@ -121,8 +121,8 @@ export default class MemberList extends React.Component {
|
||||
this.setState(this._getMembersState(this.roomMembers()));
|
||||
this._listenForMembersChanges();
|
||||
}
|
||||
} else if (membership === "invite") {
|
||||
// show the members we've got when invited
|
||||
} else {
|
||||
// show the members we already have loaded
|
||||
this.setState(this._getMembersState(this.roomMembers()));
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,6 @@ import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import PersistedElement from "../elements/PersistedElement";
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@ -30,6 +29,7 @@ import {ContextMenu} from "../../structures/ContextMenu";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
|
||||
|
||||
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
|
||||
// We sit in a context menu, so this should be given to the context menu.
|
||||
@ -212,9 +212,11 @@ export default class Stickerpicker extends React.Component {
|
||||
|
||||
_sendVisibilityToWidget(visible) {
|
||||
if (!this.state.stickerpickerWidget) return;
|
||||
const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id);
|
||||
if (widgetMessaging && visible !== this._prevSentVisibility) {
|
||||
widgetMessaging.sendVisibility(visible);
|
||||
const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
|
||||
if (messaging && visible !== this._prevSentVisibility) {
|
||||
messaging.updateVisibility(visible).catch(err => {
|
||||
console.error("Error updating widget visibility: ", err);
|
||||
});
|
||||
this._prevSentVisibility = visible;
|
||||
}
|
||||
}
|
||||
|
@ -18,9 +18,9 @@ import React, {createRef} from 'react';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import Field from "../elements/Field";
|
||||
import {User} from "matrix-js-sdk";
|
||||
import { getHostingLink } from '../../../utils/HostingLink';
|
||||
import * as sdk from "../../../index";
|
||||
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
|
||||
@ -29,21 +29,12 @@ export default class ProfileSettings extends React.Component {
|
||||
super();
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
let user = client.getUser(client.getUserId());
|
||||
if (!user) {
|
||||
// XXX: We shouldn't have to do this.
|
||||
// There seems to be a condition where the User object won't exist until a room
|
||||
// exists on the account. To work around this, we'll just create a temporary User
|
||||
// and use that.
|
||||
console.warn("User object not found - creating one for ProfileSettings");
|
||||
user = new User(client.getUserId());
|
||||
}
|
||||
let avatarUrl = user.avatarUrl;
|
||||
let avatarUrl = OwnProfileStore.instance.avatarMxc;
|
||||
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
|
||||
this.state = {
|
||||
userId: user.userId,
|
||||
originalDisplayName: user.rawDisplayName,
|
||||
displayName: user.rawDisplayName,
|
||||
userId: client.getUserId(),
|
||||
originalDisplayName: OwnProfileStore.instance.displayName,
|
||||
displayName: OwnProfileStore.instance.displayName,
|
||||
originalAvatarUrl: avatarUrl,
|
||||
avatarUrl: avatarUrl,
|
||||
avatarFile: null,
|
||||
|
@ -239,7 +239,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||
defaultValue: 50,
|
||||
},
|
||||
"redact": {
|
||||
desc: _t('Remove messages'),
|
||||
desc: _t('Remove messages sent by others'),
|
||||
defaultValue: 50,
|
||||
},
|
||||
"notifications.room": {
|
||||
|
@ -275,12 +275,17 @@ export async function _waitForMember(client: MatrixClient, roomId: string, userI
|
||||
* can encrypt to.
|
||||
*/
|
||||
export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
|
||||
const usersDeviceMap = await client.downloadKeys(userIds);
|
||||
// { "@user:host": { "DEVICE": {...}, ... }, ... }
|
||||
return Object.values(usersDeviceMap).every((userDevices) =>
|
||||
// { "DEVICE": {...}, ... }
|
||||
Object.keys(userDevices).length > 0,
|
||||
);
|
||||
try {
|
||||
const usersDeviceMap = await client.downloadKeys(userIds);
|
||||
// { "@user:host": { "DEVICE": {...}, ... }, ... }
|
||||
return Object.values(usersDeviceMap).every((userDevices) =>
|
||||
// { "DEVICE": {...}, ... }
|
||||
Object.keys(userDevices).length > 0,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error determining if it's possible to encrypt to all users: ", e);
|
||||
return false; // assume not
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
|
||||
@ -289,9 +294,9 @@ export async function ensureDMExists(client: MatrixClient, userId: string): Prom
|
||||
if (existingDMRoom) {
|
||||
roomId = existingDMRoom.roomId;
|
||||
} else {
|
||||
let encryption;
|
||||
let encryption: boolean = undefined;
|
||||
if (privateShouldBeEncrypted()) {
|
||||
encryption = canEncryptToAllUsers(client, [userId]);
|
||||
encryption = await canEncryptToAllUsers(client, [userId]);
|
||||
}
|
||||
roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false});
|
||||
await _waitForMember(client, roomId, userId);
|
||||
|
@ -18,8 +18,8 @@ import {useState, useEffect, DependencyList} from 'react';
|
||||
|
||||
type Fn<T> = () => Promise<T>;
|
||||
|
||||
export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T): T => {
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
useEffect(() => {
|
||||
fn().then(setValue);
|
||||
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {useState} from "react";
|
||||
import {Dispatch, SetStateAction, useState} from "react";
|
||||
|
||||
// Hook to simplify toggling of a boolean state value
|
||||
// Returns value, method to toggle boolean value and method to set the boolean value
|
||||
export const useStateToggle = (initialValue: boolean) => {
|
||||
export const useStateToggle = (initialValue: boolean): [boolean, () => void, Dispatch<SetStateAction<boolean>>] => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const toggleValue = () => {
|
||||
setValue(!value);
|
||||
|
@ -452,6 +452,7 @@
|
||||
"Support adding custom themes": "Support adding custom themes",
|
||||
"Show message previews for reactions in DMs": "Show message previews for reactions in DMs",
|
||||
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
|
||||
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
|
||||
"Enable advanced debugging for the room list": "Enable advanced debugging for the room list",
|
||||
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||
"Font size": "Font size",
|
||||
@ -969,7 +970,7 @@
|
||||
"Change settings": "Change settings",
|
||||
"Kick users": "Kick users",
|
||||
"Ban users": "Ban users",
|
||||
"Remove messages": "Remove messages",
|
||||
"Remove messages sent by others": "Remove messages sent by others",
|
||||
"Notify everyone": "Notify everyone",
|
||||
"No users have specific privileges in this room": "No users have specific privileges in this room",
|
||||
"Privileged Users": "Privileged Users",
|
||||
@ -1378,6 +1379,7 @@
|
||||
"View Source": "View Source",
|
||||
"Encryption enabled": "Encryption enabled",
|
||||
"Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.": "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.",
|
||||
"Ignored attempt to disable encryption": "Ignored attempt to disable encryption",
|
||||
"Encryption not enabled": "Encryption not enabled",
|
||||
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
|
||||
"Error decrypting audio": "Error decrypting audio",
|
||||
|
@ -186,6 +186,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_dehydration": {
|
||||
isFeature: true,
|
||||
displayName: _td("Offline encrypted messaging using dehydrated devices"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"advancedRoomListLogging": {
|
||||
// TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
|
||||
displayName: _td("Enable advanced debugging for the room list"),
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||
import EventEmitter from 'events';
|
||||
|
||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||
import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore";
|
||||
|
||||
/**
|
||||
* Stores information about the widgets active in the app right now:
|
||||
@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||
super();
|
||||
this._persistentWidgetId = null;
|
||||
|
||||
// A list of negotiated capabilities for each widget, by ID
|
||||
// {
|
||||
// widgetId: [caps...],
|
||||
// }
|
||||
this._capsByWidgetId = {};
|
||||
|
||||
// A WidgetMessaging instance for each widget ID
|
||||
this._widgetMessagingByWidgetId = {};
|
||||
|
||||
// What room ID each widget is associated with (if it's a room widget)
|
||||
this._roomIdByWidgetId = {};
|
||||
|
||||
@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
|
||||
}
|
||||
this._capsByWidgetId = {};
|
||||
this._widgetMessagingByWidgetId = {};
|
||||
this._roomIdByWidgetId = {};
|
||||
}
|
||||
|
||||
@ -76,9 +66,9 @@ class ActiveWidgetStore extends EventEmitter {
|
||||
if (id !== this._persistentWidgetId) return;
|
||||
const toDeleteId = this._persistentWidgetId;
|
||||
|
||||
WidgetMessagingStore.instance.stopMessagingById(id);
|
||||
|
||||
this.setWidgetPersistence(toDeleteId, false);
|
||||
this.delWidgetMessaging(toDeleteId);
|
||||
this.delWidgetCapabilities(toDeleteId);
|
||||
this.delRoomId(toDeleteId);
|
||||
}
|
||||
|
||||
@ -99,43 +89,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||
return this._persistentWidgetId;
|
||||
}
|
||||
|
||||
setWidgetCapabilities(widgetId, caps) {
|
||||
this._capsByWidgetId[widgetId] = caps;
|
||||
this.emit('update');
|
||||
}
|
||||
|
||||
widgetHasCapability(widgetId, cap) {
|
||||
return this._capsByWidgetId[widgetId] && this._capsByWidgetId[widgetId].includes(cap);
|
||||
}
|
||||
|
||||
delWidgetCapabilities(widgetId) {
|
||||
delete this._capsByWidgetId[widgetId];
|
||||
this.emit('update');
|
||||
}
|
||||
|
||||
setWidgetMessaging(widgetId, wm) {
|
||||
// Stop any existing widget messaging first
|
||||
this.delWidgetMessaging(widgetId);
|
||||
this._widgetMessagingByWidgetId[widgetId] = wm;
|
||||
this.emit('update');
|
||||
}
|
||||
|
||||
getWidgetMessaging(widgetId) {
|
||||
return this._widgetMessagingByWidgetId[widgetId];
|
||||
}
|
||||
|
||||
delWidgetMessaging(widgetId) {
|
||||
if (this._widgetMessagingByWidgetId[widgetId]) {
|
||||
try {
|
||||
this._widgetMessagingByWidgetId[widgetId].stop();
|
||||
} catch (e) {
|
||||
console.error('Failed to stop listening for widgetMessaging events', e.message);
|
||||
}
|
||||
delete this._widgetMessagingByWidgetId[widgetId];
|
||||
this.emit('update');
|
||||
}
|
||||
}
|
||||
|
||||
getRoomId(widgetId) {
|
||||
return this._roomIdByWidgetId[widgetId];
|
||||
}
|
||||
|
@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
|
||||
/**
|
||||
* Gets the user's avatar as an HTTP URL of the given size. If the user's
|
||||
* avatar is not present, this returns null.
|
||||
* @param size The size of the avatar
|
||||
* @param size The size of the avatar. If zero, a full res copy of the avatar
|
||||
* will be returned as an HTTP URL.
|
||||
* @returns The HTTP URL of the user's avatar
|
||||
*/
|
||||
public getHttpAvatarUrl(size: number): string {
|
||||
public getHttpAvatarUrl(size = 0): string {
|
||||
if (!this.avatarMxc) return null;
|
||||
return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size);
|
||||
const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through
|
||||
return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize);
|
||||
}
|
||||
|
||||
protected async onNotReady() {
|
||||
|
@ -119,6 +119,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
|
||||
}
|
||||
|
||||
private loadRoomWidgets(room: Room) {
|
||||
if (!room) return;
|
||||
const roomInfo = this.roomMap.get(room.roomId);
|
||||
roomInfo.widgets = [];
|
||||
this.generateApps(room).forEach(app => {
|
||||
|
21
src/stores/widgets/ElementWidgetActions.ts
Normal file
21
src/stores/widgets/ElementWidgetActions.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 enum ElementWidgetActions {
|
||||
ClientReady = "im.vector.ready",
|
||||
HangupCall = "im.vector.hangup",
|
||||
OpenIntegrationManager = "integration_manager_open",
|
||||
}
|
266
src/stores/widgets/StopGapWidget.ts
Normal file
266
src/stores/widgets/StopGapWidget.ts
Normal file
@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
IStickerActionRequest,
|
||||
IStickyActionRequest,
|
||||
IWidget,
|
||||
IWidgetApiRequest,
|
||||
IWidgetApiRequestEmptyData,
|
||||
IWidgetData,
|
||||
MatrixCapabilities,
|
||||
Widget,
|
||||
WidgetApiFromWidgetAction,
|
||||
} from "matrix-widget-api";
|
||||
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
|
||||
import { EventEmitter } from "events";
|
||||
import { WidgetMessagingStore } from "./WidgetMessagingStore";
|
||||
import RoomViewStore from "../RoomViewStore";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { OwnProfileStore } from "../OwnProfileStore";
|
||||
import WidgetUtils from '../../utils/WidgetUtils';
|
||||
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { WidgetType } from "../../widgets/WidgetType";
|
||||
import ActiveWidgetStore from "../ActiveWidgetStore";
|
||||
import { objectShallowClone } from "../../utils/objects";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ElementWidgetActions } from "./ElementWidgetActions";
|
||||
|
||||
// TODO: Destroy all of this code
|
||||
|
||||
interface IAppTileProps {
|
||||
// Note: these are only the props we care about
|
||||
|
||||
app: IWidget;
|
||||
room: Room;
|
||||
userId: string;
|
||||
creatorUserId: string;
|
||||
waitForIframeLoad: boolean;
|
||||
whitelistCapabilities: string[];
|
||||
userWidget: boolean;
|
||||
}
|
||||
|
||||
// TODO: Don't use this because it's wrong
|
||||
class ElementWidget extends Widget {
|
||||
constructor(w) {
|
||||
super(w);
|
||||
}
|
||||
|
||||
public get templateUrl(): string {
|
||||
if (WidgetType.JITSI.matches(this.type)) {
|
||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: true,
|
||||
auth: this.rawData?.auth,
|
||||
});
|
||||
}
|
||||
return super.templateUrl;
|
||||
}
|
||||
|
||||
public get rawData(): IWidgetData {
|
||||
let conferenceId = super.rawData['conferenceId'];
|
||||
if (conferenceId === undefined) {
|
||||
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
|
||||
const parsedUrl = new URL(this.templateUrl);
|
||||
conferenceId = parsedUrl.searchParams.get("confId");
|
||||
}
|
||||
return {
|
||||
...super.rawData,
|
||||
theme: SettingsStore.getValue("theme"),
|
||||
conferenceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class StopGapWidget extends EventEmitter {
|
||||
private messaging: ClientWidgetApi;
|
||||
private mockWidget: Widget;
|
||||
private scalarToken: string;
|
||||
|
||||
constructor(private appTileProps: IAppTileProps) {
|
||||
super();
|
||||
let app = appTileProps.app;
|
||||
|
||||
// Backwards compatibility: not all old widgets have a creatorUserId
|
||||
if (!app.creatorUserId) {
|
||||
app = objectShallowClone(app); // clone to prevent accidental mutation
|
||||
app.creatorUserId = MatrixClientPeg.get().getUserId();
|
||||
}
|
||||
|
||||
this.mockWidget = new ElementWidget(app);
|
||||
}
|
||||
|
||||
public get widgetApi(): ClientWidgetApi {
|
||||
return this.messaging;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to use in the iframe
|
||||
*/
|
||||
public get embedUrl(): string {
|
||||
const templated = this.mockWidget.getCompleteUrl({
|
||||
currentRoomId: RoomViewStore.getRoomId(),
|
||||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
});
|
||||
|
||||
// Add in some legacy support sprinkles
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
const parsed = new URL(templated);
|
||||
parsed.searchParams.set('widgetId', this.mockWidget.id);
|
||||
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
|
||||
|
||||
// Give the widget a scalar token if we're supposed to (more legacy)
|
||||
// TODO: Stop doing this
|
||||
if (this.scalarToken) {
|
||||
parsed.searchParams.set('scalar_token', this.scalarToken);
|
||||
}
|
||||
|
||||
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
|
||||
// in HTTP, but URL parsers encode them anyways.
|
||||
return parsed.toString().replace(/%24/g, '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to use in the popout
|
||||
*/
|
||||
public get popoutUrl(): string {
|
||||
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
|
||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: false,
|
||||
auth: this.mockWidget.rawData?.auth,
|
||||
});
|
||||
}
|
||||
return this.embedUrl;
|
||||
}
|
||||
|
||||
public get isManagedByManager(): boolean {
|
||||
return !!this.scalarToken;
|
||||
}
|
||||
|
||||
public get started(): boolean {
|
||||
return !!this.messaging;
|
||||
}
|
||||
|
||||
public start(iframe: HTMLIFrameElement) {
|
||||
if (this.started) return;
|
||||
const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []);
|
||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||
this.messaging.addEventListener("ready", () => this.emit("ready"));
|
||||
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);
|
||||
|
||||
if (!this.appTileProps.userWidget && this.appTileProps.room) {
|
||||
ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId);
|
||||
}
|
||||
|
||||
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
|
||||
this.messaging.addEventListener("action:set_always_on_screen",
|
||||
(ev: CustomEvent<IStickyActionRequest>) => {
|
||||
if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||
ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
|
||||
ev.preventDefault();
|
||||
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
|
||||
this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`,
|
||||
(ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
// Acknowledge first
|
||||
ev.preventDefault();
|
||||
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
|
||||
|
||||
// First close the stickerpicker
|
||||
defaultDispatcher.dispatch({action: "stickerpicker_close"});
|
||||
|
||||
// Now open the integration manager
|
||||
// TODO: Spec this interaction.
|
||||
const data = ev.detail.data;
|
||||
const integType = data?.integType
|
||||
const integId = <string>data?.integId;
|
||||
|
||||
// TODO: Open the right integration manager for the widget
|
||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
} else {
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(
|
||||
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
|
||||
`type_${integType}`,
|
||||
integId,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Replace this event listener with appropriate driver functionality once the API
|
||||
// establishes a sane way to send events back and forth.
|
||||
this.messaging.addEventListener(`action:${WidgetApiFromWidgetAction.SendSticker}`,
|
||||
(ev: CustomEvent<IStickerActionRequest>) => {
|
||||
// Acknowledge first
|
||||
ev.preventDefault();
|
||||
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
|
||||
|
||||
// Send the sticker
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'm.sticker',
|
||||
data: ev.detail.data,
|
||||
widgetId: this.mockWidget.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
if (this.scalarToken) return;
|
||||
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
|
||||
if (existingMessaging) this.messaging = existingMessaging;
|
||||
try {
|
||||
if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (managers.hasManager()) {
|
||||
// TODO: Pick the right manager for the widget
|
||||
const defaultManager = managers.getPrimaryManager();
|
||||
if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
|
||||
const scalar = defaultManager.getScalarClient();
|
||||
this.scalarToken = await scalar.getScalarToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// All errors are non-fatal
|
||||
console.error("Error preparing widget communications: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) {
|
||||
console.log("Skipping destroy - persistent widget");
|
||||
return;
|
||||
}
|
||||
if (!this.started) return;
|
||||
WidgetMessagingStore.instance.stopMessaging(this.mockWidget);
|
||||
ActiveWidgetStore.delRoomId(this.mockWidget.id);
|
||||
}
|
||||
}
|
30
src/stores/widgets/StopGapWidgetDriver.ts
Normal file
30
src/stores/widgets/StopGapWidgetDriver.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Capability, WidgetDriver } from "matrix-widget-api";
|
||||
import { iterableUnion } from "../../utils/iterables";
|
||||
|
||||
// TODO: Purge this from the universe
|
||||
|
||||
export class StopGapWidgetDriver extends WidgetDriver {
|
||||
constructor(private allowedCapabilities: Capability[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||
return new Set(iterableUnion(requested, this.allowedCapabilities));
|
||||
}
|
||||
}
|
82
src/stores/widgets/WidgetMessagingStore.ts
Normal file
82
src/stores/widgets/WidgetMessagingStore.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { ClientWidgetApi, Widget } from "matrix-widget-api";
|
||||
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { EnhancedMap } from "../../utils/maps";
|
||||
|
||||
/**
|
||||
* Temporary holding store for widget messaging instances. This is eventually
|
||||
* going to be merged with a more complete WidgetStore, but for now it's
|
||||
* easiest to split this into a single place.
|
||||
*/
|
||||
export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
|
||||
private static internalInstance = new WidgetMessagingStore();
|
||||
|
||||
// TODO: Fix uniqueness problem (widget IDs are not unique across the whole app)
|
||||
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi>
|
||||
|
||||
public constructor() {
|
||||
super(defaultDispatcher);
|
||||
}
|
||||
|
||||
public static get instance(): WidgetMessagingStore {
|
||||
return WidgetMessagingStore.internalInstance;
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload): Promise<any> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
protected async onReady(): Promise<any> {
|
||||
// just in case
|
||||
this.widgetMap.clear();
|
||||
}
|
||||
|
||||
public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) {
|
||||
this.stopMessaging(widget);
|
||||
this.widgetMap.set(widget.id, widgetApi);
|
||||
}
|
||||
|
||||
public stopMessaging(widget: Widget) {
|
||||
this.widgetMap.remove(widget.id)?.stop();
|
||||
}
|
||||
|
||||
public getMessaging(widget: Widget): ClientWidgetApi {
|
||||
return this.widgetMap.get(widget.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the widget messaging instance for a given widget ID.
|
||||
* @param {string} widgetId The widget ID.
|
||||
* @deprecated Widget IDs are not globally unique.
|
||||
*/
|
||||
public stopMessagingById(widgetId: string) {
|
||||
this.widgetMap.remove(widgetId)?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the widget messaging class for a given widget ID.
|
||||
* @param {string} widgetId The widget ID.
|
||||
* @returns {ClientWidgetApi} The widget API, or a falsey value if not found.
|
||||
* @deprecated Widget IDs are not globally unique.
|
||||
*/
|
||||
public getMessagingForId(widgetId: string): ClientWidgetApi {
|
||||
return this.widgetMap.get(widgetId);
|
||||
}
|
||||
}
|
@ -28,11 +28,11 @@ const WIDGET_WAIT_TIME = 20000;
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
||||
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
||||
import {Capability} from "../widgets/WidgetApi";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {WidgetType} from "../widgets/WidgetType";
|
||||
import {objectClone} from "./objects";
|
||||
import {_t} from "../languageHandler";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
|
||||
export default class WidgetUtils {
|
||||
/* Returns true if user is able to send state events to modify widgets in this room
|
||||
@ -416,15 +416,14 @@ export default class WidgetUtils {
|
||||
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
|
||||
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
|
||||
|
||||
const capWhitelist = enableScreenshots ? [Capability.Screenshot] : [];
|
||||
const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
|
||||
|
||||
// Obviously anyone that can add a widget can claim it's a jitsi widget,
|
||||
// so this doesn't really offer much over the set of domains we load
|
||||
// widgets from at all, but it probably makes sense for sanity.
|
||||
if (WidgetType.JITSI.matches(appType)) {
|
||||
capWhitelist.push(Capability.AlwaysOnScreen);
|
||||
capWhitelist.push(MatrixCapabilities.AlwaysOnScreen);
|
||||
}
|
||||
capWhitelist.push(Capability.ReceiveTerminate);
|
||||
|
||||
return capWhitelist;
|
||||
}
|
||||
@ -495,16 +494,4 @@ export default class WidgetUtils {
|
||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
|
||||
}
|
||||
}
|
||||
|
||||
static snapshotWidget(app) {
|
||||
console.log("Requesting widget snapshot");
|
||||
ActiveWidgetStore.getWidgetMessaging(app.id).getScreenshot().catch((err) => {
|
||||
console.error("Failed to get screenshot", err);
|
||||
}).then((screenshot) => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: screenshot,
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
21
src/utils/iterables.ts
Normal file
21
src/utils/iterables.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { arrayUnion } from "./arrays";
|
||||
|
||||
export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> {
|
||||
return arrayUnion(Array.from(a), Array.from(b));
|
||||
}
|
@ -44,3 +44,26 @@ export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] {
|
||||
const diff = mapDiff(a, b);
|
||||
return arrayMerge(diff.removed, diff.added, diff.changed);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Map<K, V> with added utility.
|
||||
*/
|
||||
export class EnhancedMap<K, V> extends Map<K, V> {
|
||||
public constructor(entries?: Iterable<[K, V]>) {
|
||||
super(entries);
|
||||
}
|
||||
|
||||
public getOrCreate(key: K, def: V): V {
|
||||
if (this.has(key)) {
|
||||
return this.get(key);
|
||||
}
|
||||
this.set(key, def);
|
||||
return def;
|
||||
}
|
||||
|
||||
public remove(key: K): V {
|
||||
const v = this.get(key);
|
||||
this.delete(key);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
@ -1,223 +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.
|
||||
*/
|
||||
|
||||
// Dev note: This is largely inspired by Dimension. Used with permission.
|
||||
// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
|
||||
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { EventEmitter } from "events";
|
||||
import { objectClone } from "../utils/objects";
|
||||
|
||||
export enum Capability {
|
||||
Screenshot = "m.capability.screenshot",
|
||||
Sticker = "m.sticker",
|
||||
AlwaysOnScreen = "m.always_on_screen",
|
||||
ReceiveTerminate = "im.vector.receive_terminate",
|
||||
}
|
||||
|
||||
export enum KnownWidgetActions {
|
||||
GetSupportedApiVersions = "supported_api_versions",
|
||||
TakeScreenshot = "screenshot",
|
||||
GetCapabilities = "capabilities",
|
||||
SendEvent = "send_event",
|
||||
UpdateVisibility = "visibility",
|
||||
GetOpenIDCredentials = "get_openid",
|
||||
ReceiveOpenIDCredentials = "openid_credentials",
|
||||
SetAlwaysOnScreen = "set_always_on_screen",
|
||||
ClientReady = "im.vector.ready",
|
||||
Terminate = "im.vector.terminate",
|
||||
Hangup = "im.vector.hangup",
|
||||
}
|
||||
|
||||
export type WidgetAction = KnownWidgetActions | string;
|
||||
|
||||
export enum WidgetApiType {
|
||||
ToWidget = "toWidget",
|
||||
FromWidget = "fromWidget",
|
||||
}
|
||||
|
||||
export interface WidgetRequest {
|
||||
api: WidgetApiType;
|
||||
widgetId: string;
|
||||
requestId: string;
|
||||
data: any;
|
||||
action: WidgetAction;
|
||||
}
|
||||
|
||||
export interface ToWidgetRequest extends WidgetRequest {
|
||||
api: WidgetApiType.ToWidget;
|
||||
}
|
||||
|
||||
export interface FromWidgetRequest extends WidgetRequest {
|
||||
api: WidgetApiType.FromWidget;
|
||||
response: any;
|
||||
}
|
||||
|
||||
export interface OpenIDCredentials {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
matrixServerName: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Element <--> Widget interactions for embedded/standalone widgets.
|
||||
*
|
||||
* Emitted events:
|
||||
* - terminate(wait): client requested the widget to terminate.
|
||||
* Call the argument 'wait(promise)' to postpone the finalization until
|
||||
* the given promise resolves.
|
||||
*/
|
||||
export class WidgetApi extends EventEmitter {
|
||||
private readonly origin: string;
|
||||
private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
|
||||
private readonly readyPromise: Promise<any>;
|
||||
private readyPromiseResolve: () => void;
|
||||
private openIDCredentialsCallback: () => void;
|
||||
public openIDCredentials: OpenIDCredentials;
|
||||
|
||||
/**
|
||||
* Set this to true if your widget is expecting a ready message from the client. False otherwise (default).
|
||||
*/
|
||||
public expectingExplicitReady = false;
|
||||
|
||||
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
|
||||
super();
|
||||
|
||||
this.origin = new URL(currentUrl).origin;
|
||||
|
||||
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
|
||||
|
||||
window.addEventListener("message", event => {
|
||||
if (event.origin !== this.origin) return; // ignore: invalid origin
|
||||
if (!event.data) return; // invalid schema
|
||||
if (event.data.widgetId !== this.widgetId) return; // not for us
|
||||
|
||||
const payload = <WidgetRequest>event.data;
|
||||
if (payload.api === WidgetApiType.ToWidget && payload.action) {
|
||||
console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);
|
||||
|
||||
if (payload.action === KnownWidgetActions.GetCapabilities) {
|
||||
this.onCapabilitiesRequest(<ToWidgetRequest>payload);
|
||||
if (!this.expectingExplicitReady) {
|
||||
this.readyPromiseResolve();
|
||||
}
|
||||
} else if (payload.action === KnownWidgetActions.ClientReady) {
|
||||
this.readyPromiseResolve();
|
||||
|
||||
// Automatically acknowledge so we can move on
|
||||
this.replyToRequest(<ToWidgetRequest>payload, {});
|
||||
} else if (payload.action === KnownWidgetActions.Terminate
|
||||
|| payload.action === KnownWidgetActions.Hangup) {
|
||||
// Finalization needs to be async, so postpone with a promise
|
||||
let finalizePromise = Promise.resolve();
|
||||
const wait = (promise) => {
|
||||
finalizePromise = finalizePromise.then(() => promise);
|
||||
};
|
||||
const emitName = payload.action === KnownWidgetActions.Terminate ? 'terminate' : 'hangup';
|
||||
this.emit(emitName, wait);
|
||||
Promise.resolve(finalizePromise).then(() => {
|
||||
// Acknowledge that we're shut down now
|
||||
this.replyToRequest(<ToWidgetRequest>payload, {});
|
||||
});
|
||||
} else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) {
|
||||
// Save OpenID credentials
|
||||
this.setOpenIDCredentials(<ToWidgetRequest>payload);
|
||||
this.replyToRequest(<ToWidgetRequest>payload, {});
|
||||
} else {
|
||||
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
|
||||
}
|
||||
} else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) {
|
||||
console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`);
|
||||
const handler = this.inFlightRequests[payload.requestId];
|
||||
delete this.inFlightRequests[payload.requestId];
|
||||
handler(<FromWidgetRequest>payload);
|
||||
} else {
|
||||
console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setOpenIDCredentials(value: WidgetRequest) {
|
||||
const data = value.data;
|
||||
if (data.state === 'allowed') {
|
||||
this.openIDCredentials = {
|
||||
accessToken: data.access_token,
|
||||
tokenType: data.token_type,
|
||||
matrixServerName: data.matrix_server_name,
|
||||
expiresIn: data.expires_in,
|
||||
}
|
||||
} else if (data.state === 'blocked') {
|
||||
this.openIDCredentials = null;
|
||||
}
|
||||
if (['allowed', 'blocked'].includes(data.state) && this.openIDCredentialsCallback) {
|
||||
this.openIDCredentialsCallback()
|
||||
}
|
||||
}
|
||||
|
||||
public requestOpenIDCredentials(credentialsResponseCallback: () => void) {
|
||||
this.openIDCredentialsCallback = credentialsResponseCallback;
|
||||
this.callAction(
|
||||
KnownWidgetActions.GetOpenIDCredentials,
|
||||
{},
|
||||
this.setOpenIDCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
public waitReady(): Promise<any> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
private replyToRequest(payload: ToWidgetRequest, reply: any) {
|
||||
if (!window.parent) return;
|
||||
|
||||
const request: ToWidgetRequest & {response?: any} = objectClone(payload);
|
||||
request.response = reply;
|
||||
|
||||
window.parent.postMessage(request, this.origin);
|
||||
}
|
||||
|
||||
private onCapabilitiesRequest(payload: ToWidgetRequest) {
|
||||
return this.replyToRequest(payload, {capabilities: this.requestedCapabilities});
|
||||
}
|
||||
|
||||
public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) {
|
||||
if (!window.parent) return;
|
||||
|
||||
const request: FromWidgetRequest = {
|
||||
api: WidgetApiType.FromWidget,
|
||||
widgetId: this.widgetId,
|
||||
action: action,
|
||||
requestId: randomString(160),
|
||||
data: payload,
|
||||
response: {}, // Not used at this layer - it's used when the client responds
|
||||
};
|
||||
|
||||
if (callback) {
|
||||
this.inFlightRequests[request.requestId] = callback;
|
||||
}
|
||||
|
||||
console.log(`[WidgetAPI] Sending request: `, request);
|
||||
window.parent.postMessage(request, "*");
|
||||
}
|
||||
|
||||
public setAlwaysOnScreen(onScreen: boolean): Promise<any> {
|
||||
return new Promise<any>(resolve => {
|
||||
this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, null);
|
||||
resolve(); // SetAlwaysOnScreen is currently fire-and-forget, but that could change.
|
||||
});
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// TODO: Move to matrix-widget-api
|
||||
export class WidgetType {
|
||||
public static readonly JITSI = new WidgetType("m.jitsi", "jitsi");
|
||||
public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker");
|
||||
|
@ -5953,6 +5953,11 @@ matrix-react-test-utils@^0.2.2:
|
||||
resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
|
||||
integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ==
|
||||
|
||||
matrix-widget-api@^0.1.0-beta.2:
|
||||
version "0.1.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.2.tgz#367da1ccd26b711f73fc5b6e02edf55ac2ea2692"
|
||||
integrity sha512-q5g5RZN+RRjM4HmcJ+LYoQAYrB1wzyERmoQ+LvKbTV/+9Ov36Kp0QEP8CleSXEd5WLp6bkRlt60axDaY6pWGmg==
|
||||
|
||||
mdast-util-compact@^1.0.0:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"
|
||||
|
Loading…
Reference in New Issue
Block a user