Merge branch 'develop' into develop
This commit is contained in:
commit
3fd13b89b8
280 changed files with 13047 additions and 4366 deletions
31
src/@types/global.d.ts
vendored
31
src/@types/global.d.ts
vendored
|
@ -32,6 +32,10 @@ import type {Renderer} from "react-dom";
|
|||
import RightPanelStore from "../stores/RightPanelStore";
|
||||
import WidgetStore from "../stores/WidgetStore";
|
||||
import CallHandler from "../CallHandler";
|
||||
import {Analytics} from "../Analytics";
|
||||
import CountlyAnalytics from "../CountlyAnalytics";
|
||||
import UserActivity from "../UserActivity";
|
||||
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
@ -56,11 +60,22 @@ declare global {
|
|||
mxRightPanelStore: RightPanelStore;
|
||||
mxWidgetStore: WidgetStore;
|
||||
mxCallHandler: CallHandler;
|
||||
mxAnalytics: Analytics;
|
||||
mxCountlyAnalytics: typeof CountlyAnalytics;
|
||||
mxUserActivity: UserActivity;
|
||||
mxModalWidgetStore: ModalWidgetStore;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
|
||||
hasStorageAccess?: () => Promise<boolean>;
|
||||
|
||||
// Safari & IE11 only have this prefixed: we used prefixed versions
|
||||
// previously so let's continue to support them for now
|
||||
webkitExitFullscreen(): Promise<void>;
|
||||
msExitFullscreen(): Promise<void>;
|
||||
readonly webkitFullscreenElement: Element | null;
|
||||
readonly msFullscreenElement: Element | null;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
@ -90,4 +105,20 @@ declare global {
|
|||
interface HTMLAudioElement {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
// Safari & IE11 only have this prefixed: we used prefixed versions
|
||||
// previously so let's continue to support them for now
|
||||
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
|
||||
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
|
||||
}
|
||||
|
||||
interface Error {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
|
||||
fileName?: string;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
|
||||
lineNumber?: number;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
|
||||
columnNumber?: number;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { getCurrentLanguage, _t, _td } from './languageHandler';
|
||||
import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import Modal from './Modal';
|
||||
|
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
|
|||
const hashVarRegex = /#\/(group|room|user)\/.*$/;
|
||||
|
||||
// Remove all but the first item in the hash path. Redact unexpected hashes.
|
||||
function getRedactedHash(hash) {
|
||||
function getRedactedHash(hash: string): string {
|
||||
// Don't leak URLs we aren't expecting - they could contain tokens/PII
|
||||
const match = hashRegex.exec(hash);
|
||||
if (!match) {
|
||||
|
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
|
|||
|
||||
// Return the current origin, path and hash separated with a `/`. This does
|
||||
// not include query parameters.
|
||||
function getRedactedUrl() {
|
||||
function getRedactedUrl(): string {
|
||||
const { origin, hash } = window.location;
|
||||
let { pathname } = window.location;
|
||||
|
||||
|
@ -56,7 +56,25 @@ function getRedactedUrl() {
|
|||
return origin + pathname + getRedactedHash(hash);
|
||||
}
|
||||
|
||||
const customVariables = {
|
||||
interface IData {
|
||||
/* eslint-disable camelcase */
|
||||
gt_ms?: string;
|
||||
e_c?: string;
|
||||
e_a?: string;
|
||||
e_n?: string;
|
||||
e_v?: string;
|
||||
ping?: string;
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
interface IVariable {
|
||||
id: number;
|
||||
expl: string; // explanation
|
||||
example: string; // example value
|
||||
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
|
||||
}
|
||||
|
||||
const customVariables: Record<string, IVariable> = {
|
||||
// The Matomo installation at https://matomo.riot.im is currently configured
|
||||
// with a limit of 10 custom variables.
|
||||
'App Platform': {
|
||||
|
@ -120,7 +138,7 @@ const customVariables = {
|
|||
},
|
||||
};
|
||||
|
||||
function whitelistRedact(whitelist, str) {
|
||||
function whitelistRedact(whitelist: string[], str: string): string {
|
||||
if (whitelist.includes(str)) return str;
|
||||
return '<redacted>';
|
||||
}
|
||||
|
@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
|
|||
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
|
||||
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
|
||||
|
||||
function getUid() {
|
||||
function getUid(): string {
|
||||
try {
|
||||
let data = localStorage && localStorage.getItem(UID_KEY);
|
||||
if (!data && localStorage) {
|
||||
|
@ -145,32 +163,36 @@ function getUid() {
|
|||
|
||||
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
|
||||
|
||||
class Analytics {
|
||||
export class Analytics {
|
||||
private baseUrl: URL = null;
|
||||
private siteId: string = null;
|
||||
private visitVariables: Record<number, [string, string]> = {}; // {[id: number]: [name: string, value: string]}
|
||||
private firstPage = true;
|
||||
private heartbeatIntervalID: number = null;
|
||||
|
||||
private readonly creationTs: string;
|
||||
private readonly lastVisitTs: string;
|
||||
private readonly visitCount: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = null;
|
||||
this.siteId = null;
|
||||
this.visitVariables = {};
|
||||
|
||||
this.firstPage = true;
|
||||
this._heartbeatIntervalID = null;
|
||||
|
||||
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
|
||||
if (!this.creationTs && localStorage) {
|
||||
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
|
||||
localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
|
||||
}
|
||||
|
||||
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
|
||||
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
|
||||
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
|
||||
this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
|
||||
if (localStorage) {
|
||||
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
|
||||
localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
|
||||
}
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
public get disabled() {
|
||||
return !this.baseUrl;
|
||||
}
|
||||
|
||||
canEnable() {
|
||||
public canEnable() {
|
||||
const config = SdkConfig.get();
|
||||
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
|
||||
}
|
||||
|
@ -179,67 +201,67 @@ class Analytics {
|
|||
* Enable Analytics if initialized but disabled
|
||||
* otherwise try and initalize, no-op if piwik config missing
|
||||
*/
|
||||
async enable() {
|
||||
public async enable() {
|
||||
if (!this.disabled) return;
|
||||
if (!this.canEnable()) return;
|
||||
const config = SdkConfig.get();
|
||||
|
||||
this.baseUrl = new URL("piwik.php", config.piwik.url);
|
||||
// set constants
|
||||
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
|
||||
this.baseUrl.searchParams.set("apiv", 1); // API version to use
|
||||
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
|
||||
this.baseUrl.searchParams.set("apiv", "1"); // API version to use
|
||||
this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
|
||||
// set user parameters
|
||||
this.baseUrl.searchParams.set("_id", getUid()); // uuid
|
||||
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
|
||||
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
|
||||
this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
|
||||
if (this.lastVisitTs) {
|
||||
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
|
||||
}
|
||||
|
||||
const platform = PlatformPeg.get();
|
||||
this._setVisitVariable('App Platform', platform.getHumanReadableName());
|
||||
this.setVisitVariable('App Platform', platform.getHumanReadableName());
|
||||
try {
|
||||
this._setVisitVariable('App Version', await platform.getAppVersion());
|
||||
this.setVisitVariable('App Version', await platform.getAppVersion());
|
||||
} catch (e) {
|
||||
this._setVisitVariable('App Version', 'unknown');
|
||||
this.setVisitVariable('App Version', 'unknown');
|
||||
}
|
||||
|
||||
this._setVisitVariable('Chosen Language', getCurrentLanguage());
|
||||
this.setVisitVariable('Chosen Language', getCurrentLanguage());
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname === 'riot.im') {
|
||||
this._setVisitVariable('Instance', window.location.pathname);
|
||||
this.setVisitVariable('Instance', window.location.pathname);
|
||||
} else if (hostname.endsWith('.element.io')) {
|
||||
this._setVisitVariable('Instance', hostname.replace('.element.io', ''));
|
||||
this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
|
||||
}
|
||||
|
||||
let installedPWA = "unknown";
|
||||
try {
|
||||
// Known to work at least for desktop Chrome
|
||||
installedPWA = window.matchMedia('(display-mode: standalone)').matches;
|
||||
installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
|
||||
} catch (e) { }
|
||||
this._setVisitVariable('Installed PWA', installedPWA);
|
||||
this.setVisitVariable('Installed PWA', installedPWA);
|
||||
|
||||
let touchInput = "unknown";
|
||||
try {
|
||||
// MDN claims broad support across browsers
|
||||
touchInput = window.matchMedia('(pointer: coarse)').matches;
|
||||
touchInput = String(window.matchMedia('(pointer: coarse)').matches);
|
||||
} catch (e) { }
|
||||
this._setVisitVariable('Touch Input', touchInput);
|
||||
this.setVisitVariable('Touch Input', touchInput);
|
||||
|
||||
// start heartbeat
|
||||
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
|
||||
this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
|
||||
*/
|
||||
disable() {
|
||||
public disable() {
|
||||
if (this.disabled) return;
|
||||
this.trackEvent('Analytics', 'opt-out');
|
||||
window.clearInterval(this._heartbeatIntervalID);
|
||||
window.clearInterval(this.heartbeatIntervalID);
|
||||
this.baseUrl = null;
|
||||
this.visitVariables = {};
|
||||
localStorage.removeItem(UID_KEY);
|
||||
|
@ -248,7 +270,7 @@ class Analytics {
|
|||
localStorage.removeItem(LAST_VISIT_TS_KEY);
|
||||
}
|
||||
|
||||
async _track(data) {
|
||||
private async _track(data: IData) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const now = new Date();
|
||||
|
@ -264,13 +286,13 @@ class Analytics {
|
|||
s: now.getSeconds(),
|
||||
};
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
const url = new URL(this.baseUrl.toString()); // copy
|
||||
for (const key in params) {
|
||||
url.searchParams.set(key, params[key]);
|
||||
}
|
||||
|
||||
try {
|
||||
await window.fetch(url, {
|
||||
await window.fetch(url.toString(), {
|
||||
method: "GET",
|
||||
mode: "no-cors",
|
||||
cache: "no-cache",
|
||||
|
@ -281,14 +303,14 @@ class Analytics {
|
|||
}
|
||||
}
|
||||
|
||||
ping() {
|
||||
public ping() {
|
||||
this._track({
|
||||
ping: 1,
|
||||
ping: "1",
|
||||
});
|
||||
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
|
||||
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
|
||||
}
|
||||
|
||||
trackPageChange(generationTimeMs) {
|
||||
public trackPageChange(generationTimeMs?: number) {
|
||||
if (this.disabled) return;
|
||||
if (this.firstPage) {
|
||||
// De-duplicate first page
|
||||
|
@ -303,11 +325,11 @@ class Analytics {
|
|||
}
|
||||
|
||||
this._track({
|
||||
gt_ms: generationTimeMs,
|
||||
gt_ms: String(generationTimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
trackEvent(category, action, name, value) {
|
||||
public trackEvent(category: string, action: string, name?: string, value?: string) {
|
||||
if (this.disabled) return;
|
||||
this._track({
|
||||
e_c: category,
|
||||
|
@ -317,12 +339,12 @@ class Analytics {
|
|||
});
|
||||
}
|
||||
|
||||
_setVisitVariable(key, value) {
|
||||
private setVisitVariable(key: keyof typeof customVariables, value: string) {
|
||||
if (this.disabled) return;
|
||||
this.visitVariables[customVariables[key].id] = [key, value];
|
||||
}
|
||||
|
||||
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
|
||||
public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const config = SdkConfig.get();
|
||||
|
@ -330,16 +352,16 @@ class Analytics {
|
|||
|
||||
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
|
||||
|
||||
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
|
||||
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
|
||||
this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
|
||||
this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
|
||||
}
|
||||
|
||||
setBreadcrumbs(state) {
|
||||
public setBreadcrumbs(state: boolean) {
|
||||
if (this.disabled) return;
|
||||
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
|
||||
this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
|
||||
}
|
||||
|
||||
showDetailsModal = () => {
|
||||
public showDetailsModal = () => {
|
||||
let rows = [];
|
||||
if (!this.disabled) {
|
||||
rows = Object.values(this.visitVariables);
|
||||
|
@ -360,7 +382,7 @@ class Analytics {
|
|||
'e.g. <CurrentPageURL>',
|
||||
{},
|
||||
{
|
||||
CurrentPageURL: getRedactedUrl(),
|
||||
CurrentPageURL: getRedactedUrl,
|
||||
},
|
||||
),
|
||||
},
|
||||
|
@ -401,7 +423,7 @@ class Analytics {
|
|||
};
|
||||
}
|
||||
|
||||
if (!global.mxAnalytics) {
|
||||
global.mxAnalytics = new Analytics();
|
||||
if (!window.mxAnalytics) {
|
||||
window.mxAnalytics = new Analytics();
|
||||
}
|
||||
export default global.mxAnalytics;
|
||||
export default window.mxAnalytics;
|
|
@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
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 {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
export type ResizeMethod = "crop" | "scale";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(member, width, height, resizeMethod) {
|
||||
let url;
|
||||
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
|
||||
let url: string;
|
||||
if (member && member.getAvatarUrl) {
|
||||
url = member.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
|
@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
|
|||
return url;
|
||||
}
|
||||
|
||||
export function avatarUrlForUser(user, width, height, resizeMethod) {
|
||||
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
const url = getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
|
||||
Math.floor(width * window.devicePixelRatio),
|
||||
|
@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
|
|||
return url;
|
||||
}
|
||||
|
||||
function isValidHexColor(color) {
|
||||
function isValidHexColor(color: string): boolean {
|
||||
return typeof color === "string" &&
|
||||
(color.length === 7 || color.lengh === 9) &&
|
||||
(color.length === 7 || color.length === 9) &&
|
||||
color.charAt(0) === "#" &&
|
||||
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
|
||||
}
|
||||
|
||||
function urlForColor(color) {
|
||||
function urlForColor(color: string): string {
|
||||
const size = 40;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
|
@ -79,9 +84,9 @@ function urlForColor(color) {
|
|||
// XXX: Ideally we'd clear this cache when the theme changes
|
||||
// but since this function is at global scope, it's a bit
|
||||
// hard to install a listener here, even if there were a clear event to listen to
|
||||
const colorToDataURLCache = new Map();
|
||||
const colorToDataURLCache = new Map<string, string>();
|
||||
|
||||
export function defaultAvatarUrlForString(s) {
|
||||
export function defaultAvatarUrlForString(s: string): string {
|
||||
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
|
||||
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
|
||||
let total = 0;
|
||||
|
@ -113,7 +118,7 @@ export function defaultAvatarUrlForString(s) {
|
|||
* @param {string} name
|
||||
* @return {string} the first letter
|
||||
*/
|
||||
export function getInitialLetter(name) {
|
||||
export function getInitialLetter(name: string): string {
|
||||
if (!name) {
|
||||
// XXX: We should find out what causes the name to sometimes be falsy.
|
||||
console.trace("`name` argument to `getInitialLetter` not supplied");
|
||||
|
@ -146,7 +151,7 @@ export function getInitialLetter(name) {
|
|||
return firstChar.toUpperCase();
|
||||
}
|
||||
|
||||
export function avatarUrlForRoom(room, width, height, resizeMethod) {
|
||||
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
if (!room) return null; // null-guard
|
||||
|
||||
const explicitRoomAvatar = room.getAvatarUrl(
|
|
@ -24,6 +24,7 @@ import {ActionPayload} from "./dispatcher/payloads";
|
|||
import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
|
||||
import {Action} from "./dispatcher/actions";
|
||||
import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
|
||||
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
|
||||
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
|
||||
|
@ -105,6 +106,9 @@ export default abstract class BasePlatform {
|
|||
* @param newVersion the version string to check
|
||||
*/
|
||||
protected shouldShowUpdate(newVersion: string): boolean {
|
||||
// If the user registered on this client in the last 24 hours then do not show them the update toast
|
||||
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
|
||||
|
||||
try {
|
||||
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
|
||||
return newVersion !== version || Date.now() > deferUntil;
|
||||
|
|
|
@ -59,8 +59,7 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
|||
import PlatformPeg from './PlatformPeg';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
|
@ -77,7 +76,9 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
|||
import WidgetStore from "./stores/WidgetStore";
|
||||
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
|
||||
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
||||
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call";
|
||||
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import Analytics from './Analytics';
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
|
||||
enum AudioID {
|
||||
Ring = 'ringAudio',
|
||||
|
@ -96,6 +97,21 @@ export enum PlaceCallType {
|
|||
ScreenSharing = 'screensharing',
|
||||
}
|
||||
|
||||
function getRemoteAudioElement(): HTMLAudioElement {
|
||||
// this needs to be somewhere at the top of the DOM which
|
||||
// always exists to avoid audio interruptions.
|
||||
// Might as well just use DOM.
|
||||
const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
|
||||
if (!remoteAudioElement) {
|
||||
console.error(
|
||||
"Failed to find remoteAudio element - cannot play audio!" +
|
||||
"You need to add an <audio/> to the DOM.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return remoteAudioElement;
|
||||
}
|
||||
|
||||
export default class CallHandler {
|
||||
private calls = new Map<string, MatrixCall>();
|
||||
private audioPromises = new Map<AudioID, Promise<void>>();
|
||||
|
@ -179,8 +195,19 @@ export default class CallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private matchesCallForThisRoom(call: MatrixCall) {
|
||||
// We don't allow placing more than one call per room, but that doesn't mean there
|
||||
// can't be more than one, eg. in a glare situation. This checks that the given call
|
||||
// is the call we consider 'the' call for its room.
|
||||
const callForThisRoom = this.getCallForRoom(call.roomId);
|
||||
return callForThisRoom && call.callId === callForThisRoom.callId;
|
||||
}
|
||||
|
||||
private setCallListeners(call: MatrixCall) {
|
||||
call.on(CallEvent.Error, (err) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
Analytics.trackEvent('voip', 'callError', 'error', err);
|
||||
console.error("Call error:", err);
|
||||
if (
|
||||
MatrixClientPeg.get().getTurnServers().length === 0 &&
|
||||
|
@ -196,9 +223,15 @@ export default class CallHandler {
|
|||
});
|
||||
});
|
||||
call.on(CallEvent.Hangup, () => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
Analytics.trackEvent('voip', 'callHangup');
|
||||
|
||||
this.removeCallForRoom(call.roomId);
|
||||
});
|
||||
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
|
@ -218,21 +251,63 @@ export default class CallHandler {
|
|||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
|
||||
this.removeCallForRoom(call.roomId);
|
||||
if (oldState === CallState.InviteSent && (
|
||||
call.hangupParty === CallParty.Remote ||
|
||||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
|
||||
)) {
|
||||
this.play(AudioID.Busy);
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
|
||||
title: _t('Call Timeout'),
|
||||
description: _t('The remote side failed to pick up') + '.',
|
||||
let title;
|
||||
let description;
|
||||
if (call.hangupReason === CallErrorCode.UserHangup) {
|
||||
title = _t("Call Declined");
|
||||
description = _t("The other party declined the call.");
|
||||
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
|
||||
title = _t("Call Failed");
|
||||
// XXX: full stop appended as some relic here, but these
|
||||
// strings need proper input from design anyway, so let's
|
||||
// not change this string until we have a proper one.
|
||||
description = _t('The remote side failed to pick up') + '.';
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) {
|
||||
this.play(AudioID.Busy);
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
description: _t("The call was answered on another device."),
|
||||
});
|
||||
} else {
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
}
|
||||
});
|
||||
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
|
||||
|
||||
if (call.state === CallState.Ringing) {
|
||||
this.pause(AudioID.Ring);
|
||||
} else if (call.state === CallState.InviteSent) {
|
||||
this.pause(AudioID.Ringback);
|
||||
}
|
||||
|
||||
this.calls.set(newCall.roomId, newCall);
|
||||
this.setCallListeners(newCall);
|
||||
this.setCallState(newCall, newCall.state);
|
||||
});
|
||||
}
|
||||
|
||||
private setCallAudioElement(call: MatrixCall) {
|
||||
const audioElement = getRemoteAudioElement();
|
||||
if (audioElement) call.setRemoteAudioElement(audioElement);
|
||||
}
|
||||
|
||||
private setCallState(call: MatrixCall, status: CallState) {
|
||||
|
@ -285,9 +360,13 @@ export default class CallHandler {
|
|||
roomId: string, type: PlaceCallType,
|
||||
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
|
||||
) {
|
||||
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
|
||||
Analytics.trackEvent('voip', 'placeCall', 'type', type);
|
||||
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
|
||||
const call = createNewMatrixCall(MatrixClientPeg.get(), roomId);
|
||||
this.calls.set(roomId, call);
|
||||
this.setCallListeners(call);
|
||||
this.setCallAudioElement(call);
|
||||
|
||||
if (type === PlaceCallType.Voice) {
|
||||
call.placeVoiceCall();
|
||||
} else if (type === 'video') {
|
||||
|
@ -362,6 +441,8 @@ export default class CallHandler {
|
|||
break;
|
||||
case 'place_conference_call':
|
||||
console.info("Place conference call in %s", payload.room_id);
|
||||
Analytics.trackEvent('voip', 'placeConferenceCall');
|
||||
CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
|
||||
this.startCallApp(payload.room_id, payload.type);
|
||||
break;
|
||||
case 'end_conference':
|
||||
|
@ -388,27 +469,37 @@ export default class CallHandler {
|
|||
}
|
||||
|
||||
const call = payload.call as MatrixCall;
|
||||
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
|
||||
this.calls.set(call.roomId, call)
|
||||
this.setCallListeners(call);
|
||||
this.setCallAudioElement(call);
|
||||
}
|
||||
break;
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
if (!this.calls.get(payload.room_id)) {
|
||||
return; // no call to hangup
|
||||
}
|
||||
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false)
|
||||
if (payload.action === 'reject') {
|
||||
this.calls.get(payload.room_id).reject();
|
||||
} else {
|
||||
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
|
||||
}
|
||||
this.removeCallForRoom(payload.room_id);
|
||||
break;
|
||||
case 'answer':
|
||||
case 'answer': {
|
||||
if (!this.calls.has(payload.room_id)) {
|
||||
return; // no call to answer
|
||||
}
|
||||
this.calls.get(payload.room_id).answer();
|
||||
const call = this.calls.get(payload.room_id);
|
||||
call.answer();
|
||||
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: payload.room_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import extend from './extend';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
@ -32,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner";
|
|||
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
|
||||
import "blueimp-canvas-to-blob";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -369,10 +369,13 @@ export default class ContentMessages {
|
|||
private mediaConfig: IMediaConfig = null;
|
||||
|
||||
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) {
|
||||
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
|
||||
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
|
||||
throw e;
|
||||
});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"});
|
||||
return prom;
|
||||
}
|
||||
|
||||
getUploadLimit() {
|
||||
|
@ -480,6 +483,7 @@ export default class ContentMessages {
|
|||
}
|
||||
|
||||
private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise<any>) {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
const content: IContent = {
|
||||
body: file.name || 'Attachment',
|
||||
info: {
|
||||
|
@ -497,7 +501,7 @@ export default class ContentMessages {
|
|||
if (file.type.indexOf('image/') === 0) {
|
||||
content.msgtype = 'm.image';
|
||||
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
|
||||
extend(content.info, imageInfo);
|
||||
Object.assign(content.info, imageInfo);
|
||||
resolve();
|
||||
}, (e) => {
|
||||
console.error(e);
|
||||
|
@ -510,7 +514,7 @@ export default class ContentMessages {
|
|||
} else if (file.type.indexOf('video/') === 0) {
|
||||
content.msgtype = 'm.video';
|
||||
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
|
||||
extend(content.info, videoInfo);
|
||||
Object.assign(content.info, videoInfo);
|
||||
resolve();
|
||||
}, (e) => {
|
||||
content.msgtype = 'm.file';
|
||||
|
@ -564,7 +568,9 @@ export default class ContentMessages {
|
|||
return promBefore;
|
||||
}).then(function() {
|
||||
if (upload.canceled) throw new UploadCanceledError();
|
||||
return matrixClient.sendMessage(roomId, content);
|
||||
const prom = matrixClient.sendMessage(roomId, content);
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
|
||||
return prom;
|
||||
}, function(err) {
|
||||
error = err;
|
||||
if (!upload.canceled) {
|
||||
|
|
973
src/CountlyAnalytics.ts
Normal file
973
src/CountlyAnalytics.ts
Normal file
|
@ -0,0 +1,973 @@
|
|||
/*
|
||||
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 {randomString} from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import {getCurrentLanguage} from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import {sleep} from "./utils/promise";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
|
||||
// polyfill textencoder if necessary
|
||||
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
|
||||
let TextEncoder = window.TextEncoder;
|
||||
if (!TextEncoder) {
|
||||
TextEncoder = TextEncodingUtf8.TextEncoder;
|
||||
}
|
||||
|
||||
const INACTIVITY_TIME = 20; // seconds
|
||||
const HEARTBEAT_INTERVAL = 5_000; // ms
|
||||
const SESSION_UPDATE_INTERVAL = 60; // seconds
|
||||
const MAX_PENDING_EVENTS = 1000;
|
||||
|
||||
enum Orientation {
|
||||
Landscape = "landscape",
|
||||
Portrait = "portrait",
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IMetrics {
|
||||
_resolution?: string;
|
||||
_app_version?: string;
|
||||
_density?: number;
|
||||
_ua?: string;
|
||||
_locale?: string;
|
||||
}
|
||||
|
||||
interface IEvent {
|
||||
key: string;
|
||||
count: number;
|
||||
sum?: number;
|
||||
dur?: number;
|
||||
segmentation?: Record<string, unknown>;
|
||||
timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp
|
||||
hour?: unknown;
|
||||
dow?: unknown;
|
||||
}
|
||||
|
||||
interface IViewEvent extends IEvent {
|
||||
key: "[CLY]_view";
|
||||
}
|
||||
|
||||
interface IOrientationEvent extends IEvent {
|
||||
key: "[CLY]_orientation";
|
||||
segmentation: {
|
||||
mode: Orientation;
|
||||
};
|
||||
}
|
||||
|
||||
interface IStarRatingEvent extends IEvent {
|
||||
key: "[CLY]_star_rating";
|
||||
segmentation: {
|
||||
// we just care about collecting feedback, no need to associate with a feedback widget
|
||||
widget_id?: string;
|
||||
contactMe?: boolean;
|
||||
email?: string;
|
||||
rating: 1 | 2 | 3 | 4 | 5;
|
||||
comment: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Value = string | number | boolean;
|
||||
|
||||
interface IOperationInc {
|
||||
"$inc": number;
|
||||
}
|
||||
interface IOperationMul {
|
||||
"$mul": number;
|
||||
}
|
||||
interface IOperationMax {
|
||||
"$max": number;
|
||||
}
|
||||
interface IOperationMin {
|
||||
"$min": number;
|
||||
}
|
||||
interface IOperationSetOnce {
|
||||
"$setOnce": Value;
|
||||
}
|
||||
interface IOperationPush {
|
||||
"$push": Value | Value[];
|
||||
}
|
||||
interface IOperationAddToSet {
|
||||
"$addToSet": Value | Value[];
|
||||
}
|
||||
interface IOperationPull {
|
||||
"$pull": Value | Value[];
|
||||
}
|
||||
|
||||
type Operation =
|
||||
IOperationInc |
|
||||
IOperationMul |
|
||||
IOperationMax |
|
||||
IOperationMin |
|
||||
IOperationSetOnce |
|
||||
IOperationPush |
|
||||
IOperationAddToSet |
|
||||
IOperationPull;
|
||||
|
||||
interface IUserDetails {
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
phone?: string;
|
||||
picture?: string;
|
||||
gender?: string;
|
||||
byear?: number;
|
||||
custom?: Record<string, Value | Operation>; // `.` and `$` will be stripped out
|
||||
}
|
||||
|
||||
interface ICrash {
|
||||
_resolution?: string;
|
||||
_app_version: string;
|
||||
|
||||
_ram_current?: number;
|
||||
_ram_total?: number;
|
||||
_disk_current?: number;
|
||||
_disk_total?: number;
|
||||
_orientation?: Orientation;
|
||||
|
||||
_online?: boolean;
|
||||
_muted?: boolean;
|
||||
_background?: boolean;
|
||||
_view?: string;
|
||||
|
||||
_name?: string;
|
||||
_error: string;
|
||||
_nonfatal?: boolean;
|
||||
_logs?: string;
|
||||
_run?: number;
|
||||
|
||||
_custom?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IParams {
|
||||
// APP_KEY of an app for which to report
|
||||
app_key: string;
|
||||
// User identifier
|
||||
device_id: string;
|
||||
|
||||
// Should provide value 1 to indicate session start
|
||||
begin_session?: number;
|
||||
// JSON object as string to provide metrics to track with the user
|
||||
metrics?: string;
|
||||
// Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds
|
||||
session_duration?: number;
|
||||
// Should provide value 1 to indicate session end
|
||||
end_session?: number;
|
||||
|
||||
// 10 digit UTC timestamp for recording past data.
|
||||
timestamp?: number;
|
||||
// current user local hour (0 - 23)
|
||||
hour?: number;
|
||||
// day of the week (0-sunday, 1 - monday, ... 6 - saturday)
|
||||
dow?: number;
|
||||
|
||||
// JSON array as string containing event objects
|
||||
events?: string; // IEvent[]
|
||||
// JSON object as string containing information about users
|
||||
user_details?: string;
|
||||
|
||||
// provide when changing device ID, so server would merge the data
|
||||
old_device_id?: string;
|
||||
|
||||
// See ICrash
|
||||
crash?: string;
|
||||
}
|
||||
|
||||
interface IRoomSegments extends Record<string, Value> {
|
||||
room_id: string; // hashed
|
||||
num_users: number;
|
||||
is_encrypted: boolean;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
interface ISendMessageEvent extends IEvent {
|
||||
key: "send_message";
|
||||
dur: number; // how long it to send (until remote echo)
|
||||
segmentation: IRoomSegments & {
|
||||
is_edit: boolean;
|
||||
is_reply: boolean;
|
||||
msgtype: string;
|
||||
format?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IRoomDirectoryEvent extends IEvent {
|
||||
key: "room_directory";
|
||||
}
|
||||
|
||||
interface IRoomDirectoryDoneEvent extends IEvent {
|
||||
key: "room_directory_done";
|
||||
dur: number; // time spent in the room directory modal
|
||||
}
|
||||
|
||||
interface IRoomDirectorySearchEvent extends IEvent {
|
||||
key: "room_directory_search";
|
||||
sum: number; // number of search results
|
||||
segmentation: {
|
||||
query_length: number;
|
||||
query_num_words: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface IStartCallEvent extends IEvent {
|
||||
key: "start_call";
|
||||
segmentation: IRoomSegments & {
|
||||
is_video: boolean;
|
||||
is_jitsi: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IJoinCallEvent extends IEvent {
|
||||
key: "join_call";
|
||||
segmentation: IRoomSegments & {
|
||||
is_video: boolean;
|
||||
is_jitsi: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IBeginInviteEvent extends IEvent {
|
||||
key: "begin_invite";
|
||||
segmentation: IRoomSegments;
|
||||
}
|
||||
|
||||
interface ISendInviteEvent extends IEvent {
|
||||
key: "send_invite";
|
||||
sum: number; // quantity that was invited
|
||||
segmentation: IRoomSegments;
|
||||
}
|
||||
|
||||
interface ICreateRoomEvent extends IEvent {
|
||||
key: "create_room";
|
||||
dur: number; // how long it took to create (until remote echo)
|
||||
segmentation: {
|
||||
room_id: string; // hashed
|
||||
num_users: number;
|
||||
is_encrypted: boolean;
|
||||
is_public: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface IJoinRoomEvent extends IEvent {
|
||||
key: "join_room";
|
||||
dur: number; // how long it took to join (until remote echo)
|
||||
segmentation: {
|
||||
room_id: string; // hashed
|
||||
num_users: number;
|
||||
is_encrypted: boolean;
|
||||
is_public: boolean;
|
||||
type: "room_directory" | "slash_command" | "link" | "invite";
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
const hashHex = async (input: string): Promise<string> => {
|
||||
const buf = new TextEncoder().encode(input);
|
||||
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
||||
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const knownScreens = new Set([
|
||||
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||
]);
|
||||
|
||||
interface IViewData {
|
||||
name: string;
|
||||
url: string;
|
||||
meta: Record<string, string>;
|
||||
}
|
||||
|
||||
// Apply fn to all hash path parts after the 1st one
|
||||
async function getViewData(anonymous = true): Promise<IViewData> {
|
||||
const rand = randomString(8);
|
||||
const { origin, hash } = window.location;
|
||||
let { pathname } = window.location;
|
||||
|
||||
// Redact paths which could contain unexpected PII
|
||||
if (origin.startsWith('file://')) {
|
||||
pathname = `/<redacted_${rand}>/`; // XXX: inject rand because Count.ly doesn't like X->X transitions
|
||||
}
|
||||
|
||||
let [_, screen, ...parts] = hash.split("/");
|
||||
|
||||
if (!knownScreens.has(screen)) {
|
||||
screen = `<redacted_${rand}>`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
parts[i] = anonymous ? `<redacted_${rand}>` : await hashHex(parts[i]);
|
||||
}
|
||||
|
||||
const hashStr = `${_}/${screen}/${parts.join("/")}`;
|
||||
const url = origin + pathname + hashStr;
|
||||
|
||||
const meta = {};
|
||||
|
||||
let name = "$/" + hash;
|
||||
switch (screen) {
|
||||
case "room": {
|
||||
name = "view_room";
|
||||
const roomId = RoomViewStore.getRoomId();
|
||||
name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions
|
||||
meta["room_id"] = parts[0];
|
||||
Object.assign(meta, getRoomStats(roomId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, url, meta };
|
||||
}
|
||||
|
||||
const getRoomStats = (roomId: string) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli?.getRoom(roomId);
|
||||
|
||||
return {
|
||||
"num_users": room?.getJoinedMemberCount(),
|
||||
"is_encrypted": cli?.isRoomEncrypted(roomId),
|
||||
// eslint-disable-next-line camelcase
|
||||
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
|
||||
}
|
||||
}
|
||||
|
||||
// async wrapper for regex-powered String.prototype.replace
|
||||
const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise<string>) => {
|
||||
const promises: Promise<string>[] = [];
|
||||
// dry-run to calculate the replace values
|
||||
str.replace(regex, (...args: string[]) => {
|
||||
promises.push(fn(...args));
|
||||
return "";
|
||||
});
|
||||
const values = await Promise.all(promises);
|
||||
return str.replace(regex, () => values.shift());
|
||||
};
|
||||
|
||||
export default class CountlyAnalytics {
|
||||
private baseUrl: URL = null;
|
||||
private appKey: string = null;
|
||||
private userKey: string = null;
|
||||
private anonymous: boolean;
|
||||
private appPlatform: string;
|
||||
private appVersion = "unknown";
|
||||
|
||||
private initTime = CountlyAnalytics.getTimestamp();
|
||||
private firstPage = true;
|
||||
private heartbeatIntervalId: NodeJS.Timeout;
|
||||
private activityIntervalId: NodeJS.Timeout;
|
||||
private trackTime = true;
|
||||
private lastBeat: number;
|
||||
private storedDuration = 0;
|
||||
private lastView: string;
|
||||
private lastViewTime = 0;
|
||||
private lastViewStoredDuration = 0;
|
||||
private sessionStarted = false;
|
||||
private heartbeatEnabled = false;
|
||||
private inactivityCounter = 0;
|
||||
private pendingEvents: IEvent[] = [];
|
||||
|
||||
private static internalInstance = new CountlyAnalytics();
|
||||
|
||||
public static get instance(): CountlyAnalytics {
|
||||
return CountlyAnalytics.internalInstance;
|
||||
}
|
||||
|
||||
public get disabled() {
|
||||
return !this.baseUrl;
|
||||
}
|
||||
|
||||
public canEnable() {
|
||||
const config = SdkConfig.get();
|
||||
return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey);
|
||||
}
|
||||
|
||||
private async changeUserKey(userKey: string, merge = false) {
|
||||
const oldUserKey = this.userKey;
|
||||
this.userKey = userKey;
|
||||
if (oldUserKey && merge) {
|
||||
await this.request({ old_device_id: oldUserKey });
|
||||
}
|
||||
}
|
||||
|
||||
public async enable(anonymous = true) {
|
||||
if (!this.disabled && this.anonymous === anonymous) return;
|
||||
if (!this.canEnable()) return;
|
||||
|
||||
if (!this.disabled) {
|
||||
// flush request queue as our userKey is going to change, no need to await it
|
||||
this.request();
|
||||
}
|
||||
|
||||
const config = SdkConfig.get();
|
||||
this.baseUrl = new URL("/i", config.countly.url);
|
||||
this.appKey = config.countly.appKey;
|
||||
|
||||
this.anonymous = anonymous;
|
||||
if (anonymous) {
|
||||
await this.changeUserKey(randomString(64))
|
||||
} else {
|
||||
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
|
||||
}
|
||||
|
||||
const platform = PlatformPeg.get();
|
||||
this.appPlatform = platform.getHumanReadableName();
|
||||
try {
|
||||
this.appVersion = await platform.getAppVersion();
|
||||
} catch (e) {
|
||||
console.warn("Failed to get app version, using 'unknown'");
|
||||
}
|
||||
|
||||
// start heartbeat
|
||||
this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL);
|
||||
this.trackSessions();
|
||||
this.trackErrors();
|
||||
}
|
||||
|
||||
public async disable() {
|
||||
if (this.disabled) return;
|
||||
await this.track("Opt-Out" );
|
||||
this.endSession();
|
||||
window.clearInterval(this.heartbeatIntervalId);
|
||||
window.clearTimeout(this.activityIntervalId)
|
||||
this.baseUrl = null;
|
||||
// remove listeners bound in trackSessions()
|
||||
window.removeEventListener("beforeunload", this.endSession);
|
||||
window.removeEventListener("unload", this.endSession);
|
||||
window.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
window.removeEventListener("mousemove", this.onUserActivity);
|
||||
window.removeEventListener("click", this.onUserActivity);
|
||||
window.removeEventListener("keydown", this.onUserActivity);
|
||||
window.removeEventListener("scroll", this.onUserActivity);
|
||||
}
|
||||
|
||||
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
|
||||
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
|
||||
}
|
||||
|
||||
public trackPageChange(generationTimeMs?: number) {
|
||||
if (this.disabled) return;
|
||||
// TODO use generationTimeMs
|
||||
this.trackPageView();
|
||||
}
|
||||
|
||||
private async trackPageView() {
|
||||
this.reportViewDuration();
|
||||
|
||||
await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one
|
||||
const viewData = await getViewData(this.anonymous);
|
||||
|
||||
const page = viewData.name;
|
||||
this.lastView = page;
|
||||
this.lastViewTime = CountlyAnalytics.getTimestamp();
|
||||
const segments = {
|
||||
...viewData.meta,
|
||||
name: page,
|
||||
visit: 1,
|
||||
domain: window.location.hostname,
|
||||
view: viewData.url,
|
||||
segment: this.appPlatform,
|
||||
start: this.firstPage,
|
||||
};
|
||||
|
||||
if (this.firstPage) {
|
||||
this.firstPage = false;
|
||||
}
|
||||
|
||||
this.track<IViewEvent>("[CLY]_view", segments);
|
||||
}
|
||||
|
||||
public static getTimestamp() {
|
||||
return Math.floor(new Date().getTime() / 1000);
|
||||
}
|
||||
|
||||
// store the last ms timestamp returned
|
||||
// we do this to prevent the ts from ever decreasing in the case of system time changing
|
||||
private lastMsTs = 0;
|
||||
|
||||
private getMsTimestamp() {
|
||||
const ts = new Date().getTime();
|
||||
if (this.lastMsTs >= ts) {
|
||||
// increment ts as to keep our data points well-ordered
|
||||
this.lastMsTs++;
|
||||
} else {
|
||||
this.lastMsTs = ts;
|
||||
}
|
||||
return this.lastMsTs;
|
||||
}
|
||||
|
||||
public async recordError(err: Error | string, fatal = false) {
|
||||
if (this.disabled || this.anonymous) return;
|
||||
|
||||
let error = "";
|
||||
if (typeof err === "object") {
|
||||
if (typeof err.stack !== "undefined") {
|
||||
error = err.stack;
|
||||
} else {
|
||||
if (typeof err.name !== "undefined") {
|
||||
error += err.name + ":";
|
||||
}
|
||||
if (typeof err.message !== "undefined") {
|
||||
error += err.message + "\n";
|
||||
}
|
||||
if (typeof err.fileName !== "undefined") {
|
||||
error += "in " + err.fileName + "\n";
|
||||
}
|
||||
if (typeof err.lineNumber !== "undefined") {
|
||||
error += "on " + err.lineNumber;
|
||||
}
|
||||
if (typeof err.columnNumber !== "undefined") {
|
||||
error += ":" + err.columnNumber;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error = err + "";
|
||||
}
|
||||
|
||||
// sanitize the error from identifiers
|
||||
error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => {
|
||||
return glyph + await hashHex(substring.substring(1));
|
||||
});
|
||||
|
||||
const metrics = this.getMetrics();
|
||||
const ob: ICrash = {
|
||||
_resolution: metrics?._resolution,
|
||||
_error: error,
|
||||
_app_version: this.appVersion,
|
||||
_run: CountlyAnalytics.getTimestamp() - this.initTime,
|
||||
_nonfatal: !fatal,
|
||||
_view: this.lastView,
|
||||
};
|
||||
|
||||
if (typeof navigator.onLine !== "undefined") {
|
||||
ob._online = navigator.onLine;
|
||||
}
|
||||
|
||||
ob._background = document.hasFocus();
|
||||
|
||||
this.request({ crash: JSON.stringify(ob) });
|
||||
}
|
||||
|
||||
private trackErrors() {
|
||||
//override global uncaught error handler
|
||||
window.onerror = (msg, url, line, col, err) => {
|
||||
if (typeof err !== "undefined") {
|
||||
this.recordError(err, false);
|
||||
} else {
|
||||
let error = "";
|
||||
if (typeof msg !== "undefined") {
|
||||
error += msg + "\n";
|
||||
}
|
||||
if (typeof url !== "undefined") {
|
||||
error += "at " + url;
|
||||
}
|
||||
if (typeof line !== "undefined") {
|
||||
error += ":" + line;
|
||||
}
|
||||
if (typeof col !== "undefined") {
|
||||
error += ":" + col;
|
||||
}
|
||||
error += "\n";
|
||||
|
||||
try {
|
||||
const stack = [];
|
||||
// eslint-disable-next-line no-caller
|
||||
let f = arguments.callee.caller;
|
||||
while (f) {
|
||||
stack.push(f.name);
|
||||
f = f.caller;
|
||||
}
|
||||
error += stack.join("\n");
|
||||
} catch (ex) {
|
||||
//silent error
|
||||
}
|
||||
this.recordError(error, false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true);
|
||||
});
|
||||
}
|
||||
|
||||
private heartbeat() {
|
||||
const args: Pick<IParams, "session_duration"> = {};
|
||||
|
||||
// extend session if needed
|
||||
if (this.sessionStarted && this.trackTime) {
|
||||
const last = CountlyAnalytics.getTimestamp();
|
||||
if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) {
|
||||
args.session_duration = last - this.lastBeat;
|
||||
this.lastBeat = last;
|
||||
}
|
||||
}
|
||||
|
||||
// process event queue
|
||||
if (this.pendingEvents.length > 0 || args.session_duration) {
|
||||
this.request(args);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
args: Omit<IParams, "app_key" | "device_id" | "timestamp" | "hour" | "dow">
|
||||
& Partial<Pick<IParams, "device_id">> = {},
|
||||
) {
|
||||
const request: IParams = {
|
||||
app_key: this.appKey,
|
||||
device_id: this.userKey,
|
||||
...this.getTimeParams(),
|
||||
...args,
|
||||
};
|
||||
|
||||
if (this.pendingEvents.length > 0) {
|
||||
const EVENT_BATCH_SIZE = 10;
|
||||
const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE);
|
||||
request.events = JSON.stringify(events);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(request as {});
|
||||
|
||||
try {
|
||||
await window.fetch(this.baseUrl.toString(), {
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
cache: "no-cache",
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Analytics error: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private getTimeParams(): Pick<IParams, "timestamp" | "hour" | "dow"> {
|
||||
const date = new Date();
|
||||
return {
|
||||
timestamp: this.getMsTimestamp(),
|
||||
hour: date.getHours(),
|
||||
dow: date.getDay(),
|
||||
};
|
||||
}
|
||||
|
||||
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
|
||||
const {count = 1, ...rest} = args;
|
||||
const ev = {
|
||||
...this.getTimeParams(),
|
||||
...rest,
|
||||
count,
|
||||
platform: this.appPlatform,
|
||||
app_version: this.appVersion,
|
||||
}
|
||||
|
||||
this.pendingEvents.push(ev);
|
||||
if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
|
||||
this.pendingEvents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private getOrientation = (): Orientation => {
|
||||
return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait;
|
||||
};
|
||||
|
||||
private reportOrientation = () => {
|
||||
this.track<IOrientationEvent>("[CLY]_orientation", {
|
||||
mode: this.getOrientation(),
|
||||
});
|
||||
};
|
||||
|
||||
private startTime() {
|
||||
if (!this.trackTime) {
|
||||
this.trackTime = true;
|
||||
this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration;
|
||||
this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration;
|
||||
this.lastViewStoredDuration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private stopTime() {
|
||||
if (this.trackTime) {
|
||||
this.trackTime = false;
|
||||
this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat;
|
||||
this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime;
|
||||
}
|
||||
}
|
||||
|
||||
private getMetrics(): IMetrics {
|
||||
if (this.anonymous) return undefined;
|
||||
const metrics: IMetrics = {};
|
||||
|
||||
// getting app version
|
||||
metrics._app_version = this.appVersion;
|
||||
metrics._ua = navigator.userAgent;
|
||||
|
||||
// getting resolution
|
||||
if (screen.width && screen.height) {
|
||||
metrics._resolution = `${screen.width}x${screen.height}`;
|
||||
}
|
||||
|
||||
// getting density ratio
|
||||
if (window.devicePixelRatio) {
|
||||
metrics._density = window.devicePixelRatio;
|
||||
}
|
||||
|
||||
// getting locale
|
||||
metrics._locale = getCurrentLanguage();
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private async beginSession(heartbeat = true) {
|
||||
if (!this.sessionStarted) {
|
||||
this.reportOrientation();
|
||||
window.addEventListener("resize", this.reportOrientation);
|
||||
|
||||
this.lastBeat = CountlyAnalytics.getTimestamp();
|
||||
this.sessionStarted = true;
|
||||
this.heartbeatEnabled = heartbeat;
|
||||
|
||||
const userDetails: IUserDetails = {
|
||||
custom: {
|
||||
"home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash?
|
||||
"anonymous": this.anonymous,
|
||||
},
|
||||
};
|
||||
|
||||
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
|
||||
begin_session: 1,
|
||||
user_details: JSON.stringify(userDetails),
|
||||
}
|
||||
|
||||
const metrics = this.getMetrics();
|
||||
if (metrics) {
|
||||
request.metrics = JSON.stringify(metrics);
|
||||
}
|
||||
|
||||
await this.request(request);
|
||||
}
|
||||
}
|
||||
|
||||
private reportViewDuration() {
|
||||
if (this.lastView) {
|
||||
this.track<IViewEvent>("[CLY]_view", {
|
||||
name: this.lastView,
|
||||
}, null, {
|
||||
dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration,
|
||||
});
|
||||
this.lastView = null;
|
||||
}
|
||||
}
|
||||
|
||||
private endSession = () => {
|
||||
if (this.sessionStarted) {
|
||||
window.removeEventListener("resize", this.reportOrientation)
|
||||
|
||||
this.reportViewDuration();
|
||||
this.request({
|
||||
end_session: 1,
|
||||
session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat,
|
||||
});
|
||||
}
|
||||
this.sessionStarted = false;
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.stopTime();
|
||||
} else {
|
||||
this.startTime();
|
||||
}
|
||||
};
|
||||
|
||||
private onUserActivity = () => {
|
||||
if (this.inactivityCounter >= INACTIVITY_TIME) {
|
||||
this.startTime();
|
||||
}
|
||||
this.inactivityCounter = 0;
|
||||
};
|
||||
|
||||
private trackSessions() {
|
||||
this.beginSession();
|
||||
this.startTime();
|
||||
|
||||
window.addEventListener("beforeunload", this.endSession);
|
||||
window.addEventListener("unload", this.endSession);
|
||||
window.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
window.addEventListener("mousemove", this.onUserActivity);
|
||||
window.addEventListener("click", this.onUserActivity);
|
||||
window.addEventListener("keydown", this.onUserActivity);
|
||||
window.addEventListener("scroll", this.onUserActivity);
|
||||
|
||||
this.activityIntervalId = setInterval(() => {
|
||||
this.inactivityCounter++;
|
||||
if (this.inactivityCounter >= INACTIVITY_TIME) {
|
||||
this.stopTime();
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
public trackBeginInvite(roomId: string) {
|
||||
this.track<IBeginInviteEvent>("begin_invite", {}, roomId);
|
||||
}
|
||||
|
||||
public trackSendInvite(startTime: number, roomId: string, qty: number) {
|
||||
this.track<ISendInviteEvent>("send_invite", {}, roomId, {
|
||||
dur: CountlyAnalytics.getTimestamp() - startTime,
|
||||
sum: qty,
|
||||
});
|
||||
}
|
||||
|
||||
public async trackRoomCreate(startTime: number, roomId: string) {
|
||||
if (this.disabled) return;
|
||||
|
||||
let endTime = CountlyAnalytics.getTimestamp();
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli.getRoom(roomId)) {
|
||||
await new Promise(resolve => {
|
||||
const handler = (room) => {
|
||||
if (room.roomId === roomId) {
|
||||
cli.off("Room", handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
cli.on("Room", handler);
|
||||
});
|
||||
endTime = CountlyAnalytics.getTimestamp();
|
||||
}
|
||||
|
||||
this.track<ICreateRoomEvent>("create_room", {}, roomId, {
|
||||
dur: endTime - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
|
||||
this.track<IJoinRoomEvent>("join_room", { type }, roomId, {
|
||||
dur: CountlyAnalytics.getTimestamp() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
public async trackSendMessage(
|
||||
startTime: number,
|
||||
// eslint-disable-next-line camelcase
|
||||
sendPromise: Promise<{event_id: string}>,
|
||||
roomId: string,
|
||||
isEdit: boolean,
|
||||
isReply: boolean,
|
||||
content: {format?: string, msgtype: string},
|
||||
) {
|
||||
if (this.disabled) return;
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
|
||||
const eventId = (await sendPromise).event_id;
|
||||
let endTime = CountlyAnalytics.getTimestamp();
|
||||
|
||||
if (!room.findEventById(eventId)) {
|
||||
await new Promise(resolve => {
|
||||
const handler = (ev) => {
|
||||
if (ev.getId() === eventId) {
|
||||
room.off("Room.localEchoUpdated", handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
room.on("Room.localEchoUpdated", handler);
|
||||
});
|
||||
endTime = CountlyAnalytics.getTimestamp();
|
||||
}
|
||||
|
||||
this.track<ISendMessageEvent>("send_message", {
|
||||
is_edit: isEdit,
|
||||
is_reply: isReply,
|
||||
msgtype: content.msgtype,
|
||||
format: content.format,
|
||||
}, roomId, {
|
||||
dur: endTime - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
public trackStartCall(roomId: string, isVideo = false, isJitsi = false) {
|
||||
this.track<IStartCallEvent>("start_call", {
|
||||
is_video: isVideo,
|
||||
is_jitsi: isJitsi,
|
||||
}, roomId);
|
||||
}
|
||||
|
||||
public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) {
|
||||
this.track<IJoinCallEvent>("join_call", {
|
||||
is_video: isVideo,
|
||||
is_jitsi: isJitsi,
|
||||
}, roomId);
|
||||
}
|
||||
|
||||
public trackRoomDirectoryBegin() {
|
||||
this.track<IRoomDirectoryEvent>("room_directory");
|
||||
}
|
||||
|
||||
public trackRoomDirectory(startTime: number) {
|
||||
this.track<IRoomDirectoryDoneEvent>("room_directory_done", {}, null, {
|
||||
dur: CountlyAnalytics.getTimestamp() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
public trackRoomDirectorySearch(numResults: number, query: string) {
|
||||
this.track<IRoomDirectorySearchEvent>("room_directory_search", {
|
||||
query_length: query.length,
|
||||
query_num_words: query.split(" ").length,
|
||||
}, null, {
|
||||
sum: numResults,
|
||||
});
|
||||
}
|
||||
|
||||
public async track<E extends IEvent>(
|
||||
key: E["key"],
|
||||
segments?: Omit<E["segmentation"], "room_id" | "num_users" | "is_encrypted" | "is_public">,
|
||||
roomId?: string,
|
||||
args?: Partial<Pick<E, "dur" | "sum" | "timestamp">>,
|
||||
anonymous = false,
|
||||
) {
|
||||
if (this.disabled && !anonymous) return;
|
||||
|
||||
let segmentation = segments || {};
|
||||
|
||||
if (roomId) {
|
||||
segmentation = {
|
||||
room_id: await hashHex(roomId),
|
||||
...getRoomStats(roomId),
|
||||
...segments,
|
||||
};
|
||||
}
|
||||
|
||||
this.queue({
|
||||
key,
|
||||
count: 1,
|
||||
segmentation,
|
||||
...args,
|
||||
});
|
||||
|
||||
// if this event can be sent anonymously and we are disabled then dispatch it right away
|
||||
if (this.disabled && anonymous) {
|
||||
await this.request({ device_id: randomString(64) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expose on window for easy access from the console
|
||||
window.mxCountlyAnalytics = CountlyAnalytics;
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
function getDaysArray() {
|
||||
function getDaysArray(): string[] {
|
||||
return [
|
||||
_t('Sun'),
|
||||
_t('Mon'),
|
||||
|
@ -29,7 +29,7 @@ function getDaysArray() {
|
|||
];
|
||||
}
|
||||
|
||||
function getMonthsArray() {
|
||||
function getMonthsArray(): string[] {
|
||||
return [
|
||||
_t('Jan'),
|
||||
_t('Feb'),
|
||||
|
@ -46,11 +46,11 @@ function getMonthsArray() {
|
|||
];
|
||||
}
|
||||
|
||||
function pad(n) {
|
||||
function pad(n: number): string {
|
||||
return (n < 10 ? '0' : '') + n;
|
||||
}
|
||||
|
||||
function twelveHourTime(date, showSeconds=false) {
|
||||
function twelveHourTime(date: Date, showSeconds = false): string {
|
||||
let hours = date.getHours() % 12;
|
||||
const minutes = pad(date.getMinutes());
|
||||
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
|
||||
|
@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) {
|
|||
return `${hours}:${minutes}${ampm}`;
|
||||
}
|
||||
|
||||
export function formatDate(date, showTwelveHour=false) {
|
||||
export function formatDate(date: Date, showTwelveHour = false): string {
|
||||
const now = new Date();
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
|
@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) {
|
|||
return formatFullDate(date, showTwelveHour);
|
||||
}
|
||||
|
||||
export function formatFullDateNoTime(date) {
|
||||
export function formatFullDateNoTime(date: Date): string {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
|
||||
|
@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatFullDate(date, showTwelveHour=false) {
|
||||
export function formatFullDate(date: Date, showTwelveHour = false): string {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
|
||||
|
@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatFullTime(date, showTwelveHour=false) {
|
||||
export function formatFullTime(date: Date, showTwelveHour = false): string {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date, true);
|
||||
}
|
||||
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||
}
|
||||
|
||||
export function formatTime(date, showTwelveHour=false) {
|
||||
export function formatTime(date: Date, showTwelveHour = false): string {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date);
|
||||
}
|
||||
|
@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) {
|
|||
}
|
||||
|
||||
const MILLIS_IN_DAY = 86400000;
|
||||
export function wantsDateSeparator(prevEventDate, nextEventDate) {
|
||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||
if (!nextEventDate || !prevEventDate) {
|
||||
return false;
|
||||
}
|
|
@ -29,6 +29,7 @@ import EMOJIBASE_REGEX from 'emojibase-regex';
|
|||
import url from 'url';
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
@ -171,7 +172,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
|
|||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||
// we don't want to allow images with `https?` `src`s.
|
||||
if (!attribs.src || !attribs.src.startsWith('mxc://')) {
|
||||
// We also drop inline images (as if they were not present at all) when the "show
|
||||
// images" preference is disabled. Future work might expose some UI to reveal them
|
||||
// like standalone image events have.
|
||||
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
|
||||
return { tagName, attribs: {}};
|
||||
}
|
||||
attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2015, 2016, 2020 Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
|
||||
* will occupy if resized to fit inside a thumbnail bounding box of size
|
||||
|
@ -30,11 +28,11 @@ limitations under the License.
|
|||
* consume in the timeline, when performing scroll offset calcuations
|
||||
* (e.g. scroll locking)
|
||||
*/
|
||||
export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) {
|
||||
export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
|
||||
if (!fullWidth || !fullHeight) {
|
||||
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
|
||||
// log this because it's spammy
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
|
||||
// no scaling needs to be applied
|
|
@ -23,6 +23,7 @@ import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
|||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg';
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
import EventIndexPeg from './indexing/EventIndexPeg';
|
||||
import createMatrixClient from './utils/createMatrixClient';
|
||||
import Analytics from './Analytics';
|
||||
|
@ -46,6 +47,7 @@ import DeviceListener from "./DeviceListener";
|
|||
import {Jitsi} from "./widgets/Jitsi";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
|
||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
|
||||
const HOMESERVER_URL_KEY = "mx_hs_url";
|
||||
const ID_SERVER_URL_KEY = "mx_is_url";
|
||||
|
@ -567,6 +569,8 @@ function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void
|
|||
localStorage.setItem("mx_device_id", credentials.deviceId);
|
||||
}
|
||||
|
||||
SecurityCustomisations.persistCredentials?.(credentials);
|
||||
|
||||
console.log(`Session persisted for ${credentials.userId}`);
|
||||
}
|
||||
|
||||
|
@ -577,6 +581,10 @@ let _isLoggingOut = false;
|
|||
*/
|
||||
export function logout(): void {
|
||||
if (!MatrixClientPeg.get()) return;
|
||||
if (!CountlyAnalytics.instance.disabled) {
|
||||
// user has logged out, fall back to anonymous
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// logout doesn't work for guest sessions
|
||||
|
|
|
@ -22,6 +22,7 @@ limitations under the License.
|
|||
import Matrix from "matrix-js-sdk";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
interface ILoginOptions {
|
||||
defaultDeviceDisplayName?: string;
|
||||
|
@ -222,11 +223,15 @@ export async function sendLoginRequest(
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const creds: IMatrixClientCreds = {
|
||||
homeserverUrl: hsUrl,
|
||||
identityServerUrl: isUrl,
|
||||
userId: data.user_id,
|
||||
deviceId: data.device_id,
|
||||
accessToken: data.access_token,
|
||||
};
|
||||
|
||||
SecurityCustomisations.examineLoginResponse?.(data, creds);
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import * as StorageManager from './utils/StorageManager';
|
|||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
|
||||
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
export interface IMatrixClientCreds {
|
||||
homeserverUrl: string;
|
||||
|
@ -100,6 +101,12 @@ export interface IMatrixClientPeg {
|
|||
*/
|
||||
currentUserIsJustRegistered(): boolean;
|
||||
|
||||
/**
|
||||
* If the current user has been registered by this device then this
|
||||
* returns a boolean of whether it was within the last N hours given.
|
||||
*/
|
||||
userRegisteredWithinLastHours(hours: number): boolean;
|
||||
|
||||
/**
|
||||
* Replace this MatrixClientPeg's client with a client instance that has
|
||||
* homeserver / identity server URLs and active credentials
|
||||
|
@ -150,6 +157,9 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
|
||||
public setJustRegisteredUserId(uid: string): void {
|
||||
this.justRegisteredUserId = uid;
|
||||
if (uid) {
|
||||
window.localStorage.setItem("mx_registration_time", String(new Date().getTime()));
|
||||
}
|
||||
}
|
||||
|
||||
public currentUserIsJustRegistered(): boolean {
|
||||
|
@ -159,6 +169,15 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
);
|
||||
}
|
||||
|
||||
public userRegisteredWithinLastHours(hours: number): boolean {
|
||||
try {
|
||||
const date = new Date(window.localStorage.getItem("mx_registration_time"));
|
||||
return ((new Date().getTime() - date.getTime()) / 36e5) <= hours;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public replaceUsingCreds(creds: IMatrixClientCreds): void {
|
||||
this.currentClientCreds = creds;
|
||||
this.createClient(creds);
|
||||
|
@ -273,7 +292,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
// These are always installed regardless of the labs flag so that
|
||||
// cross-signing features can toggle on without reloading and also be
|
||||
// accessed immediately after login.
|
||||
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
|
||||
const customisedCallbacks = {
|
||||
getDehydrationKey: SecurityCustomisations.getDehydrationKey,
|
||||
};
|
||||
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks);
|
||||
|
||||
this.matrixClient = createMatrixClient(opts);
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper';
|
|||
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
|
||||
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
|
||||
|
||||
interface IModal<T extends any[]> {
|
||||
export interface IModal<T extends any[]> {
|
||||
elem: React.ReactNode;
|
||||
className?: string;
|
||||
beforeClosePromise?: Promise<boolean>;
|
||||
|
@ -38,7 +38,7 @@ interface IModal<T extends any[]> {
|
|||
close(...args: T): void;
|
||||
}
|
||||
|
||||
interface IHandle<T extends any[]> {
|
||||
export interface IHandle<T extends any[]> {
|
||||
finished: Promise<T>;
|
||||
close(...args: T): void;
|
||||
}
|
||||
|
|
|
@ -218,7 +218,7 @@ export const Notifier = {
|
|||
// calculated value. It is determined based upon whether or not the master rule is enabled
|
||||
// and other flags. Setting it here would cause a circular reference.
|
||||
|
||||
Analytics.trackEvent('Notifier', 'Set Enabled', enable);
|
||||
Analytics.trackEvent('Notifier', 'Set Enabled', String(enable));
|
||||
|
||||
// make sure that we persist the current setting audio_enabled setting
|
||||
// before changing anything
|
||||
|
@ -287,7 +287,7 @@ export const Notifier = {
|
|||
setPromptHidden: function(hidden: boolean, persistent = true) {
|
||||
this.toolbarHidden = hidden;
|
||||
|
||||
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden);
|
||||
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden));
|
||||
|
||||
hideNotificationsToast();
|
||||
|
||||
|
|
|
@ -19,30 +19,34 @@ limitations under the License.
|
|||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Timer from './utils/Timer';
|
||||
import {ActionPayload} from "./dispatcher/payloads";
|
||||
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
|
||||
const PRESENCE_STATES = ["online", "offline", "unavailable"];
|
||||
|
||||
enum State {
|
||||
Online = "online",
|
||||
Offline = "offline",
|
||||
Unavailable = "unavailable",
|
||||
}
|
||||
|
||||
class Presence {
|
||||
constructor() {
|
||||
this._activitySignal = null;
|
||||
this._unavailableTimer = null;
|
||||
this._onAction = this._onAction.bind(this);
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
private unavailableTimer: Timer = null;
|
||||
private dispatcherRef: string = null;
|
||||
private state: State = null;
|
||||
|
||||
/**
|
||||
* Start listening the user activity to evaluate his presence state.
|
||||
* Any state change will be sent to the homeserver.
|
||||
*/
|
||||
async start() {
|
||||
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
|
||||
public async start() {
|
||||
this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
|
||||
// the user_activity_start action starts the timer
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
while (this._unavailableTimer) {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
while (this.unavailableTimer) {
|
||||
try {
|
||||
await this._unavailableTimer.finished();
|
||||
this.setState("unavailable");
|
||||
await this.unavailableTimer.finished();
|
||||
this.setState(State.Unavailable);
|
||||
} catch (e) { /* aborted, stop got called */ }
|
||||
}
|
||||
}
|
||||
|
@ -50,14 +54,14 @@ class Presence {
|
|||
/**
|
||||
* Stop tracking user activity
|
||||
*/
|
||||
stop() {
|
||||
if (this._dispatcherRef) {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
this._dispatcherRef = null;
|
||||
public stop() {
|
||||
if (this.dispatcherRef) {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.dispatcherRef = null;
|
||||
}
|
||||
if (this._unavailableTimer) {
|
||||
this._unavailableTimer.abort();
|
||||
this._unavailableTimer = null;
|
||||
if (this.unavailableTimer) {
|
||||
this.unavailableTimer.abort();
|
||||
this.unavailableTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,14 +69,14 @@ class Presence {
|
|||
* Get the current presence state.
|
||||
* @returns {string} the presence state (see PRESENCE enum)
|
||||
*/
|
||||
getState() {
|
||||
public getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
_onAction(payload) {
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'user_activity') {
|
||||
this.setState("online");
|
||||
this._unavailableTimer.restart();
|
||||
this.setState(State.Online);
|
||||
this.unavailableTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,13 +85,11 @@ class Presence {
|
|||
* If the state has changed, the homeserver will be notified.
|
||||
* @param {string} newState the new presence state (see PRESENCE enum)
|
||||
*/
|
||||
async setState(newState) {
|
||||
private async setState(newState: State) {
|
||||
if (newState === this.state) {
|
||||
return;
|
||||
}
|
||||
if (PRESENCE_STATES.indexOf(newState) === -1) {
|
||||
throw new Error("Bad presence state: " + newState);
|
||||
}
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
|
|
@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function levelRoleMap(usersDefault) {
|
||||
export function levelRoleMap(usersDefault: number) {
|
||||
return {
|
||||
undefined: _t('Default'),
|
||||
0: _t('Restricted'),
|
||||
|
@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) {
|
|||
};
|
||||
}
|
||||
|
||||
export function textualPowerLevel(level, usersDefault) {
|
||||
export function textualPowerLevel(level: number, usersDefault: number): string {
|
||||
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
|
||||
if (LEVEL_ROLE_MAP[level]) {
|
||||
return LEVEL_ROLE_MAP[level];
|
|
@ -40,11 +40,11 @@ export function inviteMultipleToRoom(roomId, addrs) {
|
|||
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
|
||||
}
|
||||
|
||||
export function showStartChatInviteDialog() {
|
||||
export function showStartChatInviteDialog(initialText) {
|
||||
// This dialog handles the room creation internally - we don't need to worry about it.
|
||||
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Start DM', '', InviteDialog, {kind: KIND_DM},
|
||||
'Start DM', '', InviteDialog, {kind: KIND_DM, initialText},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,6 +21,9 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
|||
* if any. This could be the canonical alias if one exists, otherwise
|
||||
* an alias selected arbitrarily but deterministically from the list
|
||||
* of aliases. Otherwise return null;
|
||||
*
|
||||
* @param {Object} room The room object
|
||||
* @returns {string} A display alias for the given room
|
||||
*/
|
||||
export function getDisplayAliasForRoom(room) {
|
||||
return room.getCanonicalAlias() || room.getAltAliases()[0];
|
||||
|
|
|
@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
let oldestEventFrom = previousSearchResult.oldestEventFrom;
|
||||
response.highlights = previousSearchResult.highlights;
|
||||
|
||||
if (localEvents && serverEvents) {
|
||||
if (localEvents && serverEvents && serverEvents.results) {
|
||||
// This is a first search call, combine the events from the server and
|
||||
// the local index. Note where our oldest event came from, we shall
|
||||
// fetch the next batch of events from the other source.
|
||||
|
@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
oldestEventFrom = "local";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
|
||||
} else if (serverEvents) {
|
||||
} else if (serverEvents && serverEvents.results) {
|
||||
// This is a pagination call fetching more events from the server,
|
||||
// meaning that our oldest event was in the local index.
|
||||
// Change the source of the oldest event if our server event is older
|
||||
|
@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
|
|||
return response;
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice) {
|
||||
function restoreEncryptionInfo(searchResultSlice = []) {
|
||||
for (let i = 0; i < searchResultSlice.length; i++) {
|
||||
const timeline = searchResultSlice[i].context.getTimeline();
|
||||
|
||||
|
@ -517,7 +517,7 @@ async function combinedPagination(searchResult) {
|
|||
},
|
||||
};
|
||||
|
||||
const oldResultCount = searchResult.results.length;
|
||||
const oldResultCount = searchResult.results ? searchResult.results.length : 0;
|
||||
|
||||
// Let the client process the combined result.
|
||||
const result = client._processRoomEventsSearch(searchResult, response);
|
||||
|
|
|
@ -22,11 +22,12 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
|||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
|
||||
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
// 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
|
||||
|
@ -115,6 +116,13 @@ async function getSecretStorageKey(
|
|||
}
|
||||
}
|
||||
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (secret storage)")
|
||||
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
||||
return [keyId, keyFromCustomisations];
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new Error("Could not unlock non-interactively");
|
||||
}
|
||||
|
@ -158,6 +166,12 @@ export async function getDehydrationKey(
|
|||
keyInfo: ISecretStorageKeyInfo,
|
||||
checkFunc: (Uint8Array) => void,
|
||||
): Promise<Uint8Array> {
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (dehydration)")
|
||||
return keyFromCustomisations;
|
||||
}
|
||||
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
|
||||
AccessSecretStorageDialog,
|
||||
|
@ -352,14 +366,19 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
|||
}
|
||||
console.log("Setting dehydration key");
|
||||
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
|
||||
} else if (!keyId) {
|
||||
console.warn("Not setting dehydration key: no SSSS key found");
|
||||
} else {
|
||||
console.log("Not setting dehydration key: no SSSS key found");
|
||||
console.log("Not setting dehydration key: feature disabled");
|
||||
}
|
||||
}
|
||||
|
||||
// `return await` needed here to ensure `finally` block runs after the
|
||||
// inner operation completes.
|
||||
return await func();
|
||||
} catch (e) {
|
||||
SecurityCustomisations.catchAccessSecretStorageError?.(e);
|
||||
console.error(e);
|
||||
} finally {
|
||||
// Clear secret storage key cache now that work is complete
|
||||
secretStorageBeingAccessed = false;
|
||||
|
|
|
@ -50,8 +50,8 @@ class Skinner {
|
|||
return null;
|
||||
}
|
||||
|
||||
// components have to be functions.
|
||||
const validType = typeof comp === 'function';
|
||||
// components have to be functions or forwardRef objects with a render function.
|
||||
const validType = typeof comp === 'function' || comp.render;
|
||||
if (!validType) {
|
||||
throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`);
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from
|
|||
import SdkConfig from "./SdkConfig";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {UIFeature} from "./settings/UIFeature";
|
||||
import CallHandler from "./CallHandler";
|
||||
|
||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||
interface HTMLInputEvent extends Event {
|
||||
|
@ -517,6 +518,7 @@ export const Commands = [
|
|||
action: 'view_room',
|
||||
room_alias: roomAlias,
|
||||
auto_join: true,
|
||||
_type: "slash_command", // instrumentation
|
||||
});
|
||||
return success();
|
||||
} else if (params[0][0] === '!') {
|
||||
|
@ -531,6 +533,7 @@ export const Commands = [
|
|||
},
|
||||
via_servers: viaServers, // for the rejoin button
|
||||
auto_join: true,
|
||||
_type: "slash_command", // instrumentation
|
||||
});
|
||||
return success();
|
||||
} else if (isPermalink) {
|
||||
|
@ -555,6 +558,7 @@ export const Commands = [
|
|||
const dispatch = {
|
||||
action: 'view_room',
|
||||
auto_join: true,
|
||||
_type: "slash_command", // instrumentation
|
||||
};
|
||||
|
||||
if (entity[0] === '!') dispatch["room_id"] = entity;
|
||||
|
@ -998,14 +1002,29 @@ export const Commands = [
|
|||
description: _td("Opens chat with the given user"),
|
||||
args: "<user-id>",
|
||||
runFn: function(roomId, userId) {
|
||||
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
|
||||
// easter-egg for now: look up phone numbers through the thirdparty API
|
||||
// (very dumb phone number detection...)
|
||||
const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId);
|
||||
if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) {
|
||||
return reject(this.getUsage());
|
||||
}
|
||||
|
||||
return success((async () => {
|
||||
if (isPhoneNumber) {
|
||||
const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
|
||||
'm.id.phone': userId,
|
||||
});
|
||||
if (!results || results.length === 0 || !results[0].userid) {
|
||||
throw new Error("Unable to find Matrix ID for phone number");
|
||||
}
|
||||
userId = results[0].userid;
|
||||
}
|
||||
|
||||
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: await ensureDMExists(MatrixClientPeg.get(), userId),
|
||||
room_id: roomId,
|
||||
});
|
||||
})());
|
||||
},
|
||||
|
@ -1039,6 +1058,32 @@ export const Commands = [
|
|||
},
|
||||
category: CommandCategories.actions,
|
||||
}),
|
||||
new Command({
|
||||
command: "holdcall",
|
||||
description: _td("Places the call in the current room on hold"),
|
||||
category: CommandCategories.other,
|
||||
runFn: function(roomId, args) {
|
||||
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
|
||||
if (!call) {
|
||||
return reject("No active call in this room");
|
||||
}
|
||||
call.setRemoteOnHold(true);
|
||||
return success();
|
||||
},
|
||||
}),
|
||||
new Command({
|
||||
command: "unholdcall",
|
||||
description: _td("Takes the call in the current room off hold"),
|
||||
category: CommandCategories.other,
|
||||
runFn: function(roomId, args) {
|
||||
const call = CallHandler.sharedInstance().getCallForRoom(roomId);
|
||||
if (!call) {
|
||||
return reject("No active call in this room");
|
||||
}
|
||||
call.setRemoteOnHold(false);
|
||||
return success();
|
||||
},
|
||||
}),
|
||||
|
||||
// Command definitions for autocompletion ONLY:
|
||||
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
|
||||
|
|
|
@ -198,59 +198,30 @@ function textForRelatedGroupsEvent(ev) {
|
|||
function textForServerACLEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const changes = [];
|
||||
const current = ev.getContent();
|
||||
const prev = {
|
||||
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
|
||||
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
|
||||
allow_ip_literals: !(prevContent.allow_ip_literals === false),
|
||||
};
|
||||
|
||||
let text = "";
|
||||
if (prev.deny.length === 0 && prev.allow.length === 0) {
|
||||
text = `${senderDisplayName} set server ACLs for this room: `;
|
||||
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
|
||||
} else {
|
||||
text = `${senderDisplayName} changed the server ACLs for this room: `;
|
||||
text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.allow)) {
|
||||
current.allow = [];
|
||||
}
|
||||
/* If we know for sure everyone is banned, don't bother showing the diff view */
|
||||
|
||||
// If we know for sure everyone is banned, mark the room as obliterated
|
||||
if (current.allow.length === 0) {
|
||||
return text + "🎉 All servers are banned from participating! This room can no longer be used.";
|
||||
return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.deny)) {
|
||||
current.deny = [];
|
||||
}
|
||||
|
||||
const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv));
|
||||
const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv));
|
||||
const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv));
|
||||
const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv));
|
||||
|
||||
if (bannedServers.length > 0) {
|
||||
changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`);
|
||||
}
|
||||
|
||||
if (unbannedServers.length > 0) {
|
||||
changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`);
|
||||
}
|
||||
|
||||
if (allowedServers.length > 0) {
|
||||
changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`);
|
||||
}
|
||||
|
||||
if (unallowedServers.length > 0) {
|
||||
changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`);
|
||||
}
|
||||
|
||||
if (prev.allow_ip_literals !== current.allow_ip_literals) {
|
||||
const allowban = current.allow_ip_literals ? "allowed" : "banned";
|
||||
changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`);
|
||||
}
|
||||
|
||||
return text + changes.join(" ");
|
||||
return text;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev) {
|
||||
|
@ -329,14 +300,27 @@ function textForCallHangupEvent(event) {
|
|||
reason = _t('(not supported by this browser)');
|
||||
} else if (eventContent.reason) {
|
||||
if (eventContent.reason === "ice_failed") {
|
||||
// We couldn't establish a connection at all
|
||||
reason = _t('(could not connect media)');
|
||||
} else if (eventContent.reason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
reason = _t('(connection failed)');
|
||||
} else if (eventContent.reason === "user_media_failed") {
|
||||
// The other side couldn't open capture devices
|
||||
reason = _t("(their device couldn't start the camera / microphone)");
|
||||
} else if (eventContent.reason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
reason = _t("(an error occurred)");
|
||||
} else if (eventContent.reason === "invite_timeout") {
|
||||
reason = _t('(no answer)');
|
||||
} else if (eventContent.reason === "user hangup") {
|
||||
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
|
||||
// workaround for https://github.com/vector-im/element-web/issues/5178
|
||||
// it seems Android randomly sets a reason of "user hangup" which is
|
||||
// interpreted as an error code :(
|
||||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
reason = '';
|
||||
} else {
|
||||
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
|
||||
|
@ -345,6 +329,11 @@ function textForCallHangupEvent(event) {
|
|||
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
|
||||
}
|
||||
|
||||
function textForCallRejectEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
return _t('%(senderName)s declined the call.', {senderName});
|
||||
}
|
||||
|
||||
function textForCallInviteEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
|
@ -574,6 +563,7 @@ const handlers = {
|
|||
'm.call.invite': textForCallInviteEvent,
|
||||
'm.call.answer': textForCallAnswerEvent,
|
||||
'm.call.hangup': textForCallHangupEvent,
|
||||
'm.call.reject': textForCallRejectEvent,
|
||||
};
|
||||
|
||||
const stateHandlers = {
|
||||
|
|
|
@ -16,12 +16,14 @@ limitations under the License.
|
|||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import shouldHideEvent from './shouldHideEvent';
|
||||
import * as sdk from "./index";
|
||||
import {haveTileForEvent} from "./components/views/rooms/EventTile";
|
||||
|
||||
/**
|
||||
* Returns true iff this event arriving in a room should affect the room's
|
||||
* count of unread messages
|
||||
*
|
||||
* @param {Object} ev The event
|
||||
* @returns {boolean} True if the given event should affect the unread message count
|
||||
*/
|
||||
export function eventTriggersUnreadCount(ev) {
|
||||
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
|
||||
|
|
|
@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
|
|||
* see doc on the userActive* functions for what these mean.
|
||||
*/
|
||||
export default class UserActivity {
|
||||
constructor(windowObj, documentObj) {
|
||||
this._window = windowObj;
|
||||
this._document = documentObj;
|
||||
private readonly activeNowTimeout: Timer;
|
||||
private readonly activeRecentlyTimeout: Timer;
|
||||
private attachedActiveNowTimers: Timer[] = [];
|
||||
private attachedActiveRecentlyTimers: Timer[] = [];
|
||||
private lastScreenX = 0;
|
||||
private lastScreenY = 0;
|
||||
|
||||
this._attachedActiveNowTimers = [];
|
||||
this._attachedActiveRecentlyTimers = [];
|
||||
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this._onUserActivity = this._onUserActivity.bind(this);
|
||||
this._onWindowBlurred = this._onWindowBlurred.bind(this);
|
||||
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
|
||||
this.lastScreenX = 0;
|
||||
this.lastScreenY = 0;
|
||||
constructor(private readonly window: Window, private readonly document: Document) {
|
||||
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
|
||||
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
|
||||
}
|
||||
|
||||
static sharedInstance() {
|
||||
if (global.mxUserActivity === undefined) {
|
||||
global.mxUserActivity = new UserActivity(window, document);
|
||||
if (window.mxUserActivity === undefined) {
|
||||
window.mxUserActivity = new UserActivity(window, document);
|
||||
}
|
||||
return global.mxUserActivity;
|
||||
return window.mxUserActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,8 +66,8 @@ export default class UserActivity {
|
|||
* later on when the user does become active.
|
||||
* @param {Timer} timer the timer to use
|
||||
*/
|
||||
timeWhileActiveNow(timer) {
|
||||
this._timeWhile(timer, this._attachedActiveNowTimers);
|
||||
public timeWhileActiveNow(timer: Timer) {
|
||||
this.timeWhile(timer, this.attachedActiveNowTimers);
|
||||
if (this.userActiveNow()) {
|
||||
timer.start();
|
||||
}
|
||||
|
@ -85,14 +82,14 @@ export default class UserActivity {
|
|||
* later on when the user does become active.
|
||||
* @param {Timer} timer the timer to use
|
||||
*/
|
||||
timeWhileActiveRecently(timer) {
|
||||
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
|
||||
public timeWhileActiveRecently(timer: Timer) {
|
||||
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
|
||||
if (this.userActiveRecently()) {
|
||||
timer.start();
|
||||
}
|
||||
}
|
||||
|
||||
_timeWhile(timer, attachedTimers) {
|
||||
private timeWhile(timer: Timer, attachedTimers: Timer[]) {
|
||||
// important this happens first
|
||||
const index = attachedTimers.indexOf(timer);
|
||||
if (index === -1) {
|
||||
|
@ -112,36 +109,36 @@ export default class UserActivity {
|
|||
/**
|
||||
* Start listening to user activity
|
||||
*/
|
||||
start() {
|
||||
this._document.addEventListener('mousedown', this._onUserActivity);
|
||||
this._document.addEventListener('mousemove', this._onUserActivity);
|
||||
this._document.addEventListener('keydown', this._onUserActivity);
|
||||
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
|
||||
this._window.addEventListener("blur", this._onWindowBlurred);
|
||||
this._window.addEventListener("focus", this._onUserActivity);
|
||||
public start() {
|
||||
this.document.addEventListener('mousedown', this.onUserActivity);
|
||||
this.document.addEventListener('mousemove', this.onUserActivity);
|
||||
this.document.addEventListener('keydown', this.onUserActivity);
|
||||
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
|
||||
this.window.addEventListener("blur", this.onWindowBlurred);
|
||||
this.window.addEventListener("focus", this.onUserActivity);
|
||||
// can't use document.scroll here because that's only the document
|
||||
// itself being scrolled. Need to use addEventListener's useCapture.
|
||||
// also this needs to be the wheel event, not scroll, as scroll is
|
||||
// fired when the view scrolls down for a new message.
|
||||
this._window.addEventListener('wheel', this._onUserActivity, {
|
||||
passive: true, capture: true,
|
||||
this.window.addEventListener('wheel', this.onUserActivity, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking user activity
|
||||
*/
|
||||
stop() {
|
||||
this._document.removeEventListener('mousedown', this._onUserActivity);
|
||||
this._document.removeEventListener('mousemove', this._onUserActivity);
|
||||
this._document.removeEventListener('keydown', this._onUserActivity);
|
||||
this._window.removeEventListener('wheel', this._onUserActivity, {
|
||||
passive: true, capture: true,
|
||||
public stop() {
|
||||
this.document.removeEventListener('mousedown', this.onUserActivity);
|
||||
this.document.removeEventListener('mousemove', this.onUserActivity);
|
||||
this.document.removeEventListener('keydown', this.onUserActivity);
|
||||
this.window.removeEventListener('wheel', this.onUserActivity, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
|
||||
this._window.removeEventListener("blur", this._onWindowBlurred);
|
||||
this._window.removeEventListener("focus", this._onUserActivity);
|
||||
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
|
||||
this.window.removeEventListener("blur", this.onWindowBlurred);
|
||||
this.window.removeEventListener("focus", this.onUserActivity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -151,8 +148,8 @@ export default class UserActivity {
|
|||
* user's attention at any given moment.
|
||||
* @returns {boolean} true if user is currently 'active'
|
||||
*/
|
||||
userActiveNow() {
|
||||
return this._activeNowTimeout.isRunning();
|
||||
public userActiveNow() {
|
||||
return this.activeNowTimeout.isRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,27 +160,27 @@ export default class UserActivity {
|
|||
* (or they may have gone to make tea and left the window focused).
|
||||
* @returns {boolean} true if user has been active recently
|
||||
*/
|
||||
userActiveRecently() {
|
||||
return this._activeRecentlyTimeout.isRunning();
|
||||
public userActiveRecently() {
|
||||
return this.activeRecentlyTimeout.isRunning();
|
||||
}
|
||||
|
||||
_onPageVisibilityChanged(e) {
|
||||
if (this._document.visibilityState === "hidden") {
|
||||
this._activeNowTimeout.abort();
|
||||
this._activeRecentlyTimeout.abort();
|
||||
private onPageVisibilityChanged = e => {
|
||||
if (this.document.visibilityState === "hidden") {
|
||||
this.activeNowTimeout.abort();
|
||||
this.activeRecentlyTimeout.abort();
|
||||
} else {
|
||||
this._onUserActivity(e);
|
||||
this.onUserActivity(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onWindowBlurred() {
|
||||
this._activeNowTimeout.abort();
|
||||
this._activeRecentlyTimeout.abort();
|
||||
}
|
||||
private onWindowBlurred = () => {
|
||||
this.activeNowTimeout.abort();
|
||||
this.activeRecentlyTimeout.abort();
|
||||
};
|
||||
|
||||
_onUserActivity(event) {
|
||||
private onUserActivity = (event: MouseEvent) => {
|
||||
// ignore anything if the window isn't focused
|
||||
if (!this._document.hasFocus()) return;
|
||||
if (!this.document.hasFocus()) return;
|
||||
|
||||
if (event.screenX && event.type === "mousemove") {
|
||||
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
|
||||
|
@ -195,25 +192,25 @@ export default class UserActivity {
|
|||
}
|
||||
|
||||
dis.dispatch({action: 'user_activity'});
|
||||
if (!this._activeNowTimeout.isRunning()) {
|
||||
this._activeNowTimeout.start();
|
||||
if (!this.activeNowTimeout.isRunning()) {
|
||||
this.activeNowTimeout.start();
|
||||
dis.dispatch({action: 'user_activity_start'});
|
||||
|
||||
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
|
||||
} else {
|
||||
this._activeNowTimeout.restart();
|
||||
this.activeNowTimeout.restart();
|
||||
}
|
||||
|
||||
if (!this._activeRecentlyTimeout.isRunning()) {
|
||||
this._activeRecentlyTimeout.start();
|
||||
if (!this.activeRecentlyTimeout.isRunning()) {
|
||||
this.activeRecentlyTimeout.start();
|
||||
|
||||
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
|
||||
} else {
|
||||
this._activeRecentlyTimeout.restart();
|
||||
this.activeRecentlyTimeout.restart();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async _runTimersUntilTimeout(attachedTimers, timeout) {
|
||||
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
|
||||
attachedTimers.forEach((t) => t.start());
|
||||
try {
|
||||
await timeout.finished();
|
|
@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function usersTypingApartFromMeAndIgnored(room) {
|
||||
return usersTyping(
|
||||
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
|
||||
);
|
||||
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
|
||||
return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
|
||||
}
|
||||
|
||||
export function usersTypingApartFromMe(room) {
|
||||
return usersTyping(
|
||||
room, [MatrixClientPeg.get().credentials.userId],
|
||||
);
|
||||
export function usersTypingApartFromMe(room: Room): RoomMember[] {
|
||||
return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
|
|||
* to exclude, return a list of user objects who are typing.
|
||||
* @param {Room} room: room object to get users from.
|
||||
* @param {string[]} exclude: list of user mxids to exclude.
|
||||
* @returns {string[]} list of user objects who are typing.
|
||||
* @returns {RoomMember[]} list of user objects who are typing.
|
||||
*/
|
||||
export function usersTyping(room, exclude) {
|
||||
export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
|
||||
const whoIsTyping = [];
|
||||
|
||||
if (exclude === undefined) {
|
||||
exclude = [];
|
||||
}
|
||||
|
||||
const memberKeys = Object.keys(room.currentState.members);
|
||||
for (let i = 0; i < memberKeys.length; ++i) {
|
||||
const userId = memberKeys[i];
|
||||
|
@ -57,20 +52,21 @@ export function usersTyping(room, exclude) {
|
|||
return whoIsTyping;
|
||||
}
|
||||
|
||||
export function whoIsTypingString(whoIsTyping, limit) {
|
||||
export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
|
||||
let othersCount = 0;
|
||||
if (whoIsTyping.length > limit) {
|
||||
othersCount = whoIsTyping.length - limit + 1;
|
||||
}
|
||||
|
||||
if (whoIsTyping.length === 0) {
|
||||
return '';
|
||||
} else if (whoIsTyping.length === 1) {
|
||||
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
|
||||
}
|
||||
const names = whoIsTyping.map(function(m) {
|
||||
return m.name;
|
||||
});
|
||||
if (othersCount>=1) {
|
||||
|
||||
const names = whoIsTyping.map(m => m.name);
|
||||
|
||||
if (othersCount >= 1) {
|
||||
return _t('%(names)s and %(count)s others are typing …', {
|
||||
names: names.slice(0, limit - 1).join(', '),
|
||||
count: othersCount,
|
|
@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||
// onFocus should be called when the index gained focus in any manner
|
||||
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||
export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
|
||||
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
let ref = useRef<HTMLElement>(null);
|
||||
|
||||
|
|
|
@ -17,14 +17,14 @@ limitations under the License.
|
|||
|
||||
import Analytics from '../Analytics';
|
||||
import { asyncAction } from './actionCreators';
|
||||
import TagOrderStore from '../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../stores/GroupFilterOrderStore';
|
||||
import { AsyncActionPayload } from "../dispatcher/payloads";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
export default class TagOrderActions {
|
||||
/**
|
||||
* Creates an action thunk that will do an asynchronous request to
|
||||
* move a tag in TagOrderStore to destinationIx.
|
||||
* move a tag in GroupFilterOrderStore to destinationIx.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client to set the
|
||||
* account data on.
|
||||
|
@ -36,8 +36,8 @@ export default class TagOrderActions {
|
|||
*/
|
||||
public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload {
|
||||
// Only commit tags if the state is ready, i.e. not null
|
||||
let tags = TagOrderStore.getOrderedTags();
|
||||
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
let tags = GroupFilterOrderStore.getOrderedTags();
|
||||
let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
|
||||
if (!tags) {
|
||||
return;
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export default class TagOrderActions {
|
|||
|
||||
removedTags = removedTags.filter((t) => t !== tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
const storeId = GroupFilterOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.moveTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
|
||||
|
@ -83,8 +83,8 @@ export default class TagOrderActions {
|
|||
*/
|
||||
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
|
||||
// Don't change tags, just removedTags
|
||||
const tags = TagOrderStore.getOrderedTags();
|
||||
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
const tags = GroupFilterOrderStore.getOrderedTags();
|
||||
const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
|
||||
|
||||
if (removedTags.includes(tag)) {
|
||||
// Return a thunk that doesn't do anything, we don't even need
|
||||
|
@ -94,7 +94,7 @@ export default class TagOrderActions {
|
|||
|
||||
removedTags.push(tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
const storeId = GroupFilterOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.removeTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'removeTag');
|
||||
|
|
|
@ -32,6 +32,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
|||
import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
|
||||
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
|
||||
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
|
||||
import SecurityCustomisations from "../../../../customisations/Security";
|
||||
|
||||
const PHASE_LOADING = 0;
|
||||
const PHASE_LOADERROR = 1;
|
||||
|
@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
this._passphraseField = createRef();
|
||||
|
||||
this._fetchBackupInfo();
|
||||
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
|
||||
if (this.state.accountPassword) {
|
||||
// If we have an account password in memory, let's simplify and
|
||||
// assume it means password auth is also supported for device
|
||||
|
@ -110,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
this._queryKeyUploadAuth();
|
||||
}
|
||||
|
||||
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
this._getInitialPhase();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
|
||||
}
|
||||
|
||||
_getInitialPhase() {
|
||||
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Created key via customisations, jumping to bootstrap step");
|
||||
this._recoveryKey = {
|
||||
privateKey: keyFromCustomisations,
|
||||
};
|
||||
this._bootstrapSecretStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchBackupInfo();
|
||||
}
|
||||
|
||||
async _fetchBackupInfo() {
|
||||
try {
|
||||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
|
@ -454,6 +470,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
value={CREATE_STORAGE_OPTION_KEY}
|
||||
name="keyPassphrase"
|
||||
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
|
||||
onChange={this._onKeyPassphraseChange}
|
||||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
|
@ -472,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
value={CREATE_STORAGE_OPTION_PASSPHRASE}
|
||||
name="keyPassphrase"
|
||||
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
|
||||
onChange={this._onKeyPassphraseChange}
|
||||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
|
@ -493,7 +511,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
"Safeguard against losing access to encrypted messages & data by " +
|
||||
"backing up encryption keys on your server.",
|
||||
)}</p>
|
||||
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup" onChange={this._onKeyPassphraseChange}>
|
||||
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup">
|
||||
{optionKey}
|
||||
{optionPassphrase}
|
||||
</div>
|
||||
|
|
|
@ -416,8 +416,9 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
|
|||
return menuOptions;
|
||||
};
|
||||
|
||||
export const useContextMenu = (): [boolean, RefObject<HTMLElement>, () => void, () => void, (val: boolean) => void] => {
|
||||
const button = useRef<HTMLElement>(null);
|
||||
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
|
||||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
setIsOpen(true);
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import TagOrderStore from '../../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
||||
|
@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
|
|||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import UserTagTile from "../views/elements/UserTagTile";
|
||||
|
||||
class TagPanel extends React.Component {
|
||||
class GroupFilterPanel extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
state = {
|
||||
|
@ -44,13 +44,13 @@ class TagPanel extends React.Component {
|
|||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
|
||||
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
|
||||
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
orderedTags: TagOrderStore.getOrderedTags() || [],
|
||||
selectedTags: TagOrderStore.getSelectedTags(),
|
||||
orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
|
||||
selectedTags: GroupFilterOrderStore.getSelectedTags(),
|
||||
});
|
||||
});
|
||||
// This could be done by anything with a matrix client
|
||||
|
@ -61,8 +61,8 @@ class TagPanel extends React.Component {
|
|||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._tagOrderStoreToken) {
|
||||
this._tagOrderStoreToken.remove();
|
||||
if (this._groupFilterOrderStoreToken) {
|
||||
this._groupFilterOrderStoreToken.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,7 +98,7 @@ class TagPanel extends React.Component {
|
|||
return (
|
||||
<div>
|
||||
<UserTagTile />
|
||||
<hr className="mx_TagPanel_divider" />
|
||||
<hr className="mx_GroupFilterPanel_divider" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -117,8 +117,8 @@ class TagPanel extends React.Component {
|
|||
});
|
||||
|
||||
const itemsSelected = this.state.selectedTags.length > 0;
|
||||
const classes = classNames('mx_TagPanel', {
|
||||
mx_TagPanel_items_selected: itemsSelected,
|
||||
const classes = classNames('mx_GroupFilterPanel', {
|
||||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
let createButton = (
|
||||
|
@ -141,7 +141,7 @@ class TagPanel extends React.Component {
|
|||
|
||||
return <div className={classes} onClick={this.onClearFilterClick}>
|
||||
<AutoHideScrollbar
|
||||
className="mx_TagPanel_scroller"
|
||||
className="mx_GroupFilterPanel_scroller"
|
||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
||||
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
|
||||
onMouseDown={this.onMouseDown}
|
||||
|
@ -152,7 +152,7 @@ class TagPanel extends React.Component {
|
|||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div
|
||||
className="mx_TagPanel_tagTileContainer"
|
||||
className="mx_GroupFilterPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ this.renderGlobalIcon() }
|
||||
|
@ -168,4 +168,4 @@ class TagPanel extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
}
|
||||
export default TagPanel;
|
||||
export default GroupFilterPanel;
|
|
@ -620,7 +620,7 @@ export default class GroupView extends React.Component {
|
|||
profileForm: newProfileForm,
|
||||
|
||||
// Indicate that FlairStore needs to be poked to show this change
|
||||
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
|
||||
// in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
|
||||
avatarChanged: true,
|
||||
});
|
||||
}).catch((e) => {
|
||||
|
@ -649,7 +649,6 @@ export default class GroupView extends React.Component {
|
|||
editing: false,
|
||||
summary: null,
|
||||
});
|
||||
dis.dispatch({action: 'panel_disable'});
|
||||
this._initGroupStore(this.props.groupId);
|
||||
|
||||
if (this.state.avatarChanged) {
|
||||
|
@ -870,10 +869,7 @@ export default class GroupView extends React.Component {
|
|||
{ _t('Add rooms to this community') }
|
||||
</div>
|
||||
</AccessibleButton>) : <div />;
|
||||
const roomDetailListClassName = classnames({
|
||||
"mx_fadable": true,
|
||||
"mx_fadable_faded": this.state.editing,
|
||||
});
|
||||
|
||||
return <div className="mx_GroupView_rooms">
|
||||
<div className="mx_GroupView_rooms_header">
|
||||
<h3>
|
||||
|
@ -884,9 +880,7 @@ export default class GroupView extends React.Component {
|
|||
</div>
|
||||
{ this.state.groupRoomsLoading ?
|
||||
<Spinner /> :
|
||||
<RoomDetailList
|
||||
rooms={this.state.groupRooms}
|
||||
className={roomDetailListClassName} />
|
||||
<RoomDetailList rooms={this.state.groupRooms} />
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -15,20 +15,67 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {useContext, useState} from "react";
|
||||
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import {_t} from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import {OwnProfileStore} from "../../stores/OwnProfileStore";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import {UPDATE_EVENT} from "../../stores/AsyncStore";
|
||||
import {useEventEmitter} from "../../hooks/useEventEmitter";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
|
||||
|
||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
|
||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||
|
||||
const HomePage = () => {
|
||||
interface IProps {
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
const getOwnProfile = (userId: string) => ({
|
||||
displayName: OwnProfileStore.instance.displayName || userId,
|
||||
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
|
||||
});
|
||||
|
||||
const UserWelcomeTop = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
|
||||
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
|
||||
setOwnProfile(getOwnProfile(userId));
|
||||
});
|
||||
|
||||
return <div>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!ownProfile.avatarUrl}
|
||||
hasAvatarLabel={_t("Great, that'll help people know it's you")}
|
||||
noAvatarLabel={_t("Add a photo so people know it's you.")}
|
||||
setAvatarUrl={url => cli.setAvatarUrl(url)}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={ownProfile.displayName}
|
||||
url={ownProfile.avatarUrl}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
resizeMethod="crop"
|
||||
/>
|
||||
</MiniAvatarUploader>
|
||||
|
||||
<h1>{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
|
||||
<h4>{ _t("Now, let's help you get started") }</h4>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
|
||||
const config = SdkConfig.get();
|
||||
const pageUrl = getHomePageUrl(config);
|
||||
|
||||
|
@ -37,18 +84,27 @@ const HomePage = () => {
|
|||
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
|
||||
}
|
||||
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
let introSection;
|
||||
if (justRegistered) {
|
||||
introSection = <UserWelcomeTop />;
|
||||
} else {
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
}
|
||||
|
||||
introSection = <React.Fragment>
|
||||
<img src={logoUrl} alt={config.brand} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
|
||||
<div className="mx_HomePage_default_wrapper">
|
||||
<img src={logoUrl} alt={config.brand || "Element"} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
{ introSection }
|
||||
<div className="mx_HomePage_default_buttons">
|
||||
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
|
||||
{ _t("Send a Direct Message") }
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import TagPanel from "./TagPanel";
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
|||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -46,7 +47,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showTagPanel: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
}
|
||||
|
||||
// List of CSS classes which should be included in keyboard navigation within the room list
|
||||
|
@ -60,7 +61,7 @@ const cssClasses = [
|
|||
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
@ -70,7 +71,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
|
@ -78,8 +79,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
});
|
||||
|
||||
// We watch the middle panel because we don't actually get resized, the middle panel does.
|
||||
|
@ -88,7 +89,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
|
@ -119,8 +120,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
if (settingBgMxc) {
|
||||
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
@ -139,7 +143,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
|
||||
|
||||
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = list.clientWidth - headerRightMargin;
|
||||
|
||||
// We track which styles we want on a target before making the changes to avoid
|
||||
|
@ -210,10 +214,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
|
||||
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
|
||||
const newBottom = `${offset}px`;
|
||||
if (header.style.bottom !== newBottom) {
|
||||
header.style.bottom = newBottom;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
if (header.style.bottom) {
|
||||
header.style.removeProperty('bottom');
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
|
@ -375,9 +388,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = !this.state.showTagPanel ? null : (
|
||||
<div className="mx_LeftPanel_tagPanelContainer">
|
||||
<TagPanel />
|
||||
const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -385,7 +398,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={null}
|
||||
collapsed={false}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
|
@ -394,7 +406,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_hasTagPanel": !!tagPanel,
|
||||
"mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
|
@ -405,7 +417,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
{groupFilterPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
|
@ -423,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
{roomList}
|
||||
</div>
|
||||
</div>
|
||||
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
149
src/components/structures/LeftPanelWidget.tsx
Normal file
149
src/components/structures/LeftPanelWidget.tsx
Normal file
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
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 React, {useContext, useEffect, useMemo} from "react";
|
||||
import {Resizable} from "re-resizable";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
|
||||
import {Key} from "../../Keyboard";
|
||||
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
|
||||
import {useAccountData} from "../../hooks/useAccountData";
|
||||
import AppTile from "../views/elements/AppTile";
|
||||
import {useSettingValue} from "../../hooks/useSettings";
|
||||
|
||||
interface IProps {
|
||||
onResize(): void;
|
||||
}
|
||||
|
||||
const MIN_HEIGHT = 100;
|
||||
const MAX_HEIGHT = 500; // or 50% of the window height
|
||||
const INITIAL_HEIGHT = 280;
|
||||
|
||||
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
|
||||
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
|
||||
const app = useMemo(() => {
|
||||
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
|
||||
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
|
||||
if (!widgetConfig) return null;
|
||||
|
||||
return WidgetUtils.makeAppConfig(
|
||||
widgetConfig.state_key,
|
||||
widgetConfig.content,
|
||||
widgetConfig.sender,
|
||||
null,
|
||||
widgetConfig.id);
|
||||
}, [mWidgetsEvent, leftPanelWidgetId]);
|
||||
|
||||
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
|
||||
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
|
||||
useEffect(onResize, [expanded]);
|
||||
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
let content;
|
||||
if (expanded) {
|
||||
content = <Resizable
|
||||
size={{height} as any}
|
||||
minHeight={MIN_HEIGHT}
|
||||
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
|
||||
onResize={onResize}
|
||||
onResizeStop={(e, dir, ref, d) => {
|
||||
setHeight(height + d.height);
|
||||
}}
|
||||
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
|
||||
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
|
||||
className="mx_LeftPanelWidget_resizeBox"
|
||||
enable={{ top: true }}
|
||||
>
|
||||
<AppTile
|
||||
app={app}
|
||||
fullWidth
|
||||
show
|
||||
showMenubar={false}
|
||||
userWidget
|
||||
userId={cli.getUserId()}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
/>
|
||||
</Resizable>;
|
||||
}
|
||||
|
||||
return <div className="mx_LeftPanelWidget">
|
||||
<div
|
||||
onFocus={onFocus}
|
||||
className="mx_LeftPanelWidget_headerContainer"
|
||||
onKeyDown={(ev: React.KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
setExpanded(false);
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
setExpanded(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mx_LeftPanelWidget_stickable">
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className="mx_LeftPanelWidget_headerText"
|
||||
role="treeitem"
|
||||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
"mx_LeftPanelWidget_collapseBtn": true,
|
||||
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
|
||||
})} />
|
||||
<span>{ WidgetUtils.getWidgetName(app) }</span>
|
||||
</AccessibleButton>
|
||||
|
||||
{/* Code for the maximise button for once we have full screen widgets */}
|
||||
{/*<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={() => {
|
||||
}}
|
||||
className="mx_LeftPanelWidget_maximizeButton"
|
||||
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
|
||||
title={_t("Maximize")}
|
||||
/>*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ content }
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default LeftPanelWidget;
|
|
@ -53,6 +53,7 @@ import NonUrgentToastContainer from "./NonUrgentToastContainer";
|
|||
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
|
||||
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import Modal from "../../Modal";
|
||||
import { ICollapseConfig } from "../../resizer/distributors/collapse";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -72,9 +73,6 @@ interface IProps {
|
|||
viaServers?: string[];
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
middleDisabled: boolean;
|
||||
leftDisabled: boolean;
|
||||
rightDisabled: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type: string;
|
||||
autoJoin: boolean;
|
||||
|
@ -91,6 +89,7 @@ interface IProps {
|
|||
currentUserId?: string;
|
||||
currentGroupId?: string;
|
||||
currentGroupIsNew?: boolean;
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
interface IUsageLimit {
|
||||
|
@ -101,10 +100,6 @@ interface IUsageLimit {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
mouseDown?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
syncErrorData?: {
|
||||
error: {
|
||||
data: IUsageLimit;
|
||||
|
@ -152,7 +147,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
mouseDown: undefined,
|
||||
syncErrorData: undefined,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
|
@ -214,12 +208,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
const collapseConfig = {
|
||||
let size;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
toggleSize: 260 - 50,
|
||||
onCollapsed: (collapsed) => {
|
||||
if (collapsed) {
|
||||
|
@ -229,22 +219,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
dis.dispatch({action: "show_left_panel"}, true);
|
||||
}
|
||||
},
|
||||
onResized: (size) => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
onResized: (_size) => {
|
||||
size = _size;
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
onResizeStart: () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(
|
||||
this._resizeContainer.current,
|
||||
CollapseDistributor,
|
||||
collapseConfig);
|
||||
resizer.setClassNames(classNames);
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames({
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
});
|
||||
return resizer;
|
||||
}
|
||||
|
||||
|
@ -530,8 +522,8 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// Could be "GroupTile +groupId:domain"
|
||||
const draggableId = result.draggableId.split(' ').pop();
|
||||
|
||||
// Dispatch synchronously so that the TagPanel receives an
|
||||
// optimistic update from TagOrderStore before the previous
|
||||
// Dispatch synchronously so that the GroupFilterPanel receives an
|
||||
// optimistic update from GroupFilterOrderStore before the previous
|
||||
// state is shown.
|
||||
dis.dispatch(TagOrderActions.moveTag(
|
||||
this._matrixClient,
|
||||
|
@ -562,48 +554,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
), true);
|
||||
};
|
||||
|
||||
_onMouseDown = (ev) => {
|
||||
// When the panels are disabled, clicking on them results in a mouse event
|
||||
// which bubbles to certain elements in the tree. When this happens, close
|
||||
// any settings page that is currently open (user/room/group).
|
||||
if (this.props.leftDisabled && this.props.rightDisabled) {
|
||||
const targetClasses = new Set(ev.target.className.split(' '));
|
||||
if (
|
||||
targetClasses.has('mx_MatrixChat') ||
|
||||
targetClasses.has('mx_MatrixChat_middlePanel') ||
|
||||
targetClasses.has('mx_RoomView')
|
||||
) {
|
||||
this.setState({
|
||||
mouseDown: {
|
||||
x: ev.pageX,
|
||||
y: ev.pageY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onMouseUp = (ev) => {
|
||||
if (!this.state.mouseDown) return;
|
||||
|
||||
const deltaX = ev.pageX - this.state.mouseDown.x;
|
||||
const deltaY = ev.pageY - this.state.mouseDown.y;
|
||||
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
|
||||
const maxRadius = 5; // People shouldn't be straying too far, hopefully
|
||||
|
||||
// Note: we track how far the user moved their mouse to help
|
||||
// combat against https://github.com/vector-im/element-web/issues/7158
|
||||
|
||||
if (distance < maxRadius) {
|
||||
// This is probably a real click, and not a drag
|
||||
dis.dispatch({ action: 'close_settings' });
|
||||
}
|
||||
|
||||
// Always clear the mouseDown state to ensure we don't accidentally
|
||||
// use stale values due to the mouseDown checks.
|
||||
this.setState({mouseDown: null});
|
||||
};
|
||||
|
||||
render() {
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
const UserView = sdk.getComponent('structures.UserView');
|
||||
|
@ -623,7 +573,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
oobData={this.props.roomOobData}
|
||||
viaServers={this.props.viaServers}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
disabled={this.props.middleDisabled}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
break;
|
||||
|
@ -637,7 +586,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
break;
|
||||
|
||||
case PageTypes.HomePage:
|
||||
pageElement = <HomePage />;
|
||||
pageElement = <HomePage justRegistered={this.props.justRegistered} />;
|
||||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
|
@ -671,8 +620,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
onKeyDown={this._onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
onMouseDown={this._onMouseDown}
|
||||
onMouseUp={this._onMouseUp}
|
||||
>
|
||||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
|
|
|
@ -29,6 +29,7 @@ import 'focus-visible';
|
|||
import 'what-input';
|
||||
|
||||
import Analytics from "../../Analytics";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
|
@ -61,7 +62,7 @@ import DMRoomMap from '../../utils/DMRoomMap';
|
|||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from '../../settings/watchers/FontWatcher';
|
||||
import { storeRoomAliasInCache } from '../../RoomAliasCache';
|
||||
import { defer, IDeferred } from "../../utils/promise";
|
||||
import { defer, IDeferred, sleep } from "../../utils/promise";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import * as StorageManager from "../../utils/StorageManager";
|
||||
import type LoggedInViewType from "./LoggedInView";
|
||||
|
@ -86,37 +87,37 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
|||
export enum Views {
|
||||
// a special initial state which is only used at startup, while we are
|
||||
// trying to re-animate a matrix client or register as a guest.
|
||||
LOADING = 0,
|
||||
LOADING,
|
||||
|
||||
// we are showing the welcome view
|
||||
WELCOME = 1,
|
||||
WELCOME,
|
||||
|
||||
// we are showing the login view
|
||||
LOGIN = 2,
|
||||
LOGIN,
|
||||
|
||||
// we are showing the registration view
|
||||
REGISTER = 3,
|
||||
|
||||
// completing the registration flow
|
||||
POST_REGISTRATION = 4,
|
||||
REGISTER,
|
||||
|
||||
// showing the 'forgot password' view
|
||||
FORGOT_PASSWORD = 5,
|
||||
FORGOT_PASSWORD,
|
||||
|
||||
// showing flow to trust this new device with cross-signing
|
||||
COMPLETE_SECURITY = 6,
|
||||
COMPLETE_SECURITY,
|
||||
|
||||
// flow to setup SSSS / cross-signing on this account
|
||||
E2E_SETUP = 7,
|
||||
E2E_SETUP,
|
||||
|
||||
// we are logged in with an active matrix client.
|
||||
LOGGED_IN = 8,
|
||||
// we are logged in with an active matrix client. The logged_in state also
|
||||
// includes guests users as they too are logged in at the client level.
|
||||
LOGGED_IN,
|
||||
|
||||
// We are logged out (invalid token) but have our local state again. The user
|
||||
// should log back in to rehydrate the client.
|
||||
SOFT_LOGOUT = 9,
|
||||
SOFT_LOGOUT,
|
||||
}
|
||||
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
|
||||
|
||||
// Actions that are redirected through the onboarding process prior to being
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
// re-factoring to be included in this list in future.
|
||||
|
@ -181,9 +182,6 @@ interface IState {
|
|||
currentUserId?: string;
|
||||
// this is persisted as mx_lhs_size, loaded in LoggedInView
|
||||
collapseLhs: boolean;
|
||||
leftDisabled: boolean;
|
||||
middleDisabled: boolean;
|
||||
// the right panel's disabled state is tracked in its store.
|
||||
// Parameters used in the registration dance with the IS
|
||||
// eslint-disable-next-line camelcase
|
||||
register_client_secret?: string;
|
||||
|
@ -202,6 +200,7 @@ interface IState {
|
|||
roomOobData?: object;
|
||||
viaServers?: string[];
|
||||
pendingInitialSync?: boolean;
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
@ -236,8 +235,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state = {
|
||||
view: Views.LOADING,
|
||||
collapseLhs: false,
|
||||
leftDisabled: false,
|
||||
middleDisabled: false,
|
||||
|
||||
hideToSRUsers: false,
|
||||
|
||||
|
@ -354,6 +351,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
|
||||
|
@ -368,6 +366,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (this.shouldTrackPageChange(prevState, this.state)) {
|
||||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
@ -420,6 +419,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
dis.dispatch({action: "view_welcome_page"});
|
||||
}
|
||||
} else if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ false);
|
||||
}
|
||||
});
|
||||
// Note we don't catch errors from this: we catch everything within
|
||||
|
@ -478,6 +479,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
const newState = {
|
||||
currentUserId: null,
|
||||
justRegistered: false,
|
||||
};
|
||||
Object.assign(newState, state);
|
||||
this.setState(newState);
|
||||
|
@ -559,11 +561,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
ThemeController.isLogin = true;
|
||||
this.themeWatcher.recheck();
|
||||
break;
|
||||
case 'start_post_registration':
|
||||
this.setState({
|
||||
view: Views.POST_REGISTRATION,
|
||||
});
|
||||
break;
|
||||
case 'start_password_recovery':
|
||||
this.setStateForNewView({
|
||||
view: Views.FORGOT_PASSWORD,
|
||||
|
@ -650,8 +647,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
|
||||
initialText: payload.initialText,
|
||||
}, 'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
|
@ -668,13 +666,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.viewWelcome();
|
||||
break;
|
||||
case 'view_home_page':
|
||||
this.viewHome();
|
||||
this.viewHome(payload.justRegistered);
|
||||
break;
|
||||
case 'view_start_chat_or_reuse':
|
||||
this.chatCreateOrReuse(payload.user_id);
|
||||
break;
|
||||
case 'view_create_chat':
|
||||
showStartChatInviteDialog();
|
||||
showStartChatInviteDialog(payload.initialText || "");
|
||||
break;
|
||||
case 'view_invite':
|
||||
showRoomInviteDialog(payload.roomId);
|
||||
|
@ -710,14 +708,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case 'panel_disable': {
|
||||
this.setState({
|
||||
leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
|
||||
middleDisabled: payload.middleDisabled || false,
|
||||
// We don't track the right panel being disabled here - it's tracked in the store.
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'on_logged_in':
|
||||
if (
|
||||
!Lifecycle.isSoftLogout() &&
|
||||
|
@ -763,7 +753,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
hideAnalyticsToast();
|
||||
Analytics.enable();
|
||||
if (Analytics.canEnable()) {
|
||||
Analytics.enable();
|
||||
}
|
||||
if (CountlyAnalytics.instance.canEnable()) {
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ false);
|
||||
}
|
||||
break;
|
||||
case 'reject_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
|
||||
|
@ -955,10 +950,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.themeWatcher.recheck();
|
||||
}
|
||||
|
||||
private viewHome() {
|
||||
private viewHome(justRegistered = false) {
|
||||
// The home page requires the "logged in" view, so we'll set that.
|
||||
this.setStateForNewView({
|
||||
view: Views.LOGGED_IN,
|
||||
justRegistered,
|
||||
});
|
||||
this.setPage(PageTypes.HomePage);
|
||||
this.notifyNewScreen('home');
|
||||
|
@ -1192,7 +1188,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (welcomeUserRoom === null) {
|
||||
// We didn't redirect to the welcome user room, so show
|
||||
// the homepage.
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
dis.dispatch({action: 'view_home_page', justRegistered: true});
|
||||
}
|
||||
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
|
||||
// The user has a 3pid invite pending - show them that
|
||||
|
@ -1205,7 +1201,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
// The user has just logged in after registering,
|
||||
// so show the homepage.
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
dis.dispatch({action: 'view_home_page', justRegistered: true});
|
||||
}
|
||||
} else {
|
||||
this.showScreenAfterLogin();
|
||||
|
@ -1213,7 +1209,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
StorageManager.tryPersistStorage();
|
||||
|
||||
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) {
|
||||
// defer the following actions by 30 seconds to not throw them at the user immediately
|
||||
await sleep(30);
|
||||
if (SettingsStore.getValue("showCookieBar") &&
|
||||
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
|
||||
) {
|
||||
showAnalyticsToast(this.props.config.piwik?.policyUrl);
|
||||
}
|
||||
}
|
||||
|
@ -1343,8 +1343,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.firstSyncComplete = true;
|
||||
this.firstSyncPromise.resolve();
|
||||
|
||||
if (Notifier.shouldShowPrompt()) {
|
||||
showNotificationsToast();
|
||||
if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) {
|
||||
showNotificationsToast(false);
|
||||
}
|
||||
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
@ -1407,6 +1407,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const dft = new DecryptionFailureTracker((total, errorCode) => {
|
||||
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
|
||||
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
|
||||
}, (errorCode) => {
|
||||
// Map JS-SDK error codes to tracker codes for aggregation
|
||||
switch (errorCode) {
|
||||
|
@ -1548,6 +1549,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
showScreen(screen: string, params?: {[key: string]: any}) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const isLoggedOutOrGuest = !cli || cli.isGuest();
|
||||
if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) {
|
||||
// user is logged in and landing on an auth page which will uproot their session, redirect them home instead
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen === 'register') {
|
||||
dis.dispatch({
|
||||
action: 'start_registration',
|
||||
|
@ -1564,7 +1573,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
params: params,
|
||||
});
|
||||
} else if (screen === 'soft_logout') {
|
||||
if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) {
|
||||
if (cli.getUserId() && !Lifecycle.isSoftLogout()) {
|
||||
// Logged in - visit a room
|
||||
this.viewLastRoom();
|
||||
} else {
|
||||
|
@ -1594,6 +1603,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
if (this.state.view === Views.WELCOME) {
|
||||
CountlyAnalytics.instance.track("onboarding_room_directory");
|
||||
}
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === "start_sso" || screen === "start_cas") {
|
||||
// TODO if logged in, skip SSO
|
||||
|
@ -1612,14 +1624,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
});
|
||||
} else if (screen === 'complete_security') {
|
||||
dis.dispatch({
|
||||
action: 'start_complete_security',
|
||||
});
|
||||
} else if (screen === 'post_registration') {
|
||||
dis.dispatch({
|
||||
action: 'start_post_registration',
|
||||
});
|
||||
} else if (screen.indexOf('room/') === 0) {
|
||||
// Rooms can have the following formats:
|
||||
// #room_alias:domain or !opaque_id:domain
|
||||
|
@ -1790,14 +1794,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
return Lifecycle.setLoggedIn(credentials);
|
||||
}
|
||||
|
||||
onFinishPostRegistration = () => {
|
||||
// Don't confuse this with "PageType" which is the middle window to show
|
||||
this.setState({
|
||||
view: Views.LOGGED_IN,
|
||||
});
|
||||
this.showScreen("settings");
|
||||
};
|
||||
|
||||
onSendEvent(roomId: string, event: MatrixEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
|
@ -1962,13 +1958,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
accountPassword={this.accountPassword}
|
||||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.POST_REGISTRATION) {
|
||||
// needs to be before normal PageTypes as you are logged in technically
|
||||
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
|
||||
view = (
|
||||
<PostRegistration
|
||||
onComplete={this.onFinishPostRegistration} />
|
||||
);
|
||||
} else if (this.state.view === Views.LOGGED_IN) {
|
||||
// store errors stop the client syncing and require user intervention, so we'll
|
||||
// be showing a dialog. Don't show anything else.
|
||||
|
|
|
@ -30,6 +30,8 @@ import {_t} from "../../languageHandler";
|
|||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {textForEvent} from "../../TextForEvent";
|
||||
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
||||
import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
@ -952,15 +954,25 @@ class CreationGrouper {
|
|||
}).reduce((a, b) => a.concat(b), []);
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.events[this.events.length - 1];
|
||||
|
||||
let summaryText;
|
||||
const roomId = ev.getRoomId();
|
||||
const creator = ev.sender ? ev.sender.name : ev.getSender();
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(roomId)) {
|
||||
summaryText = _t("%(creator)s created this DM.", { creator });
|
||||
} else {
|
||||
summaryText = _t("%(creator)s created and configured the room.", { creator });
|
||||
}
|
||||
|
||||
ret.push(<NewRoomIntro key="newroomintro" />);
|
||||
|
||||
ret.push(
|
||||
<EventListSummary
|
||||
key="roomcreationsummary"
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={_t("%(creator)s created and configured the room.", {
|
||||
creator: ev.sender ? ev.sender.name : ev.getSender(),
|
||||
})}
|
||||
summaryText={summaryText}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import * as sdk from '../../index';
|
||||
|
@ -304,14 +303,8 @@ export default class RightPanel extends React.Component {
|
|||
break;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RightPanel", "mx_fadable", {
|
||||
"collapsed": this.props.collapsed,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
"dark-panel": true,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className={classes} id="mx_RightPanel">
|
||||
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
|
||||
{ panel }
|
||||
</aside>
|
||||
);
|
||||
|
|
|
@ -30,9 +30,10 @@ import Analytics from '../../Analytics';
|
|||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import TagOrderStore from "../../stores/TagOrderStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
@ -43,13 +44,17 @@ function track(action) {
|
|||
|
||||
export default class RoomDirectory extends React.Component {
|
||||
static propTypes = {
|
||||
initialText: PropTypes.string,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const selectedCommunityId = TagOrderStore.getSelectedTags()[0];
|
||||
CountlyAnalytics.instance.trackRoomDirectoryBegin();
|
||||
this.startTime = CountlyAnalytics.getTimestamp();
|
||||
|
||||
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
|
@ -57,7 +62,7 @@ export default class RoomDirectory extends React.Component {
|
|||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: null,
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? selectedCommunityId
|
||||
: null,
|
||||
|
@ -198,6 +203,11 @@ export default class RoomDirectory extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.state.filterString) {
|
||||
const count = data.total_room_count_estimate || data.chunk.length;
|
||||
CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString);
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => {
|
||||
s.publicRooms.push(...(data.chunk || []));
|
||||
|
@ -407,7 +417,7 @@ export default class RoomDirectory extends React.Component {
|
|||
};
|
||||
|
||||
onCreateRoomClick = room => {
|
||||
this.props.onFinished();
|
||||
this.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
|
@ -419,11 +429,12 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
|
||||
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
|
||||
this.props.onFinished();
|
||||
this.onFinished();
|
||||
const payload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
should_peek: shouldPeek,
|
||||
_type: "room_directory", // instrumentation
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
|
@ -575,6 +586,11 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onFinished = () => {
|
||||
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
@ -671,6 +687,7 @@ export default class RoomDirectory extends React.Component {
|
|||
onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
initialText={this.props.initialText}
|
||||
/>
|
||||
{dropdown}
|
||||
</div>;
|
||||
|
@ -693,7 +710,7 @@ export default class RoomDirectory extends React.Component {
|
|||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
onFinished={this.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
|
|
|
@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Search")}
|
||||
placeholder={_t("Filter")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Search rooms")}
|
||||
title={_t("Filter rooms and people")}
|
||||
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
|
|
|
@ -41,9 +41,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
static propTypes = {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
// This is true when the user is alone in the room, but has also sent a message.
|
||||
// Used to suggest to the user to invite someone
|
||||
sentMessageAndIsAlone: PropTypes.bool,
|
||||
|
||||
// The active call in the room, if any (means we show the call bar
|
||||
// along with the status of the call)
|
||||
|
@ -68,10 +65,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
// 'you are alone' bar
|
||||
onInviteClick: PropTypes.func,
|
||||
|
||||
// callback for when the user clicks on the 'stop warning me' button in the
|
||||
// 'you are alone' bar
|
||||
onStopWarningClick: PropTypes.func,
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
|
@ -159,10 +152,7 @@ export default class RoomStatusBar extends React.Component {
|
|||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
_getSize() {
|
||||
if (this._shouldShowConnectionError() ||
|
||||
this._showCallBar() ||
|
||||
this.props.sentMessageAndIsAlone
|
||||
) {
|
||||
if (this._shouldShowConnectionError() || this._showCallBar()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
|
@ -325,24 +315,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
// If you're alone in the room, and have sent a message, suggest to invite someone
|
||||
if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_isAlone">
|
||||
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
|
||||
"or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||
{},
|
||||
{
|
||||
'inviteText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
|
||||
'nowarnText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
|
||||
},
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -71,7 +71,11 @@ import RoomHeader from "../views/rooms/RoomHeader";
|
|||
import TintableSvg from "../views/elements/TintableSvg";
|
||||
import {XOR} from "../../@types/common";
|
||||
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call";
|
||||
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import WidgetStore from "../../stores/WidgetStore";
|
||||
import {UPDATE_EVENT} from "../../stores/AsyncStore";
|
||||
import Notifier from "../../Notifier";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function(msg: string) {};
|
||||
|
@ -105,7 +109,6 @@ interface IProps {
|
|||
viaServers?: string[];
|
||||
|
||||
autoJoin?: boolean;
|
||||
disabled?: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
|
||||
// Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
|
||||
|
@ -128,6 +131,7 @@ export interface IState {
|
|||
initialEventPixelOffset?: number;
|
||||
// Whether to highlight the event scrolled to
|
||||
isInitialEventHighlighted?: boolean;
|
||||
replyToEvent?: MatrixEvent;
|
||||
forwardingEvent?: MatrixEvent;
|
||||
numUnreadMessages: number;
|
||||
draggingFile: boolean;
|
||||
|
@ -146,7 +150,6 @@ export interface IState {
|
|||
guestsCanJoin: boolean;
|
||||
canPeek: boolean;
|
||||
showApps: boolean;
|
||||
isAlone: boolean;
|
||||
isPeeking: boolean;
|
||||
showingPinned: boolean;
|
||||
showReadReceipts: boolean;
|
||||
|
@ -181,6 +184,7 @@ export interface IState {
|
|||
e2eStatus?: E2EStatus;
|
||||
rejecting?: boolean;
|
||||
rejectError?: Error;
|
||||
hasPinnedWidgets?: boolean;
|
||||
}
|
||||
|
||||
export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
@ -218,7 +222,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
guestsCanJoin: false,
|
||||
canPeek: false,
|
||||
showApps: false,
|
||||
isAlone: false,
|
||||
isPeeking: false,
|
||||
showingPinned: false,
|
||||
showReadReceipts: true,
|
||||
|
@ -251,7 +254,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
|
||||
WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate);
|
||||
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
|
||||
this.onReadReceiptsChange);
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
|
||||
|
@ -263,6 +268,18 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.onRoomViewStoreUpdate(true);
|
||||
}
|
||||
|
||||
private onWidgetStoreUpdate = () => {
|
||||
if (this.state.room) {
|
||||
this.checkWidgets(this.state.room);
|
||||
}
|
||||
}
|
||||
|
||||
private checkWidgets = (room) => {
|
||||
this.setState({
|
||||
hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0,
|
||||
})
|
||||
};
|
||||
|
||||
private onReadReceiptsChange = () => {
|
||||
this.setState({
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
|
||||
|
@ -299,6 +316,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
joining: RoomViewStore.isJoining(),
|
||||
initialEventId: RoomViewStore.getInitialEventId(),
|
||||
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
|
||||
replyToEvent: RoomViewStore.getQuotingEvent(),
|
||||
forwardingEvent: RoomViewStore.getForwardingEvent(),
|
||||
// we should only peek once we have a ready client
|
||||
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
|
||||
|
@ -585,7 +603,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.rightPanelStoreToken.remove();
|
||||
}
|
||||
|
||||
WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate);
|
||||
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
||||
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
if (this.showReadReceiptsWatchRef) {
|
||||
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
|
||||
|
@ -684,9 +703,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
private onAction = payload => {
|
||||
switch (payload.action) {
|
||||
case 'message_send_failed':
|
||||
case 'message_sent':
|
||||
this.checkIfAlone(this.state.room);
|
||||
this.checkDesktopNotifications();
|
||||
break;
|
||||
case 'post_sticker_message':
|
||||
this.injectSticker(
|
||||
|
@ -824,6 +842,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.calculateRecommendedVersion(room);
|
||||
this.updateE2EStatus(room);
|
||||
this.updatePermissions(room);
|
||||
this.checkWidgets(room);
|
||||
};
|
||||
|
||||
private async calculateRecommendedVersion(room: Room) {
|
||||
|
@ -1003,33 +1022,17 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// rate limited because a power level change will emit an event for every member in the room.
|
||||
private updateRoomMembers = rateLimitedFunc((dueToMember) => {
|
||||
private updateRoomMembers = rateLimitedFunc(() => {
|
||||
this.updateDMState();
|
||||
|
||||
let memberCountInfluence = 0;
|
||||
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
|
||||
// A member got invited, but the room hasn't detected that change yet. Influence the member
|
||||
// count by 1 to counteract this.
|
||||
memberCountInfluence = 1;
|
||||
}
|
||||
this.checkIfAlone(this.state.room, memberCountInfluence);
|
||||
|
||||
this.updateE2EStatus(this.state.room);
|
||||
}, 500);
|
||||
|
||||
private checkIfAlone(room: Room, countInfluence?: number) {
|
||||
let warnedAboutLonelyRoom = false;
|
||||
if (localStorage) {
|
||||
warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId));
|
||||
private checkDesktopNotifications() {
|
||||
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();
|
||||
// if they are not alone prompt the user about notifications so they don't miss replies
|
||||
if (memberCount > 1 && Notifier.shouldShowPrompt()) {
|
||||
showNotificationsToast(true);
|
||||
}
|
||||
if (warnedAboutLonelyRoom) {
|
||||
if (this.state.isAlone) this.setState({isAlone: false});
|
||||
return;
|
||||
}
|
||||
|
||||
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
|
||||
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
|
||||
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
|
||||
}
|
||||
|
||||
private updateDMState() {
|
||||
|
@ -1064,14 +1067,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
action: 'view_invite',
|
||||
roomId: this.state.room.roomId,
|
||||
});
|
||||
this.setState({isAlone: false}); // there's a good chance they'll invite someone
|
||||
};
|
||||
|
||||
private onStopAloneWarningClick = () => {
|
||||
if (localStorage) {
|
||||
localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true));
|
||||
}
|
||||
this.setState({isAlone: false});
|
||||
};
|
||||
|
||||
private onJoinButtonClicked = () => {
|
||||
|
@ -1093,6 +1088,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
dis.dispatch({
|
||||
action: 'join_room',
|
||||
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
|
||||
_type: "unknown", // TODO: instrumentation
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -1119,16 +1115,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
|
||||
const items = [...ev.dataTransfer.items];
|
||||
if (items.length >= 1) {
|
||||
const isDraggingFiles = items.every(function(item) {
|
||||
return item.kind == 'file';
|
||||
});
|
||||
|
||||
if (isDraggingFiles) {
|
||||
this.setState({ draggingFile: true });
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
||||
this.setState({ draggingFile: true });
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1259,7 +1248,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (!this.state.searchResults.next_batch) {
|
||||
if (this.state.searchResults.results.length == 0) {
|
||||
if (!this.state.searchResults?.results?.length) {
|
||||
ret.push(<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
|
||||
</li>,
|
||||
|
@ -1283,7 +1272,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
let lastRoomId;
|
||||
|
||||
for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) {
|
||||
for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = this.state.searchResults.results[i];
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
|
@ -1353,6 +1342,13 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
dis.fire(Action.FocusComposer);
|
||||
};
|
||||
|
||||
private onAppsClick = () => {
|
||||
dis.dispatch({
|
||||
action: "appsDrawer",
|
||||
show: !this.state.showApps,
|
||||
});
|
||||
};
|
||||
|
||||
private onLeaveClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
|
@ -1762,12 +1758,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
isStatusAreaExpanded = this.state.statusBarVisible;
|
||||
statusBar = <RoomStatusBar
|
||||
room={this.state.room}
|
||||
sentMessageAndIsAlone={this.state.isAlone}
|
||||
callState={this.state.callState}
|
||||
callType={activeCall ? activeCall.type : null}
|
||||
isPeeking={myMembership !== "join"}
|
||||
onInviteClick={this.onInviteButtonClick}
|
||||
onStopWarningClick={this.onStopAloneWarningClick}
|
||||
onVisible={this.onStatusBarVisible}
|
||||
onHidden={this.onStatusBarHidden}
|
||||
/>;
|
||||
|
@ -1853,7 +1847,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
draggingFile={this.state.draggingFile}
|
||||
maxHeight={this.state.auxPanelMaxHeight}
|
||||
showApps={this.state.showApps}
|
||||
hideAppsDrawer={false}
|
||||
onResize={this.onResize}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
|
@ -1872,10 +1865,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
<MessageComposer
|
||||
room={this.state.room}
|
||||
callState={this.state.callState}
|
||||
disabled={this.props.disabled}
|
||||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
}
|
||||
|
@ -1947,7 +1940,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.searchResults) {
|
||||
// show searching spinner
|
||||
if (this.state.searchResults.results === undefined) {
|
||||
if (this.state.searchResults.count === undefined) {
|
||||
searchResultsPanel = (
|
||||
<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />
|
||||
);
|
||||
|
@ -2028,10 +2021,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
|
||||
});
|
||||
|
||||
const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", {
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
});
|
||||
|
||||
const showRightPanel = this.state.room && this.state.showRightPanel;
|
||||
const rightPanel = showRightPanel
|
||||
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
||||
|
@ -2061,9 +2050,11 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
|
||||
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
|
||||
appsShown={this.state.showApps}
|
||||
/>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<div className={fadableSectionClasses}>
|
||||
<div className="mx_RoomView_body">
|
||||
{auxPanel}
|
||||
<div className={timelineClasses}>
|
||||
{topUnreadMessagesBar}
|
||||
|
|
|
@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component {
|
|||
if (itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
if (sn.scrollTop !== sn.scrollHeight){
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
debuglog("updateHeight to", newHeight);
|
||||
|
|
|
@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
let toast;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const {title, icon, key, component, props} = topToast;
|
||||
const {title, icon, key, component, className, props} = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
});
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
|
|
|
@ -86,7 +86,9 @@ export default class UploadBar extends React.Component {
|
|||
}
|
||||
|
||||
// MUST use var name 'count' for pluralization to kick in
|
||||
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
|
||||
const uploadText = _t(
|
||||
"Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
|
|
|
@ -23,7 +23,7 @@ import { _t } from "../../languageHandler";
|
|||
import { ContextMenuButton } from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
@ -44,7 +44,7 @@ import IconizedContextMenu, {
|
|||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import TagOrderStore from "../../stores/TagOrderStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import { showCommunityInviteDialog } from "../../RoomInvite";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
|
@ -87,7 +87,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
public componentDidMount() {
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate);
|
||||
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -186,15 +186,22 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
private onSignOutClick = async (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
|
||||
// log out without user prompt if they have no local megolm sessions
|
||||
dis.dispatch({action: 'logout'});
|
||||
} else {
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
}
|
||||
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
|
@ -203,6 +210,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({action: 'view_home_page'});
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunitySettingsClick = (ev: ButtonEvent) => {
|
||||
|
@ -257,7 +265,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
const signupLink = getHostingLink("user-context-menu");
|
||||
if (signupLink) {
|
||||
hostingLink = (
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
|
||||
{_t(
|
||||
"<a>Upgrade</a> to your own domain", {},
|
||||
{
|
||||
|
@ -452,7 +460,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const displayName = OwnProfileStore.instance.displayName || userId;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
@ -507,7 +516,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
<BaseAvatar
|
||||
idName={displayName}
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
width={avatarSize}
|
||||
|
|
|
@ -26,6 +26,7 @@ import PasswordReset from "../../../PasswordReset";
|
|||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from 'classnames';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
|
@ -64,6 +65,12 @@ export default class ForgotPassword extends React.Component {
|
|||
serverRequiresIdServer: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.reset = null;
|
||||
this._checkServerLiveliness(this.props.serverConfig);
|
||||
|
@ -299,15 +306,19 @@ export default class ForgotPassword extends React.Component {
|
|||
value={this.state.email}
|
||||
onChange={this.onInputChanged.bind(this, "email")}
|
||||
autoFocus
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
name="reset_password"
|
||||
type="password"
|
||||
label={_t('Password')}
|
||||
label={_t('New Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onInputChanged.bind(this, "password")}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
|
||||
/>
|
||||
<Field
|
||||
name="reset_password_confirm"
|
||||
|
@ -315,6 +326,8 @@ export default class ForgotPassword extends React.Component {
|
|||
label={_t('Confirm')}
|
||||
value={this.state.password2}
|
||||
onChange={this.onInputChanged.bind(this, "password2")}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
|
||||
/>
|
||||
</div>
|
||||
<span>{_t(
|
||||
|
|
|
@ -30,6 +30,7 @@ import SSOButton from "../../views/elements/SSOButton";
|
|||
import PlatformPeg from '../../../PlatformPeg';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
@ -126,6 +127,8 @@ export default class LoginComponent extends React.Component {
|
|||
'm.login.cas': () => this._renderSsoStep("cas"),
|
||||
'm.login.sso': () => this._renderSsoStep("sso"),
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_login_begin");
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket 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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
|
||||
export default class PostRegistration extends React.Component {
|
||||
static propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
|
||||
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
|
||||
// the URL to be passed to you (because it's also used for room avatars).
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({busy: true});
|
||||
const self = this;
|
||||
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
|
||||
self.setState({
|
||||
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
|
||||
busy: false,
|
||||
});
|
||||
}, function(error) {
|
||||
self.setState({
|
||||
errorString: _t("Failed to fetch avatar URL"),
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
|
||||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<div className="mx_Login_profile">
|
||||
{ _t('Set a display name:') }
|
||||
<ChangeDisplayName />
|
||||
{ _t('Upload an avatar:') }
|
||||
<ChangeAvatar
|
||||
initialAvatarUrl={this.state.avatarUrl} />
|
||||
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
|
||||
{ this.state.errorString }
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -502,6 +502,11 @@ export default class Registration extends React.Component {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Hide the server picker once the user is doing UI Auth unless encountered a fatal server error
|
||||
if (this.state.phase !== PHASE_SERVER_DETAILS && this.state.doingUIAuth && !this.state.serverErrorIsFatal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're on a different phase, we only show the server type selector,
|
||||
// which is always shown if we allow custom URLs at all.
|
||||
// (if there's a fatal server error, we need to show the full server
|
||||
|
@ -582,17 +587,6 @@ export default class Registration extends React.Component {
|
|||
<Spinner />
|
||||
</div>;
|
||||
} else if (this.state.flows.length) {
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed and we haven't selected the Free server type, wire
|
||||
// up the server details edit link.
|
||||
if (
|
||||
PHASES_ENABLED &&
|
||||
!SdkConfig.get()['disable_custom_urls'] &&
|
||||
this.state.serverType !== ServerType.FREE
|
||||
) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
return <RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
|
@ -600,7 +594,6 @@ export default class Registration extends React.Component {
|
|||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
flows={this.state.flows}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
|
@ -686,11 +679,48 @@ export default class Registration extends React.Component {
|
|||
{ regDoneText }
|
||||
</div>;
|
||||
} else {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If custom URLs are allowed, user is not doing UIA flows and they haven't selected the Free server type,
|
||||
// wire up the server details edit link.
|
||||
let editLink = null;
|
||||
if (PHASES_ENABLED &&
|
||||
!SdkConfig.get()['disable_custom_urls'] &&
|
||||
this.state.serverType !== ServerType.FREE &&
|
||||
!this.state.doingUIAuth
|
||||
) {
|
||||
editLink = (
|
||||
<a className="mx_AuthBody_editServerDetails" href="#" onClick={this.onEditServerDetailsClick}>
|
||||
{_t('Change')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
body = <div>
|
||||
<h2>{ _t('Create your account') }</h2>
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.state.phase !== PHASE_SERVER_DETAILS && <h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3> }
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
const DIV_ID = 'mx_recaptcha';
|
||||
|
||||
|
@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component {
|
|||
this._captchaWidgetId = null;
|
||||
|
||||
this._recaptchaContainer = createRef();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component {
|
|||
console.log("Loaded recaptcha script.");
|
||||
try {
|
||||
this._renderRecaptcha(DIV_ID);
|
||||
// clear error if re-rendered
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
errorText: e.toString(),
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component {
|
|||
const options = displayedCountries.map((country) => {
|
||||
return <div className="mx_CountryDropdown_option" key={country.iso2}>
|
||||
{ this._flagImgForIso2(country.iso2) }
|
||||
{ country.name } (+{ country.prefix })
|
||||
{ _t(country.name) } (+{ country.prefix })
|
||||
</div>;
|
||||
});
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import { _t } from '../../../languageHandler';
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
|
@ -189,6 +190,7 @@ export class RecaptchaAuthEntry extends React.Component {
|
|||
}
|
||||
|
||||
_onCaptchaResponse = response => {
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
|
||||
this.props.submitAuthDict({
|
||||
type: RecaptchaAuthEntry.LOGIN_TYPE,
|
||||
response: response,
|
||||
|
@ -297,6 +299,8 @@ export class TermsAuthEntry extends React.Component {
|
|||
toggledPolicies: initToggles,
|
||||
policies: pickedPolicies,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_terms_begin");
|
||||
}
|
||||
|
||||
|
||||
|
@ -326,8 +330,12 @@ export class TermsAuthEntry extends React.Component {
|
|||
allChecked = allChecked && checked;
|
||||
}
|
||||
|
||||
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
if (allChecked) {
|
||||
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
CountlyAnalytics.instance.track("onboarding_terms_complete");
|
||||
} else {
|
||||
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -413,12 +421,12 @@ export class EmailIdentityAuthEntry extends React.Component {
|
|||
return <Spinner />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("An email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
|
||||
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
|
||||
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
|
||||
) }
|
||||
</p>
|
||||
<p>{ _t("Please check your email to continue registration.") }</p>
|
||||
<p>{ _t("Open the link in the email to continue registration.") }</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { _t } from '../../../languageHandler';
|
|||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a username/password form.
|
||||
|
@ -150,7 +151,20 @@ export default class PasswordLogin extends React.Component {
|
|||
this.props.onUsernameChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onUsernameFocus() {
|
||||
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
}
|
||||
|
||||
onUsernameBlur(ev) {
|
||||
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
}
|
||||
|
||||
|
@ -161,6 +175,7 @@ export default class PasswordLogin extends React.Component {
|
|||
loginType: loginType,
|
||||
username: "", // Reset because email and username use the same state
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
}
|
||||
|
||||
onPhoneCountryChanged(country) {
|
||||
|
@ -176,8 +191,13 @@ export default class PasswordLogin extends React.Component {
|
|||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onPhoneNumberFocus() {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
}
|
||||
|
||||
onPhoneNumberBlur(ev) {
|
||||
this.props.onPhoneNumberBlur(ev.target.value);
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
}
|
||||
|
||||
onPasswordChanged(ev) {
|
||||
|
@ -202,6 +222,7 @@ export default class PasswordLogin extends React.Component {
|
|||
placeholder="joe@example.com"
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
|
@ -216,6 +237,7 @@ export default class PasswordLogin extends React.Component {
|
|||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
|
@ -240,6 +262,7 @@ export default class PasswordLogin extends React.Component {
|
|||
value={this.state.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
|
|
|
@ -29,6 +29,7 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
|||
import withValidation from '../elements/Validation';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import PassphraseField from "./PassphraseField";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
|
@ -50,7 +51,6 @@ export default class RegistrationForm extends React.Component {
|
|||
defaultUsername: PropTypes.string,
|
||||
defaultPassword: PropTypes.string,
|
||||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
|
@ -77,6 +77,8 @@ export default class RegistrationForm extends React.Component {
|
|||
passwordConfirm: this.props.defaultPassword || "",
|
||||
passwordComplexity: null,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_begin");
|
||||
}
|
||||
|
||||
onSubmit = async ev => {
|
||||
|
@ -86,6 +88,7 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -110,6 +113,8 @@ export default class RegistrationForm extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
|
@ -128,6 +133,11 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
_doSubmit(ev) {
|
||||
const email = this.state.email.trim();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
|
||||
email: !!email,
|
||||
});
|
||||
|
||||
const promise = this.props.onRegisterClick({
|
||||
username: this.state.username.trim(),
|
||||
password: this.state.password.trim(),
|
||||
|
@ -239,6 +249,7 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
validateEmailRules = withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -315,6 +326,7 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
validatePhoneNumberRules = withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -345,6 +357,7 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -422,6 +435,8 @@ export default class RegistrationForm extends React.Component {
|
|||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -433,6 +448,8 @@ export default class RegistrationForm extends React.Component {
|
|||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -443,10 +460,12 @@ export default class RegistrationForm extends React.Component {
|
|||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm")}
|
||||
label={_t("Confirm password")}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -487,37 +506,12 @@ export default class RegistrationForm extends React.Component {
|
|||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
@ -553,10 +547,6 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderUsername()}
|
||||
|
|
|
@ -26,6 +26,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import classNames from 'classnames';
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
/*
|
||||
* A pure UI component which displays the HS and IS to use.
|
||||
|
@ -70,6 +71,8 @@ export default class ServerConfig extends React.PureComponent {
|
|||
isUrl: props.serverConfig.isUrl,
|
||||
showIdentityServer: false,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_custom_server");
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
|
|
@ -23,11 +23,18 @@ import AuthPage from "./AuthPage";
|
|||
import {_td} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
// translatable strings for Welcome pages
|
||||
_td("Sign in with SSO");
|
||||
|
||||
export default class Welcome extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_welcome");
|
||||
}
|
||||
|
||||
render() {
|
||||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
|
||||
|
|
|
@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => {
|
|||
_urls = urls || [];
|
||||
|
||||
if (url) {
|
||||
_urls.unshift(url); // put in urls[0]
|
||||
// copy urls and put url first
|
||||
_urls = [url, ..._urls];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView';
|
|||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import Modal from '../../../Modal';
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import {ResizeMethod} from "../../../Avatar";
|
||||
|
||||
interface IProps {
|
||||
// Room may be left unset here, but if it is,
|
||||
|
@ -32,8 +33,9 @@ interface IProps {
|
|||
oobData?: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: string;
|
||||
resizeMethod?: ResizeMethod;
|
||||
viewAvatarOnClick?: boolean;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -129,7 +131,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
|
||||
const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
|
||||
|
@ -138,7 +140,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
name={roomName}
|
||||
idName={room ? room.roomId : null}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
58
src/components/views/avatars/WidgetAvatar.tsx
Normal file
58
src/components/views/avatars/WidgetAvatar.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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 React, {ComponentProps, useContext} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {IApp} from "../../../stores/WidgetStore";
|
||||
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
|
||||
app: IApp;
|
||||
}
|
||||
|
||||
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
|
||||
// heuristics for some better icons until Widgets support their own icons
|
||||
if (app.type.includes("jitsi")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")];
|
||||
} else if (app.type.includes("meeting") || app.type.includes("calendar")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")];
|
||||
} else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")];
|
||||
} else if (app.type.includes("clock")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")];
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...props}
|
||||
name={app.id}
|
||||
className={classNames("mx_WidgetAvatar", className)}
|
||||
// MSC2765
|
||||
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
|
||||
urls={iconUrls}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
export default WidgetAvatar;
|
|
@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore';
|
|||
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
function canCancel(eventStatus) {
|
||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||
|
@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component {
|
|||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
|
||||
// We explicitly decline to show the redact option on ACL events as it has a potential
|
||||
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
|
||||
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
|
||||
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
|
||||
|
||||
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
|
||||
export default class WidgetContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func,
|
||||
|
||||
// Callback for when the revoke button is clicked. Required.
|
||||
onRevokeClicked: PropTypes.func.isRequired,
|
||||
|
||||
// Callback for when the unpin button is clicked. If absent, unpin will be hidden.
|
||||
onUnpinClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the snapshot button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onSnapshotClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the reload button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onReloadClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the edit button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onEditClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the delete button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onDeleteClicked: PropTypes.func,
|
||||
};
|
||||
|
||||
proxyClick(fn) {
|
||||
fn();
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
}
|
||||
|
||||
// XXX: It's annoying that our context menus require us to hit onFinished() to close :(
|
||||
|
||||
onEditClicked = () => {
|
||||
this.proxyClick(this.props.onEditClicked);
|
||||
};
|
||||
|
||||
onReloadClicked = () => {
|
||||
this.proxyClick(this.props.onReloadClicked);
|
||||
};
|
||||
|
||||
onSnapshotClicked = () => {
|
||||
this.proxyClick(this.props.onSnapshotClicked);
|
||||
};
|
||||
|
||||
onDeleteClicked = () => {
|
||||
this.proxyClick(this.props.onDeleteClicked);
|
||||
};
|
||||
|
||||
onRevokeClicked = () => {
|
||||
this.proxyClick(this.props.onRevokeClicked);
|
||||
};
|
||||
|
||||
onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked);
|
||||
|
||||
render() {
|
||||
const options = [];
|
||||
|
||||
if (this.props.onEditClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
|
||||
{_t("Edit")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onUnpinClicked) {
|
||||
options.push(
|
||||
<MenuItem className="mx_WidgetContextMenu_option" onClick={this.onUnpinClicked} key="unpin">
|
||||
{_t("Unpin")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onReloadClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
|
||||
{_t("Reload")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onSnapshotClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
|
||||
{_t("Take picture")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onDeleteClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
|
||||
{_t("Remove for everyone")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
// Push this last so it appears last. It's always present.
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
|
||||
{_t("Remove for me")}
|
||||
</MenuItem>,
|
||||
);
|
||||
|
||||
// Put separators between the options
|
||||
if (options.length > 1) {
|
||||
const length = options.length;
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
|
||||
|
||||
// Insert backwards so the insertions don't affect our math on where to place them.
|
||||
// We also use our cached length to avoid worrying about options.length changing
|
||||
options.splice(length - 1 - i, 0, sep);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="mx_WidgetContextMenu">{options}</div>;
|
||||
}
|
||||
}
|
177
src/components/views/context_menus/WidgetContextMenu.tsx
Normal file
177
src/components/views/context_menus/WidgetContextMenu.tsx
Normal file
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
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 React, {useContext} from "react";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
|
||||
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
|
||||
import {ChevronFace} from "../../structures/ContextMenu";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import Modal from "../../../Modal";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
|
||||
app: IApp;
|
||||
userWidget?: boolean;
|
||||
showUnpin?: boolean;
|
||||
// override delete handler
|
||||
onDeleteClick?(): void;
|
||||
}
|
||||
|
||||
const WidgetContextMenu: React.FC<IProps> = ({
|
||||
onFinished,
|
||||
app,
|
||||
userWidget,
|
||||
onDeleteClick,
|
||||
showUnpin,
|
||||
...props
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const {room, roomId} = useContext(RoomContext);
|
||||
|
||||
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
|
||||
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId);
|
||||
|
||||
let unpinButton;
|
||||
if (showUnpin) {
|
||||
const onUnpinClick = () => {
|
||||
WidgetStore.instance.unpinWidget(app.id);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
unpinButton = <IconizedContextMenuOption onClick={onUnpinClick} label={_t("Unpin")} />;
|
||||
}
|
||||
|
||||
let editButton;
|
||||
if (canModify && WidgetUtils.isManagedByManager(app)) {
|
||||
const onEditClick = () => {
|
||||
WidgetUtils.editWidget(room, app);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
|
||||
}
|
||||
|
||||
let snapshotButton;
|
||||
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
|
||||
const onSnapshotClick = () => {
|
||||
widgetMessaging?.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
|
||||
}
|
||||
|
||||
let deleteButton;
|
||||
if (onDeleteClick || canModify) {
|
||||
const onDeleteClickDefault = () => {
|
||||
// Show delete confirmation dialog
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
WidgetUtils.setRoomWidget(roomId, app.id);
|
||||
},
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
deleteButton = <IconizedContextMenuOption
|
||||
onClick={onDeleteClick || onDeleteClickDefault}
|
||||
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
|
||||
/>;
|
||||
}
|
||||
|
||||
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
|
||||
if (isAllowedWidget === undefined) {
|
||||
isAllowedWidget = app.creatorUserId === cli.getUserId();
|
||||
}
|
||||
|
||||
const isLocalWidget = WidgetType.JITSI.matches(app.type);
|
||||
let revokeButton;
|
||||
if (!userWidget && !isLocalWidget && isAllowedWidget) {
|
||||
const onRevokeClick = () => {
|
||||
console.info("Revoking permission for widget to load: " + app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
current[app.eventId] = false;
|
||||
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
|
||||
}
|
||||
|
||||
const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId);
|
||||
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
|
||||
|
||||
let moveLeftButton;
|
||||
if (showUnpin && widgetIndex > 0) {
|
||||
const onClick = () => {
|
||||
WidgetStore.instance.movePinnedWidget(app.id, -1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
moveLeftButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move left")} />;
|
||||
}
|
||||
|
||||
let moveRightButton;
|
||||
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
|
||||
const onClick = () => {
|
||||
WidgetStore.instance.movePinnedWidget(app.id, 1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
moveRightButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move right")} />;
|
||||
}
|
||||
|
||||
return <IconizedContextMenu {...props} chevronFace={ChevronFace.None} onFinished={onFinished}>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ editButton }
|
||||
{ revokeButton }
|
||||
{ deleteButton }
|
||||
{ snapshotButton }
|
||||
{ moveLeftButton }
|
||||
{ moveRightButton }
|
||||
{ unpinButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
export default WidgetContextMenu;
|
||||
|
138
src/components/views/dialogs/FeedbackDialog.js
Normal file
138
src/components/views/dialogs/FeedbackDialog.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, {useState} from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from "../../../Modal";
|
||||
import BugReportDialog from "./BugReportDialog";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
|
||||
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
|
||||
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
|
||||
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
|
||||
|
||||
|
||||
export default (props) => {
|
||||
const [rating, setRating] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const onDebugLogsLinkClick = () => {
|
||||
props.onFinished();
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
|
||||
};
|
||||
|
||||
const hasFeedback = CountlyAnalytics.instance.canEnable();
|
||||
const onFinished = (sendFeedback) => {
|
||||
if (hasFeedback && sendFeedback) {
|
||||
CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment);
|
||||
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
|
||||
title: _t('Feedback sent'),
|
||||
description: _t('Thank you!'),
|
||||
});
|
||||
}
|
||||
props.onFinished();
|
||||
};
|
||||
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
let countlyFeedbackSection;
|
||||
if (hasFeedback) {
|
||||
countlyFeedbackSection = <React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
|
||||
<h3>{_t("Rate %(brand)s", { brand })}</h3>
|
||||
|
||||
<p>{_t("Tell us below how you feel about %(brand)s so far.", { brand })}</p>
|
||||
<p>{_t("Please go into as much detail as you like, so we can track down the problem.")}</p>
|
||||
|
||||
<StyledRadioGroup
|
||||
name="feedbackRating"
|
||||
value={rating}
|
||||
onChange={setRating}
|
||||
definitions={[
|
||||
{ value: "1", label: "😠" },
|
||||
{ value: "2", label: "😞" },
|
||||
{ value: "3", label: "😑" },
|
||||
{ value: "4", label: "😄" },
|
||||
{ value: "5", label: "😍" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Add comment")}
|
||||
placeholder={_t("Comment")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
let subheading;
|
||||
if (hasFeedback) {
|
||||
subheading = (
|
||||
<h2>{_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_FeedbackDialog"
|
||||
hasCancelButton={!!hasFeedback}
|
||||
title={_t("Feedback")}
|
||||
description={<React.Fragment>
|
||||
{ subheading }
|
||||
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
|
||||
<h3>{_t("Report a bug")}</h3>
|
||||
<p>{
|
||||
_t("Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. " +
|
||||
"No match? <newIssueLink>Start a new one</newIssueLink>.", {}, {
|
||||
existingIssuesLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
|
||||
},
|
||||
newIssueLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
|
||||
},
|
||||
})
|
||||
}</p>
|
||||
<p>{
|
||||
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
|
||||
"to help us track down the problem.", {}, {
|
||||
debugLogsLink: sub => (
|
||||
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
|
||||
),
|
||||
})
|
||||
}</p>
|
||||
</div>
|
||||
{ countlyFeedbackSection }
|
||||
</React.Fragment>}
|
||||
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
|
||||
buttonDisabled={hasFeedback && rating === ""}
|
||||
onFinished={onFinished}
|
||||
/>);
|
||||
};
|
|
@ -31,7 +31,7 @@ import dis from "../../../dispatcher/dispatcher";
|
|||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import Modal from "../../../Modal";
|
||||
import {humanizeTime} from "../../../utils/humanize";
|
||||
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
|
||||
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
|
||||
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
@ -40,6 +40,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore";
|
|||
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -279,11 +281,17 @@ class DMRoomTile extends React.PureComponent {
|
|||
</span>
|
||||
);
|
||||
|
||||
const caption = this.props.member.isEmail
|
||||
? _t("Invite by email")
|
||||
: this._highlightName(this.props.member.userId);
|
||||
|
||||
return (
|
||||
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}>
|
||||
{stackedAvatar}
|
||||
<span className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
|
||||
<span className='mx_InviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
|
||||
<span className="mx_InviteDialog_roomTile_nameStack">
|
||||
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div>
|
||||
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
|
||||
</span>
|
||||
{timestamp}
|
||||
</div>
|
||||
);
|
||||
|
@ -301,10 +309,14 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
// The room ID this dialog is for. Only required for KIND_INVITE.
|
||||
roomId: PropTypes.string,
|
||||
|
||||
// Initial value to populate the filter with
|
||||
initialText: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
kind: KIND_DM,
|
||||
initialText: "",
|
||||
};
|
||||
|
||||
_debounceTimer: number = null;
|
||||
|
@ -325,11 +337,13 @@ export default class InviteDialog extends React.PureComponent {
|
|||
room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId));
|
||||
// add banned users, so we don't try to invite them
|
||||
room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId));
|
||||
|
||||
CountlyAnalytics.instance.trackBeginInvite(props.roomId);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
targets: [], // array of Member objects (see interface above)
|
||||
filterText: "",
|
||||
filterText: this.props.initialText,
|
||||
recents: InviteDialog.buildRecents(alreadyInvited),
|
||||
numRecentsShown: INITIAL_ROOMS_SHOWN,
|
||||
suggestions: this._buildSuggestions(alreadyInvited),
|
||||
|
@ -347,6 +361,12 @@ export default class InviteDialog extends React.PureComponent {
|
|||
this._editorRef = createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.initialText) {
|
||||
this._updateSuggestions(this.props.initialText);
|
||||
}
|
||||
}
|
||||
|
||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||
|
||||
|
@ -566,7 +586,12 @@ export default class InviteDialog extends React.PureComponent {
|
|||
const targetIds = targets.map(t => t.userId);
|
||||
|
||||
// Check if there is already a DM with these people and reuse it if possible.
|
||||
const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
|
||||
let existingRoom: Room;
|
||||
if (targetIds.length === 1) {
|
||||
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
|
||||
} else {
|
||||
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
|
||||
}
|
||||
if (existingRoom) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
|
@ -627,6 +652,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
};
|
||||
|
||||
_inviteUsers = () => {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
this.setState({busy: true});
|
||||
this._convertFilter();
|
||||
const targets = this._convertFilter();
|
||||
|
@ -643,6 +669,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => {
|
||||
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
|
||||
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
@ -658,12 +685,130 @@ export default class InviteDialog extends React.PureComponent {
|
|||
};
|
||||
|
||||
_onKeyDown = (e) => {
|
||||
// when the field is empty and the user hits backspace remove the right-most target
|
||||
if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
|
||||
!e.ctrlKey && !e.shiftKey && !e.metaKey
|
||||
) {
|
||||
if (this.state.busy) return;
|
||||
const value = e.target.value.trim();
|
||||
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
|
||||
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
|
||||
// when the field is empty and the user hits backspace remove the right-most target
|
||||
e.preventDefault();
|
||||
this._removeMember(this.state.targets[this.state.targets.length - 1]);
|
||||
} else if (value && e.key === Key.ENTER && !hasModifiers) {
|
||||
// when the user hits enter with something in their field try to convert it
|
||||
e.preventDefault();
|
||||
this._convertFilter();
|
||||
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
|
||||
// when the user hits space and their input looks like an e-mail/MXID then try to convert it
|
||||
e.preventDefault();
|
||||
this._convertFilter();
|
||||
}
|
||||
};
|
||||
|
||||
_updateSuggestions = async (term) => {
|
||||
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
|
||||
if (term !== this.state.filterText) {
|
||||
// Discard the results - we were probably too slow on the server-side to make
|
||||
// these results useful. This is a race we want to avoid because we could overwrite
|
||||
// more accurate results.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.results) r.results = [];
|
||||
|
||||
// While we're here, try and autocomplete a search result for the mxid itself
|
||||
// if there's no matches (and the input looks like a mxid).
|
||||
if (term[0] === '@' && term.indexOf(':') > 1) {
|
||||
try {
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(term);
|
||||
if (profile) {
|
||||
// If we have a profile, we have enough information to assume that
|
||||
// the mxid can be invited - add it to the list. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: profile['displayname'],
|
||||
avatar_url: profile['avatar_url'],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Non-fatal error trying to make an invite for a user ID");
|
||||
console.warn(e);
|
||||
|
||||
// Add a result anyways, just without a profile. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: term,
|
||||
avatar_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
serverResultsMixin: r.results.map(u => ({
|
||||
userId: u.user_id,
|
||||
user: new DirectoryMember(u),
|
||||
})),
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error("Error searching user directory:");
|
||||
console.error(e);
|
||||
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
||||
});
|
||||
|
||||
// Whenever we search the directory, also try to search the identity server. It's
|
||||
// all debounced the same anyways.
|
||||
if (!this.state.canUseIdentityServer) {
|
||||
// The user doesn't have an identity server set - warn them of that.
|
||||
this.setState({tryingIdentityServer: true});
|
||||
return;
|
||||
}
|
||||
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
|
||||
// Start off by suggesting the plain email while we try and resolve it
|
||||
// to a real account.
|
||||
this.setState({
|
||||
// per above: the userId is a lie here - it's just a regular identifier
|
||||
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
|
||||
});
|
||||
try {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const token = await authClient.getAccessToken();
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
const lookup = await MatrixClientPeg.get().lookupThreePid(
|
||||
'email',
|
||||
term,
|
||||
undefined, // callback
|
||||
token,
|
||||
);
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
if (!lookup || !lookup.mxid) {
|
||||
// We weren't able to find anyone - we're already suggesting the plain email
|
||||
// as an alternative, so do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// We append the user suggestion to give the user an option to click
|
||||
// the email anyways, and so we don't cause things to jump around. In
|
||||
// theory, the user would see the user pop up and think "ah yes, that
|
||||
// person!"
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
|
||||
if (term !== this.state.filterText || !profile) return; // abandon hope
|
||||
this.setState({
|
||||
threepidResultsMixin: [...this.state.threepidResultsMixin, {
|
||||
user: new DirectoryMember({
|
||||
user_id: lookup.mxid,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url,
|
||||
}),
|
||||
userId: lookup.mxid,
|
||||
}],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error searching identity server:");
|
||||
console.error(e);
|
||||
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -677,113 +822,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
if (this._debounceTimer) {
|
||||
clearTimeout(this._debounceTimer);
|
||||
}
|
||||
this._debounceTimer = setTimeout(async () => {
|
||||
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
|
||||
if (term !== this.state.filterText) {
|
||||
// Discard the results - we were probably too slow on the server-side to make
|
||||
// these results useful. This is a race we want to avoid because we could overwrite
|
||||
// more accurate results.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.results) r.results = [];
|
||||
|
||||
// While we're here, try and autocomplete a search result for the mxid itself
|
||||
// if there's no matches (and the input looks like a mxid).
|
||||
if (term[0] === '@' && term.indexOf(':') > 1) {
|
||||
try {
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(term);
|
||||
if (profile) {
|
||||
// If we have a profile, we have enough information to assume that
|
||||
// the mxid can be invited - add it to the list. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: profile['displayname'],
|
||||
avatar_url: profile['avatar_url'],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Non-fatal error trying to make an invite for a user ID");
|
||||
console.warn(e);
|
||||
|
||||
// Add a result anyways, just without a profile. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: term,
|
||||
avatar_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
serverResultsMixin: r.results.map(u => ({
|
||||
userId: u.user_id,
|
||||
user: new DirectoryMember(u),
|
||||
})),
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error("Error searching user directory:");
|
||||
console.error(e);
|
||||
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
||||
});
|
||||
|
||||
// Whenever we search the directory, also try to search the identity server. It's
|
||||
// all debounced the same anyways.
|
||||
if (!this.state.canUseIdentityServer) {
|
||||
// The user doesn't have an identity server set - warn them of that.
|
||||
this.setState({tryingIdentityServer: true});
|
||||
return;
|
||||
}
|
||||
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
|
||||
// Start off by suggesting the plain email while we try and resolve it
|
||||
// to a real account.
|
||||
this.setState({
|
||||
// per above: the userId is a lie here - it's just a regular identifier
|
||||
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
|
||||
});
|
||||
try {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const token = await authClient.getAccessToken();
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
const lookup = await MatrixClientPeg.get().lookupThreePid(
|
||||
'email',
|
||||
term,
|
||||
undefined, // callback
|
||||
token,
|
||||
);
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
if (!lookup || !lookup.mxid) {
|
||||
// We weren't able to find anyone - we're already suggesting the plain email
|
||||
// as an alternative, so do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// We append the user suggestion to give the user an option to click
|
||||
// the email anyways, and so we don't cause things to jump around. In
|
||||
// theory, the user would see the user pop up and think "ah yes, that
|
||||
// person!"
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
|
||||
if (term !== this.state.filterText || !profile) return; // abandon hope
|
||||
this.setState({
|
||||
threepidResultsMixin: [...this.state.threepidResultsMixin, {
|
||||
user: new DirectoryMember({
|
||||
user_id: lookup.mxid,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url,
|
||||
}),
|
||||
userId: lookup.mxid,
|
||||
}],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error searching identity server:");
|
||||
console.error(e);
|
||||
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
|
||||
}
|
||||
}
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._updateSuggestions(term);
|
||||
}, 150); // 150ms debounce (human reaction time + some)
|
||||
};
|
||||
|
||||
|
@ -806,6 +846,10 @@ export default class InviteDialog extends React.PureComponent {
|
|||
filterText = ""; // clear the filter when the user accepts a suggestion
|
||||
}
|
||||
this.setState({targets, filterText});
|
||||
|
||||
if (this._editorRef && this._editorRef.current) {
|
||||
this._editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_removeMember = (member: Member) => {
|
||||
|
@ -815,6 +859,10 @@ export default class InviteDialog extends React.PureComponent {
|
|||
targets.splice(idx, 1);
|
||||
this.setState({targets});
|
||||
}
|
||||
|
||||
if (this._editorRef && this._editorRef.current) {
|
||||
this._editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_onPaste = async (e) => {
|
||||
|
@ -824,7 +872,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent the text being pasted into the textarea
|
||||
// Prevent the text being pasted into the input
|
||||
e.preventDefault();
|
||||
|
||||
// Process it as a list of addresses to add instead
|
||||
|
@ -1019,8 +1067,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
|
||||
));
|
||||
const input = (
|
||||
<textarea
|
||||
rows={1}
|
||||
<input
|
||||
type="text"
|
||||
onKeyDown={this._onKeyDown}
|
||||
onChange={this._updateFilter}
|
||||
value={this.state.filterText}
|
||||
|
@ -1028,6 +1076,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
onPaste={this._onPaste}
|
||||
autoFocus={true}
|
||||
disabled={this.state.busy}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
@ -1098,7 +1147,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (identityServersEnabled) {
|
||||
helpText = _t(
|
||||
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
|
||||
"Start a conversation with someone using their name, email address or username (like <userId/>).",
|
||||
{},
|
||||
{userId: () => {
|
||||
return (
|
||||
|
@ -1153,7 +1202,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (identityServersEnabled) {
|
||||
helpText = _t(
|
||||
"Invite someone using their name, username (like <userId/>), email address or " +
|
||||
"Invite someone using their name, email address, username (like <userId/>) or " +
|
||||
"<a>share this room</a>.",
|
||||
{},
|
||||
{
|
||||
|
|
199
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal file
199
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
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 * as React from 'react';
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
IModalWidgetCloseRequest,
|
||||
IModalWidgetOpenRequestData,
|
||||
IModalWidgetReturnData,
|
||||
ISetModalButtonEnabledActionRequest,
|
||||
IWidgetApiAcknowledgeResponseData,
|
||||
IWidgetApiErrorResponseData,
|
||||
BuiltInModalButtonID,
|
||||
ModalButtonID,
|
||||
ModalButtonKind,
|
||||
Widget,
|
||||
WidgetApiFromWidgetAction,
|
||||
} from "matrix-widget-api";
|
||||
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
||||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
sourceWidgetId: string;
|
||||
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
messaging?: ClientWidgetApi;
|
||||
disabledButtonIds: ModalButtonID[];
|
||||
}
|
||||
|
||||
const MAX_BUTTONS = 3;
|
||||
|
||||
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
|
||||
private readonly widget: Widget;
|
||||
private readonly possibleButtons: ModalButtonID[];
|
||||
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
|
||||
|
||||
state: IState = {
|
||||
disabledButtonIds: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.widget = new Widget({
|
||||
...this.props.widgetDefinition,
|
||||
creatorUserId: MatrixClientPeg.get().getUserId(),
|
||||
id: `modal_${this.props.sourceWidgetId}`,
|
||||
});
|
||||
this.possibleButtons = (this.props.widgetDefinition.buttons || []).map(b => b.id);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const driver = new StopGapWidgetDriver( []);
|
||||
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
|
||||
this.setState({messaging});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.messaging.off("ready", this.onReady);
|
||||
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
|
||||
this.state.messaging.stop();
|
||||
}
|
||||
|
||||
private onReady = () => {
|
||||
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition);
|
||||
};
|
||||
|
||||
private onLoad = () => {
|
||||
this.state.messaging.once("ready", this.onReady);
|
||||
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
|
||||
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
|
||||
};
|
||||
|
||||
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
|
||||
this.props.onFinished(true, ev.detail.data);
|
||||
}
|
||||
|
||||
private onButtonEnableToggle = (ev: CustomEvent<ISetModalButtonEnabledActionRequest>) => {
|
||||
ev.preventDefault();
|
||||
const isClose = ev.detail.data.button === BuiltInModalButtonID.Close;
|
||||
if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) {
|
||||
return this.state.messaging.transport.reply(ev.detail, {
|
||||
error: {message: "Invalid button"},
|
||||
} as IWidgetApiErrorResponseData);
|
||||
}
|
||||
|
||||
let buttonIds: ModalButtonID[];
|
||||
if (ev.detail.data.enabled) {
|
||||
buttonIds = arrayFastClone(this.state.disabledButtonIds).filter(i => i !== ev.detail.data.button);
|
||||
} else {
|
||||
// use a set to swap the operation to avoid memory leaky arrays.
|
||||
const tempSet = new Set(this.state.disabledButtonIds);
|
||||
tempSet.add(ev.detail.data.button);
|
||||
buttonIds = Array.from(tempSet);
|
||||
}
|
||||
this.setState({disabledButtonIds: buttonIds});
|
||||
this.state.messaging.transport.reply(ev.detail, {} as IWidgetApiAcknowledgeResponseData);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const templated = this.widget.getCompleteUrl({
|
||||
currentRoomId: RoomViewStore.getRoomId(),
|
||||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
});
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
||||
// Add in some legacy support sprinkles (for non-popout widgets)
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
parsed.searchParams.set('widgetId', this.widget.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.
|
||||
const widgetUrl = parsed.toString().replace(/%24/g, '$');
|
||||
|
||||
let buttons;
|
||||
if (this.props.widgetDefinition.buttons) {
|
||||
// show first button rightmost for a more natural specification
|
||||
buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => {
|
||||
let kind = "secondary";
|
||||
switch (def.kind) {
|
||||
case ModalButtonKind.Primary:
|
||||
kind = "primary";
|
||||
break;
|
||||
case ModalButtonKind.Secondary:
|
||||
kind = "primary_outline";
|
||||
break
|
||||
case ModalButtonKind.Danger:
|
||||
kind = "danger";
|
||||
break;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
|
||||
};
|
||||
|
||||
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
|
||||
{ def.label }
|
||||
</AccessibleButton>;
|
||||
});
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={this.props.widgetDefinition.name || _t("Modal Widget")}
|
||||
className="mx_ModalWidgetDialog"
|
||||
contentId="mx_Dialog_content"
|
||||
onFinished={this.props.onFinished}
|
||||
>
|
||||
<div className="mx_ModalWidgetDialog_warning">
|
||||
<img
|
||||
src={require("../../../../res/img/element-icons/warning-badge.svg")}
|
||||
height="16"
|
||||
width="16"
|
||||
alt=""
|
||||
/>
|
||||
{_t("Data on this screen is shared with %(widgetDomain)s", {
|
||||
widgetDomain: parsed.hostname,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<iframe
|
||||
ref={this.appFrame}
|
||||
sandbox="allow-forms allow-scripts allow-same-origin"
|
||||
src={widgetUrl}
|
||||
onLoad={this.onLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_ModalWidgetDialog_buttons">
|
||||
{ buttons }
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
|
@ -26,12 +28,14 @@ export default class QuestionDialog extends React.Component {
|
|||
description: PropTypes.node,
|
||||
extraButtons: PropTypes.node,
|
||||
button: PropTypes.string,
|
||||
buttonDisabled: PropTypes.bool,
|
||||
danger: PropTypes.bool,
|
||||
focus: PropTypes.bool,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
headerImage: PropTypes.string,
|
||||
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
|
||||
fixedWidth: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -61,7 +65,7 @@ export default class QuestionDialog extends React.Component {
|
|||
}
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_QuestionDialog"
|
||||
className={classNames("mx_QuestionDialog", this.props.className)}
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
|
@ -74,6 +78,7 @@ export default class QuestionDialog extends React.Component {
|
|||
</div>
|
||||
<DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
primaryButtonClass={primaryButtonClass}
|
||||
primaryDisabled={this.props.buttonDisabled}
|
||||
cancelButton={this.props.cancelButton}
|
||||
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
|
|
|
@ -1,49 +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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default (props) => {
|
||||
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
|
||||
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
|
||||
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
|
||||
|
||||
const description1 =
|
||||
_t("If you run into any bugs or have feedback you'd like to share, " +
|
||||
"please let us know on GitHub.");
|
||||
const description2 = _t("To help avoid duplicate issues, " +
|
||||
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
|
||||
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
|
||||
"if you can't find it.", {},
|
||||
{
|
||||
existingIssuesLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
|
||||
},
|
||||
newIssueLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
|
||||
},
|
||||
});
|
||||
|
||||
return (<QuestionDialog
|
||||
hasCancelButton={false}
|
||||
title={_t("Report bugs & give feedback")}
|
||||
description={<div><p>{description1}</p><p>{description2}</p></div>}
|
||||
button={_t("Go back")}
|
||||
onFinished={props.onFinished}
|
||||
/>);
|
||||
};
|
|
@ -84,7 +84,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
"A widget located at %(widgetUrl)s would like to verify your identity. " +
|
||||
"By allowing this, the widget will be able to verify your user ID, but not " +
|
||||
"perform actions as you.", {
|
||||
widgetUrl: this.props.widgetUrl,
|
||||
widgetUrl: this.props.widgetUrl.split("?")[0],
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
|
|
|
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
|
|||
tabIndex?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?(e?: ButtonEvent): void;
|
||||
onClick(e?: ButtonEvent): void;
|
||||
}
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
|
|
|
@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
forceHide?: boolean;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
|
||||
render() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {title, tooltip, children, tooltipClassName, forceHide, ...props} = this.props;
|
||||
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
yOffset={yOffset}
|
||||
/> : <div />;
|
||||
return (
|
||||
<AccessibleButton
|
||||
|
|
|
@ -46,7 +46,7 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(props) {
|
||||
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
|
||||
// Make sure the selected item isn't outside the list bounds
|
||||
const selected = this.state.selected;
|
||||
const maxSelected = this._maxSelected(props.addressList);
|
||||
|
|
|
@ -22,56 +22,54 @@ import React, {createRef} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import AppPermission from './AppPermission';
|
||||
import AppWarning from './AppWarning';
|
||||
import Spinner from './Spinner';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import classNames from 'classnames';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement from "./PersistedElement";
|
||||
import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement, {getPersistKey} from "./PersistedElement";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
|
||||
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
|
||||
export default class AppTile extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// The key used for PersistedElement
|
||||
this._persistKey = 'widget_' + this.props.app.id;
|
||||
this._persistKey = getPersistKey(this.props.app.id);
|
||||
this._sgWidget = new StopGapWidget(this.props);
|
||||
this._sgWidget.on("preparing", this._onWidgetPrepared);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
this.iframe = null; // ref to the iframe (callback style)
|
||||
|
||||
this.state = this._getNewState(props);
|
||||
|
||||
this._onAction = this._onAction.bind(this);
|
||||
this._onEditClick = this._onEditClick.bind(this);
|
||||
this._onDeleteClick = this._onDeleteClick.bind(this);
|
||||
this._onRevokeClicked = this._onRevokeClicked.bind(this);
|
||||
this._onSnapshotClick = this._onSnapshotClick.bind(this);
|
||||
this.onClickMenuBar = this.onClickMenuBar.bind(this);
|
||||
this._onMinimiseClick = this._onMinimiseClick.bind(this);
|
||||
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
|
||||
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
|
||||
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
|
||||
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
|
||||
|
||||
this._contextMenuButton = createRef();
|
||||
this._menu_bar = createRef();
|
||||
|
||||
this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
|
||||
}
|
||||
|
||||
// This is a function to make the impact of calling SettingsStore slightly less
|
||||
hasPermissionToLoad = (props) => {
|
||||
if (this._usingLocalWidget()) return true;
|
||||
if (!props.room) return true; // user widgets always have permissions
|
||||
|
||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
||||
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
|
||||
return props.userId === props.creatorUserId;
|
||||
}
|
||||
return !!currentlyAllowedWidgets[props.app.eventId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Set initial component state when the App wUrl (widget URL) is being updated.
|
||||
* Component props *must* be passed (rather than relying on this.props).
|
||||
|
@ -79,28 +77,32 @@ export default class AppTile extends React.Component {
|
|||
* @return {Object} Updated component state to be set with setState
|
||||
*/
|
||||
_getNewState(newProps) {
|
||||
// This is a function to make the impact of calling SettingsStore slightly less
|
||||
const hasPermissionToLoad = () => {
|
||||
if (this._usingLocalWidget()) return true;
|
||||
|
||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId);
|
||||
return !!currentlyAllowedWidgets[newProps.app.eventId];
|
||||
};
|
||||
|
||||
return {
|
||||
initialising: true, // True while we are mangling the widget URL
|
||||
// True while the iframe content is loading
|
||||
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
|
||||
// Assume that widget has permission to load if we are the user who
|
||||
// added it to the room, or if explicitly granted by the user
|
||||
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
|
||||
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
|
||||
error: null,
|
||||
deleting: false,
|
||||
widgetPageTitle: newProps.widgetPageTitle,
|
||||
menuDisplayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
onAllowedWidgetsChange = () => {
|
||||
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
|
||||
|
||||
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
this.setState({ hasPermissionToLoad });
|
||||
};
|
||||
|
||||
isMixedContent() {
|
||||
const parentContentProtocol = window.location.protocol;
|
||||
const u = url.parse(this.props.app.url);
|
||||
|
@ -115,7 +117,7 @@ export default class AppTile extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
// Only fetch IM token on mount if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
|
@ -136,6 +138,8 @@ export default class AppTile extends React.Component {
|
|||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef);
|
||||
}
|
||||
|
||||
_resetWidget(newProps) {
|
||||
|
@ -167,21 +171,8 @@ export default class AppTile extends React.Component {
|
|||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||
if (nextProps.app.url !== this.props.app.url) {
|
||||
this._getNewState(nextProps);
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextProps.show && !this.props.show) {
|
||||
// We assume that persisted widgets are loaded and don't need a spinner.
|
||||
if (this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey)) {
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
}
|
||||
// Start the widget now that we're showing if we already have permission to load
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this._startWidget();
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -192,35 +183,6 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_canUserModify() {
|
||||
// User widgets should always be modifiable by their creator
|
||||
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
|
||||
return true;
|
||||
}
|
||||
// Check if the current user can modify widgets in the current room
|
||||
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
|
||||
}
|
||||
|
||||
_onEditClick() {
|
||||
console.log("Edit widget ID ", this.props.app.id);
|
||||
if (this.props.onEditClick) {
|
||||
this.props.onEditClick();
|
||||
} else {
|
||||
WidgetUtils.editWidget(this.props.room, this.props.app);
|
||||
}
|
||||
}
|
||||
|
||||
_onSnapshotClick() {
|
||||
this._sgWidget.widgetApi.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
||||
* @private
|
||||
|
@ -240,61 +202,14 @@ export default class AppTile extends React.Component {
|
|||
this.iframe.src = 'about:blank';
|
||||
}
|
||||
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
dis.dispatch({action: 'hangup_conference'});
|
||||
}
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
/* If user has permission to modify widgets, delete the widget,
|
||||
* otherwise revoke access for the widget to load in the user's browser
|
||||
*/
|
||||
_onDeleteClick() {
|
||||
if (this.props.onDeleteClick) {
|
||||
this.props.onDeleteClick();
|
||||
} else if (this._canUserModify()) {
|
||||
// Show delete confirmation dialog
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.setState({deleting: true});
|
||||
|
||||
this._endWidgetActions().then(() => {
|
||||
return WidgetUtils.setRoomWidget(
|
||||
this.props.room.roomId,
|
||||
this.props.app.id,
|
||||
);
|
||||
}).catch((e) => {
|
||||
console.error('Failed to delete widget', e);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Failed to remove widget', '', ErrorDialog, {
|
||||
title: _t('Failed to remove widget'),
|
||||
description: _t('An error ocurred whilst trying to remove the widget from the room'),
|
||||
});
|
||||
}).finally(() => {
|
||||
this.setState({deleting: false});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onUnpinClicked = () => {
|
||||
WidgetStore.instance.unpinWidget(this.props.app.id);
|
||||
}
|
||||
|
||||
_onRevokeClicked() {
|
||||
console.info("Revoke widget permissions - %s", this.props.app.id);
|
||||
this._revokeWidgetPermission();
|
||||
this._sgWidget.stop({forceDestroy: true});
|
||||
}
|
||||
|
||||
_onWidgetPrepared = () => {
|
||||
|
@ -307,7 +222,7 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
_onAction = payload => {
|
||||
if (payload.widgetId === this.props.app.id) {
|
||||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
|
@ -317,19 +232,11 @@ export default class AppTile extends React.Component {
|
|||
console.warn('Ignoring sticker message. Invalid capability');
|
||||
}
|
||||
break;
|
||||
|
||||
case Action.AppTileDelete:
|
||||
this._onDeleteClick();
|
||||
break;
|
||||
|
||||
case Action.AppTileRevoke:
|
||||
this._onRevokeClicked();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_grantWidgetPermission() {
|
||||
_grantWidgetPermission = () => {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Granting permission for widget to load: " + this.props.app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
|
@ -343,26 +250,7 @@ export default class AppTile extends React.Component {
|
|||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
}
|
||||
|
||||
_revokeWidgetPermission() {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Revoking permission for widget to load: " + this.props.app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
current[this.props.app.eventId] = false;
|
||||
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
|
||||
this.setState({hasPermissionToLoad: false});
|
||||
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
formatAppTileName() {
|
||||
let appTileName = "No name";
|
||||
|
@ -372,29 +260,6 @@ export default class AppTile extends React.Component {
|
|||
return appTileName;
|
||||
}
|
||||
|
||||
onClickMenuBar(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// Ignore clicks on menu bar children
|
||||
if (ev.target !== this._menu_bar.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the view state of the apps drawer
|
||||
if (this.props.userWidget) {
|
||||
this._onMinimiseClick();
|
||||
} else {
|
||||
if (this.props.show) {
|
||||
// if we were being shown, end the widget as we're about to be minimized.
|
||||
this._endWidgetActions();
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: !this.props.show,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we're using a local version of the widget rather than loading the
|
||||
* actual widget URL
|
||||
|
@ -414,22 +279,18 @@ export default class AppTile extends React.Component {
|
|||
|
||||
return (
|
||||
<span>
|
||||
<WidgetAvatar app={this.props.app} />
|
||||
<b>{ name }</b>
|
||||
<span>{ title ? titleSpacer : '' }{ title }</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
_onMinimiseClick(e) {
|
||||
if (this.props.onMinimiseClick) {
|
||||
this.props.onMinimiseClick();
|
||||
}
|
||||
}
|
||||
|
||||
_onPopoutWidgetClick() {
|
||||
// TODO replace with full screen interactions
|
||||
_onPopoutWidgetClick = () => {
|
||||
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
|
||||
// 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._endWidgetActions().then(() => {
|
||||
if (this.iframe) {
|
||||
// Reload iframe
|
||||
|
@ -442,13 +303,7 @@ export default class AppTile extends React.Component {
|
|||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
|
||||
}
|
||||
|
||||
_onReloadWidgetClick() {
|
||||
// Reload iframe in this way to avoid cross-origin restrictions
|
||||
// eslint-disable-next-line no-self-assign
|
||||
this.iframe.src = this.iframe.src;
|
||||
}
|
||||
};
|
||||
|
||||
_onContextMenuClick = () => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
|
@ -461,11 +316,6 @@ export default class AppTile extends React.Component {
|
|||
render() {
|
||||
let appTileBody;
|
||||
|
||||
// Don't render widget if it is in the process of being deleted
|
||||
if (this.state.deleting) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
|
||||
// because that would allow the iframe to programmatically remove the sandbox attribute, but
|
||||
// this would only be for content hosted on the same origin as the element client: anything
|
||||
|
@ -480,71 +330,67 @@ export default class AppTile extends React.Component {
|
|||
|
||||
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
|
||||
|
||||
if (this.props.show) {
|
||||
const loadingElement = (
|
||||
<div className="mx_AppLoading_spinner_fadeIn">
|
||||
<Spinner message={_t("Loading...")} />
|
||||
const loadingElement = (
|
||||
<div className="mx_AppLoading_spinner_fadeIn">
|
||||
<Spinner message={_t("Loading...")} />
|
||||
</div>
|
||||
);
|
||||
if (!this.state.hasPermissionToLoad) {
|
||||
// only possible for room widgets, can assert this.props.room here
|
||||
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppPermission
|
||||
roomId={this.props.room.roomId}
|
||||
creatorUserId={this.props.creatorUserId}
|
||||
url={this._sgWidget.embedUrl}
|
||||
isRoomEncrypted={isEncrypted}
|
||||
onPermissionGranted={this._grantWidgetPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (!this.state.hasPermissionToLoad) {
|
||||
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
} else if (this.state.initialising) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ loadingElement }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (this.isMixedContent()) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppPermission
|
||||
roomId={this.props.room.roomId}
|
||||
creatorUserId={this.props.creatorUserId}
|
||||
url={this._sgWidget.embedUrl}
|
||||
isRoomEncrypted={isEncrypted}
|
||||
onPermissionGranted={this._grantWidgetPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.initialising) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ loadingElement }
|
||||
<AppWarning errorMsg="Error - Mixed content" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (this.isMixedContent()) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppWarning errorMsg="Error - Mixed content" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ this.state.loading && loadingElement }
|
||||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._iframeRefChange}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// if the widget would be allowed to remain on screen, we must put it in
|
||||
// a PersistedElement from the get-go, otherwise the iframe will be
|
||||
// re-mounted later when we do.
|
||||
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||||
// AppTile's border is in the wrong place
|
||||
appTileBody = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ this.state.loading && loadingElement }
|
||||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._iframeRefChange}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// if the widget would be allowed to remain on screen, we must put it in
|
||||
// a PersistedElement from the get-go, otherwise the iframe will be
|
||||
// re-mounted later when we do.
|
||||
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||||
// AppTile's border is in the wrong place
|
||||
appTileBody = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showMinimiseButton = this.props.showMinimise && this.props.show;
|
||||
const showMaximiseButton = this.props.showMinimise && !this.props.show;
|
||||
|
||||
let appTileClasses;
|
||||
if (this.props.miniMode) {
|
||||
appTileClasses = {mx_AppTile_mini: true};
|
||||
|
@ -553,73 +399,37 @@ export default class AppTile extends React.Component {
|
|||
} else {
|
||||
appTileClasses = {mx_AppTile: true};
|
||||
}
|
||||
appTileClasses.mx_AppTile_minimised = !this.props.show;
|
||||
appTileClasses = classNames(appTileClasses);
|
||||
|
||||
const menuBarClasses = classNames({
|
||||
mx_AppTileMenuBar: true,
|
||||
mx_AppTileMenuBar_expanded: this.props.show,
|
||||
});
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
|
||||
|
||||
const canUserModify = this._canUserModify();
|
||||
const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
|
||||
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
|
||||
const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
|
||||
this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
|
||||
|
||||
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
|
||||
contextMenu = (
|
||||
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}>
|
||||
<WidgetContextMenu
|
||||
onUnpinClicked={
|
||||
ActiveWidgetStore.getWidgetPersistence(this.props.app.id) ? null : this._onUnpinClicked
|
||||
}
|
||||
onRevokeClicked={this._onRevokeClicked}
|
||||
onEditClicked={showEditButton ? this._onEditClick : undefined}
|
||||
onDeleteClicked={showDeleteButton ? this._onDeleteClick : undefined}
|
||||
onSnapshotClicked={showPictureSnapshotButton ? this._onSnapshotClick : undefined}
|
||||
onReloadClicked={this.props.showReload ? this._onReloadWidgetClick : undefined}
|
||||
onFinished={this._closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<RoomWidgetContextMenu
|
||||
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
|
||||
app={this.props.app}
|
||||
onFinished={this._closeContextMenu}
|
||||
showUnpin={!this.props.userWidget}
|
||||
userWidget={this.props.userWidget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<div className={appTileClasses} id={this.props.app.id}>
|
||||
{ this.props.showMenubar &&
|
||||
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
|
||||
<div className="mx_AppTileMenuBar">
|
||||
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
|
||||
{ /* Minimise widget */ }
|
||||
{ showMinimiseButton && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_minimise"
|
||||
title={_t('Minimize widget')}
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ /* Maximise widget */ }
|
||||
{ showMaximiseButton && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
|
||||
title={_t('Maximize widget')}
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ /* Title */ }
|
||||
{ this.props.showTitle && this._getTileTitle() }
|
||||
</span>
|
||||
<span className="mx_AppTileMenuBarWidgets">
|
||||
{ /* Popout widget */ }
|
||||
{ this.props.showPopout && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
|
||||
title={_t('Popout widget')}
|
||||
onClick={this._onPopoutWidgetClick}
|
||||
/> }
|
||||
{ /* Context menu */ }
|
||||
{ <ContextMenuButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
|
||||
label={_t('More options')}
|
||||
label={_t("Options")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
inputRef={this._contextMenuButton}
|
||||
onClick={this._onContextMenuClick}
|
||||
|
@ -638,7 +448,9 @@ AppTile.displayName = 'AppTile';
|
|||
|
||||
AppTile.propTypes = {
|
||||
app: PropTypes.object.isRequired,
|
||||
room: PropTypes.object.isRequired,
|
||||
// If room is not specified then it is an account level widget
|
||||
// which bypasses permission prompts as it was added explicitly by that user
|
||||
room: PropTypes.object,
|
||||
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
|
||||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||||
fullWidth: PropTypes.bool,
|
||||
|
@ -650,8 +462,6 @@ AppTile.propTypes = {
|
|||
creatorUserId: PropTypes.string,
|
||||
waitForIframeLoad: PropTypes.bool,
|
||||
showMenubar: PropTypes.bool,
|
||||
// Should the AppTile render itself
|
||||
show: PropTypes.bool,
|
||||
// Optional onEditClickHandler (overrides default behaviour)
|
||||
onEditClick: PropTypes.func,
|
||||
// Optional onDeleteClickHandler (overrides default behaviour)
|
||||
|
@ -660,19 +470,10 @@ AppTile.propTypes = {
|
|||
onMinimiseClick: PropTypes.func,
|
||||
// Optionally hide the tile title
|
||||
showTitle: PropTypes.bool,
|
||||
// Optionally hide the tile minimise icon
|
||||
showMinimise: PropTypes.bool,
|
||||
// Optionally handle minimise button pointer events (default false)
|
||||
handleMinimisePointerEvents: PropTypes.bool,
|
||||
// Optionally hide the delete icon
|
||||
showDelete: PropTypes.bool,
|
||||
// Optionally hide the popout widget icon
|
||||
showPopout: PropTypes.bool,
|
||||
// Optionally show the reload widget icon
|
||||
// This is not currently intended for use with production widgets. However
|
||||
// it can be useful when developing persistent widgets in order to avoid
|
||||
// having to reload all of Element to get new widget content.
|
||||
showReload: PropTypes.bool,
|
||||
// Widget capabilities to allow by default (without user confirmation)
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
|
@ -685,10 +486,7 @@ AppTile.defaultProps = {
|
|||
waitForIframeLoad: true,
|
||||
showMenubar: true,
|
||||
showTitle: true,
|
||||
showMinimise: true,
|
||||
showDelete: true,
|
||||
showPopout: true,
|
||||
showReload: false,
|
||||
handleMinimisePointerEvents: false,
|
||||
whitelistCapabilities: [],
|
||||
userWidget: false,
|
||||
|
|
|
@ -16,13 +16,12 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class DirectorySearchBox extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._collectInput = this._collectInput.bind(this);
|
||||
this._onClearClick = this._onClearClick.bind(this);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
|
@ -32,7 +31,7 @@ export default class DirectorySearchBox extends React.Component {
|
|||
this.input = null;
|
||||
|
||||
this.state = {
|
||||
value: '',
|
||||
value: this.props.initialText || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -78,28 +77,33 @@ export default class DirectorySearchBox extends React.Component {
|
|||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const searchbox_classes = {
|
||||
const searchboxClasses = {
|
||||
mx_DirectorySearchBox: true,
|
||||
};
|
||||
searchbox_classes[this.props.className] = true;
|
||||
searchboxClasses[this.props.className] = true;
|
||||
|
||||
let join_button;
|
||||
let joinButton;
|
||||
if (this.props.showJoinButton) {
|
||||
join_button = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
onClick={this._onJoinButtonClick}
|
||||
>{_t("Join")}</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className={`mx_DirectorySearchBox ${this.props.className} mx_textinput`}>
|
||||
<input type="text" name="dirsearch" value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange} onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder} autoFocus
|
||||
/>
|
||||
{ join_button }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick}></AccessibleButton>
|
||||
</div>;
|
||||
<input
|
||||
type="text"
|
||||
name="dirsearch"
|
||||
value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange}
|
||||
onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
{ joinButton }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,4 +114,5 @@ DirectorySearchBox.propTypes = {
|
|||
onJoinClick: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
showJoinButton: PropTypes.bool,
|
||||
initialText: PropTypes.string,
|
||||
};
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
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 React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
export default function IconButton(props) {
|
||||
const {icon, className, ...restProps} = props;
|
||||
|
||||
let newClassName = (className || "") + " mx_IconButton";
|
||||
newClassName = newClassName + " mx_IconButton_icon_" + icon;
|
||||
|
||||
const allProps = Object.assign({}, restProps, {className: newClassName});
|
||||
|
||||
return React.createElement(AccessibleButton, allProps);
|
||||
}
|
||||
|
||||
IconButton.propTypes = Object.assign({
|
||||
icon: PropTypes.string,
|
||||
}, AccessibleButton.propTypes);
|
90
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
90
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
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 React, {useContext, useRef, useState} from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Tooltip from './Tooltip';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {useTimeout} from "../../../hooks/useTimeout";
|
||||
|
||||
export const AVATAR_SIZE = 52;
|
||||
|
||||
interface IProps {
|
||||
hasAvatar: boolean;
|
||||
noAvatarLabel?: string;
|
||||
hasAvatarLabel?: string;
|
||||
setAvatarUrl(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useTimeout(() => {
|
||||
setShow(true);
|
||||
}, 3000); // show after 3 seconds
|
||||
useTimeout(() => {
|
||||
setShow(false);
|
||||
}, 13000); // hide after being shown for 10 seconds
|
||||
|
||||
const uploadRef = useRef<HTMLInputElement>();
|
||||
|
||||
const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel;
|
||||
|
||||
return <React.Fragment>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadRef}
|
||||
className="mx_MiniAvatarUploader_input"
|
||||
onChange={async (ev) => {
|
||||
if (!ev.target.files?.length) return;
|
||||
setBusy(true);
|
||||
const file = ev.target.files[0];
|
||||
const uri = await cli.uploadContent(file);
|
||||
await setAvatarUrl(uri);
|
||||
setBusy(false);
|
||||
}}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
<AccessibleButton
|
||||
className={classNames("mx_MiniAvatarUploader", {
|
||||
mx_MiniAvatarUploader_busy: busy,
|
||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
||||
})}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
uploadRef.current.click();
|
||||
}}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{ children }
|
||||
|
||||
<Tooltip
|
||||
label={label}
|
||||
visible={!!label && (hover || show)}
|
||||
forceOnRight
|
||||
/>
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
export default MiniAvatarUploader;
|
|
@ -21,6 +21,8 @@ import {throttle} from "lodash";
|
|||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -144,9 +146,11 @@ export default class PersistedElement extends React.Component {
|
|||
}
|
||||
|
||||
renderApp() {
|
||||
const content = <div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
|
||||
<div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</MatrixClientContext.Provider>;
|
||||
|
||||
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
|
||||
}
|
||||
|
@ -173,3 +177,5 @@ export default class PersistedElement extends React.Component {
|
|||
return <div ref={this.collectChildContainer} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const getPersistKey = (appId: string) => 'widget_' + appId;
|
||||
|
|
|
@ -58,6 +58,11 @@ export default class PersistentApp extends React.Component {
|
|||
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
|
||||
if (this.state.roomId !== persistentWidgetInRoomId) {
|
||||
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
|
||||
|
||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||
// thus no room is associated anymore.
|
||||
if (!persistentWidgetInRoom) return null;
|
||||
|
||||
// get the widget data
|
||||
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
|
||||
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
|
||||
|
@ -74,13 +79,10 @@ export default class PersistentApp extends React.Component {
|
|||
fullWidth={true}
|
||||
room={persistentWidgetInRoom}
|
||||
userId={MatrixClientPeg.get().credentials.userId}
|
||||
show={true}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
whitelistCapabilities={capWhitelist}
|
||||
showDelete={false}
|
||||
showMinimise={false}
|
||||
miniMode={true}
|
||||
showMenubar={false}
|
||||
/>;
|
||||
|
|
|
@ -26,12 +26,12 @@ import * as FormattingUtils from '../../../utils/FormattingUtils';
|
|||
|
||||
import FlairStore from '../../../stores/FlairStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// a thing to click on for the user to filter the visible rooms in the RoomList to:
|
||||
// - Rooms that are part of the group
|
||||
// - Direct messages with members of the group
|
||||
|
@ -142,7 +142,7 @@ export default class TagTile extends React.Component {
|
|||
mx_TagTile_selected_prototype: this.props.selected && isPrototype,
|
||||
});
|
||||
|
||||
const badge = TagOrderStore.getGroupBadge(this.props.tag);
|
||||
const badge = GroupFilterOrderStore.getGroupBadge(this.props.tag);
|
||||
let badgeElement;
|
||||
if (badge && !this.state.hover && !this.props.menuDisplayed) {
|
||||
const badgeClasses = classNames({
|
||||
|
|
|
@ -36,6 +36,7 @@ interface IProps {
|
|||
// the react element to put into the tooltip
|
||||
label: React.ReactNode;
|
||||
forceOnRight?: boolean;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
export default class Tooltip extends React.Component<IProps> {
|
||||
|
@ -46,6 +47,7 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
|
||||
public static readonly defaultProps = {
|
||||
visible: true,
|
||||
yOffset: 0,
|
||||
};
|
||||
|
||||
// Create a wrapper for the tooltip outside the parent and attach it to the body element
|
||||
|
@ -82,9 +84,9 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
|
||||
}
|
||||
|
||||
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
|
||||
style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
|
||||
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
|
||||
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8;
|
||||
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
|
||||
} else {
|
||||
style.left = parentBox.right + window.pageXOffset + 6;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import TagOrderStore from "../../../stores/TagOrderStore";
|
||||
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
||||
import AccessibleTooltipButton from "./AccessibleTooltipButton";
|
||||
import classNames from "classnames";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
@ -36,12 +36,12 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: TagOrderStore.getSelectedTags().length === 0,
|
||||
selected: GroupFilterOrderStore.getSelectedTags().length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate);
|
||||
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -49,7 +49,7 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
private onTagStoreUpdate = () => {
|
||||
const selected = TagOrderStore.getSelectedTags().length === 0;
|
||||
const selected = GroupFilterOrderStore.getSelectedTags().length === 0;
|
||||
this.setState({selected});
|
||||
};
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ interface IRule<T, D = void> {
|
|||
interface IArgs<T, D = void> {
|
||||
rules: IRule<T, D>[];
|
||||
description(this: T, derivedData: D): React.ReactChild;
|
||||
hideDescriptionIfValid?: boolean;
|
||||
deriveData?(data: Data): Promise<D>;
|
||||
}
|
||||
|
||||
|
@ -54,6 +55,8 @@ export interface IValidationResult {
|
|||
* @param {Function} description
|
||||
* Function that returns a string summary of the kind of value that will
|
||||
* meet the validation rules. Shown at the top of the validation feedback.
|
||||
* @param {Boolean} hideDescriptionIfValid
|
||||
* If true, don't show the description if the validation passes validation.
|
||||
* @param {Function} deriveData
|
||||
* Optional function that returns a Promise to an object of generic type D.
|
||||
* The result of this Promise is passed to rule methods `skip`, `test`, `valid`, and `invalid`.
|
||||
|
@ -71,7 +74,9 @@ export interface IValidationResult {
|
|||
* A validation function that takes in the current input value and returns
|
||||
* the overall validity and a feedback UI that can be rendered for more detail.
|
||||
*/
|
||||
export default function withValidation<T = undefined, D = void>({ description, deriveData, rules }: IArgs<T, D>) {
|
||||
export default function withValidation<T = undefined, D = void>({
|
||||
description, hideDescriptionIfValid, deriveData, rules,
|
||||
}: IArgs<T, D>) {
|
||||
return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
|
||||
if (!value && allowEmpty) {
|
||||
return {
|
||||
|
@ -156,7 +161,7 @@ export default function withValidation<T = undefined, D = void>({ description, d
|
|||
}
|
||||
|
||||
let summary;
|
||||
if (description) {
|
||||
if (description && (details || !hideDescriptionIfValid)) {
|
||||
// We're setting `this` to whichever component holds the validation
|
||||
// function. That allows rules to access the state of the component.
|
||||
const content = description.call(this, derivedData);
|
||||
|
|
|
@ -1,63 +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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
||||
export default class EncryptionEvent extends React.Component {
|
||||
render() {
|
||||
const {mxEvent} = this.props;
|
||||
|
||||
let body;
|
||||
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
|
||||
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
|
||||
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">
|
||||
{_t(
|
||||
"Messages in this room are end-to-end encrypted. " +
|
||||
"Learn more & verify this user in their user profile.",
|
||||
)}
|
||||
</div>
|
||||
</div>;
|
||||
} else if (isRoomEncrypted) {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">
|
||||
{_t("Ignored attempt to disable encryption")}
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">{_t("The encryption used by this room isn't supported.")}</div>
|
||||
</div>;
|
||||
classes += " mx_cryptoEvent_icon_warning";
|
||||
}
|
||||
|
||||
return (<div className={classes}>
|
||||
{body}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
EncryptionEvent.propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
};
|
68
src/components/views/messages/EncryptionEvent.tsx
Normal file
68
src/components/views/messages/EncryptionEvent.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
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 React, {forwardRef, useContext} from 'react';
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({mxEvent}, ref) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
|
||||
|
||||
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
|
||||
let subtitle: string;
|
||||
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
if (dmPartner) {
|
||||
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
|
||||
subtitle = _t("Messages here are end-to-end encrypted. " +
|
||||
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
|
||||
} else {
|
||||
subtitle = _t("Messages in this room are end-to-end encrypted. " +
|
||||
"When people join, you can verify them in their profile, just tap on their avatar.");
|
||||
}
|
||||
|
||||
return <EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon"
|
||||
title={_t("Encryption enabled")}
|
||||
subtitle={subtitle}
|
||||
/>;
|
||||
} else if (isRoomEncrypted) {
|
||||
return <EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon"
|
||||
title={_t("Encryption enabled")}
|
||||
subtitle={_t("Ignored attempt to disable encryption")}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon mx_cryptoEvent_icon_warning"
|
||||
title={_t("Encryption not enabled")}
|
||||
subtitle={_t("The encryption used by this room isn't supported.")}
|
||||
ref={ref}
|
||||
/>;
|
||||
});
|
||||
|
||||
export default EncryptionEvent;
|
34
src/components/views/messages/EventTileBubble.tsx
Normal file
34
src/components/views/messages/EventTileBubble.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
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 React, {forwardRef, ReactNode} from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface IProps {
|
||||
className: string;
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
}
|
||||
|
||||
const EventTileBubble = forwardRef<HTMLDivElement, IProps>(({ className, title, subtitle, children }, ref) => {
|
||||
return <div className={classNames("mx_EventTileBubble", className)} ref={ref}>
|
||||
<div className="mx_EventTileBubble_title">{ title }</div>
|
||||
{ subtitle && <div className="mx_EventTileBubble_subtitle">{ subtitle }</div> }
|
||||
{ children }
|
||||
</div>;
|
||||
});
|
||||
|
||||
export default EventTileBubble;
|
|
@ -144,7 +144,7 @@ export default class MFileBody extends React.Component {
|
|||
* Extracts a human readable label for the file attachment to use as
|
||||
* link text.
|
||||
*
|
||||
* @params {Object} content The "content" key of the matrix event.
|
||||
* @param {Object} content The "content" key of the matrix event.
|
||||
* @return {string} the human readable link text for the attachment.
|
||||
*/
|
||||
presentableTextForFile(content) {
|
||||
|
|
|
@ -85,6 +85,7 @@ export default class MImageBody extends React.Component {
|
|||
showImage() {
|
||||
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
|
||||
this.setState({showImage: true});
|
||||
this._downloadImage();
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
|
@ -253,10 +254,7 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on('sync', this.onClientSync);
|
||||
|
||||
_downloadImage() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
|
@ -289,9 +287,18 @@ export default class MImageBody extends React.Component {
|
|||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remember that the user wanted to show this particular image
|
||||
if (!this.state.showImage && localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true") {
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on('sync', this.onClientSync);
|
||||
|
||||
const showImage = this.state.showImage ||
|
||||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
|
||||
|
||||
if (showImage) {
|
||||
// Don't download anything becaue we don't want to display anything.
|
||||
this._downloadImage();
|
||||
this.setState({showImage: true});
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import React from 'react';
|
|||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -40,37 +41,24 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
|
|||
|
||||
if (!url) {
|
||||
// removed
|
||||
return (
|
||||
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
|
||||
<div className='mx_MJitsiWidgetEvent_title'>
|
||||
{_t('Video conference ended by %(senderName)s', {senderName})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EventTileBubble
|
||||
className="mx_MJitsiWidgetEvent"
|
||||
title={_t('Video conference ended by %(senderName)s', {senderName})}
|
||||
/>;
|
||||
} else if (prevUrl) {
|
||||
// modified
|
||||
return (
|
||||
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
|
||||
<div className='mx_MJitsiWidgetEvent_title'>
|
||||
{_t('Video conference updated by %(senderName)s', {senderName})}
|
||||
</div>
|
||||
<div className='mx_MJitsiWidgetEvent_subtitle'>
|
||||
{joinCopy}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EventTileBubble
|
||||
className="mx_MJitsiWidgetEvent"
|
||||
title={_t('Video conference updated by %(senderName)s', {senderName})}
|
||||
subtitle={joinCopy}
|
||||
/>;
|
||||
} else {
|
||||
// assume added
|
||||
return (
|
||||
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
|
||||
<div className='mx_MJitsiWidgetEvent_title'>
|
||||
{_t("Video conference started by %(senderName)s", {senderName})}
|
||||
</div>
|
||||
<div className='mx_MJitsiWidgetEvent_subtitle'>
|
||||
{joinCopy}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EventTileBubble
|
||||
className="mx_MJitsiWidgetEvent"
|
||||
title={_t("Video conference started by %(senderName)s", {senderName})}
|
||||
subtitle={joinCopy}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import {getNameForEventRoom, userLabelForEventRoom}
|
||||
from '../../../utils/KeyVerificationStateObserver';
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
|
||||
export default class MKeyVerificationConclusion extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -115,14 +116,14 @@ export default class MKeyVerificationConclusion extends React.Component {
|
|||
}
|
||||
|
||||
if (title) {
|
||||
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId());
|
||||
const classes = classNames("mx_EventTile_bubble", "mx_cryptoEvent", "mx_cryptoEvent_icon", {
|
||||
const classes = classNames("mx_cryptoEvent mx_cryptoEvent_icon", {
|
||||
mx_cryptoEvent_icon_verified: request.done,
|
||||
});
|
||||
return (<div className={classes}>
|
||||
<div className="mx_cryptoEvent_title">{title}</div>
|
||||
<div className="mx_cryptoEvent_subtitle">{subtitle}</div>
|
||||
</div>);
|
||||
return <EventTileBubble
|
||||
className={classes}
|
||||
title={title}
|
||||
subtitle={userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId())}
|
||||
/>;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -24,6 +24,7 @@ import {getNameForEventRoom, userLabelForEventRoom}
|
|||
import dis from "../../../dispatcher/dispatcher";
|
||||
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
|
||||
export default class MKeyVerificationRequest extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -146,10 +147,8 @@ export default class MKeyVerificationRequest extends React.Component {
|
|||
|
||||
if (!request.initiatedByMe) {
|
||||
const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
|
||||
title = (<div className="mx_cryptoEvent_title">{
|
||||
_t("%(name)s wants to verify", {name})}</div>);
|
||||
subtitle = (<div className="mx_cryptoEvent_subtitle">{
|
||||
userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
|
||||
title = _t("%(name)s wants to verify", {name});
|
||||
subtitle = userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId());
|
||||
if (request.canAccept) {
|
||||
stateNode = (<div className="mx_cryptoEvent_buttons">
|
||||
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
|
||||
|
@ -157,18 +156,18 @@ export default class MKeyVerificationRequest extends React.Component {
|
|||
</div>);
|
||||
}
|
||||
} else { // request sent by us
|
||||
title = (<div className="mx_cryptoEvent_title">{
|
||||
_t("You sent a verification request")}</div>);
|
||||
subtitle = (<div className="mx_cryptoEvent_subtitle">{
|
||||
userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
|
||||
title = _t("You sent a verification request");
|
||||
subtitle = userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId());
|
||||
}
|
||||
|
||||
if (title) {
|
||||
return (<div className="mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon">
|
||||
{title}
|
||||
{subtitle}
|
||||
{stateNode}
|
||||
</div>);
|
||||
return <EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon"
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
{ stateNode }
|
||||
</EventTileBubble>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import MFileBody from './MFileBody';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
|
@ -24,23 +23,34 @@ import { _t } from '../../../languageHandler';
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
|
||||
export default class MVideoBody extends React.Component {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: any;
|
||||
/* called when the video has loaded */
|
||||
onHeightChanged: () => void;
|
||||
}
|
||||
|
||||
/* called when the video has loaded */
|
||||
onHeightChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
interface IState {
|
||||
decryptedUrl: string|null,
|
||||
decryptedThumbnailUrl: string|null,
|
||||
decryptedBlob: Blob|null,
|
||||
error: any|null,
|
||||
fetchingData: boolean,
|
||||
}
|
||||
|
||||
state = {
|
||||
decryptedUrl: null,
|
||||
decryptedThumbnailUrl: null,
|
||||
decryptedBlob: null,
|
||||
error: null,
|
||||
};
|
||||
export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
fetchingData: false,
|
||||
decryptedUrl: null,
|
||||
decryptedThumbnailUrl: null,
|
||||
decryptedBlob: null,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
thumbScale(fullWidth, fullHeight, thumbWidth, thumbHeight) {
|
||||
thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
|
||||
if (!fullWidth || !fullHeight) {
|
||||
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
|
||||
// log this because it's spammy
|
||||
|
@ -61,7 +71,7 @@ export default class MVideoBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
_getContentUrl(): string|null {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined) {
|
||||
return this.state.decryptedUrl;
|
||||
|
@ -70,7 +80,7 @@ export default class MVideoBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_getThumbUrl() {
|
||||
_getThumbUrl(): string|null {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined) {
|
||||
return this.state.decryptedThumbnailUrl;
|
||||
|
@ -81,7 +91,8 @@ export default class MVideoBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
async componentDidMount() {
|
||||
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
|
@ -92,26 +103,33 @@ export default class MVideoBody extends React.Component {
|
|||
return URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
let decryptedBlob;
|
||||
thumbnailPromise.then((thumbnailUrl) => {
|
||||
return decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return URL.createObjectURL(blob);
|
||||
}).then((contentUrl) => {
|
||||
try {
|
||||
const thumbnailUrl = await thumbnailPromise;
|
||||
if (autoplay) {
|
||||
console.log("Preloading video");
|
||||
const decryptedBlob = await decryptFile(content.file);
|
||||
const contentUrl = URL.createObjectURL(decryptedBlob);
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
});
|
||||
this.props.onHeightChanged();
|
||||
});
|
||||
}).catch((err) => {
|
||||
} else {
|
||||
console.log("NOT preloading video");
|
||||
this.setState({
|
||||
decryptedUrl: null,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
// Set a placeholder image when we can't decrypt the image.
|
||||
this.setState({
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -124,8 +142,35 @@ export default class MVideoBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async _videoOnPlay() {
|
||||
if (this._getContentUrl() || this.state.fetchingData || this.state.error) {
|
||||
// We have the file, we are fetching the file, or there is an error.
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
// To stop subsequent download attempts
|
||||
fetchingData: true,
|
||||
});
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (!content.file) {
|
||||
this.setState({
|
||||
error: "No file given in content",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decryptedBlob = await decryptFile(content.file);
|
||||
const contentUrl = URL.createObjectURL(decryptedBlob);
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
fetchingData: false,
|
||||
});
|
||||
this.props.onHeightChanged();
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
|
||||
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
|
@ -136,7 +181,8 @@ export default class MVideoBody extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
|
||||
// Need to decrypt the attachment
|
||||
// The attachment is decrypted in componentDidMount.
|
||||
// For now add an img tag with a spinner.
|
||||
|
@ -151,7 +197,6 @@ export default class MVideoBody extends React.Component {
|
|||
|
||||
const contentUrl = this._getContentUrl();
|
||||
const thumbUrl = this._getThumbUrl();
|
||||
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
|
||||
let height = null;
|
||||
let width = null;
|
||||
let poster = null;
|
||||
|
@ -170,9 +215,9 @@ export default class MVideoBody extends React.Component {
|
|||
}
|
||||
return (
|
||||
<span className="mx_MVideoBody">
|
||||
<video className="mx_MVideoBody" src={contentUrl} alt={content.body}
|
||||
<video className="mx_MVideoBody" src={contentUrl} title={content.body}
|
||||
controls preload={preload} muted={autoplay} autoPlay={autoplay}
|
||||
height={height} width={width} poster={poster}>
|
||||
height={height} width={width} poster={poster} onPlay={this._videoOnPlay.bind(this)}>
|
||||
</video>
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
|
||||
</span>
|
|
@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
|
|||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
|
||||
export default class RoomCreate extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -45,23 +46,22 @@ export default class RoomCreate extends React.Component {
|
|||
render() {
|
||||
const predecessor = this.props.mxEvent.getContent()['predecessor'];
|
||||
if (predecessor === undefined) {
|
||||
return <div />; // We should never have been instaniated in this case
|
||||
return <div />; // We should never have been instantiated in this case
|
||||
}
|
||||
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
|
||||
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);
|
||||
permalinkCreator.load();
|
||||
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
|
||||
return <div className="mx_CreateEvent">
|
||||
<div className="mx_CreateEvent_image" />
|
||||
<div className="mx_CreateEvent_header">
|
||||
{_t("This room is a continuation of another conversation.")}
|
||||
</div>
|
||||
<a className="mx_CreateEvent_link"
|
||||
href={predecessorPermalink}
|
||||
onClick={this._onLinkClicked}
|
||||
>
|
||||
const link = (
|
||||
<a href={predecessorPermalink} onClick={this._onLinkClicked}>
|
||||
{_t("Click here to see older messages.")}
|
||||
</a>
|
||||
</div>;
|
||||
);
|
||||
|
||||
return <EventTileBubble
|
||||
className="mx_CreateEvent"
|
||||
title={_t("This room is a continuation of another conversation.")}
|
||||
subtitle={link}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -416,7 +416,9 @@ export default class TextualBody extends React.Component {
|
|||
if (this.props.highlightLink) {
|
||||
body = <a href={this.props.highlightLink}>{ body }</a>;
|
||||
} else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") {
|
||||
body = <a href="#" onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}>{ body }</a>;
|
||||
body = <a href="#"
|
||||
onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}
|
||||
>{ body }</a>;
|
||||
}
|
||||
|
||||
let widgets;
|
||||
|
|
|
@ -31,7 +31,7 @@ interface IProps {
|
|||
// The badge to display above the icon
|
||||
badge?: React.ReactNode;
|
||||
// The parameters to track the click event
|
||||
analytics: string[];
|
||||
analytics: Parameters<typeof Analytics.trackEvent>;
|
||||
|
||||
// Button name
|
||||
name: string;
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
import React, {useCallback, useState, useEffect, useContext} from "react";
|
||||
import classNames from "classnames";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useIsEncrypted } from '../../../hooks/useIsEncrypted';
|
||||
|
@ -32,17 +31,18 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
|
|||
import Modal from "../../../Modal";
|
||||
import ShareDialog from '../dialogs/ShareDialog';
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
|
||||
import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -68,22 +68,105 @@ const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => {
|
|||
};
|
||||
|
||||
export const useWidgets = (room: Room) => {
|
||||
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room));
|
||||
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room.roomId));
|
||||
|
||||
const updateApps = useCallback(() => {
|
||||
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings
|
||||
setApps([...WidgetStore.instance.getApps(room)]);
|
||||
setApps([...WidgetStore.instance.getApps(room.roomId)]);
|
||||
}, [room]);
|
||||
|
||||
useEffect(updateApps, [room]);
|
||||
useEventEmitter(WidgetEchoStore, "update", updateApps);
|
||||
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
interface IAppRowProps {
|
||||
app: IApp;
|
||||
}
|
||||
|
||||
const AppRow: React.FC<IAppRowProps> = ({ app }) => {
|
||||
const name = WidgetUtils.getWidgetName(app);
|
||||
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
|
||||
const subtitle = dataTitle && " - " + dataTitle;
|
||||
|
||||
const onOpenWidgetClick = () => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.Widget,
|
||||
refireParams: {
|
||||
widgetId: app.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isPinned = WidgetStore.instance.isPinned(app.id);
|
||||
const togglePin = isPinned
|
||||
? () => { WidgetStore.instance.unpinWidget(app.id); }
|
||||
: () => { WidgetStore.instance.pinWidget(app.id); };
|
||||
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current.getBoundingClientRect();
|
||||
contextMenu = <WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={window.innerWidth - rect.right}
|
||||
bottom={window.innerHeight - rect.top}
|
||||
onFinished={closeMenu}
|
||||
app={app}
|
||||
/>;
|
||||
}
|
||||
|
||||
const cannotPin = !isPinned && !WidgetStore.instance.canPin(app.id);
|
||||
|
||||
let pinTitle: string;
|
||||
if (cannotPin) {
|
||||
pinTitle = _t("You can only pin up to %(count)s widgets", { count: MAX_PINNED });
|
||||
} else {
|
||||
pinTitle = isPinned ? _t("Unpin") : _t("Pin");
|
||||
}
|
||||
|
||||
const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", {
|
||||
mx_RoomSummaryCard_Button_pinned: isPinned,
|
||||
});
|
||||
|
||||
return <div className={classes} ref={handle}>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomSummaryCard_icon_app"
|
||||
onClick={onOpenWidgetClick}
|
||||
// only show a tooltip if the widget is pinned
|
||||
title={isPinned ? _t("Unpin a widget to view it in this panel") : ""}
|
||||
forceHide={!isPinned}
|
||||
disabled={isPinned}
|
||||
yOffset={-48}
|
||||
>
|
||||
<WidgetAvatar app={app} />
|
||||
<span>{name}</span>
|
||||
{ subtitle }
|
||||
</AccessibleTooltipButton>
|
||||
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomSummaryCard_app_options"
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={openMenu}
|
||||
title={_t("Options")}
|
||||
yOffset={-24}
|
||||
/>
|
||||
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomSummaryCard_app_pinToggle"
|
||||
onClick={togglePin}
|
||||
title={pinTitle}
|
||||
disabled={cannotPin}
|
||||
yOffset={-24}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const apps = useWidgets(room);
|
||||
|
||||
const onManageIntegrations = () => {
|
||||
|
@ -100,65 +183,7 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
|
|||
};
|
||||
|
||||
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
|
||||
{ apps.map(app => {
|
||||
const name = WidgetUtils.getWidgetName(app);
|
||||
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
|
||||
const subtitle = dataTitle && " - " + dataTitle;
|
||||
|
||||
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
|
||||
// heuristics for some better icons until Widgets support their own icons
|
||||
if (app.type.includes("meeting") || app.type.includes("calendar")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")];
|
||||
} else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")];
|
||||
} else if (app.type.includes("clock")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")];
|
||||
}
|
||||
|
||||
if (app.avatar_url) { // MSC2765
|
||||
iconUrls.unshift(getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop"));
|
||||
}
|
||||
|
||||
const isPinned = WidgetStore.instance.isPinned(app.id);
|
||||
const classes = classNames("mx_RoomSummaryCard_icon_app", {
|
||||
mx_RoomSummaryCard_icon_app_pinned: isPinned,
|
||||
});
|
||||
|
||||
if (isPinned) {
|
||||
const onClick = () => {
|
||||
WidgetStore.instance.unpinWidget(app.id);
|
||||
};
|
||||
|
||||
return <AccessibleTooltipButton
|
||||
key={app.id}
|
||||
className={classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", classes)}
|
||||
onClick={onClick}
|
||||
title={_t("Unpin app")}
|
||||
>
|
||||
<BaseAvatar name={app.id} urls={iconUrls} width={20} height={20} />
|
||||
<span>{name}</span>
|
||||
{ subtitle }
|
||||
</AccessibleTooltipButton>
|
||||
}
|
||||
|
||||
const onOpenWidgetClick = () => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.Widget,
|
||||
refireParams: {
|
||||
widgetId: app.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button key={app.id} className={classes} onClick={onOpenWidgetClick}>
|
||||
<BaseAvatar name={app.id} urls={iconUrls} width={20} height={20} />
|
||||
<span>{name}</span>
|
||||
{ subtitle }
|
||||
</Button>
|
||||
);
|
||||
}) }
|
||||
{ apps.map(app => <AppRow key={app.id} app={app} />) }
|
||||
|
||||
<AccessibleButton kind="link" onClick={onManageIntegrations}>
|
||||
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }
|
||||
|
|
|
@ -28,7 +28,7 @@ import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import Modal from '../../../Modal';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
|
||||
import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
@ -51,7 +51,6 @@ 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";
|
||||
|
@ -60,6 +59,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
|
|||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
interface IDevice {
|
||||
deviceId: string;
|
||||
|
@ -105,17 +105,7 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice
|
|||
};
|
||||
|
||||
async function openDMForUser(matrixClient: MatrixClient, userId: string) {
|
||||
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
|
||||
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
|
||||
const room = matrixClient.getRoom(roomId);
|
||||
if (!room || room.getMyMembership() === "leave") {
|
||||
return lastActiveRoom;
|
||||
}
|
||||
if (!lastActiveRoom || lastActiveRoom.getLastActiveTimestamp() < room.getLastActiveTimestamp()) {
|
||||
return room;
|
||||
}
|
||||
return lastActiveRoom;
|
||||
}, null);
|
||||
const lastActiveRoom = findDMForUser(matrixClient, userId);
|
||||
|
||||
if (lastActiveRoom) {
|
||||
dis.dispatch({
|
||||
|
@ -586,7 +576,10 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
|
|||
while (timeline) {
|
||||
eventsToRedact = timeline.getEvents().reduce((events, event) => {
|
||||
if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction() &&
|
||||
event.getType() !== "m.room.create"
|
||||
event.getType() !== EventType.RoomCreate &&
|
||||
// Don't redact ACLs because that'll obliterate the room
|
||||
// See https://github.com/matrix-org/synapse/issues/4042 for details.
|
||||
event.getType() !== EventType.RoomServerAcl
|
||||
) {
|
||||
return events.concat(event);
|
||||
} else {
|
||||
|
@ -801,6 +794,11 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
|||
} = powerLevels;
|
||||
|
||||
const me = room.getMember(cli.getUserId());
|
||||
if (!me) {
|
||||
// we aren't in the room, so return no admin tooling
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isMe = me.userId === member.userId;
|
||||
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
|
||||
|
||||
|
@ -1019,24 +1017,15 @@ const PowerLevelSection: React.FC<{
|
|||
roomPermissions: IRoomPermissions;
|
||||
powerLevels: IPowerLevelsContent;
|
||||
}> = ({user, room, roomPermissions, powerLevels}) => {
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
if (isEditing) {
|
||||
return (<PowerLevelEditor
|
||||
user={user} room={room} roomPermissions={roomPermissions}
|
||||
onFinished={() => setEditing(false)} />);
|
||||
if (roomPermissions.canEdit) {
|
||||
return (<PowerLevelEditor user={user} room={room} roomPermissions={roomPermissions} />);
|
||||
} else {
|
||||
const powerLevelUsersDefault = powerLevels.users_default || 0;
|
||||
const powerLevel = parseInt(user.powerLevel, 10);
|
||||
const modifyButton = roomPermissions.canEdit ?
|
||||
(<IconButton icon="edit" onClick={() => setEditing(true)} />) : null;
|
||||
const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
|
||||
const label = _t("<strong>%(role)s</strong> in %(roomName)s",
|
||||
{role, roomName: room.name},
|
||||
{strong: label => <strong>{label}</strong>},
|
||||
);
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
<div className="mx_UserInfo_roleDescription">{label}{modifyButton}</div>
|
||||
<div className="mx_UserInfo_roleDescription">{role}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1046,20 +1035,15 @@ const PowerLevelEditor: React.FC<{
|
|||
user: User;
|
||||
room: Room;
|
||||
roomPermissions: IRoomPermissions;
|
||||
onFinished(): void;
|
||||
}> = ({user, room, roomPermissions, onFinished}) => {
|
||||
}> = ({user, room, roomPermissions}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedPowerLevel, setSelectedPowerLevel] = useState(parseInt(user.powerLevel, 10));
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const onPowerChange = useCallback((powerLevel) => {
|
||||
setIsDirty(true);
|
||||
setSelectedPowerLevel(parseInt(powerLevel, 10));
|
||||
}, [setSelectedPowerLevel, setIsDirty]);
|
||||
const onPowerChange = useCallback(async (powerLevelStr: string) => {
|
||||
const powerLevel = parseInt(powerLevelStr, 10);
|
||||
setSelectedPowerLevel(powerLevel);
|
||||
|
||||
const changePowerLevel = useCallback(async () => {
|
||||
const _applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {
|
||||
const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {
|
||||
return cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then(
|
||||
function() {
|
||||
// NO-OP; rely on the m.room.member event coming down else we could
|
||||
|
@ -1075,64 +1059,42 @@ const PowerLevelEditor: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
try {
|
||||
if (!isDirty) {
|
||||
return;
|
||||
}
|
||||
const roomId = user.roomId;
|
||||
const target = user.userId;
|
||||
|
||||
setIsUpdating(true);
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
if (!powerLevelEvent) return;
|
||||
|
||||
const powerLevel = selectedPowerLevel;
|
||||
const myUserId = cli.getUserId();
|
||||
const myPower = powerLevelEvent.getContent().users[myUserId];
|
||||
if (myPower && parseInt(myPower) === powerLevel) {
|
||||
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
<div>
|
||||
{ _t("You will not be able to undo this change as you are promoting the user " +
|
||||
"to have the same power level as yourself.") }<br />
|
||||
{ _t("Are you sure?") }
|
||||
</div>,
|
||||
button: _t("Continue"),
|
||||
});
|
||||
|
||||
const roomId = user.roomId;
|
||||
const target = user.userId;
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
if (!powerLevelEvent) return;
|
||||
|
||||
if (!powerLevelEvent.getContent().users) {
|
||||
_applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) return;
|
||||
} else if (myUserId === target) {
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
if (myUserId === target) {
|
||||
try {
|
||||
if (!(await warnSelfDemote())) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
|
||||
return;
|
||||
try {
|
||||
if (!(await warnSelfDemote())) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
|
||||
const myPower = powerLevelEvent.getContent().users[myUserId];
|
||||
if (parseInt(myPower) === powerLevel) {
|
||||
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description:
|
||||
<div>
|
||||
{ _t("You will not be able to undo this change as you are promoting the user " +
|
||||
"to have the same power level as yourself.") }<br />
|
||||
{ _t("Are you sure?") }
|
||||
</div>,
|
||||
button: _t("Continue"),
|
||||
});
|
||||
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
|
||||
} finally {
|
||||
onFinished();
|
||||
}
|
||||
}, [user.roomId, user.userId, cli, selectedPowerLevel, isDirty, setIsUpdating, onFinished, room]);
|
||||
|
||||
await applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
|
||||
}, [user.roomId, user.userId, cli, room]);
|
||||
|
||||
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
|
||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||
const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
|
||||
<IconButton icon="check" onClick={changePowerLevel} />;
|
||||
|
||||
return (
|
||||
<div className="mx_UserInfo_profileField">
|
||||
|
@ -1142,9 +1104,7 @@ const PowerLevelEditor: React.FC<{
|
|||
maxValue={roomPermissions.modifyLevelMax}
|
||||
usersDefault={powerLevelUsersDefault}
|
||||
onChange={onPowerChange}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
{buttonOrSpinner}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1334,13 +1294,17 @@ const BasicUserInfo: React.FC<{
|
|||
}
|
||||
|
||||
let memberDetails;
|
||||
if (room && member.roomId) {
|
||||
memberDetails = <PowerLevelSection
|
||||
powerLevels={powerLevels}
|
||||
user={member}
|
||||
room={room}
|
||||
roomPermissions={roomPermissions}
|
||||
/>;
|
||||
// hide the Roles section for DMs as it doesn't make sense there
|
||||
if (room && member.roomId && !DMRoomMap.shared().getUserIdForRoomId(member.roomId)) {
|
||||
memberDetails = <div className="mx_UserInfo_container">
|
||||
<h3>{ _t("Role") }</h3>
|
||||
<PowerLevelSection
|
||||
powerLevels={powerLevels}
|
||||
user={member}
|
||||
room={room}
|
||||
roomPermissions={roomPermissions}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// only display the devices list if our client supports E2E
|
||||
|
@ -1410,12 +1374,7 @@ const BasicUserInfo: React.FC<{
|
|||
);
|
||||
|
||||
return <React.Fragment>
|
||||
{ memberDetails &&
|
||||
<div className="mx_UserInfo_container mx_UserInfo_separator mx_UserInfo_memberDetailsContainer">
|
||||
<div className="mx_UserInfo_memberDetails">
|
||||
{ memberDetails }
|
||||
</div>
|
||||
</div> }
|
||||
{ memberDetails }
|
||||
|
||||
{ securitySection }
|
||||
<UserOptionsSection
|
||||
|
|
|
@ -20,7 +20,6 @@ import {Room} from "matrix-js-sdk/src/models/room";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import BaseCard from "./BaseCard";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AppTile from "../elements/AppTile";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {useWidgets} from "./RoomSummaryCard";
|
||||
|
@ -30,16 +29,7 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
|
|||
import {Action} from "../../../dispatcher/actions";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import { MatrixCapabilities } from "matrix-widget-api";
|
||||
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -69,111 +59,22 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
|||
// Don't render anything as we are about to transition
|
||||
if (!app || isPinned) return null;
|
||||
|
||||
const header = <React.Fragment>
|
||||
<h2>{ WidgetUtils.getWidgetName(app) }</h2>
|
||||
</React.Fragment>;
|
||||
|
||||
const canModify = WidgetUtils.canUserModifyWidgets(room.roomId);
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
let snapshotButton;
|
||||
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
|
||||
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
|
||||
const onSnapshotClick = () => {
|
||||
widgetMessaging.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
|
||||
}
|
||||
|
||||
let deleteButton;
|
||||
if (canModify) {
|
||||
const onDeleteClick = () => {
|
||||
defaultDispatcher.dispatch<AppTileActionPayload>({
|
||||
action: Action.AppTileDelete,
|
||||
widgetId: app.id,
|
||||
});
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
deleteButton = <IconizedContextMenuOption onClick={onDeleteClick} label={_t("Remove for everyone")} />;
|
||||
}
|
||||
|
||||
const onRevokeClick = () => {
|
||||
defaultDispatcher.dispatch<AppTileActionPayload>({
|
||||
action: Action.AppTileRevoke,
|
||||
widgetId: app.id,
|
||||
});
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const rect = handle.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<IconizedContextMenu
|
||||
<WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={window.innerWidth - rect.right}
|
||||
bottom={window.innerHeight - rect.top}
|
||||
right={window.innerWidth - rect.right - 12}
|
||||
top={rect.bottom + 12}
|
||||
onFinished={closeMenu}
|
||||
>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ snapshotButton }
|
||||
{ deleteButton }
|
||||
<IconizedContextMenuOption onClick={onRevokeClick} label={_t("Remove for me")} />
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onPinClick = () => {
|
||||
WidgetStore.instance.pinWidget(app.id);
|
||||
};
|
||||
|
||||
const onEditClick = () => {
|
||||
WidgetUtils.editWidget(room, app);
|
||||
};
|
||||
|
||||
let editButton;
|
||||
if (canModify) {
|
||||
editButton = <AccessibleButton kind="secondary" onClick={onEditClick}>
|
||||
{ _t("Edit") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const pinButtonClasses = canModify ? "" : "mx_WidgetCard_widePinButton";
|
||||
|
||||
let pinButton;
|
||||
if (WidgetStore.instance.canPin(app.id)) {
|
||||
pinButton = <AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={onPinClick}
|
||||
className={pinButtonClasses}
|
||||
>
|
||||
{ _t("Pin to room") }
|
||||
</AccessibleButton>;
|
||||
} else {
|
||||
pinButton = <AccessibleTooltipButton
|
||||
title={_t("You can only pin 2 widgets at a time")}
|
||||
tooltipClassName="mx_WidgetCard_maxPinnedTooltip"
|
||||
kind="secondary"
|
||||
className={pinButtonClasses}
|
||||
disabled
|
||||
>
|
||||
{ _t("Pin to room") }
|
||||
</AccessibleTooltipButton>;
|
||||
}
|
||||
|
||||
const footer = <React.Fragment>
|
||||
{ editButton }
|
||||
{ pinButton }
|
||||
const header = <React.Fragment>
|
||||
<h2>{ WidgetUtils.getWidgetName(app) }</h2>
|
||||
<ContextMenuButton
|
||||
kind="secondary"
|
||||
className="mx_WidgetCard_optionsButton"
|
||||
|
@ -182,16 +83,12 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
|||
isExpanded={menuDisplayed}
|
||||
label={_t("Options")}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
|
||||
return <BaseCard
|
||||
header={header}
|
||||
footer={footer}
|
||||
className={classNames("mx_WidgetCard", {
|
||||
mx_WidgetCard_noEdit: !canModify,
|
||||
})}
|
||||
className="mx_WidgetCard"
|
||||
onClose={onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useState} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {Resizable} from "re-resizable";
|
||||
|
@ -24,15 +24,16 @@ import AppTile from '../elements/AppTile';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as sdk from '../../../index';
|
||||
import * as ScalarMessaging from '../../../ScalarMessaging';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {useLocalStorageState} from "../../../hooks/useLocalStorageState";
|
||||
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import ResizeHandle from "../elements/ResizeHandle";
|
||||
import Resizer from "../../../resizer/resizer";
|
||||
import PercentageDistributor from "../../../resizer/distributors/percentage";
|
||||
|
||||
export default class AppsDrawer extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -40,12 +41,10 @@ export default class AppsDrawer extends React.Component {
|
|||
room: PropTypes.object.isRequired,
|
||||
resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired,
|
||||
showApps: PropTypes.bool, // Should apps be rendered
|
||||
hide: PropTypes.bool, // If rendered, should apps drawer be visible
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
showApps: true,
|
||||
hide: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -54,6 +53,11 @@ export default class AppsDrawer extends React.Component {
|
|||
this.state = {
|
||||
apps: this._getApps(),
|
||||
};
|
||||
|
||||
this._resizeContainer = null;
|
||||
this.resizer = this._createResizer();
|
||||
|
||||
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -66,6 +70,10 @@ export default class AppsDrawer extends React.Component {
|
|||
ScalarMessaging.stopListening();
|
||||
WidgetStore.instance.off(this.props.room.roomId, this._updateApps);
|
||||
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
|
||||
if (this._resizeContainer) {
|
||||
this.resizer.detach();
|
||||
}
|
||||
this.props.resizeNotifier.off("isResizing", this.onIsResizing);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
@ -75,6 +83,95 @@ export default class AppsDrawer extends React.Component {
|
|||
this._updateApps();
|
||||
}
|
||||
|
||||
onIsResizing = (resizing) => {
|
||||
this.setState({ resizing });
|
||||
if (!resizing) {
|
||||
this._relaxResizer();
|
||||
}
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
const collapseConfig = {
|
||||
onResizeStart: () => {
|
||||
this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
|
||||
},
|
||||
onResizeStop: () => {
|
||||
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
|
||||
// persist to localStorage
|
||||
localStorage.setItem(this._getStorageKey(), JSON.stringify([
|
||||
this.state.apps.map(app => app.id),
|
||||
...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
|
||||
]));
|
||||
},
|
||||
};
|
||||
// pass a truthy container for now, we won't call attach until we update it
|
||||
const resizer = new Resizer({}, PercentageDistributor, collapseConfig);
|
||||
resizer.setClassNames(classNames);
|
||||
return resizer;
|
||||
}
|
||||
|
||||
_collectResizer = (ref) => {
|
||||
if (this._resizeContainer) {
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
this.resizer.container = ref;
|
||||
this.resizer.attach();
|
||||
}
|
||||
this._resizeContainer = ref;
|
||||
this._loadResizerPreferences();
|
||||
};
|
||||
|
||||
_getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`;
|
||||
|
||||
_getAppsHash = (apps) => apps.map(app => app.id).join("~");
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
|
||||
this._loadResizerPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
_relaxResizer = () => {
|
||||
const distributors = this.resizer.getDistributors();
|
||||
|
||||
// relax all items if they had any overconstrained flexboxes
|
||||
distributors.forEach(d => d.start());
|
||||
distributors.forEach(d => d.finish());
|
||||
};
|
||||
|
||||
_loadResizerPreferences = () => {
|
||||
try {
|
||||
const [[...lastIds], ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey()));
|
||||
// Every app was included in the last split, reuse the last sizes
|
||||
if (this.state.apps.length <= lastIds.length && this.state.apps.every((app, i) => lastIds[i] === app.id)) {
|
||||
sizes.forEach((size, i) => {
|
||||
const distributor = this.resizer.forHandleAt(i);
|
||||
if (distributor) {
|
||||
distributor.size = size;
|
||||
distributor.finish();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// this is expected
|
||||
}
|
||||
|
||||
if (this.state.apps) {
|
||||
const distributors = this.resizer.getDistributors();
|
||||
distributors.forEach(d => d.item.clearSize());
|
||||
distributors.forEach(d => d.start());
|
||||
distributors.forEach(d => d.finish());
|
||||
}
|
||||
};
|
||||
|
||||
onAction = (action) => {
|
||||
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
|
||||
switch (action.action) {
|
||||
|
@ -93,7 +190,7 @@ export default class AppsDrawer extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
_getApps = () => WidgetStore.instance.getApps(this.props.room, true);
|
||||
_getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
|
||||
|
||||
_updateApps = () => {
|
||||
this.setState({
|
||||
|
@ -101,15 +198,6 @@ export default class AppsDrawer extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_canUserModify() {
|
||||
try {
|
||||
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_launchManageIntegrations() {
|
||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||
IntegrationManagers.sharedInstance().openAll();
|
||||
|
@ -118,12 +206,9 @@ export default class AppsDrawer extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
onClickAddWidget = (e) => {
|
||||
e.preventDefault();
|
||||
this._launchManageIntegrations();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.props.showApps) return <div />;
|
||||
|
||||
const apps = this.state.apps.map((app, index, arr) => {
|
||||
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
|
||||
|
||||
|
@ -133,7 +218,6 @@ export default class AppsDrawer extends React.Component {
|
|||
fullWidth={arr.length < 2}
|
||||
room={this.props.room}
|
||||
userId={this.props.userId}
|
||||
show={this.props.showApps}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
|
@ -145,21 +229,6 @@ export default class AppsDrawer extends React.Component {
|
|||
return <div />;
|
||||
}
|
||||
|
||||
let addWidget;
|
||||
if (this.props.showApps &&
|
||||
this._canUserModify()
|
||||
) {
|
||||
addWidget = <AccessibleButton
|
||||
onClick={this.onClickAddWidget}
|
||||
className={this.state.apps.length<2 ?
|
||||
'mx_AddWidget_button mx_AddWidget_button_full_width' :
|
||||
'mx_AddWidget_button'
|
||||
}
|
||||
title={_t('Add a widget')}>
|
||||
[+] { _t('Add a widget') }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let spinner;
|
||||
if (
|
||||
apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets(
|
||||
|
@ -172,10 +241,11 @@ export default class AppsDrawer extends React.Component {
|
|||
}
|
||||
|
||||
const classes = classNames({
|
||||
"mx_AppsDrawer": true,
|
||||
"mx_AppsDrawer_hidden": this.props.hide,
|
||||
"mx_AppsDrawer_fullWidth": apps.length < 2,
|
||||
"mx_AppsDrawer_minimised": !this.props.showApps,
|
||||
mx_AppsDrawer: true,
|
||||
mx_AppsDrawer_fullWidth: apps.length < 2,
|
||||
mx_AppsDrawer_resizing: this.state.resizing,
|
||||
mx_AppsDrawer_2apps: apps.length === 2,
|
||||
mx_AppsDrawer_3apps: apps.length === 3,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -185,13 +255,20 @@ export default class AppsDrawer extends React.Component {
|
|||
minHeight={100}
|
||||
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
|
||||
handleClass="mx_AppsContainer_resizerHandle"
|
||||
className="mx_AppsContainer"
|
||||
className="mx_AppsContainer_resizer"
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
{ apps }
|
||||
{ spinner }
|
||||
<div className="mx_AppsContainer" ref={this._collectResizer}>
|
||||
{ apps.map((app, i) => {
|
||||
if (i < 1) return app;
|
||||
return <React.Fragment key={app.key}>
|
||||
<ResizeHandle reverse={i > apps.length / 2} />
|
||||
{ app }
|
||||
</React.Fragment>;
|
||||
}) }
|
||||
</div>
|
||||
</PersistentVResizer>
|
||||
{ this._canUserModify() && addWidget }
|
||||
{ spinner }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -208,14 +285,12 @@ const PersistentVResizer = ({
|
|||
children,
|
||||
}) => {
|
||||
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
return <Resizable
|
||||
size={{height: Math.min(height, maxHeight)}}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
onResizeStart={() => {
|
||||
if (!resizing) setResizing(true);
|
||||
resizeNotifier.startResizing();
|
||||
}}
|
||||
onResize={() => {
|
||||
|
@ -223,14 +298,11 @@ const PersistentVResizer = ({
|
|||
}}
|
||||
onResizeStop={(e, dir, ref, d) => {
|
||||
setHeight(height + d.height);
|
||||
if (resizing) setResizing(false);
|
||||
resizeNotifier.stopResizing();
|
||||
}}
|
||||
handleWrapperClass={handleWrapperClass}
|
||||
handleClasses={{bottom: handleClass}}
|
||||
className={classNames(className, {
|
||||
mx_AppsDrawer_resizing: resizing,
|
||||
})}
|
||||
className={className}
|
||||
enable={{bottom: true}}
|
||||
>
|
||||
{ children }
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue