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
57 changed files with 1424 additions and 1747 deletions
|
@ -79,6 +79,7 @@
|
||||||
"linkifyjs": "^2.1.9",
|
"linkifyjs": "^2.1.9",
|
||||||
"lodash": "^4.17.19",
|
"lodash": "^4.17.19",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||||
|
"matrix-widget-api": "^0.1.0-beta.2",
|
||||||
"minimist": "^1.2.5",
|
"minimist": "^1.2.5",
|
||||||
"pako": "^1.0.11",
|
"pako": "^1.0.11",
|
||||||
"parse5": "^5.1.1",
|
"parse5": "^5.1.1",
|
||||||
|
|
|
@ -133,6 +133,10 @@ limitations under the License.
|
||||||
.mx_RoomDirectory_topic {
|
.mx_RoomDirectory_topic {
|
||||||
cursor: initial;
|
cursor: initial;
|
||||||
color: $light-fg-color;
|
color: $light-fg-color;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomDirectory_alias {
|
.mx_RoomDirectory_alias {
|
||||||
|
|
|
@ -78,10 +78,6 @@ $MiniAppTileHeight: 200px;
|
||||||
font-size: $font-12px;
|
font-size: $font-12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AddWidget_button_full_width {
|
|
||||||
max-width: 960px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SetAppURLDialog_input {
|
.mx_SetAppURLDialog_input {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid $input-border-color;
|
border: 1px solid $input-border-color;
|
||||||
|
@ -92,7 +88,6 @@ $MiniAppTileHeight: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AppTile {
|
.mx_AppTile {
|
||||||
max-width: 960px;
|
|
||||||
width: 50%;
|
width: 50%;
|
||||||
border: 5px solid $widget-menu-bar-bg-color;
|
border: 5px solid $widget-menu-bar-bg-color;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -105,7 +100,6 @@ $MiniAppTileHeight: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AppTileFullWidth {
|
.mx_AppTileFullWidth {
|
||||||
max-width: 960px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -116,7 +110,6 @@ $MiniAppTileHeight: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AppTile_mini {
|
.mx_AppTile_mini {
|
||||||
max-width: 960px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_AvatarSetting_avatar {
|
.mx_AvatarSetting_avatar {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
|
min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
|
||||||
height: 90px;
|
height: 90px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -59,7 +59,7 @@ limitations under the License.
|
||||||
display: flex;
|
display: flex;
|
||||||
direction: row;
|
direction: row;
|
||||||
|
|
||||||
img {
|
img, .mx_BaseAvatar_initial {
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,6 +82,7 @@ function urlForColor(color) {
|
||||||
const colorToDataURLCache = new Map();
|
const colorToDataURLCache = new Map();
|
||||||
|
|
||||||
export function defaultAvatarUrlForString(s) {
|
export function defaultAvatarUrlForString(s) {
|
||||||
|
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
|
||||||
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
|
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let i = 0; i < s.length; ++i) {
|
for (let i = 0; i < s.length; ++i) {
|
||||||
|
|
|
@ -75,7 +75,8 @@ import {base32} from "rfc4648";
|
||||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||||
import WidgetStore from "./stores/WidgetStore";
|
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
|
// until we ts-ify the js-sdk voip code
|
||||||
type Call = any;
|
type Call = any;
|
||||||
|
@ -503,10 +504,10 @@ export default class CallHandler {
|
||||||
|
|
||||||
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
|
||||||
jitsiWidgets.forEach(w => {
|
jitsiWidgets.forEach(w => {
|
||||||
const messaging = ActiveWidgetStore.getWidgetMessaging(w.id);
|
const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
|
||||||
if (!messaging) return; // more "should never happen" words
|
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");
|
console.log("Logged in with token");
|
||||||
return _clearStorage().then(() => {
|
return _clearStorage().then(() => {
|
||||||
_persistCredentialsToLocalStorage(creds);
|
_persistCredentialsToLocalStorage(creds);
|
||||||
|
// remember that we just logged in
|
||||||
|
sessionStorage.setItem("mx_fresh_login", true);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
@ -312,6 +314,9 @@ async function _restoreFromLocalStorage(opts) {
|
||||||
console.log("No pickle key available");
|
console.log("No pickle key available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freshLogin = sessionStorage.getItem("mx_fresh_login");
|
||||||
|
sessionStorage.removeItem("mx_fresh_login");
|
||||||
|
|
||||||
console.log(`Restoring session for ${userId}`);
|
console.log(`Restoring session for ${userId}`);
|
||||||
await _doSetLoggedIn({
|
await _doSetLoggedIn({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
@ -321,6 +326,7 @@ async function _restoreFromLocalStorage(opts) {
|
||||||
identityServerUrl: isUrl,
|
identityServerUrl: isUrl,
|
||||||
guest: isGuest,
|
guest: isGuest,
|
||||||
pickleKey: pickleKey,
|
pickleKey: pickleKey,
|
||||||
|
freshLogin: freshLogin,
|
||||||
}, false);
|
}, false);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -364,6 +370,7 @@ async function _handleLoadSessionFailure(e) {
|
||||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||||
*/
|
*/
|
||||||
export async function setLoggedIn(credentials) {
|
export async function setLoggedIn(credentials) {
|
||||||
|
credentials.freshLogin = true;
|
||||||
stopMatrixClient();
|
stopMatrixClient();
|
||||||
const pickleKey = credentials.userId && credentials.deviceId
|
const pickleKey = credentials.userId && credentials.deviceId
|
||||||
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
|
||||||
|
@ -429,6 +436,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
||||||
" guest: " + credentials.guest +
|
" guest: " + credentials.guest +
|
||||||
" hs: " + credentials.homeserverUrl +
|
" hs: " + credentials.homeserverUrl +
|
||||||
" softLogout: " + softLogout,
|
" softLogout: " + softLogout,
|
||||||
|
" freshLogin: " + credentials.freshLogin,
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is dispatched to indicate that the user is still in the process of logging in
|
// 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);
|
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) {
|
if (localStorage) {
|
||||||
try {
|
try {
|
||||||
_persistCredentialsToLocalStorage(credentials);
|
_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
|
// 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.
|
// is cached here such that the user can change it at a later time.
|
||||||
if (credentials.password) {
|
if (credentials.password) {
|
||||||
|
@ -482,12 +508,10 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
||||||
console.warn("No local storage available: can't persist session!");
|
console.warn("No local storage available: can't persist session!");
|
||||||
}
|
}
|
||||||
|
|
||||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
|
||||||
|
|
||||||
dis.dispatch({ action: 'on_logged_in' });
|
dis.dispatch({ action: 'on_logged_in' });
|
||||||
|
|
||||||
await startMatrixClient(/*startSyncing=*/!softLogout);
|
await startMatrixClient(/*startSyncing=*/!softLogout);
|
||||||
return MatrixClientPeg.get();
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showStorageEvictedDialog() {
|
function _showStorageEvictedDialog() {
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
|
||||||
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
||||||
import * as StorageManager from './utils/StorageManager';
|
import * as StorageManager from './utils/StorageManager';
|
||||||
import IdentityAuthClient from './IdentityAuthClient';
|
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";
|
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||||
|
|
||||||
export interface IMatrixClientCreds {
|
export interface IMatrixClientCreds {
|
||||||
|
@ -42,6 +42,7 @@ export interface IMatrixClientCreds {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
guest: boolean;
|
guest: boolean;
|
||||||
pickleKey?: string;
|
pickleKey?: string;
|
||||||
|
freshLogin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move this to the js-sdk
|
// TODO: Move this to the js-sdk
|
||||||
|
@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
||||||
this.matrixClient.setCryptoTrustCrossSignedDevices(
|
this.matrixClient.setCryptoTrustCrossSignedDevices(
|
||||||
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
|
!SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
|
||||||
);
|
);
|
||||||
|
await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
|
||||||
StorageManager.setCryptoInitialised(true);
|
StorageManager.setCryptoInitialised(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||||
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
|
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
|
||||||
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
|
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
|
// 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
|
// 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
|
// single secret storage operation, as it will clear the cached keys once the
|
||||||
// operation ends.
|
// operation ends.
|
||||||
let secretStorageKeys = {};
|
let secretStorageKeys = {};
|
||||||
|
let secretStorageKeyInfo = {};
|
||||||
let secretStorageBeingAccessed = false;
|
let secretStorageBeingAccessed = false;
|
||||||
|
|
||||||
|
let nonInteractive = false;
|
||||||
|
|
||||||
|
let dehydrationCache = {};
|
||||||
|
|
||||||
function isCachingAllowed() {
|
function isCachingAllowed() {
|
||||||
return secretStorageBeingAccessed;
|
return secretStorageBeingAccessed;
|
||||||
}
|
}
|
||||||
|
@ -66,6 +72,20 @@ async function confirmToDismiss() {
|
||||||
return !sure;
|
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) {
|
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||||
const keyInfoEntries = Object.entries(keyInfos);
|
const keyInfoEntries = Object.entries(keyInfos);
|
||||||
if (keyInfoEntries.length > 1) {
|
if (keyInfoEntries.length > 1) {
|
||||||
|
@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||||
return [keyId, secretStorageKeys[keyId]];
|
return [keyId, secretStorageKeys[keyId]];
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputToKey = async ({ passphrase, recoveryKey }) => {
|
if (dehydrationCache.key) {
|
||||||
if (passphrase) {
|
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
|
||||||
return deriveKey(
|
cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
|
||||||
passphrase,
|
return [keyId, dehydrationCache.key];
|
||||||
keyInfo.passphrase.salt,
|
|
||||||
keyInfo.passphrase.iterations,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return decodeRecoveryKey(recoveryKey);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (nonInteractive) {
|
||||||
|
throw new Error("Could not unlock non-interactively");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputToKey = makeInputToKey(keyInfo);
|
||||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||||
AccessSecretStorageDialog,
|
AccessSecretStorageDialog,
|
||||||
/* props= */
|
/* props= */
|
||||||
|
@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
|
||||||
const key = await inputToKey(input);
|
const key = await inputToKey(input);
|
||||||
|
|
||||||
// Save to cache to avoid future prompts in the current session
|
// Save to cache to avoid future prompts in the current session
|
||||||
cacheSecretStorageKey(keyId, key);
|
cacheSecretStorageKey(keyId, key, keyInfo);
|
||||||
|
|
||||||
return [keyId, key];
|
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()) {
|
if (isCachingAllowed()) {
|
||||||
secretStorageKeys[keyId] = key;
|
secretStorageKeys[keyId] = key;
|
||||||
|
secretStorageKeyInfo[keyId] = keyInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,6 +239,7 @@ export const crossSigningCallbacks = {
|
||||||
getSecretStorageKey,
|
getSecretStorageKey,
|
||||||
cacheSecretStorageKey,
|
cacheSecretStorageKey,
|
||||||
onSecretRequested,
|
onSecretRequested,
|
||||||
|
getDehydrationKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function promptForBackupPassphrase() {
|
export async function promptForBackupPassphrase() {
|
||||||
|
@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
||||||
await cli.bootstrapSecretStorage({
|
await cli.bootstrapSecretStorage({
|
||||||
getKeyBackupPassphrase: promptForBackupPassphrase,
|
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
|
// `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;
|
secretStorageBeingAccessed = false;
|
||||||
if (!isCachingAllowed()) {
|
if (!isCachingAllowed()) {
|
||||||
secretStorageKeys = {};
|
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();
|
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||||
if (communityId) {
|
if (communityId) {
|
||||||
// double check the user will have permission to associate this room with the community
|
// 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, {
|
Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
|
||||||
title: _t("Cannot create rooms in this community"),
|
title: _t("Cannot create rooms in this community"),
|
||||||
description: _t("You do not have permission to 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({
|
dis.dispatch({
|
||||||
action: "view_home_page",
|
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 {
|
} else {
|
||||||
// Otherwise we have got our user from RoomViewStore which means we're being shown
|
// 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,
|
// 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.
|
// or the member list if we were in the member panel... phew.
|
||||||
|
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: Action.ViewUser,
|
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";
|
import FlairStore from "../../stores/FlairStore";
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
const MAX_TOPIC_LENGTH = 160;
|
const MAX_TOPIC_LENGTH = 800;
|
||||||
|
|
||||||
function track(action) {
|
function track(action) {
|
||||||
Analytics.trackEvent('RoomDirectory', action);
|
Analytics.trackEvent('RoomDirectory', action);
|
||||||
|
@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
let topic = room.topic || '';
|
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) {
|
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||||
topic = `${topic.substring(0, 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 aux = null;
|
||||||
let previewBar;
|
let previewBar;
|
||||||
let hideCancel = false;
|
let hideCancel = false;
|
||||||
let forceHideRightPanel = false;
|
|
||||||
if (this.state.forwardingEvent) {
|
if (this.state.forwardingEvent) {
|
||||||
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
|
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
|
||||||
} else if (this.state.searching) {
|
} else if (this.state.searching) {
|
||||||
|
@ -1865,8 +1864,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||||
{ previewBar }
|
{ previewBar }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
forceHideRightPanel = true;
|
|
||||||
}
|
}
|
||||||
} else if (hiddenHighlightCount > 0) {
|
} else if (hiddenHighlightCount > 0) {
|
||||||
aux = (
|
aux = (
|
||||||
|
@ -2069,7 +2066,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||||
"mx_fadable_faded": this.props.disabled,
|
"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
|
const rightPanel = showRightPanel
|
||||||
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
||||||
: null;
|
: null;
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions";
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import BaseAvatar from "./BaseAvatar";
|
import BaseAvatar from "./BaseAvatar";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
|
||||||
member: RoomMember;
|
member: RoomMember;
|
||||||
fallbackUserId?: string;
|
fallbackUserId?: string;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
|
@ -18,11 +18,9 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import qs from 'qs';
|
|
||||||
import React, {createRef} from 'react';
|
import React, {createRef} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||||
import WidgetMessaging from '../../../WidgetMessaging';
|
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton from './AccessibleButton';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||||
import PersistedElement from "./PersistedElement";
|
import PersistedElement from "./PersistedElement";
|
||||||
import {WidgetType} from "../../../widgets/WidgetType";
|
import {WidgetType} from "../../../widgets/WidgetType";
|
||||||
import {Capability} from "../../../widgets/WidgetApi";
|
|
||||||
import {sleep} from "../../../utils/promise";
|
|
||||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||||
import WidgetStore from "../../../stores/WidgetStore";
|
import WidgetStore from "../../../stores/WidgetStore";
|
||||||
import {Action} from "../../../dispatcher/actions";
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
|
||||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
|
||||||
const ENABLE_REACT_PERF = false;
|
import {MatrixCapabilities} from "matrix-widget-api";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class AppTile extends React.Component {
|
export default class AppTile extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
@ -72,11 +49,13 @@ export default class AppTile extends React.Component {
|
||||||
|
|
||||||
// The key used for PersistedElement
|
// The key used for PersistedElement
|
||||||
this._persistKey = 'widget_' + this.props.app.id;
|
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.state = this._getNewState(props);
|
||||||
|
|
||||||
this._onAction = this._onAction.bind(this);
|
this._onAction = this._onAction.bind(this);
|
||||||
this._onLoaded = this._onLoaded.bind(this);
|
|
||||||
this._onEditClick = this._onEditClick.bind(this);
|
this._onEditClick = this._onEditClick.bind(this);
|
||||||
this._onDeleteClick = this._onDeleteClick.bind(this);
|
this._onDeleteClick = this._onDeleteClick.bind(this);
|
||||||
this._onRevokeClicked = this._onRevokeClicked.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._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
|
||||||
|
|
||||||
this._contextMenuButton = createRef();
|
this._contextMenuButton = createRef();
|
||||||
this._appFrame = createRef();
|
|
||||||
this._menu_bar = createRef();
|
this._menu_bar = createRef();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,12 +86,10 @@ export default class AppTile extends React.Component {
|
||||||
return !!currentlyAllowedWidgets[newProps.app.eventId];
|
return !!currentlyAllowedWidgets[newProps.app.eventId];
|
||||||
};
|
};
|
||||||
|
|
||||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
|
||||||
return {
|
return {
|
||||||
initialising: true, // True while we are mangling the widget URL
|
initialising: true, // True while we are mangling the widget URL
|
||||||
// True while the iframe content is loading
|
// True while the iframe content is loading
|
||||||
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
|
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
|
// 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
|
// added it to the room, or if explicitly granted by the user
|
||||||
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
|
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() {
|
isMixedContent() {
|
||||||
const parentContentProtocol = window.location.protocol;
|
const parentContentProtocol = window.location.protocol;
|
||||||
const u = url.parse(this.props.app.url);
|
const u = url.parse(this.props.app.url);
|
||||||
|
@ -176,7 +115,7 @@ export default class AppTile extends React.Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// Only fetch IM token on mount if we're showing and have permission to load
|
// Only fetch IM token on mount if we're showing and have permission to load
|
||||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||||
this.setScalarToken();
|
this._startWidget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Widget action listeners
|
// 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 it's not remaining on screen, get rid of the PersistedElement container
|
||||||
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
|
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
|
||||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
|
||||||
PersistedElement.destroyElement(this._persistKey);
|
PersistedElement.destroyElement(this._persistKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._sgWidget) {
|
||||||
|
this._sgWidget.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Generify the name of this function. It's not just scalar tokens.
|
_resetWidget(newProps) {
|
||||||
/**
|
if (this._sgWidget) {
|
||||||
* Adds a scalar token to the widget URL, if required
|
this._sgWidget.stop();
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
this._sgWidget = new StopGapWidget(newProps);
|
||||||
|
this._sgWidget.on("ready", this._onWidgetReady);
|
||||||
|
this._startWidget();
|
||||||
|
}
|
||||||
|
|
||||||
const managers = IntegrationManagers.sharedInstance();
|
_startWidget() {
|
||||||
if (!managers.hasManager()) {
|
this._sgWidget.prepare().then(() => {
|
||||||
console.warn("No integration manager - not setting scalar token", url);
|
this.setState({initialising: false});
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_iframeRefChange = (ref) => {
|
||||||
|
this.iframe = ref;
|
||||||
|
if (ref) {
|
||||||
|
this._sgWidget.start(ref);
|
||||||
|
} else {
|
||||||
|
this._resetWidget(this.props);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||||
if (nextProps.app.url !== this.props.app.url) {
|
if (nextProps.app.url !== this.props.app.url) {
|
||||||
this._getNewState(nextProps);
|
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) {
|
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,
|
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) {
|
if (this.state.hasPermissionToLoad) {
|
||||||
this.setScalarToken();
|
this._startWidget();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -319,7 +209,14 @@ export default class AppTile extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSnapshotClick() {
|
_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
|
* @private
|
||||||
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
||||||
*/
|
*/
|
||||||
_endWidgetActions() {
|
async _endWidgetActions() { // widget migration dev note: async to maintain signature
|
||||||
let terminationPromise;
|
// 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
|
||||||
if (this._hasCapability(Capability.ReceiveTerminate)) {
|
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
|
||||||
// Wait for widget to terminate within a timeout
|
if (this.iframe) {
|
||||||
const timeout = 2000;
|
// In practice we could just do `+= ''` to trick the browser
|
||||||
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
|
// into thinking the URL changed, however I can foresee this
|
||||||
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
|
// being optimized out by a browser. Instead, we'll just point
|
||||||
} else {
|
// the iframe at a page that is reasonably safe to use in the
|
||||||
terminationPromise = Promise.resolve();
|
// 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(() => {
|
// Delete the widget from the persisted store for good measure.
|
||||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
PersistedElement.destroyElement(this._persistKey);
|
||||||
// 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.
|
this._sgWidget.stop();
|
||||||
PersistedElement.destroyElement(this._persistKey);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If user has permission to modify widgets, delete the widget,
|
/* If user has permission to modify widgets, delete the widget,
|
||||||
|
@ -409,73 +295,18 @@ export default class AppTile extends React.Component {
|
||||||
this._revokeWidgetPermission();
|
this._revokeWidgetPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
_onWidgetReady = () => {
|
||||||
* 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);
|
|
||||||
this.setState({loading: false});
|
this.setState({loading: false});
|
||||||
}
|
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||||
|
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
|
||||||
_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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onAction(payload) {
|
_onAction(payload) {
|
||||||
if (payload.widgetId === this.props.app.id) {
|
if (payload.widgetId === this.props.app.id) {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'm.sticker':
|
case 'm.sticker':
|
||||||
if (this._hasCapability('m.sticker')) {
|
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
|
||||||
dis.dispatch({action: 'post_sticker_message', data: payload.data});
|
dis.dispatch({action: 'post_sticker_message', data: payload.data});
|
||||||
} else {
|
} else {
|
||||||
console.warn('Ignoring sticker message. Invalid capability');
|
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() {
|
_grantWidgetPermission() {
|
||||||
const roomId = this.props.room.roomId;
|
const roomId = this.props.room.roomId;
|
||||||
console.info("Granting permission for widget to load: " + this.props.app.eventId);
|
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});
|
this.setState({hasPermissionToLoad: true});
|
||||||
|
|
||||||
// Fetch a token for the integration manager, now that we're allowed to
|
// Fetch a token for the integration manager, now that we're allowed to
|
||||||
this.setScalarToken();
|
this._startWidget();
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
// We don't really need to do anything about this - the user will just hit the button again.
|
// 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);
|
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||||
PersistedElement.destroyElement(this._persistKey);
|
PersistedElement.destroyElement(this._persistKey);
|
||||||
|
this._sgWidget.stop();
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
// We don't really need to do anything about this - the user will just hit the button again.
|
// 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
|
* Whether we're using a local version of the widget rather than loading the
|
||||||
* actual widget URL
|
* actual widget URL
|
||||||
|
@ -615,67 +399,11 @@ export default class AppTile extends React.Component {
|
||||||
return WidgetType.JITSI.matches(this.props.app.type);
|
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() {
|
_getTileTitle() {
|
||||||
const name = this.formatAppTileName();
|
const name = this.formatAppTileName();
|
||||||
const titleSpacer = <span> - </span>;
|
const titleSpacer = <span> - </span>;
|
||||||
let title = '';
|
let title = '';
|
||||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
|
if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
|
||||||
title = this.state.widgetPageTitle;
|
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).
|
// 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) {
|
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
|
||||||
this._endWidgetActions().then(() => {
|
this._endWidgetActions().then(() => {
|
||||||
if (this._appFrame.current) {
|
if (this.iframe) {
|
||||||
// Reload iframe
|
// Reload iframe
|
||||||
this._appFrame.current.src = this._getRenderedUrl();
|
this.iframe.src = this._sgWidget.embedUrl;
|
||||||
this.setState({});
|
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.
|
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||||
Object.assign(document.createElement('a'),
|
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() {
|
_onReloadWidgetClick() {
|
||||||
// Reload iframe in this way to avoid cross-origin restrictions
|
// Reload iframe in this way to avoid cross-origin restrictions
|
||||||
// eslint-disable-next-line no-self-assign
|
// eslint-disable-next-line no-self-assign
|
||||||
this._appFrame.current.src = this._appFrame.current.src;
|
this.iframe.src = this.iframe.src;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onContextMenuClick = () => {
|
_onContextMenuClick = () => {
|
||||||
|
@ -760,7 +488,7 @@ export default class AppTile extends React.Component {
|
||||||
<AppPermission
|
<AppPermission
|
||||||
roomId={this.props.room.roomId}
|
roomId={this.props.room.roomId}
|
||||||
creatorUserId={this.props.creatorUserId}
|
creatorUserId={this.props.creatorUserId}
|
||||||
url={this.state.widgetUrl}
|
url={this._sgWidget.embedUrl}
|
||||||
isRoomEncrypted={isEncrypted}
|
isRoomEncrypted={isEncrypted}
|
||||||
onPermissionGranted={this._grantWidgetPermission}
|
onPermissionGranted={this._grantWidgetPermission}
|
||||||
/>
|
/>
|
||||||
|
@ -785,11 +513,11 @@ export default class AppTile extends React.Component {
|
||||||
{ this.state.loading && loadingElement }
|
{ this.state.loading && loadingElement }
|
||||||
<iframe
|
<iframe
|
||||||
allow={iframeFeatures}
|
allow={iframeFeatures}
|
||||||
ref={this._appFrame}
|
ref={this._iframeRefChange}
|
||||||
src={this._getRenderedUrl()}
|
src={this._sgWidget.embedUrl}
|
||||||
allowFullScreen={true}
|
allowFullScreen={true}
|
||||||
sandbox={sandboxFlags}
|
sandbox={sandboxFlags}
|
||||||
onLoad={this._onLoaded} />
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
// if the widget would be allowed to remain on screen, we must put it in
|
// 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 elementRect = this._contextMenuButton.current.getBoundingClientRect();
|
||||||
|
|
||||||
const canUserModify = this._canUserModify();
|
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 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');
|
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
|
||||||
contextMenu = (
|
contextMenu = (
|
||||||
|
@ -943,9 +672,6 @@ AppTile.propTypes = {
|
||||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||||
// basic widget capabilities, e.g. injecting sticker message events.
|
// basic widget capabilities, e.g. injecting sticker message events.
|
||||||
whitelistCapabilities: PropTypes.array,
|
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
|
// Is this an instance of a user widget
|
||||||
userWidget: PropTypes.bool,
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useEffect} from 'react';
|
import React, {ReactChildren, useEffect} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
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 MemberAvatar from '../avatars/MemberAvatar';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import {MatrixEvent, RoomMember} from "matrix-js-sdk";
|
|
||||||
import {useStateToggle} from "../../../hooks/useStateToggle";
|
import {useStateToggle} from "../../../hooks/useStateToggle";
|
||||||
import AccessibleButton from "./AccessibleButton";
|
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);
|
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
|
||||||
|
|
||||||
// Whenever expanded changes call onToggle
|
// 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;
|
export default EventListSummary;
|
|
@ -16,32 +16,60 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { ReactChildren } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||||
import * as sdk from "../../../index";
|
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||||
import {MatrixEvent} from "matrix-js-sdk";
|
import EventListSummary from "./EventListSummary";
|
||||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
|
||||||
|
|
||||||
export default class MemberEventListSummary extends React.Component {
|
interface IProps {
|
||||||
static propTypes = {
|
// An array of member events to summarise
|
||||||
// An array of member events to summarise
|
events: MatrixEvent[];
|
||||||
events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
|
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||||
// An array of EventTiles to render when expanded
|
summaryLength?: number;
|
||||||
children: PropTypes.array.isRequired,
|
// The maximum number of avatars to display in the summary
|
||||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
avatarsMaxLength?: number;
|
||||||
summaryLength: PropTypes.number,
|
// The minimum number of events needed to trigger summarisation
|
||||||
// The maximum number of avatars to display in the summary
|
threshold?: number,
|
||||||
avatarsMaxLength: PropTypes.number,
|
// Whether or not to begin with state.expanded=true
|
||||||
// The minimum number of events needed to trigger summarisation
|
startExpanded?: boolean,
|
||||||
threshold: PropTypes.number,
|
// An array of EventTiles to render when expanded
|
||||||
// Called when the MELS expansion is toggled
|
children: ReactChildren;
|
||||||
onToggle: PropTypes.func,
|
// Called when the MELS expansion is toggled
|
||||||
// Whether or not to begin with state.expanded=true
|
onToggle?(): void,
|
||||||
startExpanded: PropTypes.bool,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
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 = {
|
static defaultProps = {
|
||||||
summaryLength: 1,
|
summaryLength: 1,
|
||||||
threshold: 3,
|
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
|
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
|
||||||
* the sequences are ordered by `orderedTransitionSequences`.
|
* 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.
|
* or user IDs.
|
||||||
* @param {string[]} orderedTransitionSequences an array which is some ordering of
|
* @param {string[]} orderedTransitionSequences an array which is some ordering of
|
||||||
* `Object.keys(eventAggregates)`.
|
* `Object.keys(eventAggregates)`.
|
||||||
* @returns {string} the textual summary of the aggregated events that occurred.
|
* @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 summaries = orderedTransitionSequences.map((transitions) => {
|
||||||
const userNames = eventAggregates[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"
|
// Some neighbouring transitions are common, so canonicalise some into "pair"
|
||||||
// transitions
|
// transitions
|
||||||
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
|
const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
|
||||||
// Transform into consecutive repetitions of the same transition (like 5
|
// Transform into consecutive repetitions of the same transition (like 5
|
||||||
// consecutive 'joined_and_left's)
|
// consecutive 'joined_and_left's)
|
||||||
const coalescedTransitions = this._coalesceRepeatedTransitions(
|
const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
|
||||||
canonicalTransitions,
|
|
||||||
);
|
|
||||||
|
|
||||||
const descs = coalescedTransitions.map((t) => {
|
const descs = coalescedTransitions.map((t) => {
|
||||||
return this._getDescriptionForTransition(
|
return MemberEventListSummary.getDescriptionForTransition(
|
||||||
t.transitionType, userNames.length, t.repeats,
|
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
|
* more items in `users` than `this.props.summaryLength`, which is the number of names
|
||||||
* included before "and [n] others".
|
* included before "and [n] others".
|
||||||
*/
|
*/
|
||||||
_renderNameList(users) {
|
private renderNameList(users: string[]) {
|
||||||
return formatCommaSeparatedList(users, this.props.summaryLength);
|
return formatCommaSeparatedList(users, this.props.summaryLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
* @param {string[]} transitions an array of transitions.
|
* @param {string[]} transitions an array of transitions.
|
||||||
* @returns {string[]} an array of transitions.
|
* @returns {string[]} an array of transitions.
|
||||||
*/
|
*/
|
||||||
_getCanonicalTransitions(transitions) {
|
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
|
||||||
const modMap = {
|
const modMap = {
|
||||||
'joined': {
|
[TransitionType.Joined]: {
|
||||||
'after': 'left',
|
after: TransitionType.Left,
|
||||||
'newTransition': 'joined_and_left',
|
newTransition: TransitionType.JoinedAndLeft,
|
||||||
},
|
},
|
||||||
'left': {
|
[TransitionType.Left]: {
|
||||||
'after': 'joined',
|
after: TransitionType.Joined,
|
||||||
'newTransition': 'left_and_joined',
|
newTransition: TransitionType.LeftAndJoined,
|
||||||
},
|
},
|
||||||
// $currentTransition : {
|
// $currentTransition : {
|
||||||
// 'after' : $nextTransition,
|
// 'after' : $nextTransition,
|
||||||
// 'newTransition' : 'new_transition_type',
|
// 'newTransition' : 'new_transition_type',
|
||||||
// },
|
// },
|
||||||
};
|
};
|
||||||
const res = [];
|
const res: TransitionType[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < transitions.length; i++) {
|
for (let i = 0; i < transitions.length; i++) {
|
||||||
const t = transitions[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.
|
* @param {string[]} transitions the array of transitions to transform.
|
||||||
* @returns {object[]} an array of coalesced transitions.
|
* @returns {object[]} an array of coalesced transitions.
|
||||||
*/
|
*/
|
||||||
_coalesceRepeatedTransitions(transitions) {
|
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
|
||||||
const res = [];
|
const res: {
|
||||||
|
transitionType: TransitionType;
|
||||||
|
repeats: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < transitions.length; i++) {
|
for (let i = 0; i < transitions.length; i++) {
|
||||||
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
|
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
|
||||||
res[res.length - 1].repeats += 1;
|
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.
|
* @param {number} repeats the number of times the transition was repeated in a row.
|
||||||
* @returns {string} the written Human Readable equivalent of the transition.
|
* @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'
|
// The empty interpolations 'severalUsers' and 'oneUser'
|
||||||
// are there only to show translators to non-English languages
|
// are there only to show translators to non-English languages
|
||||||
// that the verb is conjugated to plural or singular Subject.
|
// that the verb is conjugated to plural or singular Subject.
|
||||||
|
@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
break;
|
break;
|
||||||
case "invite_reject":
|
case "invite_reject":
|
||||||
res = (userCount > 1)
|
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 });
|
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
|
||||||
break;
|
break;
|
||||||
case "invite_withdrawal":
|
case "invite_withdrawal":
|
||||||
res = (userCount > 1)
|
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 });
|
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
|
||||||
break;
|
break;
|
||||||
case "invited":
|
case "invited":
|
||||||
|
@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getTransitionSequence(events) {
|
private static getTransitionSequence(events: MatrixEvent[]) {
|
||||||
return events.map(this._getTransition);
|
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`
|
* @returns {string?} the transition type given to this event. This defaults to `null`
|
||||||
* if a transition is not recognised.
|
* if a transition is not recognised.
|
||||||
*/
|
*/
|
||||||
_getTransition(e) {
|
private static getTransition(e: MatrixEvent): TransitionType {
|
||||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||||
// Handle 3pid invites the same as invites so they get bundled together
|
// Handle 3pid invites the same as invites so they get bundled together
|
||||||
if (!isValid3pidInvite(e.mxEvent)) {
|
if (!isValid3pidInvite(e.mxEvent)) {
|
||||||
return 'invite_withdrawal';
|
return TransitionType.InviteWithdrawal;
|
||||||
}
|
}
|
||||||
return 'invited';
|
return TransitionType.Invited;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (e.mxEvent.getContent().membership) {
|
switch (e.mxEvent.getContent().membership) {
|
||||||
case 'invite': return 'invited';
|
case 'invite': return TransitionType.Invited;
|
||||||
case 'ban': return 'banned';
|
case 'ban': return TransitionType.Banned;
|
||||||
case 'join':
|
case 'join':
|
||||||
if (e.mxEvent.getPrevContent().membership === 'join') {
|
if (e.mxEvent.getPrevContent().membership === 'join') {
|
||||||
if (e.mxEvent.getContent().displayname !==
|
if (e.mxEvent.getContent().displayname !==
|
||||||
e.mxEvent.getPrevContent().displayname) {
|
e.mxEvent.getPrevContent().displayname) {
|
||||||
return 'changed_name';
|
return TransitionType.ChangedName;
|
||||||
} else if (e.mxEvent.getContent().avatar_url !==
|
} else if (e.mxEvent.getContent().avatar_url !==
|
||||||
e.mxEvent.getPrevContent().avatar_url) {
|
e.mxEvent.getPrevContent().avatar_url) {
|
||||||
return 'changed_avatar';
|
return TransitionType.ChangedAvatar;
|
||||||
}
|
}
|
||||||
// console.log("MELS ignoring duplicate membership join event");
|
// console.log("MELS ignoring duplicate membership join event");
|
||||||
return 'no_change';
|
return TransitionType.NoChange;
|
||||||
} else {
|
} else {
|
||||||
return 'joined';
|
return TransitionType.Joined;
|
||||||
}
|
}
|
||||||
case 'leave':
|
case 'leave':
|
||||||
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
|
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
|
||||||
switch (e.mxEvent.getPrevContent().membership) {
|
switch (e.mxEvent.getPrevContent().membership) {
|
||||||
case 'invite': return 'invite_reject';
|
case 'invite': return TransitionType.InviteReject;
|
||||||
default: return 'left';
|
default: return TransitionType.Left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch (e.mxEvent.getPrevContent().membership) {
|
switch (e.mxEvent.getPrevContent().membership) {
|
||||||
case 'invite': return 'invite_withdrawal';
|
case 'invite': return TransitionType.InviteWithdrawal;
|
||||||
case 'ban': return 'unbanned';
|
case 'ban': return TransitionType.Unbanned;
|
||||||
// sender is not target and made the target leave, if not from invite/ban then this is a kick
|
// 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;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAggregate(userEvents) {
|
getAggregate(userEvents: Record<string, IUserEvents[]>) {
|
||||||
// A map of aggregate type to arrays of display names. Each aggregate type
|
// 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".
|
// 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
|
// The array of display names is the array of users who went through that
|
||||||
// sequence during eventsToRender.
|
// sequence during eventsToRender.
|
||||||
const aggregate = {
|
const aggregate: Record<string, string[]> = {
|
||||||
// $aggregateType : []:string
|
// $aggregateType : []:string
|
||||||
};
|
};
|
||||||
// A map of aggregate types to the indices that order them (the index of
|
// A map of aggregate types to the indices that order them (the index of
|
||||||
// the first event for a given transition sequence)
|
// the first event for a given transition sequence)
|
||||||
const aggregateIndices = {
|
const aggregateIndices: Record<string, number> = {
|
||||||
// $aggregateType : int
|
// $aggregateType : int
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
const firstEvent = userEvents[userId][0];
|
const firstEvent = userEvents[userId][0];
|
||||||
const displayName = firstEvent.displayName;
|
const displayName = firstEvent.displayName;
|
||||||
|
|
||||||
const seq = this._getTransitionSequence(userEvents[userId]);
|
const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
|
||||||
if (!aggregate[seq]) {
|
if (!aggregate[seq]) {
|
||||||
aggregate[seq] = [];
|
aggregate[seq] = [];
|
||||||
aggregateIndices[seq] = -1;
|
aggregateIndices[seq] = -1;
|
||||||
|
@ -349,8 +385,9 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
aggregate[seq].push(displayName);
|
aggregate[seq].push(displayName);
|
||||||
|
|
||||||
if (aggregateIndices[seq] === -1 ||
|
if (aggregateIndices[seq] === -1 ||
|
||||||
firstEvent.index < aggregateIndices[seq]) {
|
firstEvent.index < aggregateIndices[seq]
|
||||||
aggregateIndices[seq] = firstEvent.index;
|
) {
|
||||||
|
aggregateIndices[seq] = firstEvent.index;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const eventsToRender = this.props.events;
|
const eventsToRender = this.props.events;
|
||||||
|
|
||||||
// Map user IDs to an array of objects:
|
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
|
||||||
const userEvents = {
|
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
|
||||||
// $userId : [{
|
const latestUserAvatarMember = new Map<string, RoomMember>();
|
||||||
// // 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,
|
|
||||||
// }]
|
|
||||||
};
|
|
||||||
|
|
||||||
const avatarMembers = [];
|
// Object mapping user IDs to an array of IUserEvents
|
||||||
|
const userEvents: Record<string, IUserEvents[]> = {};
|
||||||
eventsToRender.forEach((e, index) => {
|
eventsToRender.forEach((e, index) => {
|
||||||
const userId = e.getStateKey();
|
const userId = e.getStateKey();
|
||||||
// Initialise a user's events
|
// Initialise a user's events
|
||||||
if (!userEvents[userId]) {
|
if (!userEvents[userId]) {
|
||||||
userEvents[userId] = [];
|
userEvents[userId] = [];
|
||||||
if (e.target) avatarMembers.push(e.target);
|
}
|
||||||
|
|
||||||
|
if (e.target) {
|
||||||
|
latestUserAvatarMember.set(userId, e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = userId;
|
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
|
// Sort types by order of lowest event index within sequence
|
||||||
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
|
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
|
return <EventListSummary
|
||||||
events={this.props.events}
|
events={this.props.events}
|
||||||
threshold={this.props.threshold}
|
threshold={this.props.threshold}
|
||||||
onToggle={this.props.onToggle}
|
onToggle={this.props.onToggle}
|
||||||
startExpanded={this.props.startExpanded}
|
startExpanded={this.props.startExpanded}
|
||||||
children={this.props.children}
|
children={this.props.children}
|
||||||
summaryMembers={avatarMembers}
|
summaryMembers={[...latestUserAvatarMember.values()]}
|
||||||
summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, {RefObject} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker";
|
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;
|
const OVERFLOW_ROWS = 3;
|
||||||
|
|
||||||
class Category extends React.PureComponent {
|
export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent";
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
_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 { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
|
||||||
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
|
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
|
||||||
const Emoji = sdk.getComponent("emojipicker.Emoji");
|
|
||||||
return (<div key={rowIndex}>{
|
return (<div key={rowIndex}>{
|
||||||
emojisForRow.map(emoji =>
|
emojisForRow.map(emoji => ((
|
||||||
<Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
|
<Emoji
|
||||||
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)
|
key={emoji.hexcode}
|
||||||
|
emoji={emoji}
|
||||||
|
selectedEmojis={selectedEmojis}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
/>
|
||||||
|
)))
|
||||||
}</div>);
|
}</div>);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -52,7 +74,6 @@ class Category extends React.PureComponent {
|
||||||
for (let counter = 0; counter < rows.length; ++counter) {
|
for (let counter = 0; counter < rows.length; ++counter) {
|
||||||
rows[counter] = counter;
|
rows[counter] = counter;
|
||||||
}
|
}
|
||||||
const LazyRenderList = sdk.getComponent('elements.LazyRenderList');
|
|
||||||
|
|
||||||
const viewportTop = scrollTop;
|
const viewportTop = scrollTop;
|
||||||
const viewportBottom = viewportTop + viewportHeight;
|
const viewportBottom = viewportTop + viewportHeight;
|
||||||
|
@ -84,7 +105,7 @@ class Category extends React.PureComponent {
|
||||||
height={localHeight}
|
height={localHeight}
|
||||||
overflowItems={OVERFLOW_ROWS}
|
overflowItems={OVERFLOW_ROWS}
|
||||||
overflowMargin={0}
|
overflowMargin={0}
|
||||||
renderItem={this._renderEmojiRow}>
|
renderItem={this.renderEmojiRow}>
|
||||||
</LazyRenderList>
|
</LazyRenderList>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {MenuItem} from "../../structures/ContextMenu";
|
import {MenuItem} from "../../structures/ContextMenu";
|
||||||
|
import {IEmoji} from "../../../emoji";
|
||||||
|
|
||||||
class Emoji extends React.PureComponent {
|
interface IProps {
|
||||||
static propTypes = {
|
emoji: IEmoji;
|
||||||
onClick: PropTypes.func,
|
selectedEmojis?: Set<string>;
|
||||||
onMouseEnter: PropTypes.func,
|
onClick(emoji: IEmoji): void;
|
||||||
onMouseLeave: PropTypes.func,
|
onMouseEnter(emoji: IEmoji): void;
|
||||||
emoji: PropTypes.object.isRequired,
|
onMouseLeave(emoji: IEmoji): void;
|
||||||
selectedEmojis: PropTypes.instanceOf(Set),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
class Emoji extends React.PureComponent<IProps> {
|
||||||
render() {
|
render() {
|
||||||
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
|
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
|
||||||
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
|
const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
|
||||||
import * as recent from '../../../emojipicker/recent';
|
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 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 CATEGORY_HEADER_HEIGHT = 22;
|
||||||
export const EMOJI_HEIGHT = 37;
|
export const EMOJI_HEIGHT = 37;
|
||||||
export const EMOJIS_PER_ROW = 8;
|
export const EMOJIS_PER_ROW = 8;
|
||||||
|
|
||||||
class EmojiPicker extends React.Component {
|
interface IProps {
|
||||||
static propTypes = {
|
selectedEmojis: Set<string>;
|
||||||
onChoose: PropTypes.func.isRequired,
|
showQuickReactions?: boolean;
|
||||||
selectedEmojis: PropTypes.instanceOf(Set),
|
onChoose(unicode: string): boolean;
|
||||||
showQuickReactions: PropTypes.bool,
|
}
|
||||||
};
|
|
||||||
|
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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -42,9 +61,6 @@ class EmojiPicker extends React.Component {
|
||||||
filter: "",
|
filter: "",
|
||||||
previewEmoji: null,
|
previewEmoji: null,
|
||||||
scrollTop: 0,
|
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,
|
viewportHeight: 280,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -110,18 +126,9 @@ class EmojiPicker extends React.Component {
|
||||||
visible: false,
|
visible: false,
|
||||||
ref: React.createRef(),
|
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;
|
const body = this.bodyRef.current;
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollTop: body.scrollTop,
|
scrollTop: body.scrollTop,
|
||||||
|
@ -130,7 +137,7 @@ class EmojiPicker extends React.Component {
|
||||||
this.updateVisibility();
|
this.updateVisibility();
|
||||||
};
|
};
|
||||||
|
|
||||||
updateVisibility() {
|
private updateVisibility = () => {
|
||||||
const body = this.bodyRef.current;
|
const body = this.bodyRef.current;
|
||||||
const rect = body.getBoundingClientRect();
|
const rect = body.getBoundingClientRect();
|
||||||
for (const cat of this.categories) {
|
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.
|
// We update this here instead of through React to avoid re-render on scroll.
|
||||||
if (cat.visible) {
|
if (cat.visible) {
|
||||||
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
|
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
|
||||||
cat.ref.current.setAttribute("aria-selected", true);
|
cat.ref.current.setAttribute("aria-selected", "true");
|
||||||
cat.ref.current.setAttribute("tabindex", 0);
|
cat.ref.current.setAttribute("tabindex", "0");
|
||||||
} else {
|
} else {
|
||||||
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
|
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
|
||||||
cat.ref.current.setAttribute("aria-selected", false);
|
cat.ref.current.setAttribute("aria-selected", "false");
|
||||||
cat.ref.current.setAttribute("tabindex", -1);
|
cat.ref.current.setAttribute("tabindex", "-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
scrollToCategory(category) {
|
private scrollToCategory = (category: string) => {
|
||||||
this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
|
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
|
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
|
||||||
for (const cat of this.categories) {
|
for (const cat of this.categories) {
|
||||||
let emojis;
|
let emojis;
|
||||||
|
@ -181,27 +188,34 @@ class EmojiPicker extends React.Component {
|
||||||
// Header underlines need to be updated, but updating requires knowing
|
// Header underlines need to be updated, but updating requires knowing
|
||||||
// where the categories are, so we wait for a tick.
|
// where the categories are, so we wait for a tick.
|
||||||
setTimeout(this.updateVisibility, 0);
|
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({
|
this.setState({
|
||||||
previewEmoji: emoji,
|
previewEmoji: emoji,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
onHoverEmojiEnd(emoji) {
|
private onHoverEmojiEnd = (emoji: IEmoji) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
previewEmoji: null,
|
previewEmoji: null,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
onClickEmoji(emoji) {
|
private onClickEmoji = (emoji: IEmoji) => {
|
||||||
if (this.props.onChoose(emoji.unicode) !== false) {
|
if (this.props.onChoose(emoji.unicode) !== false) {
|
||||||
recent.add(emoji.unicode);
|
recent.add(emoji.unicode);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
_categoryHeightForEmojiCount(count) {
|
private static categoryHeightForEmojiCount(count: number) {
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
@ -209,25 +223,37 @@ class EmojiPicker extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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;
|
let heightBefore = 0;
|
||||||
return (
|
return (
|
||||||
<div className="mx_EmojiPicker">
|
<div className="mx_EmojiPicker">
|
||||||
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
|
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
|
||||||
<Search query={this.state.filter} onChange={this.onChangeFilter} />
|
<Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
|
||||||
<AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}>
|
<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 => {
|
{this.categories.map(category => {
|
||||||
const emojis = this.memoizedDataByCategory[category.id];
|
const emojis = this.memoizedDataByCategory[category.id];
|
||||||
const categoryElement = (<Category key={category.id} id={category.id} name={category.name}
|
const categoryElement = ((
|
||||||
heightBefore={heightBefore} viewportHeight={this.state.viewportHeight}
|
<Category
|
||||||
scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji}
|
key={category.id}
|
||||||
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
|
id={category.id}
|
||||||
selectedEmojis={this.props.selectedEmojis} />);
|
name={category.name}
|
||||||
const height = this._categoryHeightForEmojiCount(emojis.length);
|
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;
|
heightBefore += height;
|
||||||
return categoryElement;
|
return categoryElement;
|
||||||
})}
|
})}
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import {_t} from "../../../languageHandler";
|
import {_t} from "../../../languageHandler";
|
||||||
import {Key} from "../../../Keyboard";
|
import {Key} from "../../../Keyboard";
|
||||||
|
import {CategoryKey, ICategory} from "./Category";
|
||||||
|
|
||||||
class Header extends React.PureComponent {
|
interface IProps {
|
||||||
static propTypes = {
|
categories: ICategory[];
|
||||||
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
|
onAnchorClick(id: CategoryKey): void
|
||||||
onAnchorClick: PropTypes.func.isRequired,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
findNearestEnabled(index, delta) {
|
class Header extends React.PureComponent<IProps> {
|
||||||
|
private findNearestEnabled(index: number, delta: number) {
|
||||||
index += this.props.categories.length;
|
index += this.props.categories.length;
|
||||||
const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories];
|
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);
|
const current = this.props.categories.findIndex(c => c.visible);
|
||||||
this.changeCategoryAbsolute(current + delta, delta);
|
this.changeCategoryAbsolute(current + delta, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCategoryAbsolute(index, delta=1) {
|
private changeCategoryAbsolute(index: number, delta=1) {
|
||||||
const category = this.props.categories[this.findNearestEnabled(index, delta)];
|
const category = this.props.categories[this.findNearestEnabled(index, delta)];
|
||||||
if (category) {
|
if (category) {
|
||||||
this.props.onAnchorClick(category.id);
|
this.props.onAnchorClick(category.id);
|
||||||
|
@ -52,7 +53,7 @@ class Header extends React.PureComponent {
|
||||||
|
|
||||||
// Implements ARIA Tabs with Automatic Activation pattern
|
// Implements ARIA Tabs with Automatic Activation pattern
|
||||||
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
|
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
|
||||||
onKeyDown = (ev) => {
|
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.ARROW_LEFT:
|
case Key.ARROW_LEFT:
|
||||||
|
@ -80,7 +81,12 @@ class Header extends React.PureComponent {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
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 => {
|
{this.props.categories.map(category => {
|
||||||
const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
|
const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
|
||||||
mx_EmojiPicker_anchor_visible: category.visible,
|
mx_EmojiPicker_anchor_visible: category.visible,
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
class Preview extends React.PureComponent {
|
import {IEmoji} from "../../../emoji";
|
||||||
static propTypes = {
|
|
||||||
emoji: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
emoji: IEmoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Preview extends React.PureComponent<IProps> {
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
unicode = "",
|
unicode = "",
|
||||||
annotation = "",
|
annotation = "",
|
||||||
shortcodes: [shortcode = ""],
|
shortcodes: [shortcode = ""],
|
||||||
} = this.props.emoji || {};
|
} = this.props.emoji || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
|
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
|
||||||
<div className="mx_EmojiPicker_preview_emoji">
|
<div className="mx_EmojiPicker_preview_emoji">
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import * as sdk from '../../../index';
|
|
||||||
import { _t } from '../../../languageHandler';
|
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
|
// We use the variation-selector Heart in Quick Reactions for some reason
|
||||||
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
|
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
|
||||||
|
@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
class QuickReactions extends React.Component {
|
interface IProps {
|
||||||
static propTypes = {
|
selectedEmojis?: Set<string>;
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick(emoji: IEmoji): void;
|
||||||
selectedEmojis: PropTypes.instanceOf(Set),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
hover?: IEmoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuickReactions extends React.Component<IProps, IState> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
hover: null,
|
hover: null,
|
||||||
};
|
};
|
||||||
this.onMouseEnter = this.onMouseEnter.bind(this);
|
|
||||||
this.onMouseLeave = this.onMouseLeave.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseEnter(emoji) {
|
private onMouseEnter = (emoji: IEmoji) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
hover: emoji,
|
hover: emoji,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
onMouseLeave() {
|
private onMouseLeave = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
hover: null,
|
hover: null,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const Emoji = sdk.getComponent("emojipicker.Emoji");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
|
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
|
||||||
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
|
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
|
||||||
|
@ -72,10 +72,16 @@ class QuickReactions extends React.Component {
|
||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
|
<ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
|
||||||
{QUICK_REACTIONS.map(emoji => <Emoji
|
{QUICK_REACTIONS.map(emoji => ((
|
||||||
key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick}
|
<Emoji
|
||||||
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}
|
key={emoji.hexcode}
|
||||||
selectedEmojis={this.props.selectedEmojis} />)}
|
emoji={emoji}
|
||||||
|
onClick={this.props.onClick}
|
||||||
|
onMouseEnter={this.onMouseEnter}
|
||||||
|
onMouseLeave={this.onMouseLeave}
|
||||||
|
selectedEmojis={this.props.selectedEmojis}
|
||||||
|
/>
|
||||||
|
)))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from "prop-types";
|
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||||
|
|
||||||
import EmojiPicker from "./EmojiPicker";
|
import EmojiPicker from "./EmojiPicker";
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
|
||||||
class ReactionPicker extends React.Component {
|
interface IProps {
|
||||||
static propTypes = {
|
mxEvent: MatrixEvent;
|
||||||
mxEvent: PropTypes.object.isRequired,
|
reactions: any; // TODO type this once js-sdk is more typescripted
|
||||||
onFinished: PropTypes.func.isRequired,
|
onFinished(): void;
|
||||||
reactions: PropTypes.object,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
selectedEmojis: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactionPicker extends React.Component<IProps, IState> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
||||||
};
|
};
|
||||||
this.onChoose = this.onChoose.bind(this);
|
|
||||||
this.onReactionsChange = this.onReactionsChange.bind(this);
|
|
||||||
this.addListeners();
|
this.addListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,7 +49,7 @@ class ReactionPicker extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addListeners() {
|
private addListeners() {
|
||||||
if (this.props.reactions) {
|
if (this.props.reactions) {
|
||||||
this.props.reactions.on("Relations.add", this.onReactionsChange);
|
this.props.reactions.on("Relations.add", this.onReactionsChange);
|
||||||
this.props.reactions.on("Relations.remove", this.onReactionsChange);
|
this.props.reactions.on("Relations.remove", this.onReactionsChange);
|
||||||
|
@ -55,22 +59,13 @@ class ReactionPicker extends React.Component {
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
if (this.props.reactions) {
|
if (this.props.reactions) {
|
||||||
this.props.reactions.removeListener(
|
this.props.reactions.removeListener("Relations.add", this.onReactionsChange);
|
||||||
"Relations.add",
|
this.props.reactions.removeListener("Relations.remove", this.onReactionsChange);
|
||||||
this.onReactionsChange,
|
this.props.reactions.removeListener("Relations.redaction", 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) {
|
if (!this.props.reactions) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -81,13 +76,13 @@ class ReactionPicker extends React.Component {
|
||||||
.map(event => [event.getRelation().key, event.getId()]));
|
.map(event => [event.getRelation().key, event.getId()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
onReactionsChange() {
|
private onReactionsChange = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
selectedEmojis: new Set(Object.keys(this.getReactions())),
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
onChoose(reaction) {
|
onChoose = (reaction: string) => {
|
||||||
this.componentWillUnmount();
|
this.componentWillUnmount();
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
const myReactions = this.getReactions();
|
const myReactions = this.getReactions();
|
||||||
|
@ -109,7 +104,7 @@ class ReactionPicker extends React.Component {
|
||||||
dis.dispatch({action: "message_sent"});
|
dis.dispatch({action: "message_sent"});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <EmojiPicker
|
return <EmojiPicker
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 Tulir Asokan <tulir@maunium.net>
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
import {Key} from "../../../Keyboard";
|
||||||
|
|
||||||
class Search extends React.PureComponent {
|
interface IProps {
|
||||||
static propTypes = {
|
query: string;
|
||||||
query: PropTypes.string.isRequired,
|
onChange(value: string): void;
|
||||||
onChange: PropTypes.func.isRequired,
|
onEnter(): void;
|
||||||
};
|
}
|
||||||
|
|
||||||
constructor(props) {
|
class Search extends React.PureComponent<IProps> {
|
||||||
super(props);
|
private inputRef = React.createRef<HTMLInputElement>();
|
||||||
this.inputRef = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
|
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
|
||||||
setTimeout(() => this.inputRef.current.focus(), 0);
|
setTimeout(() => this.inputRef.current.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
|
if (ev.key === Key.ENTER) {
|
||||||
|
this.props.onEnter();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let rightButton;
|
let rightButton;
|
||||||
if (this.props.query) {
|
if (this.props.query) {
|
||||||
rightButton = (
|
rightButton = (
|
||||||
<button onClick={() => this.props.onChange("")}
|
<button
|
||||||
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
|
onClick={() => this.props.onChange("")}
|
||||||
title={_t("Cancel search")} />
|
className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
|
||||||
|
title={_t("Cancel search")}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
rightButton = <span className="mx_EmojiPicker_search_icon" />;
|
rightButton = <span className="mx_EmojiPicker_search_icon" />;
|
||||||
|
@ -48,8 +58,15 @@ class Search extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_EmojiPicker_search">
|
<div className="mx_EmojiPicker_search">
|
||||||
<input autoFocus type="text" placeholder="Search" value={this.props.query}
|
<input
|
||||||
onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} />
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
value={this.props.query}
|
||||||
|
onChange={ev => this.props.onChange(ev.target.value)}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
ref={this.inputRef}
|
||||||
|
/>
|
||||||
{rightButton}
|
{rightButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -25,10 +25,8 @@ export default class EncryptionEvent extends React.Component {
|
||||||
|
|
||||||
let body;
|
let body;
|
||||||
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
|
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
|
||||||
if (
|
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
|
||||||
mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' &&
|
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
|
||||||
MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId())
|
|
||||||
) {
|
|
||||||
body = <div>
|
body = <div>
|
||||||
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
||||||
<div className="mx_cryptoEvent_subtitle">
|
<div className="mx_cryptoEvent_subtitle">
|
||||||
|
@ -38,6 +36,13 @@ export default class EncryptionEvent extends React.Component {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
} else {
|
||||||
body = <div>
|
body = <div>
|
||||||
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
|
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
|
||||||
|
|
|
@ -31,6 +31,7 @@ interface IProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
withoutScrollContainer?: boolean;
|
withoutScrollContainer?: boolean;
|
||||||
previousPhase?: RightPanelPhases;
|
previousPhase?: RightPanelPhases;
|
||||||
|
closeLabel?: string;
|
||||||
onClose?(): void;
|
onClose?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const BaseCard: React.FC<IProps> = ({
|
const BaseCard: React.FC<IProps> = ({
|
||||||
|
closeLabel,
|
||||||
onClose,
|
onClose,
|
||||||
className,
|
className,
|
||||||
header,
|
header,
|
||||||
|
@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({
|
||||||
|
|
||||||
let closeButton;
|
let closeButton;
|
||||||
if (onClose) {
|
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) {
|
if (!withoutScrollContainer) {
|
||||||
|
|
|
@ -27,6 +27,9 @@ import * as sdk from "../../../index";
|
||||||
import {_t} from "../../../languageHandler";
|
import {_t} from "../../../languageHandler";
|
||||||
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
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
|
// cancellation codes which constitute a key mismatch
|
||||||
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
|
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 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);
|
const [request, setRequest] = useState(verificationRequest);
|
||||||
// state to show a spinner immediately after clicking "start verification",
|
// state to show a spinner immediately after clicking "start verification",
|
||||||
// before we have a request
|
// before we have a request
|
||||||
|
@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
}, [onClose, request]);
|
}, [onClose, request]);
|
||||||
useEventEmitter(request, "change", changeHandler);
|
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 () => {
|
const onStartVerification = useCallback(async () => {
|
||||||
setRequesting(true);
|
setRequesting(true);
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
|
@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
|
const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
|
||||||
setRequest(verificationRequest_);
|
setRequest(verificationRequest_);
|
||||||
setPhase(verificationRequest_.phase);
|
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 =
|
const requested =
|
||||||
(!request && isRequesting) ||
|
(!request && isRequesting) ||
|
||||||
|
@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
member.userId === MatrixClientPeg.get().getUserId();
|
member.userId === MatrixClientPeg.get().getUserId();
|
||||||
if (!request || requested) {
|
if (!request || requested) {
|
||||||
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
|
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
|
||||||
return (<React.Fragment>
|
return (
|
||||||
{cancelButton}
|
|
||||||
<EncryptionInfo
|
<EncryptionInfo
|
||||||
isRoomEncrypted={isRoomEncrypted}
|
isRoomEncrypted={isRoomEncrypted}
|
||||||
onStartVerification={onStartVerification}
|
onStartVerification={onStartVerification}
|
||||||
|
@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
waitingForOtherParty={requested && initiatedByMe}
|
waitingForOtherParty={requested && initiatedByMe}
|
||||||
waitingForNetwork={requested && !initiatedByMe}
|
waitingForNetwork={requested && !initiatedByMe}
|
||||||
inDialog={layout === "dialog"} />
|
inDialog={layout === "dialog"} />
|
||||||
</React.Fragment>);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (<React.Fragment>
|
return (
|
||||||
{cancelButton}
|
|
||||||
<VerificationPanel
|
<VerificationPanel
|
||||||
isRoomEncrypted={isRoomEncrypted}
|
isRoomEncrypted={isRoomEncrypted}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
inDialog={layout === "dialog"}
|
inDialog={layout === "dialog"}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,20 +17,22 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react';
|
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
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 dis from '../../../dispatcher/dispatcher';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import * as sdk from '../../../index';
|
import {_t} from '../../../languageHandler';
|
||||||
import { _t } from '../../../languageHandler';
|
|
||||||
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
|
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
|
||||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import SdkConfig from '../../../SdkConfig';
|
import SdkConfig from '../../../SdkConfig';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import {EventTimeline} from "matrix-js-sdk";
|
|
||||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||||
import MultiInviter from "../../../utils/MultiInviter";
|
import MultiInviter from "../../../utils/MultiInviter";
|
||||||
import GroupStore from "../../../stores/GroupStore";
|
import GroupStore from "../../../stores/GroupStore";
|
||||||
|
@ -41,13 +43,31 @@ import {textualPowerLevel} from '../../../Roles';
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
||||||
import EncryptionPanel from "./EncryptionPanel";
|
import EncryptionPanel from "./EncryptionPanel";
|
||||||
import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
|
import {useAsyncMemo} from '../../../hooks/useAsyncMemo';
|
||||||
import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification';
|
import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification';
|
||||||
import {Action} from "../../../dispatcher/actions";
|
import {Action} from "../../../dispatcher/actions";
|
||||||
import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
|
import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
|
||||||
import BaseCard from "./BaseCard";
|
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);
|
const names = Object.create(null);
|
||||||
for (let i = 0; i < devices.length; i++) {
|
for (let i = 0; i < devices.length; i++) {
|
||||||
const name = devices[i].getDisplayName();
|
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 isMe = userId === cli.getUserId();
|
||||||
const userTrust = cli.checkUserTrust(userId);
|
const userTrust = cli.checkUserTrust(userId);
|
||||||
if (!userTrust.isCrossSigningVerified()) {
|
if (!userTrust.isCrossSigningVerified()) {
|
||||||
return userTrust.wasCrossSigningVerified() ? "warning" : "normal";
|
return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyDeviceUnverified = devices.some(device => {
|
const anyDeviceUnverified = devices.some(device => {
|
||||||
|
@ -81,10 +101,10 @@ export const getE2EStatus = (cli, userId, devices) => {
|
||||||
const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
|
const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
|
||||||
return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified();
|
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 dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
|
||||||
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
|
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
|
||||||
const room = matrixClient.getRoom(roomId);
|
const room = matrixClient.getRoom(roomId);
|
||||||
|
@ -107,6 +127,7 @@ async function openDMForUser(matrixClient, userId) {
|
||||||
|
|
||||||
const createRoomOptions = {
|
const createRoomOptions = {
|
||||||
dmUserId: userId,
|
dmUserId: userId,
|
||||||
|
encryption: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (privateShouldBeEncrypted()) {
|
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 () => {
|
return useAsyncMemo(async () => {
|
||||||
if (!canVerify) {
|
if (!canVerify) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -142,7 +165,7 @@ function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
|
||||||
}, [cli, member, canVerify], undefined);
|
}, [cli, member, canVerify], undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceItem({userId, device}) {
|
function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const isMe = userId === cli.getUserId();
|
const isMe = userId === cli.getUserId();
|
||||||
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
|
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
|
||||||
|
@ -169,8 +192,8 @@ function DeviceItem({userId, device}) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const deviceName = device.ambiguous ?
|
const deviceName = device.ambiguous ?
|
||||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||||
device.getDisplayName();
|
device.getDisplayName();
|
||||||
let trustedLabel = null;
|
let trustedLabel = null;
|
||||||
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
||||||
|
|
||||||
|
@ -198,8 +221,7 @@ function DeviceItem({userId, device}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DevicesSection({devices, userId, loading}) {
|
function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) {
|
||||||
const Spinner = sdk.getComponent("elements.Spinner");
|
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const userTrust = cli.checkUserTrust(userId);
|
const userTrust = cli.checkUserTrust(userId);
|
||||||
|
|
||||||
|
@ -210,7 +232,7 @@ function DevicesSection({devices, userId, loading}) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
if (devices === null) {
|
if (devices === null) {
|
||||||
return _t("Unable to load session list");
|
return <>{_t("Unable to load session list")}</>;
|
||||||
}
|
}
|
||||||
const isMe = userId === cli.getUserId();
|
const isMe = userId === cli.getUserId();
|
||||||
const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
|
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);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
let ignoreButton = null;
|
let ignoreButton = null;
|
||||||
|
@ -296,7 +322,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||||
const isMe = member.userId === cli.getUserId();
|
const isMe = member.userId === cli.getUserId();
|
||||||
|
|
||||||
const onShareUserClick = () => {
|
const onShareUserClick = () => {
|
||||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
|
||||||
Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
|
Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
|
||||||
target: member,
|
target: member,
|
||||||
});
|
});
|
||||||
|
@ -318,7 +343,10 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
ignoreButton = (
|
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") }
|
{ isIgnored ? _t("Unignore") : _t("Ignore") }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
|
@ -367,7 +395,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
|
||||||
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
|
||||||
title: _t('Failed to invite'),
|
title: _t('Failed to invite'),
|
||||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||||
|
@ -413,8 +440,7 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _warnSelfDemote = async () => {
|
const warnSelfDemote = async () => {
|
||||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
||||||
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
|
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
|
||||||
title: _t("Demote yourself?"),
|
title: _t("Demote yourself?"),
|
||||||
description:
|
description:
|
||||||
|
@ -430,7 +456,7 @@ const _warnSelfDemote = async () => {
|
||||||
return confirmed;
|
return confirmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GenericAdminToolsContainer = ({children}) => {
|
const GenericAdminToolsContainer: React.FC<{}> = ({children}) => {
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserInfo_container">
|
<div className="mx_UserInfo_container">
|
||||||
<h3>{ _t("Admin Tools") }</h3>
|
<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;
|
if (!powerLevelContent || !member) return false;
|
||||||
|
|
||||||
const levelToSend = (
|
const levelToSend = (
|
||||||
|
@ -451,8 +490,8 @@ const _isMuted = (member, powerLevelContent) => {
|
||||||
return member.powerLevel < levelToSend;
|
return member.powerLevel < levelToSend;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRoomPowerLevels = (cli, room) => {
|
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
|
||||||
const [powerLevels, setPowerLevels] = useState({});
|
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
if (!room) {
|
if (!room) {
|
||||||
|
@ -479,14 +518,19 @@ export const useRoomPowerLevels = (cli, room) => {
|
||||||
return powerLevels;
|
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);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
// check if user can be kicked/disinvited
|
// check if user can be kicked/disinvited
|
||||||
if (member.membership !== "invite" && member.membership !== "join") return null;
|
if (member.membership !== "invite" && member.membership !== "join") return null;
|
||||||
|
|
||||||
const onKick = async () => {
|
const onKick = async () => {
|
||||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
|
||||||
const {finished} = Modal.createTrackedDialog(
|
const {finished} = Modal.createTrackedDialog(
|
||||||
'Confirm User Action Dialog',
|
'Confirm User Action Dialog',
|
||||||
'onKick',
|
'onKick',
|
||||||
|
@ -509,7 +553,6 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
|
||||||
// get out of sync if we force setState here!
|
// get out of sync if we force setState here!
|
||||||
console.log("Kick success");
|
console.log("Kick success");
|
||||||
}, function(err) {
|
}, function(err) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
console.error("Kick error: " + err);
|
console.error("Kick error: " + err);
|
||||||
Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, {
|
||||||
title: _t("Failed to kick"),
|
title: _t("Failed to kick"),
|
||||||
|
@ -526,7 +569,7 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RedactMessagesButton = ({member}) => {
|
const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const onRedactAllMessages = async () => {
|
const onRedactAllMessages = async () => {
|
||||||
|
@ -554,7 +597,6 @@ const RedactMessagesButton = ({member}) => {
|
||||||
const user = member.name;
|
const user = member.name;
|
||||||
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
const InfoDialog = sdk.getComponent("dialogs.InfoDialog");
|
|
||||||
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
|
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
|
||||||
title: _t("No recent messages by %(user)s found", {user}),
|
title: _t("No recent messages by %(user)s found", {user}),
|
||||||
description:
|
description:
|
||||||
|
@ -563,14 +605,14 @@ const RedactMessagesButton = ({member}) => {
|
||||||
</div>,
|
</div>,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
|
||||||
|
|
||||||
const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
|
const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
|
||||||
title: _t("Remove recent messages by %(user)s", {user}),
|
title: _t("Remove recent messages by %(user)s", {user}),
|
||||||
description:
|
description:
|
||||||
<div>
|
<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("You are about to remove %(count)s messages by %(user)s. " +
|
||||||
<p>{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }</p>
|
"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>,
|
</div>,
|
||||||
button: _t("Remove %(count)s messages", {count}),
|
button: _t("Remove %(count)s messages", {count}),
|
||||||
});
|
});
|
||||||
|
@ -603,11 +645,10 @@ const RedactMessagesButton = ({member}) => {
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
const BanToggleButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const onBanOrUnban = async () => {
|
const onBanOrUnban = async () => {
|
||||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
|
||||||
const {finished} = Modal.createTrackedDialog(
|
const {finished} = Modal.createTrackedDialog(
|
||||||
'Confirm User Action Dialog',
|
'Confirm User Action Dialog',
|
||||||
'onBanOrUnban',
|
'onBanOrUnban',
|
||||||
|
@ -636,7 +677,6 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
||||||
// get out of sync if we force setState here!
|
// get out of sync if we force setState here!
|
||||||
console.log("Ban success");
|
console.log("Ban success");
|
||||||
}, function(err) {
|
}, function(err) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
console.error("Ban error: " + err);
|
console.error("Ban error: " + err);
|
||||||
Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, {
|
||||||
title: _t("Error"),
|
title: _t("Error"),
|
||||||
|
@ -661,22 +701,26 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
|
||||||
</AccessibleButton>;
|
</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);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
// Don't show the mute/unmute option if the user is not in the room
|
// Don't show the mute/unmute option if the user is not in the room
|
||||||
if (member.membership !== "join") return null;
|
if (member.membership !== "join") return null;
|
||||||
|
|
||||||
const isMuted = _isMuted(member, powerLevels);
|
const muted = isMuted(member, powerLevels);
|
||||||
const onMuteToggle = async () => {
|
const onMuteToggle = async () => {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
const roomId = member.roomId;
|
const roomId = member.roomId;
|
||||||
const target = member.userId;
|
const target = member.userId;
|
||||||
|
|
||||||
// if muting self, warn as it may be irreversible
|
// if muting self, warn as it may be irreversible
|
||||||
if (target === cli.getUserId()) {
|
if (target === cli.getUserId()) {
|
||||||
try {
|
try {
|
||||||
if (!(await _warnSelfDemote())) return;
|
if (!(await warnSelfDemote())) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to warn about self demotion: ", e);
|
console.error("Failed to warn about self demotion: ", e);
|
||||||
return;
|
return;
|
||||||
|
@ -692,7 +736,7 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
|
||||||
powerLevels.events_default
|
powerLevels.events_default
|
||||||
);
|
);
|
||||||
let level;
|
let level;
|
||||||
if (isMuted) { // unmute
|
if (muted) { // unmute
|
||||||
level = levelToSend;
|
level = levelToSend;
|
||||||
} else { // mute
|
} else { // mute
|
||||||
level = levelToSend - 1;
|
level = levelToSend - 1;
|
||||||
|
@ -718,16 +762,23 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
|
||||||
};
|
};
|
||||||
|
|
||||||
const classes = classNames("mx_UserInfo_field", {
|
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}>
|
return <AccessibleButton className={classes} onClick={onMuteToggle}>
|
||||||
{ muteLabel }
|
{ muteLabel }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpdating, powerLevels}) => {
|
const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||||
|
room,
|
||||||
|
children,
|
||||||
|
member,
|
||||||
|
startUpdating,
|
||||||
|
stopUpdating,
|
||||||
|
powerLevels,
|
||||||
|
}) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
let kickButton;
|
let kickButton;
|
||||||
let banButton;
|
let banButton;
|
||||||
|
@ -786,7 +837,18 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd
|
||||||
return <div />;
|
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 cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const [isPrivileged, setIsPrivileged] = useState(false);
|
const [isPrivileged, setIsPrivileged] = useState(false);
|
||||||
|
@ -814,8 +876,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||||
}, [groupId, groupMember.userId]);
|
}, [groupId, groupMember.userId]);
|
||||||
|
|
||||||
if (isPrivileged) {
|
if (isPrivileged) {
|
||||||
const _onKick = async () => {
|
const onKick = async () => {
|
||||||
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
|
|
||||||
const {finished} = Modal.createDialog(ConfirmUserActionDialog, {
|
const {finished} = Modal.createDialog(ConfirmUserActionDialog, {
|
||||||
matrixClient: cli,
|
matrixClient: cli,
|
||||||
groupMember,
|
groupMember,
|
||||||
|
@ -836,7 +897,6 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||||
member: null,
|
member: null,
|
||||||
});
|
});
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
|
||||||
title: _t('Error'),
|
title: _t('Error'),
|
||||||
description: isInvited ?
|
description: isInvited ?
|
||||||
|
@ -850,7 +910,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||||
};
|
};
|
||||||
|
|
||||||
const kickButton = (
|
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') }
|
{ isInvited ? _t('Disinvite') : _t('Remove from community') }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
|
@ -870,13 +930,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
|
||||||
return <div />;
|
return <div />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupMember = PropTypes.shape({
|
const useIsSynapseAdmin = (cli: MatrixClient) => {
|
||||||
userId: PropTypes.string.isRequired,
|
|
||||||
displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :((
|
|
||||||
avatarUrl: PropTypes.string,
|
|
||||||
});
|
|
||||||
|
|
||||||
const useIsSynapseAdmin = (cli) => {
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cli.isSynapseAdministrator().then((isAdmin) => {
|
cli.isSynapseAdministrator().then((isAdmin) => {
|
||||||
|
@ -888,14 +942,20 @@ const useIsSynapseAdmin = (cli) => {
|
||||||
return isAdmin;
|
return isAdmin;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useHomeserverSupportsCrossSigning = (cli) => {
|
const useHomeserverSupportsCrossSigning = (cli: MatrixClient) => {
|
||||||
return useAsyncMemo(async () => {
|
return useAsyncMemo<boolean>(async () => {
|
||||||
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||||
}, [cli], false);
|
}, [cli], false);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useRoomPermissions(cli, room, user) {
|
interface IRoomPermissions {
|
||||||
const [roomPermissions, setRoomPermissions] = useState({
|
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 is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
|
||||||
modifyLevelMax: -1,
|
modifyLevelMax: -1,
|
||||||
canEdit: false,
|
canEdit: false,
|
||||||
|
@ -940,7 +1000,7 @@ function useRoomPermissions(cli, room, user) {
|
||||||
updateRoomPermissions();
|
updateRoomPermissions();
|
||||||
return () => {
|
return () => {
|
||||||
setRoomPermissions({
|
setRoomPermissions({
|
||||||
maximalPowerLevel: -1,
|
modifyLevelMax: -1,
|
||||||
canEdit: false,
|
canEdit: false,
|
||||||
canInvite: false,
|
canInvite: false,
|
||||||
});
|
});
|
||||||
|
@ -950,14 +1010,18 @@ function useRoomPermissions(cli, room, user) {
|
||||||
return roomPermissions;
|
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);
|
const [isEditing, setEditing] = useState(false);
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (<PowerLevelEditor
|
return (<PowerLevelEditor
|
||||||
user={user} room={room} roomPermissions={roomPermissions}
|
user={user} room={room} roomPermissions={roomPermissions}
|
||||||
onFinished={() => setEditing(false)} />);
|
onFinished={() => setEditing(false)} />);
|
||||||
} else {
|
} else {
|
||||||
const IconButton = sdk.getComponent('elements.IconButton');
|
|
||||||
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
||||||
const powerLevel = parseInt(user.powerLevel, 10);
|
const powerLevel = parseInt(user.powerLevel, 10);
|
||||||
const modifyButton = roomPermissions.canEdit ?
|
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 cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
@ -994,7 +1063,6 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||||
// get out of sync if we force setState here!
|
// get out of sync if we force setState here!
|
||||||
console.log("Power change success");
|
console.log("Power change success");
|
||||||
}, function(err) {
|
}, function(err) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
||||||
console.error("Failed to change power level " + err);
|
console.error("Failed to change power level " + err);
|
||||||
Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, {
|
||||||
title: _t("Error"),
|
title: _t("Error"),
|
||||||
|
@ -1025,12 +1093,10 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const myUserId = cli.getUserId();
|
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 we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||||
if (myUserId === target) {
|
if (myUserId === target) {
|
||||||
try {
|
try {
|
||||||
if (!(await _warnSelfDemote())) return;
|
if (!(await warnSelfDemote())) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to warn about self demotion: ", 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];
|
const myPower = powerLevelEvent.getContent().users[myUserId];
|
||||||
if (parseInt(myPower) === parseInt(powerLevel)) {
|
if (parseInt(myPower) === powerLevel) {
|
||||||
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
|
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
|
||||||
title: _t("Warning!"),
|
title: _t("Warning!"),
|
||||||
description:
|
description:
|
||||||
|
@ -1062,12 +1128,9 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||||
|
|
||||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
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} /> :
|
const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
|
||||||
<IconButton icon="check" onClick={changePowerLevel} />;
|
<IconButton icon="check" onClick={changePowerLevel} />;
|
||||||
|
|
||||||
const PowerSelector = sdk.getComponent('elements.PowerSelector');
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserInfo_profileField">
|
<div className="mx_UserInfo_profileField">
|
||||||
<PowerSelector
|
<PowerSelector
|
||||||
|
@ -1083,7 +1146,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDevices = (userId) => {
|
export const useDevices = (userId: string) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
|
// 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;
|
let cancelled = false;
|
||||||
|
|
||||||
async function _downloadDeviceList() {
|
async function downloadDeviceList() {
|
||||||
try {
|
try {
|
||||||
await cli.downloadKeys([userId], true);
|
await cli.downloadKeys([userId], true);
|
||||||
const devices = cli.getStoredDevicesForUser(userId);
|
const devices = cli.getStoredDevicesForUser(userId);
|
||||||
|
@ -1104,13 +1167,13 @@ export const useDevices = (userId) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disambiguateDevices(devices);
|
disambiguateDevices(devices);
|
||||||
setDevices(devices);
|
setDevices(devices);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDevices(null);
|
setDevices(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_downloadDeviceList();
|
downloadDeviceList();
|
||||||
|
|
||||||
// Handle being unmounted
|
// Handle being unmounted
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -1153,7 +1216,13 @@ export const useDevices = (userId) => {
|
||||||
return devices;
|
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 cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const powerLevels = useRoomPowerLevels(cli, room);
|
const powerLevels = useRoomPowerLevels(cli, room);
|
||||||
|
@ -1186,7 +1255,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||||
const roomPermissions = useRoomPermissions(cli, room, member);
|
const roomPermissions = useRoomPermissions(cli, room, member);
|
||||||
|
|
||||||
const onSynapseDeactivate = useCallback(async () => {
|
const onSynapseDeactivate = useCallback(async () => {
|
||||||
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
|
|
||||||
const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
|
const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
|
||||||
title: _t("Deactivate user?"),
|
title: _t("Deactivate user?"),
|
||||||
description:
|
description:
|
||||||
|
@ -1207,7 +1275,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||||
console.error("Failed to deactivate user");
|
console.error("Failed to deactivate user");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
|
||||||
Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, {
|
Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, {
|
||||||
title: _t('Failed to deactivate user'),
|
title: _t('Failed to deactivate user'),
|
||||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||||
|
@ -1260,8 +1327,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingUpdateCount > 0) {
|
if (pendingUpdateCount > 0) {
|
||||||
const Loader = sdk.getComponent("elements.Spinner");
|
spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
|
||||||
spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let memberDetails;
|
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,
|
// HACK: only show a spinner if the device section spinner is not shown,
|
||||||
// to avoid showing a double spinner
|
// to avoid showing a double spinner
|
||||||
// We should ask for a design that includes all the different loading states here
|
// We should ask for a design that includes all the different loading states here
|
||||||
const Spinner = sdk.getComponent('elements.Spinner');
|
|
||||||
verifyButton = <Spinner />;
|
verifyButton = <Spinner />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1351,7 +1416,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||||
|
|
||||||
{ securitySection }
|
{ securitySection }
|
||||||
<UserOptionsSection
|
<UserOptionsSection
|
||||||
devices={devices}
|
|
||||||
canInvite={roomPermissions.canInvite}
|
canInvite={roomPermissions.canInvite}
|
||||||
isIgnored={isIgnored}
|
isIgnored={isIgnored}
|
||||||
member={member} />
|
member={member} />
|
||||||
|
@ -1362,7 +1426,12 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
|
||||||
</React.Fragment>;
|
</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 cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const onMemberAvatarClick = useCallback(() => {
|
const onMemberAvatarClick = useCallback(() => {
|
||||||
|
@ -1370,7 +1439,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||||
if (!avatarUrl) return;
|
if (!avatarUrl) return;
|
||||||
|
|
||||||
const httpUrl = cli.mxcUrlToHttp(avatarUrl);
|
const httpUrl = cli.mxcUrlToHttp(avatarUrl);
|
||||||
const ImageView = sdk.getComponent("elements.ImageView");
|
|
||||||
const params = {
|
const params = {
|
||||||
src: httpUrl,
|
src: httpUrl,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
@ -1379,7 +1447,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||||
}, [cli, member]);
|
}, [cli, member]);
|
||||||
|
|
||||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
|
||||||
const avatarElement = (
|
const avatarElement = (
|
||||||
<div className="mx_UserInfo_avatar">
|
<div className="mx_UserInfo_avatar">
|
||||||
<div>
|
<div>
|
||||||
|
@ -1421,10 +1488,13 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||||
|
|
||||||
let presenceLabel = null;
|
let presenceLabel = null;
|
||||||
if (showPresence) {
|
if (showPresence) {
|
||||||
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
|
presenceLabel = (
|
||||||
presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo}
|
<PresenceLabel
|
||||||
currentlyActive={presenceCurrentlyActive}
|
activeAgo={presenceLastActiveAgo}
|
||||||
presenceState={presenceState} />;
|
currentlyActive={presenceCurrentlyActive}
|
||||||
|
presenceState={presenceState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let statusLabel = null;
|
let statusLabel = null;
|
||||||
|
@ -1461,7 +1531,32 @@ const UserInfoHeader = ({member, e2eStatus}) => {
|
||||||
</React.Fragment>;
|
</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);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
// fetch latest room member if we have a room, so we don't show historical information, falling back to user
|
// 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
|
<BasicUserInfo
|
||||||
room={room}
|
room={room}
|
||||||
member={member}
|
member={member}
|
||||||
groupId={groupId}
|
groupId={groupId as string}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
isRoomEncrypted={isRoomEncrypted} />
|
isRoomEncrypted={isRoomEncrypted} />
|
||||||
);
|
);
|
||||||
|
@ -1493,7 +1588,12 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
|
||||||
case RightPanelPhases.EncryptionPanel:
|
case RightPanelPhases.EncryptionPanel:
|
||||||
classes.push("mx_UserInfo_smallAvatar");
|
classes.push("mx_UserInfo_smallAvatar");
|
||||||
content = (
|
content = (
|
||||||
<EncryptionPanel {...props} member={member} onClose={onClose} isRoomEncrypted={isRoomEncrypted} />
|
<EncryptionPanel
|
||||||
|
{...props as React.ComponentProps<typeof EncryptionPanel>}
|
||||||
|
member={member}
|
||||||
|
onClose={onClose}
|
||||||
|
isRoomEncrypted={isRoomEncrypted}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1504,23 +1604,24 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
|
||||||
previousPhase = RightPanelPhases.RoomMemberList;
|
previousPhase = RightPanelPhases.RoomMemberList;
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />;
|
let closeLabel = undefined;
|
||||||
return <BaseCard className={classes.join(" ")} header={header} onClose={onClose} previousPhase={previousPhase}>
|
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 }
|
{ content }
|
||||||
</BaseCard>;
|
</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;
|
export default UserInfo;
|
|
@ -29,16 +29,17 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||||
import {Action} from "../../../dispatcher/actions";
|
import {Action} from "../../../dispatcher/actions";
|
||||||
import WidgetStore from "../../../stores/WidgetStore";
|
import WidgetStore from "../../../stores/WidgetStore";
|
||||||
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
|
|
||||||
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
||||||
import IconizedContextMenu, {
|
import IconizedContextMenu, {
|
||||||
IconizedContextMenuOption,
|
IconizedContextMenuOption,
|
||||||
IconizedContextMenuOptionList,
|
IconizedContextMenuOptionList,
|
||||||
} from "../context_menus/IconizedContextMenu";
|
} from "../context_menus/IconizedContextMenu";
|
||||||
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
|
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
|
||||||
import {Capability} from "../../../widgets/WidgetApi";
|
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||||
|
import { MatrixCapabilities } from "matrix-widget-api";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -77,9 +78,17 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
||||||
let contextMenu;
|
let contextMenu;
|
||||||
if (menuDisplayed) {
|
if (menuDisplayed) {
|
||||||
let snapshotButton;
|
let snapshotButton;
|
||||||
if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) {
|
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
|
||||||
|
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
|
||||||
const onSnapshotClick = () => {
|
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();
|
closeMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -121,8 +121,8 @@ export default class MemberList extends React.Component {
|
||||||
this.setState(this._getMembersState(this.roomMembers()));
|
this.setState(this._getMembersState(this.roomMembers()));
|
||||||
this._listenForMembersChanges();
|
this._listenForMembersChanges();
|
||||||
}
|
}
|
||||||
} else if (membership === "invite") {
|
} else {
|
||||||
// show the members we've got when invited
|
// show the members we already have loaded
|
||||||
this.setState(this._getMembersState(this.roomMembers()));
|
this.setState(this._getMembersState(this.roomMembers()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import * as sdk from '../../../index';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
|
||||||
import PersistedElement from "../elements/PersistedElement";
|
import PersistedElement from "../elements/PersistedElement";
|
||||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
@ -30,6 +29,7 @@ import {ContextMenu} from "../../structures/ContextMenu";
|
||||||
import {WidgetType} from "../../../widgets/WidgetType";
|
import {WidgetType} from "../../../widgets/WidgetType";
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import {Action} from "../../../dispatcher/actions";
|
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).
|
// 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.
|
// 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) {
|
_sendVisibilityToWidget(visible) {
|
||||||
if (!this.state.stickerpickerWidget) return;
|
if (!this.state.stickerpickerWidget) return;
|
||||||
const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id);
|
const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
|
||||||
if (widgetMessaging && visible !== this._prevSentVisibility) {
|
if (messaging && visible !== this._prevSentVisibility) {
|
||||||
widgetMessaging.sendVisibility(visible);
|
messaging.updateVisibility(visible).catch(err => {
|
||||||
|
console.error("Error updating widget visibility: ", err);
|
||||||
|
});
|
||||||
this._prevSentVisibility = visible;
|
this._prevSentVisibility = visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,9 +18,9 @@ import React, {createRef} from 'react';
|
||||||
import {_t} from "../../../languageHandler";
|
import {_t} from "../../../languageHandler";
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import Field from "../elements/Field";
|
import Field from "../elements/Field";
|
||||||
import {User} from "matrix-js-sdk";
|
|
||||||
import { getHostingLink } from '../../../utils/HostingLink';
|
import { getHostingLink } from '../../../utils/HostingLink';
|
||||||
import * as sdk from "../../../index";
|
import * as sdk from "../../../index";
|
||||||
|
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
|
|
||||||
|
@ -29,21 +29,12 @@ export default class ProfileSettings extends React.Component {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
let user = client.getUser(client.getUserId());
|
let avatarUrl = OwnProfileStore.instance.avatarMxc;
|
||||||
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;
|
|
||||||
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
|
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
|
||||||
this.state = {
|
this.state = {
|
||||||
userId: user.userId,
|
userId: client.getUserId(),
|
||||||
originalDisplayName: user.rawDisplayName,
|
originalDisplayName: OwnProfileStore.instance.displayName,
|
||||||
displayName: user.rawDisplayName,
|
displayName: OwnProfileStore.instance.displayName,
|
||||||
originalAvatarUrl: avatarUrl,
|
originalAvatarUrl: avatarUrl,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
avatarFile: null,
|
avatarFile: null,
|
||||||
|
|
|
@ -239,7 +239,7 @@ export default class RolesRoomSettingsTab extends React.Component {
|
||||||
defaultValue: 50,
|
defaultValue: 50,
|
||||||
},
|
},
|
||||||
"redact": {
|
"redact": {
|
||||||
desc: _t('Remove messages'),
|
desc: _t('Remove messages sent by others'),
|
||||||
defaultValue: 50,
|
defaultValue: 50,
|
||||||
},
|
},
|
||||||
"notifications.room": {
|
"notifications.room": {
|
||||||
|
|
|
@ -275,12 +275,17 @@ export async function _waitForMember(client: MatrixClient, roomId: string, userI
|
||||||
* can encrypt to.
|
* can encrypt to.
|
||||||
*/
|
*/
|
||||||
export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
|
export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
|
||||||
const usersDeviceMap = await client.downloadKeys(userIds);
|
try {
|
||||||
// { "@user:host": { "DEVICE": {...}, ... }, ... }
|
const usersDeviceMap = await client.downloadKeys(userIds);
|
||||||
return Object.values(usersDeviceMap).every((userDevices) =>
|
// { "@user:host": { "DEVICE": {...}, ... }, ... }
|
||||||
// { "DEVICE": {...}, ... }
|
return Object.values(usersDeviceMap).every((userDevices) =>
|
||||||
Object.keys(userDevices).length > 0,
|
// { "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> {
|
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) {
|
if (existingDMRoom) {
|
||||||
roomId = existingDMRoom.roomId;
|
roomId = existingDMRoom.roomId;
|
||||||
} else {
|
} else {
|
||||||
let encryption;
|
let encryption: boolean = undefined;
|
||||||
if (privateShouldBeEncrypted()) {
|
if (privateShouldBeEncrypted()) {
|
||||||
encryption = canEncryptToAllUsers(client, [userId]);
|
encryption = await canEncryptToAllUsers(client, [userId]);
|
||||||
}
|
}
|
||||||
roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false});
|
roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false});
|
||||||
await _waitForMember(client, roomId, userId);
|
await _waitForMember(client, roomId, userId);
|
||||||
|
|
|
@ -18,8 +18,8 @@ import {useState, useEffect, DependencyList} from 'react';
|
||||||
|
|
||||||
type Fn<T> = () => Promise<T>;
|
type Fn<T> = () => Promise<T>;
|
||||||
|
|
||||||
export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T) => {
|
export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T): T => {
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState<T>(initialValue);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fn().then(setValue);
|
fn().then(setValue);
|
||||||
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
}, 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useState} from "react";
|
import {Dispatch, SetStateAction, useState} from "react";
|
||||||
|
|
||||||
// Hook to simplify toggling of a boolean state value
|
// Hook to simplify toggling of a boolean state value
|
||||||
// Returns value, method to toggle boolean value and method to set the boolean 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 [value, setValue] = useState(initialValue);
|
||||||
const toggleValue = () => {
|
const toggleValue = () => {
|
||||||
setValue(!value);
|
setValue(!value);
|
||||||
|
|
|
@ -452,6 +452,7 @@
|
||||||
"Support adding custom themes": "Support adding custom themes",
|
"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 DMs": "Show message previews for reactions in DMs",
|
||||||
"Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
|
"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",
|
"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",
|
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||||
"Font size": "Font size",
|
"Font size": "Font size",
|
||||||
|
@ -969,7 +970,7 @@
|
||||||
"Change settings": "Change settings",
|
"Change settings": "Change settings",
|
||||||
"Kick users": "Kick users",
|
"Kick users": "Kick users",
|
||||||
"Ban users": "Ban users",
|
"Ban users": "Ban users",
|
||||||
"Remove messages": "Remove messages",
|
"Remove messages sent by others": "Remove messages sent by others",
|
||||||
"Notify everyone": "Notify everyone",
|
"Notify everyone": "Notify everyone",
|
||||||
"No users have specific privileges in this room": "No users have specific privileges in this room",
|
"No users have specific privileges in this room": "No users have specific privileges in this room",
|
||||||
"Privileged Users": "Privileged Users",
|
"Privileged Users": "Privileged Users",
|
||||||
|
@ -1378,6 +1379,7 @@
|
||||||
"View Source": "View Source",
|
"View Source": "View Source",
|
||||||
"Encryption enabled": "Encryption enabled",
|
"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.",
|
"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",
|
"Encryption not enabled": "Encryption not enabled",
|
||||||
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
|
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
|
||||||
"Error decrypting audio": "Error decrypting audio",
|
"Error decrypting audio": "Error decrypting audio",
|
||||||
|
|
|
@ -186,6 +186,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||||
supportedLevels: LEVELS_FEATURE,
|
supportedLevels: LEVELS_FEATURE,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
"feature_dehydration": {
|
||||||
|
isFeature: true,
|
||||||
|
displayName: _td("Offline encrypted messaging using dehydrated devices"),
|
||||||
|
supportedLevels: LEVELS_FEATURE,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
"advancedRoomListLogging": {
|
"advancedRoomListLogging": {
|
||||||
// TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
|
// TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
|
||||||
displayName: _td("Enable advanced debugging for the room list"),
|
displayName: _td("Enable advanced debugging for the room list"),
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||||
|
import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores information about the widgets active in the app right now:
|
* Stores information about the widgets active in the app right now:
|
||||||
|
@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||||
super();
|
super();
|
||||||
this._persistentWidgetId = null;
|
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)
|
// What room ID each widget is associated with (if it's a room widget)
|
||||||
this._roomIdByWidgetId = {};
|
this._roomIdByWidgetId = {};
|
||||||
|
|
||||||
|
@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||||
if (MatrixClientPeg.get()) {
|
if (MatrixClientPeg.get()) {
|
||||||
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
|
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
|
||||||
}
|
}
|
||||||
this._capsByWidgetId = {};
|
|
||||||
this._widgetMessagingByWidgetId = {};
|
|
||||||
this._roomIdByWidgetId = {};
|
this._roomIdByWidgetId = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,9 +66,9 @@ class ActiveWidgetStore extends EventEmitter {
|
||||||
if (id !== this._persistentWidgetId) return;
|
if (id !== this._persistentWidgetId) return;
|
||||||
const toDeleteId = this._persistentWidgetId;
|
const toDeleteId = this._persistentWidgetId;
|
||||||
|
|
||||||
|
WidgetMessagingStore.instance.stopMessagingById(id);
|
||||||
|
|
||||||
this.setWidgetPersistence(toDeleteId, false);
|
this.setWidgetPersistence(toDeleteId, false);
|
||||||
this.delWidgetMessaging(toDeleteId);
|
|
||||||
this.delWidgetCapabilities(toDeleteId);
|
|
||||||
this.delRoomId(toDeleteId);
|
this.delRoomId(toDeleteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,43 +89,6 @@ class ActiveWidgetStore extends EventEmitter {
|
||||||
return this._persistentWidgetId;
|
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) {
|
getRoomId(widgetId) {
|
||||||
return this._roomIdByWidgetId[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
|
* Gets the user's avatar as an HTTP URL of the given size. If the user's
|
||||||
* avatar is not present, this returns null.
|
* 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
|
* @returns The HTTP URL of the user's avatar
|
||||||
*/
|
*/
|
||||||
public getHttpAvatarUrl(size: number): string {
|
public getHttpAvatarUrl(size = 0): string {
|
||||||
if (!this.avatarMxc) return null;
|
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() {
|
protected async onNotReady() {
|
||||||
|
|
|
@ -119,6 +119,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadRoomWidgets(room: Room) {
|
private loadRoomWidgets(room: Room) {
|
||||||
|
if (!room) return;
|
||||||
const roomInfo = this.roomMap.get(room.roomId);
|
const roomInfo = this.roomMap.get(room.roomId);
|
||||||
roomInfo.widgets = [];
|
roomInfo.widgets = [];
|
||||||
this.generateApps(room).forEach(app => {
|
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 SettingsStore from "../settings/SettingsStore";
|
||||||
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
||||||
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
||||||
import {Capability} from "../widgets/WidgetApi";
|
|
||||||
import {Room} from "matrix-js-sdk/src/models/room";
|
import {Room} from "matrix-js-sdk/src/models/room";
|
||||||
import {WidgetType} from "../widgets/WidgetType";
|
import {WidgetType} from "../widgets/WidgetType";
|
||||||
import {objectClone} from "./objects";
|
import {objectClone} from "./objects";
|
||||||
import {_t} from "../languageHandler";
|
import {_t} from "../languageHandler";
|
||||||
|
import {MatrixCapabilities} from "matrix-widget-api";
|
||||||
|
|
||||||
export default class WidgetUtils {
|
export default class WidgetUtils {
|
||||||
/* Returns true if user is able to send state events to modify widgets in this room
|
/* 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) {
|
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
|
||||||
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", 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,
|
// 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
|
// 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.
|
// widgets from at all, but it probably makes sense for sanity.
|
||||||
if (WidgetType.JITSI.matches(appType)) {
|
if (WidgetType.JITSI.matches(appType)) {
|
||||||
capWhitelist.push(Capability.AlwaysOnScreen);
|
capWhitelist.push(MatrixCapabilities.AlwaysOnScreen);
|
||||||
}
|
}
|
||||||
capWhitelist.push(Capability.ReceiveTerminate);
|
|
||||||
|
|
||||||
return capWhitelist;
|
return capWhitelist;
|
||||||
}
|
}
|
||||||
|
@ -495,16 +494,4 @@ export default class WidgetUtils {
|
||||||
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
|
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);
|
const diff = mapDiff(a, b);
|
||||||
return arrayMerge(diff.removed, diff.added, diff.changed);
|
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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// TODO: Move to matrix-widget-api
|
||||||
export class WidgetType {
|
export class WidgetType {
|
||||||
public static readonly JITSI = new WidgetType("m.jitsi", "jitsi");
|
public static readonly JITSI = new WidgetType("m.jitsi", "jitsi");
|
||||||
public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker");
|
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"
|
resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
|
||||||
integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ==
|
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:
|
mdast-util-compact@^1.0.0:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"
|
resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue