Merge branch 'develop' into katex

This commit is contained in:
Aleks Kissinger 2020-10-25 12:28:17 +00:00
commit 4536f51ec0
157 changed files with 3749 additions and 2177 deletions

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr";
import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg";
@ -31,6 +32,9 @@ 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 UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
declare global {
interface Window {
@ -55,6 +59,9 @@ declare global {
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
}
interface Document {

View file

@ -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;

View file

@ -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(

View file

@ -77,13 +77,29 @@ 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 Analytics from './Analytics';
// until we ts-ify the js-sdk voip code
type Call = any;
enum AudioID {
Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
Busy = 'busyAudio',
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
export default class CallHandler {
private calls = new Map<string, Call>();
private audioPromises = new Map<string, Promise<void>>();
private calls = new Map<string, MatrixCall>();
private audioPromises = new Map<AudioID, Promise<void>>();
static sharedInstance() {
if (!window.mxCallHandler) {
@ -108,20 +124,20 @@ export default class CallHandler {
}
}
getCallForRoom(roomId: string): Call {
getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null;
}
getAnyActiveCall() {
for (const call of this.calls.values()) {
if (call.state !== "ended") {
if (call.state !== CallState.Ended) {
return call;
}
}
return null;
}
play(audioId: string) {
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -150,7 +166,7 @@ export default class CallHandler {
}
}
pause(audioId: string) {
pause(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -164,8 +180,19 @@ export default class CallHandler {
}
}
private setCallListeners(call: Call) {
call.on("error", (err) => {
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 &&
@ -180,74 +207,103 @@ export default class CallHandler {
description: err.message,
});
});
call.on("hangup", () => {
call.on(CallEvent.Hangup, () => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callHangup');
this.removeCallForRoom(call.roomId);
});
// map web rtc states to dummy UI state
// ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
call.on("state", (newState, oldState) => {
if (newState === "ringing") {
this.setCallState(call, call.roomId, "ringing");
this.pause("ringbackAudio");
} else if (newState === "invite_sent") {
this.setCallState(call, call.roomId, "ringback");
this.play("ringbackAudio");
} else if (newState === "ended" && oldState === "connected") {
this.removeCallForRoom(call.roomId);
this.pause("ringbackAudio");
this.play("callendAudio");
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
)) {
this.setCallState(call, call.roomId, "busy");
this.pause("ringbackAudio");
this.play("busyAudio");
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
title: _t('Call Timeout'),
description: _t('The remote side failed to pick up') + '.',
});
} else if (oldState === "invite_sent") {
this.setCallState(call, call.roomId, "stop_ringback");
this.pause("ringbackAudio");
} else if (oldState === "ringing") {
this.setCallState(call, call.roomId, "stop_ringing");
this.pause("ringbackAudio");
} else if (newState === "connected") {
this.setCallState(call, call.roomId, "connected");
this.pause("ringbackAudio");
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
if (!this.matchesCallForThisRoom(call)) return;
this.setCallState(call, newState);
switch (oldState) {
case CallState.Ringing:
this.pause(AudioID.Ring);
break;
case CallState.InviteSent:
this.pause(AudioID.Ringback);
break;
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
break;
case CallState.InviteSent:
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);
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 setCallState(call: Call, roomId: string, status: string) {
private setCallState(call: MatrixCall, status: CallState) {
console.log(
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
`Call state in ${call.roomId} changed to ${status}`,
);
if (call) {
this.calls.set(roomId, call);
} else {
this.calls.delete(roomId);
}
if (status === "ringing") {
this.play("ringAudio");
} else if (call && call.call_state === "ringing") {
this.pause("ringAudio");
}
if (call) {
call.call_state = status;
}
dis.dispatch({
action: 'call_state',
room_id: roomId,
room_id: call.roomId,
state: status,
});
}
private removeCallForRoom(roomId: string) {
this.setCallState(null, roomId, null);
this.calls.delete(roomId);
}
private showICEFallbackPrompt() {
@ -279,36 +335,40 @@ export default class CallHandler {
}, null, true);
}
private onAction = (payload: ActionPayload) => {
const placeCall = (newCall) => {
this.setCallListeners(newCall);
if (payload.type === 'voice') {
newCall.placeVoiceCall();
} else if (payload.type === 'video') {
newCall.placeVideoCall(
payload.remote_element,
payload.local_element,
);
} else if (payload.type === 'screensharing') {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(newCall.roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element,
);
} else {
console.error("Unknown conf call type: %s", payload.type);
}
}
private placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
this.calls.set(roomId, call);
this.setCallListeners(call);
if (type === PlaceCallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall(
remoteElement,
localElement,
);
} else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
call.placeScreenSharingCall(remoteElement, localElement);
} else {
console.error("Unknown conf call type: %s", type);
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'place_call':
{
@ -343,8 +403,8 @@ export default class CallHandler {
return;
} else if (members.length === 2) {
console.info("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
@ -358,6 +418,7 @@ export default class CallHandler {
break;
case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
this.startCallApp(payload.room_id, payload.type);
break;
case 'end_conference':
@ -383,24 +444,29 @@ export default class CallHandler {
return;
}
const call = payload.call;
const call = payload.call as MatrixCall;
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(call.roomId, call)
this.setCallListeners(call);
this.setCallState(call, call.roomId, "ringing");
}
break;
case 'hangup':
case 'reject':
if (!this.calls.get(payload.room_id)) {
return; // no call to hangup
}
this.calls.get(payload.room_id).hangup();
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':
if (!this.calls.get(payload.room_id)) {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer
}
this.calls.get(payload.room_id).answer();
this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected");
dis.dispatch({
action: "view_room",
room_id: payload.room_id,

View file

@ -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";
@ -497,7 +496,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 +509,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';

View file

@ -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;
}

View file

@ -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';
@ -567,6 +568,8 @@ function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void
localStorage.setItem("mx_device_id", credentials.deviceId);
}
SecurityCustomisations.persistCredentials?.(credentials);
console.log(`Session persisted for ${credentials.userId}`);
}

View file

@ -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;
}

View file

@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
import {MatrixClient} from 'matrix-js-sdk/src/client';
import {MemoryStore} from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils';
@ -249,8 +250,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk
const opts = {
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken,

View file

@ -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;
}
@ -132,7 +132,7 @@ export class ModalManager {
public createTrackedDialogAsync<T extends any[]>(
analyticsAction: string,
analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialogAsync"]>
...rest: Parameters<ModalManager["createDialogAsync"]>
) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync<T>(...rest);

View file

@ -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();

View file

@ -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;

View file

@ -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];

View file

@ -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);

View file

@ -14,32 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal';
import * as sdk from './index';
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
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys = {};
let secretStorageKeyInfo = {};
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
let secretStorageBeingAccessed = false;
let nonInteractive = false;
let dehydrationCache = {};
let dehydrationCache: {
key?: Uint8Array,
keyInfo?: ISecretStorageKeyInfo,
} = {};
function isCachingAllowed() {
function isCachingAllowed(): boolean {
return secretStorageBeingAccessed;
}
@ -50,7 +56,7 @@ function isCachingAllowed() {
*
* @returns {bool}
*/
export function isSecretStorageBeingAccessed() {
export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed;
}
@ -60,7 +66,7 @@ export class AccessCancelledError extends Error {
}
}
async function confirmToDismiss() {
async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
@ -72,7 +78,9 @@ async function confirmToDismiss() {
return !sure;
}
function makeInputToKey(keyInfo) {
function makeInputToKey(
keyInfo: ISecretStorageKeyInfo,
): (keyParams: { passphrase: string, recoveryKey: string }) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
@ -86,7 +94,10 @@ function makeInputToKey(keyInfo) {
};
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
async function getSecretStorageKey(
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
ssssItemName,
): Promise<[string, Uint8Array]> {
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
@ -100,11 +111,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
if (dehydrationCache.key) {
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key];
}
}
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");
}
@ -139,12 +157,21 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, key, keyInfo);
cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key];
}
export async function getDehydrationKey(keyInfo, checkFunc) {
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,
@ -185,20 +212,24 @@ export async function getDehydrationKey(keyInfo, checkFunc) {
return key;
}
function cacheSecretStorageKey(keyId, key, keyInfo) {
function cacheSecretStorageKey(
keyId: string,
keyInfo: ISecretStorageKeyInfo,
key: Uint8Array,
): void {
if (isCachingAllowed()) {
secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo;
}
}
const onSecretRequested = async function({
user_id: userId,
device_id: deviceId,
request_id: requestId,
name,
device_trust: deviceTrust,
}) {
async function onSecretRequested(
userId: string,
deviceId: string,
requestId: string,
name: string,
deviceTrust: IDeviceTrustLevel,
): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) {
@ -233,16 +264,16 @@ const onSecretRequested = async function({
return key && encodeBase64(key);
}
console.warn("onSecretRequested didn't recognise the secret named ", name);
};
}
export const crossSigningCallbacks = {
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
};
export async function promptForBackupPassphrase() {
export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
@ -292,7 +323,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
/* priority = */ false,
/* static = */ true,
/* options = */ {
onBeforeClose(reason) {
onBeforeClose: async (reason) => {
// If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") {
return !isSecureBackupRequired();
@ -329,20 +360,25 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
const keyId = Object.keys(secretStorageKeys)[0];
if (keyId && SettingsStore.getValue("feature_dehydration")) {
const dehydrationKeyInfo =
secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase
? {passphrase: secretStorageKeyInfo[keyId].passphrase}
: {};
let dehydrationKeyInfo = {};
if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
}
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;
@ -354,7 +390,9 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
}
// FIXME: this function name is a bit of a mouthful
export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
export async function tryToUnlockSecretStorageWithDehydrationKey(
client: MatrixClient,
): Promise<void> {
const key = dehydrationCache.key;
let restoringBackup = false;
if (key && await client.isSecretStorageReady()) {
@ -366,10 +404,10 @@ export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
// we also need to set a new dehydrated device to replace the
// device we rehydrated
const dehydrationKeyInfo =
dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase
? {passphrase: dehydrationCache.keyInfo.passphrase}
: {};
let dehydrationKeyInfo = {};
if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) {
dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase };
}
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
// and restore from backup

View file

@ -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 = {

View file

@ -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();

View file

@ -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,

View file

@ -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);

View file

@ -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');

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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>;
}

View file

@ -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>
);

View 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;

View file

@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
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.
@ -71,9 +72,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;
@ -100,10 +98,6 @@ interface IUsageLimit {
}
interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData?: {
error: {
data: IUsageLimit;
@ -151,7 +145,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'),
@ -213,12 +206,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) {
@ -228,22 +217,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;
}
@ -518,8 +509,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,
@ -550,48 +541,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');
@ -611,7 +560,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;
@ -659,8 +607,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}>

View file

@ -181,9 +181,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;
@ -236,8 +233,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state = {
view: Views.LOADING,
collapseLhs: false,
leftDisabled: false,
middleDisabled: false,
hideToSRUsers: false,
@ -710,14 +705,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() &&

View file

@ -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>
);

View file

@ -30,7 +30,7 @@ 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";
@ -49,7 +49,7 @@ export default class RoomDirectory extends React.Component {
constructor(props) {
super(props);
const selectedCommunityId = TagOrderStore.getSelectedTags()[0];
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
loading: true,

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015-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.
@ -26,6 +24,7 @@ import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -46,10 +45,12 @@ export default class RoomStatusBar extends React.Component {
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool,
// true if there is an active call in this room (means we show
// the 'Active Call' text in the status bar if there is nothing
// more interesting)
hasActiveCall: PropTypes.bool,
// The active call in the room, if any (means we show the call bar
// along with the status of the call)
callState: PropTypes.string,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
@ -121,6 +122,12 @@ export default class RoomStatusBar extends React.Component {
});
};
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
@ -153,7 +160,7 @@ export default class RoomStatusBar extends React.Component {
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError() ||
this.props.hasActiveCall ||
this._showCallBar() ||
this.props.sentMessageAndIsAlone
) {
return STATUS_BAR_EXPANDED;
@ -165,7 +172,7 @@ export default class RoomStatusBar extends React.Component {
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this.props.hasActiveCall) {
if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
@ -269,6 +276,25 @@ export default class RoomStatusBar extends React.Component {
</div>;
}
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
if (this._shouldShowConnectionError()) {
@ -291,10 +317,10 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent();
}
if (this.props.hasActiveCall) {
if (this._showCallBar()) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ _t('Active call') }</b>
<b>{ this._getCallStatusText() }</b>
</div>
);
}

View file

@ -71,6 +71,9 @@ 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 WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -104,7 +107,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)
@ -141,7 +143,7 @@ export interface IState {
}>;
searchHighlights?: string[];
searchInProgress?: boolean;
callState?: string;
callState?: CallState;
guestsCanJoin: boolean;
canPeek: boolean;
showApps: boolean;
@ -180,6 +182,7 @@ export interface IState {
e2eStatus?: E2EStatus;
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
}
export default class RoomView extends React.Component<IProps, IState> {
@ -250,7 +253,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);
@ -262,6 +267,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),
@ -479,7 +496,7 @@ export default class RoomView extends React.Component<IProps, IState> {
componentDidMount() {
const call = this.getCallForRoom();
const callState = call ? call.call_state : "ended";
const callState = call ? call.state : null;
this.setState({
callState: callState,
});
@ -584,7 +601,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);
@ -712,14 +730,9 @@ export default class RoomView extends React.Component<IProps, IState> {
}
const call = this.getCallForRoom();
let callState = "ended";
if (call) {
callState = call.call_state;
}
this.setState({
callState: callState,
callState: call ? call.state : null,
});
break;
}
@ -828,6 +841,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) {
@ -1263,7 +1277,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>,
@ -1287,7 +1301,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();
@ -1357,6 +1371,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',
@ -1605,7 +1626,7 @@ export default class RoomView extends React.Component<IProps, IState> {
/**
* get any current call for this room
*/
private getCallForRoom() {
private getCallForRoom(): MatrixCall {
if (!this.state.room) {
return null;
}
@ -1742,10 +1763,13 @@ export default class RoomView extends React.Component<IProps, IState> {
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
const call = this.getCallForRoom();
let inCall = false;
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
inCall = true;
let activeCall = null;
{
// New block because this variable doesn't need to hang around for the rest of the function
const call = this.getCallForRoom();
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
activeCall = call;
}
}
const scrollheaderClasses = classNames({
@ -1764,7 +1788,8 @@ export default class RoomView extends React.Component<IProps, IState> {
statusBar = <RoomStatusBar
room={this.state.room}
sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall}
callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
@ -1853,7 +1878,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,7 +1896,6 @@ 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}
@ -1890,10 +1913,10 @@ export default class RoomView extends React.Component<IProps, IState> {
};
}
if (inCall) {
if (activeCall) {
let zoomButton; let videoMuteButton;
if (call.type === "video") {
if (activeCall.type === CallType.Video) {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
@ -1908,10 +1931,11 @@ export default class RoomView extends React.Component<IProps, IState> {
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={call.isLocalVideoMuted() ?
src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")}
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width=""
height="27"
/>
@ -1920,10 +1944,10 @@ export default class RoomView extends React.Component<IProps, IState> {
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={call.isMicrophoneMuted() ?
src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
@ -1946,7 +1970,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" />
);
@ -2027,10 +2051,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} />
@ -2041,7 +2061,7 @@ export default class RoomView extends React.Component<IProps, IState> {
});
const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: inCall,
mx_RoomView_inCall: Boolean(activeCall),
});
return (
@ -2060,9 +2080,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}

View file

@ -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() {
@ -257,7 +257,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 +452,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 +508,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}

View file

@ -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,7 +33,7 @@ interface IProps {
oobData?: any;
width?: number;
height?: number;
resizeMethod?: string;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
}

View 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;

View file

@ -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>;
}
}

View 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;

View file

@ -0,0 +1,165 @@
/*
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,
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";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
sourceWidgetId: string;
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
}
interface IState {
messaging?: ClientWidgetApi;
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private readonly widget: Widget;
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {};
constructor(props) {
super(props);
this.widget = new Widget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
}
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);
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
this.props.onFinished(true, ev.detail.data);
}
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>;
}
}

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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}
/>;

View file

@ -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({

View file

@ -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;
}

View file

@ -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});
};

View file

@ -45,7 +45,7 @@ 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']);

View file

@ -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;

View file

@ -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") }

View file

@ -801,6 +801,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;

View file

@ -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

View file

@ -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 }

View file

@ -37,7 +37,6 @@ export default class AuxPanel extends React.Component {
room: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
showApps: PropTypes.bool, // Render apps
hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered)
// set to true to show the file drop target
draggingFile: PropTypes.bool,
@ -54,7 +53,6 @@ export default class AuxPanel extends React.Component {
static defaultProps = {
showApps: true,
hideAppsDrawer: false,
};
constructor(props) {
@ -170,7 +168,6 @@ export default class AuxPanel extends React.Component {
userId={this.props.userId}
maxHeight={this.props.maxHeight}
showApps={this.props.showApps}
hide={this.props.hideAppsDrawer}
resizeNotifier={this.props.resizeNotifier}
/>;
}

View file

@ -46,6 +46,7 @@ const eventTileTypes = {
'm.call.invite': 'messages.TextualEvent',
'm.call.answer': 'messages.TextualEvent',
'm.call.hangup': 'messages.TextualEvent',
'm.call.reject': 'messages.TextualEvent',
};
const stateEventTileTypes = {
@ -657,8 +658,7 @@ export default class EventTile extends React.Component {
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
const useSource = !tileHandler || this.props.mxEvent.isRelation("m.replace");
if (useSource && SettingsStore.getValue("showHiddenEventsInTimeline")) {
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
// Reuse info message avatar and sender profile styling
isInfoMessage = true;

View file

@ -18,7 +18,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import {Key} from '../../../Keyboard';
@ -28,19 +27,10 @@ export default class ForwardMessage extends React.Component {
};
componentDidMount() {
dis.dispatch({
action: 'panel_disable',
middleDisabled: true,
});
document.addEventListener('keydown', this._onKeyDown);
}
componentWillUnmount() {
dis.dispatch({
action: 'panel_disable',
middleDisabled: false,
});
document.removeEventListener('keydown', this._onKeyDown);
}

View file

@ -37,6 +37,8 @@ import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import { PlaceCallType } from "../../../CallHandler";
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -53,7 +55,7 @@ function CallButton(props) {
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: "voice",
type: PlaceCallType.Voice,
room_id: props.roomId,
});
};
@ -73,7 +75,7 @@ function VideoCallButton(props) {
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
type: ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video,
room_id: props.roomId,
});
};
@ -103,8 +105,11 @@ function HangupButton(props) {
if (!call) {
return;
}
const action = call.state === CallState.Ringing ? 'reject' : 'hangup';
dis.dispatch({
action: 'hangup',
action,
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId,

View file

@ -76,7 +76,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
};
private viewRoom = (room: Room, index: number) => {
Analytics.trackEvent("Breadcrumbs", "click_node", index);
Analytics.trackEvent("Breadcrumbs", "click_node", String(index));
defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
};

View file

@ -42,6 +42,8 @@ export default class RoomHeader extends React.Component {
onLeaveClick: PropTypes.func,
onCancelClick: PropTypes.func,
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
};
static defaultProps = {
@ -230,6 +232,17 @@ export default class RoomHeader extends React.Component {
title={_t("Forget room")} />;
}
let appsButton;
if (this.props.onAppsClick) {
appsButton =
<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")} />;
}
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
@ -243,6 +256,7 @@ export default class RoomHeader extends React.Component {
<div className="mx_RoomHeader_buttons">
{ pinnedEventsButton }
{ forgetButton }
{ appsButton }
{ searchButton }
</div>;

View file

@ -53,7 +53,6 @@ interface IProps {
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier;
collapsed: boolean;
isMinimized: boolean;
}
@ -366,7 +365,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
public render() {
let explorePrompt: JSX.Element;
if (RoomListStore.instance.getFirstNameFilterCondition()) {
if (!this.props.isMinimized && RoomListStore.instance.getFirstNameFilterCondition()) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}>

View file

@ -399,6 +399,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
};
private onTagSortChanged = async (sort: SortAlgorithm) => {

View file

@ -272,13 +272,10 @@ export default class Stickerpicker extends React.Component {
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
show={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showMinimise={true}
showDelete={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}

View file

@ -42,6 +42,14 @@ export default class IntegrationManager extends React.Component {
loading: false,
};
constructor(props) {
super(props);
this.state = {
errored: false,
};
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown);
@ -66,6 +74,10 @@ export default class IntegrationManager extends React.Component {
}
};
onError = () => {
this.setState({ errored: true });
};
render() {
if (this.props.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
@ -77,7 +89,7 @@ export default class IntegrationManager extends React.Component {
);
}
if (!this.props.connected) {
if (!this.props.connected || this.state.errored) {
return (
<div className='mx_IntegrationManager_error'>
<h3>{_t("Cannot connect to integration manager")}</h3>
@ -86,6 +98,6 @@ export default class IntegrationManager extends React.Component {
);
}
return <iframe src={this.props.url}></iframe>;
return <iframe src={this.props.url} onError={this.onError} />;
}
}

View file

@ -24,13 +24,14 @@ import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { CallState, MatrixCall } from 'matrix-js-sdk/lib/webrtc/call';
interface IProps {
}
interface IState {
roomId: string;
activeCall: any;
activeCall: MatrixCall;
}
export default class CallPreview extends React.Component<IProps, IState> {
@ -84,7 +85,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
if (call) {
dis.dispatch({
action: 'view_room',
room_id: call.groupRoomId || call.roomId,
room_id: call.roomId,
});
}
};
@ -93,7 +94,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = (
this.state.activeCall &&
this.state.activeCall.call_state === 'connected' &&
this.state.activeCall.state === CallState.Connected &&
!callForRoom
);

View file

@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import VideoView from "./VideoView";
import RoomAvatar from "../avatars/RoomAvatar";
import PulsedAvatar from '../avatars/PulsedAvatar';
import { CallState, MatrixCall } from 'matrix-js-sdk/lib/webrtc/call';
interface IProps {
// js-sdk room object. If set, we will only show calls for the given
@ -87,7 +88,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private showCall() {
let call;
let call: MatrixCall;
if (this.props.room) {
const roomId = this.props.room.roomId;
@ -120,7 +121,7 @@ export default class CallView extends React.Component<IProps, IState> {
call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
}
}
if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
if (call && call.type === "video" && call.state !== CallState.Ended && call.state !== CallState.Ringing) {
this.getVideoView().getLocalVideoElement().style.display = "block";
this.getVideoView().getRemoteVideoElement().style.display = "block";
} else {

View file

@ -25,6 +25,7 @@ import CallHandler from '../../../CallHandler';
import PulsedAvatar from '../avatars/PulsedAvatar';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
interface IProps {
}
@ -53,7 +54,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
switch (payload.action) {
case 'call_state': {
const call = CallHandler.sharedInstance().getCallForRoom(payload.room_id);
if (call && call.call_state === 'ringing') {
if (call && call.state === CallState.Ringing) {
this.setState({
incomingCall: call,
});
@ -77,7 +78,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
private onRejectClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
action: 'hangup',
action: 'reject',
room_id: this.state.incomingCall.roomId,
});
};

View file

@ -0,0 +1,81 @@
/*
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 { IMatrixClientCreds } from "../MatrixClientPeg";
import { Kind as SetupEncryptionKind } from "../toasts/SetupEncryptionToast";
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function examineLoginResponse(
response: any,
credentials: IMatrixClientCreds,
): void {
// E.g. add additional data to the persisted credentials
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function persistCredentials(
credentials: IMatrixClientCreds,
): void {
// E.g. store any additional credential fields
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function createSecretStorageKey(): Uint8Array {
// E.g. generate or retrieve secret storage key somehow
return null;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function getSecretStorageKey(): Uint8Array {
// E.g. retrieve secret storage key from some other place
return null;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function catchAccessSecretStorageError(e: Error): void {
// E.g. notify the user in some way
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function setupEncryptionNeeded(kind: SetupEncryptionKind): boolean {
// E.g. trigger some kind of setup
return false;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface ISecurityCustomisations {
examineLoginResponse?: (
response: any,
credentials: IMatrixClientCreds,
) => void;
persistCredentials?: (
credentials: IMatrixClientCreds,
) => void;
createSecretStorageKey?: () => Uint8Array,
getSecretStorageKey?: () => Uint8Array,
catchAccessSecretStorageError?: (
e: Error,
) => void,
setupEncryptionNeeded?: (
kind: SetupEncryptionKind,
) => boolean,
}
// A real customisation module will define and export one or more of the
// customisation points that make up `ISecurityCustomisations`.
export default {} as ISecurityCustomisations;

View file

@ -94,14 +94,4 @@ export enum Action {
* Trigged after the phase of the right panel is set. Should be used with AfterRightPanelPhaseChangePayload.
*/
AfterRightPanelPhaseChange = "after_right_panel_phase_change",
/**
* Requests that the AppTile deletes the widget. Should be used with the AppTileActionPayload.
*/
AppTileDelete = "appTile_delete",
/**
* Requests that the AppTile revokes the widget. Should be used with the AppTileActionPayload.
*/
AppTileRevoke = "appTile_revoke",
}

View file

@ -1,23 +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 { ActionPayload } from "../payloads";
import { Action } from "../actions";
export interface AppTileActionPayload extends ActionPayload {
action: Action.AppTileDelete | Action.AppTileRevoke;
widgetId: string;
}

View file

@ -16,6 +16,6 @@ limitations under the License.
const EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
export function looksValid(email) {
export function looksValid(email: string): boolean {
return EMAIL_ADDRESS_REGEX.test(email);
}

View file

@ -1,26 +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.
*/
'use strict';
export default function(dest, src) {
for (const i in src) {
if (src.hasOwnProperty(i)) {
dest[i] = src[i];
}
}
return dest;
}

View file

@ -26,7 +26,7 @@ const getValue = <T>(key: string, initialValue: T): T => {
};
// Hook behaving like useState but persisting the value to localStorage. Returns same as useState
export const useLocalStorageState = <T>(key: string, initialValue: T) => {
export const useLocalStorageState = <T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] => {
const lsKey = "mx_" + key;
const [value, setValue] = useState<T>(getValue(lsKey, initialValue));

View file

@ -2305,5 +2305,22 @@
"You were uninvited": "Поканата към вас беше премахната",
"%(targetName)s was uninvited": "Поканата към %(targetName)s беше премахната",
"You were banned (%(reason)s)": "Бяхте блокирани (%(reason)s)",
"%(targetName)s was banned (%(reason)s)": "%(targetName)s беше блокиран(а) (%(reason)s)"
"%(targetName)s was banned (%(reason)s)": "%(targetName)s беше блокиран(а) (%(reason)s)",
"%(senderName)s: %(message)s": "%(senderName)s: %(message)s",
"* %(senderName)s %(emote)s": "%(senderName)s%(emote)s",
"The person who invited you already left the room, or their server is offline.": "Участникът който ви е поканил вече е напуснал стаята или техният сървър не е на линия.",
"The person who invited you already left the room.": "Участникът който ви покани вече напусна стаята.",
"Safeguard against losing access to encrypted messages & data": "Защитете се срещу загуба на достъп до криптирани съобшения и информация",
"Set up Secure Backup": "Конфигуриране на Защитен Архив",
"Unknown App": "Неизвестно приложение",
"Error leaving room": "Грешка при напускане на стаята",
"%(senderName)s declined the call.": "%(senderName)s отказа разговора.",
"(their device couldn't start the camera / microphone)": "(тяхното устройство не може да стартира камерата / микрофонът)",
"(connection failed)": "(връзката се разпадна)",
"Are you sure you want to cancel entering passphrase?": "Сигурни ли сте че желате да прекратите въвеждането на паролата?",
"This will end the conference for everyone. Continue?": "Това ще прекрати конферентният разговор за всички. Продължи?",
"End conference": "Прекрати конфетентният разговор",
"Call Declined": "Обаждането е отказано",
"The call could not be established": "Обаждането не може да бъде осъществено",
"The other party declined the call.": "Другата страна отказа обаждането."
}

View file

@ -2519,5 +2519,11 @@
"Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert",
"Video conference started by %(senderName)s": "Videokonferenz wurde von %(senderName)s gestartet",
"Ignored attempt to disable encryption": "Versuch, die Verschlüsselung zu deaktivieren, wurde ignoriert",
"Failed to save your profile": "Profil speichern fehlgeschlagen"
"Failed to save your profile": "Profil speichern fehlgeschlagen",
"The operation could not be completed": "Die Operation konnte nicht abgeschlossen werden",
"Remove messages sent by others": "Nachrichten von anderen entfernen",
"Starting camera...": "Starte Kamera...",
"Call connecting...": "Verbinde den Anruf...",
"Calling...": "Rufe an...",
"Starting microphone...": "Starte Mikrofon..."
}

View file

@ -35,8 +35,12 @@
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss",
"Call Failed": "Call Failed",
"Call Timeout": "Call Timeout",
"Call Declined": "Call Declined",
"The other party declined the call.": "The other party declined the call.",
"The remote side failed to pick up": "The remote side failed to pick up",
"The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
@ -242,6 +246,9 @@
"%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.",
"%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.",
"%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s set the server ACLs for this room.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s changed the server ACLs for this room.",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 All servers are banned from participating! This room can no longer be used.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.",
"%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.",
@ -256,9 +263,13 @@
"(not supported by this browser)": "(not supported by this browser)",
"%(senderName)s answered the call.": "%(senderName)s answered the call.",
"(could not connect media)": "(could not connect media)",
"(connection failed)": "(connection failed)",
"(their device couldn't start the camera / microphone)": "(their device couldn't start the camera / microphone)",
"(an error occurred)": "(an error occurred)",
"(no answer)": "(no answer)",
"(unknown failure: %(reason)s)": "(unknown failure: %(reason)s)",
"%(senderName)s ended the call.": "%(senderName)s ended the call.",
"%(senderName)s declined the call.": "%(senderName)s declined the call.",
"%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.",
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)",
"%(senderName)s placed a video call.": "%(senderName)s placed a video call.",
@ -1023,7 +1034,6 @@
"Remove %(phone)s?": "Remove %(phone)s?",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"Phone Number": "Phone Number",
"Add a widget": "Add a widget",
"Drop File Here": "Drop File Here",
"Drop file here to upload": "Drop file here to upload",
"This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
@ -1105,6 +1115,8 @@
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
"Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Invites": "Invites",
"Favourites": "Favourites",
@ -1270,8 +1282,11 @@
"Yours, or the other users session": "Yours, or the other users session",
"Members": "Members",
"Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin": "Unpin",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Widgets": "Widgets",
"Unpin app": "Unpin app",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
"Add widgets, bridges & bots": "Add widgets, bridges & bots",
"Not encrypted": "Not encrypted",
@ -1294,7 +1309,6 @@
"Invite": "Invite",
"Share Link to User": "Share Link to User",
"Direct message": "Direct message",
"Options": "Options",
"Demote yourself?": "Demote yourself?",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
"Demote": "Demote",
@ -1358,12 +1372,6 @@
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji",
"Take a picture": "Take a picture",
"Remove for everyone": "Remove for everyone",
"Remove for me": "Remove for me",
"Edit": "Edit",
"Pin to room": "Pin to room",
"You can only pin 2 widgets at a time": "You can only pin 2 widgets at a time",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -1382,6 +1390,7 @@
"Error decrypting audio": "Error decrypting audio",
"React": "React",
"Reply": "Reply",
"Edit": "Edit",
"Message Actions": "Message Actions",
"Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment",
@ -1474,15 +1483,7 @@
"Widgets do not use message encryption.": "Widgets do not use message encryption.",
"Widget added by": "Widget added by",
"This widget may use cookies.": "This widget may use cookies.",
"Delete Widget": "Delete Widget",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Failed to remove widget": "Failed to remove widget",
"An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room",
"Minimize widget": "Minimize widget",
"Maximize widget": "Maximize widget",
"Popout widget": "Popout widget",
"More options": "More options",
"Use the <a>Desktop app</a> to see all encrypted files": "Use the <a>Desktop app</a> to see all encrypted files",
"Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
@ -1760,6 +1761,8 @@
"Verify session": "Verify session",
"Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.",
"Message edits": "Message edits",
"Modal Widget": "Modal Widget",
"Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s",
"Your account is not secure": "Your account is not secure",
"Your password": "Your password",
"This session, or the other session": "This session, or the other session",
@ -1915,9 +1918,14 @@
"Set status": "Set status",
"Set a new status...": "Set a new status...",
"View Community": "View Community",
"Unpin": "Unpin",
"Reload": "Reload",
"Take picture": "Take picture",
"Take a picture": "Take a picture",
"Delete Widget": "Delete Widget",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Remove for everyone": "Remove for everyone",
"Revoke permissions": "Revoke permissions",
"Move left": "Move left",
"Move right": "Move right",
"This room is public": "This room is public",
"Away": "Away",
"User Status": "User Status",
@ -1992,6 +2000,8 @@
"You must join the room to see its files": "You must join the room to see its files",
"No files visible in this room": "No files visible in this room",
"Attach files from chat or just drag and drop them anywhere in a room.": "Attach files from chat or just drag and drop them anywhere in a room.",
"Communities": "Communities",
"Create community": "Create community",
"<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n": "<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n",
"Add rooms to the community summary": "Add rooms to the community summary",
"Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?",
@ -2061,7 +2071,6 @@
"Did you know: you can use communities to filter your %(brand)s experience!": "Did you know: you can use communities to filter your %(brand)s experience!",
"To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.",
"Error whilst fetching joined communities": "Error whilst fetching joined communities",
"Communities": "Communities",
"Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"Youre all caught up": "Youre all caught up",
@ -2096,6 +2105,10 @@
"%(count)s of your messages have not been sent.|one": "Your message was not sent.",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|other": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.",
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.",
"Calling...": "Calling...",
"Call connecting...": "Call connecting...",
"Starting camera...": "Starting camera...",
"Starting microphone...": "Starting microphone...",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
@ -2113,7 +2126,6 @@
"Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio",
"Create community": "Create community",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position",

View file

@ -2524,5 +2524,19 @@
"Offline encrypted messaging using dehydrated devices": "Võrguühenduseta kasutamiseks mõeldud krüptitud sõnumid dehydrated teenuse abil",
"Remove messages sent by others": "Kustuta teiste saadetud sõnumid",
"Failed to save your profile": "Sinu profiili salvestamine ei õnnestunud",
"The operation could not be completed": "Toimingut ei õnnestunud lõpetada"
"The operation could not be completed": "Toimingut ei õnnestunud lõpetada",
"Calling...": "Helistan...",
"Call connecting...": "Kõne on ühendamisel...",
"Starting camera...": "Käivitan kaamerat...",
"Starting microphone...": "Lülitan mikrofoni sisse...",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s muutis seda jututuba teenindavate koduserverite loendit.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s seadistas seda jututuba teenindavate koduserverite loendi.",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Kõikidel serveritel on keeld seda jututuba teenindada! Seega seda jututuba ei saa enam kasutada.",
"(an error occurred)": "(tekkis viga)",
"(their device couldn't start the camera / microphone)": "(teise osapoole seadmes ei õnnestunud sisse lülitada kaamerat või mikrofoni)",
"(connection failed)": "(ühendus ebaõnnestus)",
"The call could not be established": "Kõnet ei saa korraldada",
"%(senderName)s declined the call.": "%(senderName)s ei võtnud kõnet vastu.",
"The other party declined the call.": "Teine osapool ei võtnud kõnet vastu.",
"Call Declined": "Kõne on tagasilükatud"
}

View file

@ -36,7 +36,7 @@
"Unavailable": "غیرقابل‌دسترسی",
"View Decrypted Source": "دیدن منبع رمزگشایی شده",
"Failed to update keywords": "به‌روزرسانی کلیدواژه‌ها موفقیت‌آمیز نبود",
"remove %(name)s from the directory.": "%(name)s را از فهرست گپ‌ها حذف کن",
"remove %(name)s from the directory.": "برداشتن %(name)s از فهرست گپ‌ها.",
"Please set a password!": "لطفا یک پسورد اختیار کنید!",
"powered by Matrix": "قدرت‌یافته از ماتریکس",
"You have successfully set a password!": "شما با موفقیت رمزتان را انتخاب کردید!",
@ -149,5 +149,7 @@
"Restart": "شروع دوباره",
"Upgrade your %(brand)s": "ارتقای %(brand)s تان",
"A new version of %(brand)s is available!": "نگارشی جدید از %(brand)s موجود است!",
"Guest": "مهمان"
"Guest": "مهمان",
"Confirm adding this email address by using Single Sign On to prove your identity.": "برای تأیید هویتتان، این نشانی رایانامه را با ورود یکپارچه تأیید کنید.",
"Click the button below to confirm adding this email address.": "برای تأیید افزودن این نشانی رایانامه، دکمهٔ زیر را بزنید."
}

View file

@ -525,9 +525,9 @@
"Unignored user": "Sallittu käyttäjä",
"%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s tasolta %(fromPowerLevel)s tasolle %(toPowerLevel)s",
"%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s muutti %(powerLevelDiffText)s:n oikeustasoa.",
"%(widgetName)s widget modified by %(senderName)s": "%(senderName)s muutti pienoisohjelmaa %(widgetName)s",
"%(widgetName)s widget added by %(senderName)s": "%(senderName)s lisäsi pienoisohjelman %(widgetName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(senderName)s poisti pienoisohjelman %(widgetName)s",
"%(widgetName)s widget modified by %(senderName)s": "%(senderName)s muokkasi sovelmaa %(widgetName)s",
"%(widgetName)s widget added by %(senderName)s": "%(senderName)s lisäsi sovelman %(widgetName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(senderName)s poisti sovelman %(widgetName)s",
"Send": "Lähetä",
"Ongoing conference call%(supportedText)s.": "Menossa oleva ryhmäpuhelu %(supportedText)s.",
"%(duration)ss": "%(duration)s s",
@ -549,7 +549,7 @@
"URL previews are disabled by default for participants in this room.": "URL-esikatselut ovat oletuksena pois päältä tämän huoneen jäsenillä.",
"Token incorrect": "Väärä tunniste",
"Something went wrong when trying to get your communities.": "Jokin meni pieleen yhteisöjäsi haettaessa.",
"Delete Widget": "Poista pienoisohjelma",
"Delete Widget": "Poista sovelma",
"%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s liittyivät",
"%(oneUser)sjoined %(count)s times|other": "%(oneUser)s liittyi %(count)s kertaa",
"%(oneUser)sjoined %(count)s times|one": "%(oneUser)s liittyi",
@ -591,7 +591,7 @@
"expand": "laajenna",
"collapse": "supista",
"Display your community flair in rooms configured to show it.": "Näytä yhteisötyylisi huoneissa joissa ominaisuus on päällä.",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Pienoisohjelman poistaminen poistaa sen kaikilta huoneen käyttäjiltä. Oletko varma että haluat poistaa pienoisohjelman?",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Sovelman poistaminen poistaa sen kaikilta huoneen käyttäjiltä. Haluatko varmasti poistaa tämän sovelman?",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s liittyivät %(count)s kertaa",
"%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s vaihtoivat nimensä %(count)s kertaa",
"%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s vaihtoivat nimensä",
@ -743,7 +743,7 @@
"Every page you use in the app": "Jokainen sivu, jota käytät sovelluksessa",
"e.g. <CurrentPageURL>": "esim. <CurrentPageURL>",
"Your device resolution": "Laitteesi näytön tarkkuus",
"You do not have permission to start a conference call in this room": "Sinulla ei ole oikeutta aloittaa konferenssipuhelua tässä huoneessa",
"You do not have permission to start a conference call in this room": "Sinulla ei ole oikeutta aloittaa ryhmäpuhelua tässä huoneessa",
"Upgrades a room to a new version": "Päivittää huoneen uuteen versioon",
"Gets or sets the room topic": "Hakee tai asettaa huoneen aiheen",
"This room has no topic.": "Tässä huoneessa ei ole aihetta.",
@ -1096,7 +1096,7 @@
"There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "Huoneen tyylin päivittämisessä tapahtui virhe. Palvelin ei välttämättä salli sitä tai kyseessä on tilapäinen virhe.",
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Salatuissa huoneissa, kuten tässä, osoitteiden esikatselut ovat oletuksena pois käytöstä, jotta kotipalvelimesi (missä osoitteiden esikatselut luodaan) ei voi kerätä tietoa siitä, mitä linkkejä näet tässä huoneessa.",
"Failed to remove widget": "Sovelman poisto epäonnistui",
"An error ocurred whilst trying to remove the widget from the room": "Sovelman poistossa huoneesta tapahtui virhe",
"An error ocurred whilst trying to remove the widget from the room": "Poistaessa sovelmaa huoneesta tapahtui virhe",
"Minimize apps": "Pienennä sovellukset",
"Popout widget": "Avaa sovelma omassa ikkunassaan",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Lisää ”¯\\_(ツ)_/¯” viestin alkuun",
@ -2222,5 +2222,27 @@
"Were excited to announce Riot is now Element!": "Meillä on ilo ilmoittaa, että Riot on nyt Element!",
"Learn more at <a>element.io/previously-riot</a>": "Lue lisää osoitteessa <a>element.io/previously-riot</a>",
"Security & privacy": "Tietoturva ja -suoja",
"User menu": "Käyttäjän valikko"
"User menu": "Käyttäjän valikko",
"Video conference started by %(senderName)s": "%(senderName)s aloitti videopuhelun",
"Video conference updated by %(senderName)s": "%(senderName)s muokkasi videopuhelua",
"Video conference ended by %(senderName)s": "%(senderName)s päätti videopuhelun",
"Join the conference from the room information card on the right": "Liity ryhmäpuheluun oikealla olevasta huoneen tiedoista",
"Join the conference at the top of this room": "Liity ryhmäpuheluun huoneen ylälaidassa",
"This will end the conference for everyone. Continue?": "Tämä päättää ryhmäpuhelun kaikilta. Jatka?",
"End conference": "Päätä ryhmäpuhelu",
"Wrong Recovery Key": "Väärä palautusavain",
"Wrong file type": "Väärä tiedostotyyppi",
"Please provide a room address": "Anna huoneen osoite",
"Room address": "Huoneen osoite",
"Message deleted on %(date)s": "Viesti poistettu %(date)s",
"Show %(count)s more|one": "Näytä %(count)s lisää",
"Show %(count)s more|other": "Näytä %(count)s lisää",
"Mod": "Moderaattori",
"Read Marker off-screen lifetime (ms)": "Viestin luetuksi merkkaamisen kesto, kun Element ei ole näkyvissä (ms)",
"Maximize widget": "Suurenna sovelma",
"Minimize widget": "Pienennä sovelma",
"You can only pin 2 widgets at a time": "Vain kaksi sovelmaa voi olla kiinnitettynä samaan aikaan",
"Add widgets, bridges & bots": "Lisää sovelmia, siltoja ja botteja",
"Edit widgets, bridges & bots": "Muokkaa sovelmia, siltoja ja botteja",
"Widgets": "Sovelmat"
}

View file

@ -163,7 +163,7 @@
"Current password": "Contrasinal actual",
"Password": "Contrasinal",
"New Password": "Novo contrasinal",
"Confirm password": "Confirme o contrasinal",
"Confirm password": "Confirma o contrasinal",
"Change Password": "Cambiar contrasinal",
"Authentication": "Autenticación",
"Last seen": "Visto por última vez",
@ -424,7 +424,7 @@
"email address": "enderezo de correo",
"Try using one of the following valid address types: %(validTypesList)s.": "Intentar utilizar algún dos seguintes tipos de enderezo válidos: %(validTypesList)s.",
"You have entered an invalid address.": "Introduciu un enderezo non válido.",
"Confirm Removal": "Confirme a retirada",
"Confirm Removal": "Confirma a retirada",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Estás certa de que queres quitar (eliminar) este evento? Debes saber que se eliminas un nome de sala ou cambias o asunto, poderías desfacer o cambio.",
"Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Os ID de comunidade só poden conter caracteres a-z, 0-9, or '=_-./'",
"Community IDs cannot be empty.": "O ID de comunidade non pode quedar baldeiro.",
@ -614,7 +614,7 @@
"This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "Este proceso permíteche exportar a un ficheiro local as chaves para as mensaxes que recibiches en salas cifradas. Após poderás importar as chaves noutro cliente Matrix no futuro, así o cliente poderá descifrar esas mensaxes.",
"The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "O ficheiro exportado permitiralle a calquera que poida lelo descifrar e cifrar mensaxes que ti ves, así que deberías ter coidado e gardalo de xeito seguro. Para axudarche, deberías escribir unha frase de paso aquí abaixo que será usada para cifrar os datos exportados. Só será posible importar os datos utilizando a mesma frase de paso.",
"Enter passphrase": "Introduza a frase de paso",
"Confirm passphrase": "Confirme a frase de paso",
"Confirm passphrase": "Confirma a frase de paso",
"Export": "Exportar",
"Import room keys": "Importar chaves de sala",
"This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "Este proceso permíteche importar chaves de cifrado que exportaches doutro cliente Matrix. Así poderás descifrar calquera mensaxe que o outro cliente puidese cifrar.",
@ -1887,7 +1887,7 @@
"Power level": "Nivel de permisos",
"Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifica este dispositivo para marcalo como confiable. Confiando neste dispositivo permite que ti e outras usuarias estedes máis tranquilas ao utilizar mensaxes cifradas.",
"Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Ao verificar este dispositivo marcaralo como confiable, e as usuarias que confiaron en ti tamén confiarán nel.",
"Waiting for partner to confirm...": "Agardando a que o compañeiro confirme...",
"Waiting for partner to confirm...": "Agardando a que o contacto confirme...",
"Incoming Verification Request": "Solicitude entrante de verificación",
"Integrations are disabled": "As Integracións están desactivadas",
"Enable 'Manage Integrations' in Settings to do this.": "Activa 'Xestionar Integracións' nos Axustes para facer esto.",
@ -2523,5 +2523,19 @@
"Ignored attempt to disable encryption": "Intento ignorado de desactivar o cifrado",
"Failed to save your profile": "Non se gardaron os cambios",
"The operation could not be completed": "Non se puido realizar a acción",
"Remove messages sent by others": "Eliminar mensaxes enviadas por outras"
"Remove messages sent by others": "Eliminar mensaxes enviadas por outras",
"Calling...": "Chamando...",
"Call connecting...": "Conectando a chamada...",
"Starting camera...": "Iniciando a cámara...",
"Starting microphone...": "Iniciando o micrófono...",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Tódolos servidores están prohibidos! Esta sala xa non pode ser utilizada.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s cambiou ACLs de servidor para esta sala.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s estableceu ACLs de servidor para esta sala.",
"%(senderName)s declined the call.": "%(senderName)s rexeitou a chamada.",
"(an error occurred)": "(algo fallou)",
"(their device couldn't start the camera / microphone)": "(o dispositivo deles non puido iniciar a cámara / micrófono)",
"(connection failed)": "(fallou a conexión)",
"The call could not be established": "Non se puido establecer a chamada",
"The other party declined the call.": "A outra persoa rexeitou a chamada.",
"Call Declined": "Chamada rexeitada"
}

View file

@ -2524,5 +2524,19 @@
"Video conference started by %(senderName)s": "A videókonferenciát elindította: %(senderName)s",
"Failed to save your profile": "A profilodat nem sikerült elmenteni",
"The operation could not be completed": "A műveletet nem lehetett befejezni",
"Remove messages sent by others": "Mások által küldött üzenetek törlése"
"Remove messages sent by others": "Mások által küldött üzenetek törlése",
"Starting microphone...": "Mikrofon bekapcsolása…",
"Starting camera...": "Kamera bekapcsolása…",
"Call connecting...": "Híváshoz csatlakozás…",
"Calling...": "Hívás…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Minden szerver ki van tiltva! Ezt a szobát nem lehet többet használni.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s megváltoztatta a jogosultságokat a szobában.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s beállította a jogosultságokat a szobában.",
"%(senderName)s declined the call.": "%(senderName)s visszautasította a hívást.",
"(an error occurred)": "(hiba történt)",
"(their device couldn't start the camera / microphone)": "(az ő eszköze nem tudja a kamerát / mikrofont használni)",
"(connection failed)": "(kapcsolódás sikertelen)",
"The call could not be established": "A hívás kapcsolatot nem lehet felépíteni",
"The other party declined the call.": "A másik fél elutasította a hívást.",
"Call Declined": "Hívás elutasítva"
}

View file

@ -805,7 +805,7 @@
"To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Per continuare a usare l'homeserver %(homeserverDomain)s devi leggere e accettare i nostri termini e condizioni.",
"Review terms and conditions": "Leggi i termini e condizioni",
"Muted Users": "Utenti silenziati",
"Message Pinning": "Messaggi appuntati",
"Message Pinning": "Ancoraggio messaggi",
"Mirror local video feed": "Feed video dai ripetitori locali",
"Replying": "Rispondere",
"Popout widget": "Oggetto a comparsa",
@ -2523,5 +2523,22 @@
"Video conference started by %(senderName)s": "Conferenza video iniziata da %(senderName)s",
"End conference": "Termina conferenza",
"This will end the conference for everyone. Continue?": "Verrà terminata la conferenza per tutti. Continuare?",
"Ignored attempt to disable encryption": "Tentativo di disattivare la crittografia ignorato"
"Ignored attempt to disable encryption": "Tentativo di disattivare la crittografia ignorato",
"Failed to save your profile": "Salvataggio del profilo fallito",
"The operation could not be completed": "Impossibile completare l'operazione",
"Remove messages sent by others": "Rimuovi i messaggi inviati dagli altri",
"Calling...": "Chiamata in corso...",
"Call connecting...": "In connessione...",
"Starting camera...": "Avvio fotocamera...",
"Starting microphone...": "Avvio microfono...",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Tutti i server sono banditi dalla partecipazione! Questa stanza non può più essere usata.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ha cambiato le ACL del server per questa stanza.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s ha impostato le ACL del server per questa stanza.",
"%(senderName)s declined the call.": "%(senderName)s ha rifiutato la chiamata.",
"(an error occurred)": "(si è verificato un errore)",
"(their device couldn't start the camera / microphone)": "(il suo dispositivo non ha potuto avviare la fotocamera / il microfono)",
"(connection failed)": "(connessione fallita)",
"The call could not be established": "Impossibile stabilire la chiamata",
"The other party declined the call.": "Il destinatario ha rifiutato la chiamata.",
"Call Declined": "Chiamata rifiutata"
}

View file

@ -1423,5 +1423,15 @@
"Upload files": "ファイルのアップロード",
"Upload all": "全てアップロード",
"No files visible in this room": "この部屋にファイルはありません",
"Attach files from chat or just drag and drop them anywhere in a room.": "チャットでファイルを添付するか、部屋のどこかにドラッグ&ドロップするとファイルを追加できます。"
"Attach files from chat or just drag and drop them anywhere in a room.": "チャットでファイルを添付するか、部屋のどこかにドラッグ&ドロップするとファイルを追加できます。",
"Add widgets, bridges & bots": "ウィジェット、ブリッジ、ボットの追加",
"Widgets": "ウィジェット",
"Cross-signing is ready for use.": "クロス署名の使用準備が完了しています。",
"Secure Backup": "セキュアバックアップ",
"Set up Secure Backup": "セキュアバックアップのセットアップ",
"Restart": "再起動",
"Go back": "戻る",
"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.": "重複した issue の報告が発生しないようにするため、まず<existingIssuesLink>既存の issue を確認</existingIssuesLink>してあなたが行おうとしているのと同様の報告が見つかった場合はその issue を +1 してください。見つからなかった場合は、<newIssueLink>新しい issue を作成</newIssueLink>して報告を行ってください。",
"If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "バグが発生したり、共有したいフィードバックがある場合は、GitHub でお知らせください。",
"Report bugs & give feedback": "バグ報告とフィードバック"
}

View file

@ -2438,5 +2438,16 @@
"There was an error updating your community. The server is unable to process your request.": "Tella-d tuccḍa deg uleqqem n temɣiwent-ik•im. Aqeddac ur izmir ara ad isesfer asuter.",
"Update community": "Leqqem tamɣiwent",
"May include members not in %(communityName)s": "Yezmer ad d-isseddu iɛeggalen ur nelli deg %(communityName)s",
"Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.": "Bdu adiwenni akked ḥedd s useqdec n yisem-is, isem uffir (am <userId/>) neɣ tansa imayl. Aya ur ten-iecced ara ɣer %(communityName)s. Akked ad d-tnecdeḍ yiwen ɣer %(communityName)s sit ɣef <a>da</a>."
"Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.": "Bdu adiwenni akked ḥedd s useqdec n yisem-is, isem uffir (am <userId/>) neɣ tansa imayl. Aya ur ten-iecced ara ɣer %(communityName)s. Akked ad d-tnecdeḍ yiwen ɣer %(communityName)s sit ɣef <a>da</a>.",
"not found in storage": "Ulac-it deg uklas",
"Failed to save your profile": "Yecceḍ usekles n umaɣnu-ik•im",
"The operation could not be completed": "Tamahilt ur tezmir ara ad tettwasmed",
"Backup key cached:": "Tasarut n ukles tettwaffer:",
"Secret storage:": "Aklas uffir:",
"Remove messages sent by others": "Kkes iznan i uznen wiyaḍ",
"%(count)s results|one": "%(count)s n ugmuḍ",
"Widgets": "Iwiǧiten",
"Unpin app": "Serreḥ i usnas",
"Pin to room": "Sentu deg texxamt",
"You can only pin 2 widgets at a time": "Tzemreḍ ad tsentuḍ 2 kan n yiwiǧiten ɣef tikkelt"
}

View file

@ -10,7 +10,7 @@
"Banned users": "Usuários banidos",
"Bans user with given id": "Bane o usuário com o ID indicado",
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s alterou a descrição para \"%(topic)s\".",
"Changes your display nickname": "Alterar seu nome e sobrenome",
"Changes your display nickname": "Altera o seu nome e sobrenome",
"Click here to fix": "Clique aqui para resolver isso",
"Commands": "Comandos",
"Confirm password": "Confirme a nova senha",
@ -20,7 +20,7 @@
"Current password": "Senha atual",
"Deactivate Account": "Desativar minha conta",
"Default": "Padrão",
"Deops user with given id": "Retirar nível de moderador do usuário com o identificador informado",
"Deops user with given id": "Retira o nível de moderador do usuário com o ID informado",
"Displays action": "Visualizar atividades",
"Emoji": "Emoji",
"Error": "Erro",
@ -70,7 +70,7 @@
"Return to login screen": "Retornar à tela de login",
"Room Colour": "Cores da sala",
"Rooms": "Salas",
"Searches DuckDuckGo for results": "Buscar por resultados no buscador DuckDuckGo",
"Searches DuckDuckGo for results": "Buscar resultados no DuckDuckGo",
"Send Reset Email": "Enviar e-mail para redefinição de senha",
"Server may be unavailable, overloaded, or you hit a bug.": "O servidor pode estar indisponível ou sobrecarregado, ou então você encontrou uma falha no sistema.",
"Session ID": "Identificador de sessão",
@ -168,7 +168,7 @@
"The remote side failed to pick up": "A pessoa não atendeu a chamada",
"This room is not recognised.": "Esta sala não é reconhecida.",
"This phone number is already in use": "Este número de telefone já está em uso",
"To use it, just wait for autocomplete results to load and tab through them.": "Para usar este recurso, aguarde o carregamento dos resultados de autocompletar e então escolha entre as opções.",
"To use it, just wait for autocomplete results to load and tab through them.": "Para usar este recurso, aguarde o carregamento dos resultados de preenchimento automático, e então escolha dentre as opções.",
"%(senderName)s unbanned %(targetName)s.": "%(senderName)s removeu o banimento de %(targetName)s.",
"Unable to capture screen": "Não foi possível capturar a imagem da tela",
"Unable to enable Notifications": "Não foi possível ativar as notificações",
@ -414,7 +414,7 @@
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(day)s de %(monthName)s de %(fullYear)s",
"Who would you like to add to this community?": "Quem você gostaria de adicionar a esta comunidade?",
"Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Atenção: qualquer pessoa que você adicionar a esta comunidade estará publicamente visível para todas as pessoas que conheçam o ID da comunidade",
"Invite new community members": "Convidar novos participantes para a comunidade",
"Invite new community members": "Convidar novos integrantes para a comunidade",
"Which rooms would you like to add to this community?": "Quais salas você quer adicionar a esta comunidade?",
"Show these rooms to non-members on the community page and room list?": "Exibir estas salas para não participantes na página da comunidade e na lista de salas?",
"Unable to create widget.": "Não foi possível criar o widget.",
@ -632,9 +632,9 @@
"An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "Um e-mail foi enviado para %(emailAddress)s. Após clicar no link contido no e-mail, clique abaixo.",
"Please note you are logging into the %(hs)s server, not matrix.org.": "Note que você está se conectando ao servidor %(hs)s, e não ao servidor matrix.org.",
"This homeserver doesn't offer any login flows which are supported by this client.": "Este servidor de base (homeserver) não oferece fluxos de login que funcionem neste cliente.",
"Define the power level of a user": "Definir o nível de permissões de um(a) usuário(a)",
"Ignores a user, hiding their messages from you": "Bloquear um usuário, esconderá as mensagens dele de você",
"Stops ignoring a user, showing their messages going forward": "Desbloquear um usuário, exibe suas mensagens daqui para frente",
"Define the power level of a user": "Define o nível de permissões de um usuário",
"Ignores a user, hiding their messages from you": "Bloqueia um usuário, escondendo as mensagens dele de você",
"Stops ignoring a user, showing their messages going forward": "Desbloqueia um usuário, exibindo as mensagens dele daqui para frente",
"Notify the whole room": "Notifica a sala inteira",
"Room Notification": "Notificação da sala",
"Failed to set direct chat tag": "Falha ao definir esta conversa como direta",
@ -708,13 +708,13 @@
"Failed to set Direct Message status of room": "Falha em definir a descrição da conversa",
"Monday": "Segunda-feira",
"All messages (noisy)": "Todas as mensagens (com som)",
"Enable them now": "Ativá-los agora",
"Enable them now": "Ativar agora",
"Toolbox": "Ferramentas",
"Collecting logs": "Coletando logs",
"You must specify an event type!": "Você precisa especificar um tipo do evento!",
"(HTTP status %(httpStatus)s)": "(Status HTTP %(httpStatus)s)",
"Invite to this room": "Convidar para esta sala",
"Send logs": "Enviar registros",
"Send logs": "Enviar relatórios",
"All messages": "Todas as mensagens novas",
"Call invitation": "Recebendo chamada",
"Downloading update...": "Baixando atualização...",
@ -821,8 +821,8 @@
"Messages containing @room": "Mensagens contendo @room",
"Encrypted messages in one-to-one chats": "Mensagens criptografadas em conversas individuais",
"Encrypted messages in group chats": "Mensagens criptografadas em salas",
"Delete Backup": "Deletar Backup",
"Unable to load key backup status": "Não é possível carregar o status da chave de backup",
"Delete Backup": "Remover backup",
"Unable to load key backup status": "Não foi possível carregar o status do backup da chave",
"Backup version: ": "Versão do Backup: ",
"Algorithm: ": "Algoritmo: ",
"This event could not be displayed": "Este evento não pôde ser exibido",
@ -860,12 +860,12 @@
"An error ocurred whilst trying to remove the widget from the room": "Ocorreu um erro ao tentar remover o widget da sala",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Não é possível carregar o evento que foi respondido, ele não existe ou você não tem permissão para visualizá-lo.",
"That doesn't look like a valid email address": "Este não parece ser um endereço de e-mail válido",
"Preparing to send logs": "Preparando para enviar registros",
"Logs sent": "Registros enviados",
"Failed to send logs: ": "Falha ao enviar registros:· ",
"Submit debug logs": "Submeter registros de depuração",
"Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Os registros de depuração contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou aliases das salas ou comunidades que você visitou e os nomes de usuários de outros usuários. Eles não contêm mensagens.",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Antes de enviar os registros, você deve <a>criar um bilhete de erro no GitHub</a> para descrever seu problema.",
"Preparing to send logs": "Preparando para enviar relatórios",
"Logs sent": "Relatórios enviados",
"Failed to send logs: ": "Falha ao enviar os relatórios:· ",
"Submit debug logs": "Enviar relatórios de erros",
"Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Os relatórios de erros contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou nomes das salas ou comunidades que você visitou e os nomes de usuários de seus contatos. Eles não contêm mensagens.",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Antes de enviar os relatórios, você deve <a>criar um bilhete de erro no GitHub</a> para descrever seu problema.",
"Unable to load commit detail: %(msg)s": "Não foi possível carregar os detalhes do envio: %(msg)s",
"To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Para evitar perder seu histórico de bate-papo, você precisa exportar as chaves da sua sala antes de se desconectar. Quando entrar novamente, você precisará usar a versão mais atual do %(brand)s",
"Incompatible Database": "Banco de dados incompatível",
@ -888,7 +888,7 @@
"Put a link back to the old room at the start of the new room so people can see old messages": "Colocar um link para a sala antiga no começo da sala nova de modo que as pessoas possam ver mensagens antigas",
"You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "Você já usou o %(brand)s em %(host)s com o carregamento Lazy de participantes ativado. Nesta versão, o carregamento Lazy está desativado. Como o cache local não é compatível entre essas duas configurações, o %(brand)s precisa ressincronizar sua conta.",
"If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Se a outra versão do %(brand)s ainda estiver aberta em outra aba, por favor, feche-a pois usar o %(brand)s no mesmo host com o carregamento Lazy ativado e desativado simultaneamente causará problemas.",
"Update any local room aliases to point to the new room": "Atualize todos os aliases da sala local para apontar para a nova sala",
"Update any local room aliases to point to the new room": "Atualize todos os nomes locais da sala para apontar para a nova sala",
"Clear Storage and Sign Out": "Limpar armazenamento e sair",
"Refresh": "Recarregar",
"We encountered an error trying to restore your previous session.": "Encontramos um erro ao tentar restaurar sua sessão anterior.",
@ -901,8 +901,8 @@
"Share Room Message": "Compartilhar Mensagem da Sala",
"Link to selected message": "Link da mensagem selecionada",
"COPY": "COPIAR",
"Unable to load backup status": "Não é possível carregar o status do backup",
"Unable to restore backup": "Não é possível restaurar o backup",
"Unable to load backup status": "Não foi possível carregar o status do backup",
"Unable to restore backup": "Não foi possível restaurar o backup",
"No backup found!": "Nenhum backup encontrado!",
"Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Acesse seu histórico de mensagens seguras e configure mensagens seguras digitando sua frase secreta de recuperação.",
"Next": "Próximo",
@ -912,7 +912,7 @@
"Access your secure message history and set up secure messaging by entering your recovery key.": "Acesse seu histórico seguro de mensagens e configure mensagens seguras inserindo sua chave de recuperação.",
"Share Message": "Compartilhar Mensagem",
"Popout widget": "Widget Popout",
"Send Logs": "Enviar registros",
"Send Logs": "Enviar relatórios",
"Failed to decrypt %(failedCount)s sessions!": "Falha ao descriptografar as sessões de %(failedCount)s!",
"Set a new status...": "Definir um novo status ...",
"Collapse Reply Thread": "Recolher grupo de respostas",
@ -933,7 +933,7 @@
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "Você não pode enviar nenhuma mensagem até revisar e concordar com <consentLink>nossos termos e condições</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este homeserver atingiu seu Limite de usuário ativo mensal. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este servidor local excedeu o limite de recursos. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.",
"If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Se você enviou um bug por meio do GitHub, os registros de depuração podem nos ajudar a rastrear o problema. Os registros de depuração contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou apelidos das salas ou comunidades que você visitou e os nomes de usuários de outros usuários. Eles não contêm mensagens.",
"If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Se você informou um erro por meio do GitHub, os relatórios de erros podem nos ajudar a rastrear o problema. Os relatórios de erros contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou apelidos das salas ou comunidades que você visitou e os nomes de usuários de seus contatos. Eles não contêm mensagens.",
"Legal": "Legal",
"No Audio Outputs detected": "Nenhuma caixa de som detectada",
"Audio Output": "Caixa de som",
@ -951,12 +951,12 @@
"<b>Save it</b> on a USB key or backup drive": "<b>Salve isto</ b> em uma chave USB ou unidade de backup",
"<b>Copy it</b> to your personal cloud storage": "<b>Copie isto</ b> para seu armazenamento em nuvem pessoal",
"Set up Secure Message Recovery": "Configurar Recuperação Segura de Mensagens",
"Unable to create key backup": "Não é possível criar backup de chave",
"Unable to create key backup": "Não foi possível criar backup da chave",
"Retry": "Tentar novamente",
"Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Sem configurar a Recuperação Segura de Mensagens, você perderá seu histórico de mensagens seguras quando fizer logout.",
"If you don't want to set this up now, you can later in Settings.": "Se você não quiser configurá-lo agora, poderá fazê-lo posteriormente em Configurações.",
"New Recovery Method": "Novo método de recuperação",
"If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não definiu o novo método de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina um novo método de recuperação imediatamente nas Configurações.",
"New Recovery Method": "Nova opção de recuperação",
"If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não definiu a nova opção de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina uma nova opção de recuperação imediatamente nas Configurações.",
"Set up Secure Messages": "Configurar mensagens seguras",
"Go to Settings": "Ir para as configurações",
"Unrecognised address": "Endereço não reconhecido",
@ -967,10 +967,10 @@
"Invite anyway and never warn me again": "Convide mesmo assim e nunca mais me avise",
"Invite anyway": "Convide mesmo assim",
"Whether or not you're logged in (we don't record your username)": "Se você está logado ou não (não gravamos seu nome de usuário)",
"Upgrades a room to a new version": "Atualiza uma sala para uma nova versão",
"Gets or sets the room topic": "Consultar ou definir a descrição da sala",
"Upgrades a room to a new version": "Atualiza a sala para uma nova versão",
"Gets or sets the room topic": "Consulta ou altera a descrição da sala",
"This room has no topic.": "Esta sala não tem descrição.",
"Sets the room name": "Define o nome da sala",
"Sets the room name": "Altera o nome da sala",
"Group & filter rooms by custom tags (refresh to apply changes)": "Agrupar e filtrar salas por tags personalizadas (recarregue para aplicar as alterações)",
"Render simple counters in room header": "Renderizar contadores simples no cabeçalho da sala",
"Enable Emoji suggestions while typing": "Ativar sugestões de emojis ao digitar",
@ -978,7 +978,7 @@
"Show join/leave messages (invites/kicks/bans unaffected)": "Mostrar mensagens de entrar/sair (não considera convites/remoções/banimentos)",
"Show avatar changes": "Mostrar alterações de foto de perfil",
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "O arquivo '%(fileName)s' excede o limite de tamanho deste homeserver para uploads",
"Changes your display nickname in the current room only": "Altera o seu nome e sobrenome apenas na sala atual",
"Changes your display nickname in the current room only": "Altera o seu nome e sobrenome apenas nesta sala",
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s atualizou esta sala.",
"%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s tornou a sala pública para quem conhece o link.",
"%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s tornou a sala disponível apenas por convite.",
@ -1005,7 +1005,7 @@
"You've successfully verified this user.": "Você confirmou este usuário com sucesso.",
"Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "As mensagens com este usuário estão protegidas com a criptografia de ponta a ponta e não podem ser lidas por terceiros.",
"Got It": "Ok, entendi",
"Unable to find a supported verification method.": "Nenhum método de confirmação é suportado.",
"Unable to find a supported verification method.": "Nenhuma opção de confirmação é suportada.",
"Dog": "Cachorro",
"Cat": "Gato",
"Lion": "Leão",
@ -1042,7 +1042,7 @@
"Glasses": "Óculos",
"Spanner": "Chave inglesa",
"Santa": "Papai-noel",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Adiciona ¯ \\ _ (ツ) _ / ¯ no início de uma mensagem de texto simples",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Adiciona ¯ \\ _ (ツ) _ / ¯ a uma mensagem de texto",
"User %(userId)s is already in the room": "O usuário %(userId)s já está na sala",
"The user must be unbanned before they can be invited.": "O banimento do usuário precisa ser removido antes de ser convidado.",
"Show display name changes": "Mostrar alterações de nome e sobrenome",
@ -1079,13 +1079,13 @@
"No": "Não",
"We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "Enviamos um e-mail para você confirmar seu endereço. Por favor, siga as instruções e clique no botão abaixo.",
"Email Address": "Endereço de e-mail",
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Você tem certeza? Você perderá suas mensagens criptografadas se não for feito o backup correto de suas chaves.",
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Tem certeza? Você perderá suas mensagens criptografadas se não tiver feito o backup de suas chaves.",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "As mensagens estão protegidas com a criptografia de ponta a ponta. Somente você e o(s) destinatário(s) têm as chaves para ler essas mensagens.",
"Restore from Backup": "Restaurar do Backup",
"Restore from Backup": "Restaurar do backup",
"Back up your keys before signing out to avoid losing them.": "Faça o backup das suas chaves antes de sair, para evitar perdê-las.",
"Backing up %(sessionsRemaining)s keys...": "Fazendo o backup das chaves de %(sessionsRemaining)s...",
"All keys backed up": "O Backup de todas as chaves foi realizado",
"Start using Key Backup": "Comece a usar o Backup de chave",
"All keys backed up": "O backup de todas as chaves foi realizado",
"Start using Key Backup": "Comece a usar backup de chave",
"Add an email address to configure email notifications": "Adicione um endereço de e-mail para configurar notificações por e-mail",
"Unable to verify phone number.": "Não foi possível confirmar o número de telefone.",
"Verification code": "Código de confirmação",
@ -1181,12 +1181,12 @@
"Messages": "Mensagens",
"Actions": "Ações",
"Other": "Outros",
"Sends a message as plain text, without interpreting it as markdown": "Envia uma mensagem como texto simples, sem formatar o texto",
"Sends a message as html, without interpreting it as markdown": "Envia uma mensagem como HTML, sem formatar o texto",
"Sends a message as plain text, without interpreting it as markdown": "Envia uma mensagem de texto sem formatação",
"Sends a message as html, without interpreting it as markdown": "Envia uma mensagem como HTML, sem formatação",
"You do not have the required permissions to use this command.": "Você não tem as permissões necessárias para usar este comando.",
"Error upgrading room": "Erro atualizando a sala",
"Double check that your server supports the room version chosen and try again.": "Verifique se seu servidor suporta a versão de sala escolhida e tente novamente.",
"Changes the avatar of the current room": "Altera a foto da sala atual",
"Changes the avatar of the current room": "Altera a foto da sala",
"Changes your avatar in this current room only": "Altera a sua foto de perfil apenas nesta sala",
"Changes your avatar in all rooms": "Altera a sua foto de perfil em todas as salas",
"Failed to set topic": "Não foi possível definir a descrição",
@ -1211,9 +1211,9 @@
"Sends the given emote coloured as a rainbow": "Envia o emoji colorido como um arco-íris",
"Displays list of commands with usages and descriptions": "Exibe a lista de comandos com usos e descrições",
"Displays information about a user": "Exibe informação sobre um usuário",
"Send a bug report with logs": "Envia um relatório de erros com os logs",
"Send a bug report with logs": "Envia um relatório de erro",
"Opens chat with the given user": "Abre um chat com determinada pessoa",
"Sends a message to the given user": "Envia uma mensagem com determinada pessoa",
"Sends a message to the given user": "Envia uma mensagem para determinada pessoa",
"%(senderName)s made no change.": "%(senderName)s não fez nenhuma alteração.",
"%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s alterou o nome da sala de %(oldRoomName)s para %(newRoomName)s.",
"%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s adicionou os endereços alternativos %(addresses)s desta sala.",
@ -1328,7 +1328,7 @@
"Try out new ways to ignore people (experimental)": "Tente novas maneiras de bloquear pessoas (experimental)",
"Support adding custom themes": "Permite adicionar temas personalizados",
"Enable advanced debugging for the room list": "Ativar a depuração avançada para a lista de salas",
"Show info about bridges in room settings": "Exibir informações sobre bridges nas configurações das salas",
"Show info about bridges in room settings": "Exibir informações sobre integrações nas configurações das salas",
"Font size": "Tamanho da fonte",
"Use custom size": "Usar tamanho personalizado",
"Use a more compact Modern layout": "Usar um layout mais compacto 'Moderno'",
@ -1378,8 +1378,8 @@
"Decline (%(counter)s)": "Recusar (%(counter)s)",
"Accept <policyLink /> to continue:": "Aceitar <policyLink /> para continuar:",
"Upload": "Enviar",
"This bridge was provisioned by <user />.": "Esta ponte foi disponibilizada por <user />.",
"This bridge is managed by <user />.": "Esta ponte é gerida por <user />.",
"This bridge was provisioned by <user />.": "Esta integração foi disponibilizada por <user />.",
"This bridge is managed by <user />.": "Esta integração é desenvolvida por <user />.",
"Workspace: %(networkName)s": "Espaço de trabalho: %(networkName)s",
"Channel: %(channelName)s": "Canal: %(channelName)s",
"Show less": "Mostrar menos",
@ -1432,23 +1432,23 @@
"Connecting to integration manager...": "Conectando ao gestor de integrações...",
"Cannot connect to integration manager": "Não foi possível conectar ao gerenciador de integrações",
"The integration manager is offline or it cannot reach your homeserver.": "Ou o gerenciador de integrações está desconectado, ou ele não conseguiu acessar o seu servidor.",
"This session is backing up your keys. ": "Esta sessão está fazendo a cópia (backup) das suas chaves. ",
"This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.": "Esta sessão <b>não está fazendo cópia (backup) de suas chaves</b>, mas você tem uma cópia existente que pode restaurar e adicionar para continuar.",
"Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Conecte esta sessão à cópia de segurança (backup) das chaves antes de fazer logout para evitar perder quaisquer chaves que possam estar apenas nesta sessão.",
"Connect this session to Key Backup": "Conecte esta sessão à Cópia de Segurança (Backup) da Chave",
"This session is backing up your keys. ": "Esta sessão está fazendo backup das suas chaves. ",
"This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.": "Esta sessão <b>não está fazendo backup de suas chaves</b>, mas você tem um backup existente que pode restaurar para continuar.",
"Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Autorize esta sessão a fazer o backup de chaves antes de se desconectar, para evitar perder chaves que possam estar apenas nesta sessão.",
"Connect this session to Key Backup": "Autorize esta sessão a fazer o backup de chaves",
"not stored": "não armazenado",
"Backup has a <validity>valid</validity> signature from this user": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> deste(a) usuário(a)",
"Backup has a <validity>invalid</validity> signature from this user": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> deste(a) usuário(a)",
"Backup has a signature from <verify>unknown</verify> user with ID %(deviceId)s": "A cópia de segurança tem uma assinatura de um usuário <verify>desconhecido</verify> com ID %(deviceId)s",
"Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "A cópia de segurança tem uma assinatura de uma sessão <verify>desconhecida</verify> com ID %(deviceId)s",
"Backup has a <validity>valid</validity> signature from this session": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> desta sessão",
"Backup has an <validity>invalid</validity> signature from this session": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> desta sessão",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> da sessão <verify>confirmada</verify> <device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "A cópia de segurança tem uma assinatura <validity>válida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "A cópia de segurança tem uma assinatura <validity>inválida</validity> de uma sessão <verify>confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup is not signed by any of your sessions": "A cópia de segurança (backup) não foi assinada por nenhuma de suas sessões",
"This backup is trusted because it has been restored on this session": "Esta cópia de segurança (backup) é confiável, pois foi restaurada nesta sessão",
"Backup has a <validity>valid</validity> signature from this user": "O backup tem uma assinatura <validity>válida</validity> deste usuário",
"Backup has a <validity>invalid</validity> signature from this user": "O backup tem uma assinatura <validity>inválida</validity> deste usuário",
"Backup has a signature from <verify>unknown</verify> user with ID %(deviceId)s": "O backup tem uma assinatura de um usuário <verify>desconhecido</verify> com ID %(deviceId)s",
"Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "O backup tem uma assinatura de uma sessão <verify>desconhecida</verify> com ID %(deviceId)s",
"Backup has a <validity>valid</validity> signature from this session": "O backup tem uma assinatura <validity>válida</validity> desta sessão",
"Backup has an <validity>invalid</validity> signature from this session": "O backup tem uma assinatura <validity>inválida</validity> desta sessão",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "O backup tem uma assinatura <validity>válida</validity> da sessão <verify>confirmada</verify> <device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "O backup tem uma assinatura <validity>válida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "O backup tem uma assinatura <validity>inválida</validity> de uma sessão <verify>confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "O backup tem uma assinatura <validity>inválida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup is not signed by any of your sessions": "O backup não foi assinado por nenhuma de suas sessões",
"This backup is trusted because it has been restored on this session": "Este backup é confiável, pois foi restaurado nesta sessão",
"Backup key stored: ": "Chave de segurança (backup) armazenada: ",
"Your keys are <b>not being backed up from this session</b>.": "Suas chaves <b>não estão sendo copiadas desta sessão</b>.",
"wait and try again later": "aguarde e tente novamente mais tarde",
@ -1460,7 +1460,7 @@
"A session's public name is visible to people you communicate with": "O nome público de uma sessão é visível para as pessoas com quem você se comunica",
"Enable room encryption": "Ativar criptografia nesta sala",
"Enable encryption?": "Ativar criptografia?",
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Uma vez ativada, a criptografia da sala não poderá ser desativada. Mensagens enviadas em uma sala criptografada não podem ser lidas pelo servidor, apenas pelos participantes da sala. Ativar a criptografia poderá impedir que vários bots e bridges funcionem corretamente. <a>Saiba mais sobre criptografia.</a>",
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Uma vez ativada, a criptografia da sala não poderá ser desativada. Mensagens enviadas em uma sala criptografada não podem ser lidas pelo servidor, apenas pelos participantes da sala. Ativar a criptografia poderá impedir que vários bots e integrações funcionem corretamente. <a>Saiba mais sobre criptografia.</a>",
"Encryption": "Criptografia",
"Once enabled, encryption cannot be disabled.": "Uma vez ativada, a criptografia não poderá ser desativada.",
"Encrypted": "Criptografada",
@ -1514,7 +1514,7 @@
"Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Por favor, diga-nos o que aconteceu de errado ou, ainda melhor, crie um bilhete de erro no GitHub que descreva o problema.",
"Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Apagar todos os dados desta sessão é uma ação permanente. Mensagens criptografadas serão perdidas, a não ser que as chaves delas tenham sido copiadas para o backup.",
"Set a room address to easily share your room with other people.": "Defina um endereço de sala para facilmente compartilhar sua sala com outras pessoas.",
"You cant disable this later. Bridges & most bots wont work yet.": "Você não poderá desativar isso mais tarde. Pontes e a maioria dos bots não funcionarão.",
"You cant disable this later. Bridges & most bots wont work yet.": "Você não poderá desativar isso mais tarde. Integrações e a maioria dos bots não funcionarão.",
"Enable end-to-end encryption": "Ativar a criptografia de ponta a ponta",
"Create a public room": "Criar uma sala pública",
"Create a private room": "Criar uma sala privada",
@ -1540,7 +1540,7 @@
"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.": "Para evitar a duplicação de registro de problemas, por favor <existingIssuesLink>veja os problemas existentes</existingIssuesLink> antes e adicione um +1, ou então <newIssueLink>crie um novo item</newIssueLink> se seu problema ainda não foi reportado.",
"Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reportar esta mensagem enviará o seu 'event ID' único para o/a administrador/a do seu Homeserver. Se as mensagens nesta sala são criptografadas, o/a administrador/a não conseguirá ler o texto da mensagem nem ver nenhuma imagem ou arquivo.",
"Sign out and remove encryption keys?": "Fazer logout e remover as chaves de criptografia?",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Alguns dados de sessão, incluindo chaves de mensagens criptografadas, estão faltando. Faça logout e entre novamente para resolver isso, restaurando as chaves do backup.",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Alguns dados de sessão, incluindo chaves de mensagens criptografadas, estão faltando. Desconecte-se e entre novamente para resolver isso, o que restaurará as chaves do backup.",
"Verify other session": "Confirmar outra sessão",
"A widget would like to verify your identity": "Um widget deseja confirmar sua identidade",
"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.": "Um widget localizado em %(widgetUrl)s deseja confirmar sua identidade. Permitindo isso, o widget poderá verificar sua ID de usuário, mas não poderá realizar nenhuma ação em seu nome.",
@ -1553,15 +1553,15 @@
"Recovery key mismatch": "Chave de recuperação incorreta",
"Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.": "O backup não pôde ser descriptografado com esta chave de recuperação: por favor, verifique se você digitou a chave de recuperação correta.",
"Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.": "O backup não pôde ser descriptografado com esta frase de recuperação: por favor, verifique se você digitou a frase de recuperação correta.",
"<b>Warning</b>: you should only set up key backup from a trusted computer.": "<b>Atenção</b>: você só deve configurar a cópia de segurança (backup) das chaves em um computador de sua confiança.",
"<b>Warning</b>: you should only set up key backup from a trusted computer.": "<b>Atenção</b>: você só deve configurar o backup de chave em um computador de sua confiança.",
"Enter recovery key": "Digite a chave de recuperação",
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Atenção</b>: Você só deve configurar a cópia de segurança (backup) das chaves em um computador de sua confiança.",
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Atenção</b>: Você só deve configurar o backup de chave em um computador de sua confiança.",
"If you've forgotten your recovery key you can <button>set up new recovery options</button>": "Se você esqueceu sua chave de recuperação, pode <button>configurar novas opções de recuperação</button>",
"Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Está faltando a chave pública do captcha no Servidor (homeserver). Por favor, reporte isso aos(às) administradores(as) do servidor.",
"Enter the location of your Element Matrix Services homeserver. It may use your own domain name or be a subdomain of <a>element.io</a>.": "Entre com a localização do seu Servidor Matrix. Pode ser seu próprio domínio ou ser um subdomínio de <a>element.io</a>.",
"Create your Matrix account on %(serverName)s": "Criar sua conta Matrix em %(serverName)s",
"Create your Matrix account on <underlinedServerName />": "Crie sua conta Matrix em <underlinedServerName />",
"Welcome to %(appName)s": "Bem-vinda/o ao %(appName)s",
"Welcome to %(appName)s": "Boas-vindas ao %(appName)s",
"Liberate your communication": "Liberte sua comunicação",
"Send a Direct Message": "Enviar uma mensagem",
"Explore Public Rooms": "Explorar salas públicas",
@ -1571,7 +1571,7 @@
"%(creator)s created and configured the room.": "%(creator)s criou e configurou esta sala.",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "Se você não conseguir encontrar a sala que está procurando, peça um convite para a sala ou <a>Crie uma nova sala</a>.",
"Verify this login": "Confirmar este login",
"Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Alterar a sua senha redefinirá todas as chaves de criptografia de ponta a ponta existentes em todas as suas sessões, tornando o histórico de mensagens criptografadas ilegível. Faça uma cópia (backup) das suas chaves, ou exporte as chaves de outra sessão antes de alterar a sua senha.",
"Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Alterar a sua senha redefinirá todas as chaves de criptografia de ponta a ponta existentes em todas as suas sessões, tornando o histórico de mensagens criptografadas ilegível. Faça um backup das suas chaves, ou exporte as chaves de outra sessão antes de alterar a sua senha.",
"Create account": "Criar conta",
"Create your account": "Criar sua conta",
"Use Recovery Key or Passphrase": "Use a chave de recuperação, ou a frase de recuperação",
@ -1583,17 +1583,17 @@
"Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Atenção: Seus dados pessoais (incluindo chaves de criptografia) ainda estão armazenados nesta sessão. Apague-os quando tiver finalizado esta sessão, ou se quer entrar com outra conta.",
"Confirm encryption setup": "Confirmar a configuração de criptografia",
"Click the button below to confirm setting up encryption.": "Clique no botão abaixo para confirmar a configuração da criptografia.",
"Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Proteja-se contra a perda de acesso a mensagens e dados criptografados fazendo a cópia segura (backup) das chaves de criptografia no seu servidor.",
"Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Proteja-se contra a perda de acesso a mensagens e dados criptografados fazendo backup das chaves de criptografia no seu servidor.",
"Generate a Security Key": "Gerar uma Chave de Segurança",
"Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Nós geramos uma Chave de Segurança para você. Por favor, guarde-a em um lugar seguro, como um gerenciador de senhas ou um cofre.",
"Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use uma frase secreta que apenas você conhece, e opcionalmente salve uma Chave de Segurança para usar como cópia de segurança (backup).",
"Restore your key backup to upgrade your encryption": "Restaurar a sua cópia segura (backup) de chaves para atualizar a sua criptografia",
"Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use uma frase secreta que apenas você conhece, e opcionalmente salve uma Chave de Segurança para acessar o backup.",
"Restore your key backup to upgrade your encryption": "Restaurar o backup das suas chaves para atualizar a sua criptografia",
"Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Atualize esta sessão para permitir que ela confirme outras sessões, dando a elas acesso às mensagens criptografadas e marcando-as como confiáveis para os seus contatos.",
"Store your Security Key somewhere safe, like a password manager or a safe, as its used to safeguard your encrypted data.": "Guarde sua Chave de Segurança em algum lugar seguro, como por exemplo um gestor de senhas ou um cofre, já que esta chave é a proteção para seus dados criptografados.",
"If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Se você cancelar agora, poderá perder mensagens e dados criptografados se você perder acesso aos seus logins atuais.",
"Upgrade your encryption": "Atualizar sua criptografia",
"Save your Security Key": "Salve sua Chave de Segurança",
"We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Nós vamos armazenar uma cópia criptografada de suas chaves no nosso servidor. Por favor, proteja esta cópia (backup) com uma frase de recuperação.",
"We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Nós armazenaremos uma cópia criptografada de suas chaves no nosso servidor. Por favor, proteja este backup com uma frase de recuperação.",
"Set up with a recovery key": "Configurar com uma chave de recuperação",
"Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "Sua chave de recuperação é uma rede de proteção - você pode usá-la para restaurar o acesso às suas mensagens criptografadas se você esquecer sua frase de recuperação.",
"Your recovery key": "Sua chave de recuperação",
@ -1601,12 +1601,12 @@
"Your recovery key is in your <b>Downloads</b> folder.": "Sua chave de recuperação está na sua pasta de <b>Downloads</b>.",
"Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Sem configurar a Recuperação Segura de Mensagens, você não será capaz de restaurar seu histórico de mensagens criptografadas e fizer logout ou usar outra sessão.",
"Make a copy of your recovery key": "Fazer uma cópia de sua chave de recuperação",
"Starting backup...": "Iniciando cópia de segurança (backup)...",
"Create key backup": "Criar cópia de segurança (backup) da chave",
"Starting backup...": "Começando o backup...",
"Create key backup": "Criar backup de chave",
"A new recovery passphrase and key for Secure Messages have been detected.": "Uma nova frase e chave de recuperação para Mensagens Seguras foram detectadas.",
"This session is encrypting history using the new recovery method.": "Esta sessão está criptografando o histórico de mensagens usando o novo método de restauração.",
"This session is encrypting history using the new recovery method.": "Esta sessão está criptografando o histórico de mensagens usando a nova opção de recuperação.",
"This session has detected that your recovery passphrase and key for Secure Messages have been removed.": "Esta sessão detectou que sua frase e chave de recuperação para Mensagens Seguras foram removidas.",
"If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Se você fez isso acidentalmente, você pode configurar Mensagens Seguras nesta sessão, o que vai re-criptografar o histórico de mensagens desta sessão com um novo método de recuperação.",
"If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Se você fez isso acidentalmente, você pode configurar Mensagens Seguras nesta sessão, o que vai re-criptografar o histórico de mensagens desta sessão com uma nova opção de recuperação.",
"If disabled, messages from encrypted rooms won't appear in search results.": "Se desativado, as mensagens de salas criptografadas não aparecerão em resultados de buscas.",
"%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s está armazenando de forma segura as mensagens criptografadas localmente, para que possam aparecer nos resultados das buscas:",
"%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s de %(totalRooms)s",
@ -1642,7 +1642,7 @@
"Edited at %(date)s. Click to view edits.": "Editado em %(date)s. Clique para ver edições.",
"edited": "editado",
"Can't load this message": "Não foi possível carregar esta mensagem",
"Submit logs": "Enviar registros",
"Submit logs": "Enviar relatórios",
"Frequently Used": "Mais usados",
"Animals & Nature": "Animais e natureza",
"Food & Drink": "Comidas e bebidas",
@ -1722,7 +1722,7 @@
"Published Addresses": "Endereços publicados",
"Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "Os endereços publicados podem ser usados por qualquer pessoa em qualquer servidor para entrar na sala. Para publicar um endereço, primeiramente ele precisa ser definido como um endereço local.",
"Other published addresses:": "Outros endereços publicados:",
"New published address (e.g. #alias:server)": "Novo endereço publicado (por exemplo, #apelido:server)",
"New published address (e.g. #alias:server)": "Novo endereço publicado (por exemplo, #nome:server)",
"Local Addresses": "Endereços locais",
"%(name)s cancelled verifying": "%(name)s cancelou a confirmação",
"Your display name": "Seu nome e sobrenome",
@ -1889,12 +1889,12 @@
"All settings": "Todas as configurações",
"You're signed out": "Você está desconectada/o",
"Clear personal data": "Limpar dados pessoais",
"Command Autocomplete": "Preenchimento automático de comandos",
"Command Autocomplete": "Preenchimento automático do comando",
"Community Autocomplete": "Preenchimento automático da comunidade",
"DuckDuckGo Results": "Resultados no DuckDuckGo",
"If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não excluiu o método de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina imediatamente um novo método de recuperação nas Configurações.",
"If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não excluiu a opção de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina imediatamente uma nova opção de recuperação nas Configurações.",
"Room List": "Lista de salas",
"Autocomplete": "Autocompletar",
"Autocomplete": "Preencher automaticamente",
"Alt": "Alt",
"Alt Gr": "Alt Gr",
"Shift": "Shift",
@ -2056,11 +2056,11 @@
"You'll upgrade this room from <oldVersion /> to <newVersion />.": "Você atualizará esta sala de <oldVersion /> para <newVersion />.",
"A username can only contain lower case letters, numbers and '=_-./'": "Um nome de usuário só pode ter letras minúsculas, números e '=_-./'",
"Command Help": "Ajuda com Comandos",
"To help us prevent this in future, please <a>send us logs</a>.": "Para nos ajudar a evitar isso no futuro, <a>envie-nos os registros</a>.",
"To help us prevent this in future, please <a>send us logs</a>.": "Para nos ajudar a evitar isso no futuro, <a>envie-nos os relatórios</a>.",
"Your browser likely removed this data when running low on disk space.": "O seu navegador provavelmente removeu esses dados quando o espaço de armazenamento ficou insuficiente.",
"Integration Manager": "Gerenciador de Integrações",
"Find others by phone or email": "Encontre outras pessoas por telefone ou e-mail",
"Use bots, bridges, widgets and sticker packs": "Use bots, pontes, widgets e pacotes de figurinhas",
"Use bots, bridges, widgets and sticker packs": "Use bots, integrações, widgets e pacotes de figurinhas",
"Terms of Service": "Termos de serviço",
"To continue you need to accept the terms of this service.": "Para continuar, você precisa aceitar os termos deste serviço.",
"Service": "Serviço",
@ -2308,8 +2308,8 @@
"Room Info": "Informações da sala",
"Widgets": "Widgets",
"Unpin app": "Desafixar app",
"Edit widgets, bridges & bots": "Editar widgets, pontes & bots",
"Add widgets, bridges & bots": "Adicionar widgets, pontes & bots",
"Edit widgets, bridges & bots": "Editar widgets, integrações & bots",
"Add widgets, bridges & bots": "Adicionar widgets, integrações & bots",
"%(count)s people|other": "%(count)s pessoas",
"%(count)s people|one": "%(count)s pessoa",
"Show files": "Mostrar arquivos",
@ -2329,5 +2329,145 @@
"What's the name of your community or team?": "Qual é o nome da sua comunidade ou equipe?",
"Add image (optional)": "Adicionar foto (opcional)",
"An image will help people identify your community.": "Uma foto ajudará as pessoas identificarem a sua comunidade.",
"Preview": "Visualizar"
"Preview": "Visualizar",
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Adiciona ( ͡° ͜ʖ ͡°) a uma mensagem de texto",
"Set up Secure Backup": "Configurar o backup online",
"Safeguard against losing access to encrypted messages & data": "Proteja-se contra a perda de acesso a mensagens & dados criptografados",
"Show message previews for reactions in DMs": "Mostrar pré-visualizações para reações em mensagens privadas",
"Show message previews for reactions in all rooms": "Mostrar pré-visualizações para reações em todas as salas",
"Uploading logs": "Enviando relatórios",
"Downloading logs": "Baixando relatórios",
"Backup version:": "Versão do backup:",
"Backup key stored:": "Backup da chave armazenada:",
"Backup key cached:": "Backup da chave em cache:",
"Secure Backup": "Backup online",
"Your keys are being backed up (the first backup could take a few minutes).": "O backup de suas chaves está sendo feito (o primeiro backup pode demorar alguns minutos).",
"Secure your backup with a recovery passphrase": "Proteja o seu backup com uma frase de recuperação",
"You can also set up Secure Backup & manage your keys in Settings.": "Você também pode configurar o Backup online & configurar as suas senhas nas Configurações.",
"End conference": "Terminar conferência",
"This will end the conference for everyone. Continue?": "Isso encerrará a chamada para todos. Prosseguir?",
"Cross-signing is ready for use.": "A autoverificação está pronta para uso.",
"Cross-signing is not set up.": "A autoverificação não está configurada.",
"Reset": "Redefinir",
"not found in storage": "não encontrado no armazenamento",
"Master private key:": "Chave privada principal:",
"Failed to save your profile": "Houve uma falha ao salvar o seu perfil",
"The operation could not be completed": "A operação não foi concluída",
"Algorithm:": "Algoritmo:",
"Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.": "Faça backup de suas chaves de criptografia com os dados da sua conta, para se prevenir a perder o acesso às suas sessões. Suas chaves serão protegidas com uma chave de recuperação exclusiva.",
"Secret storage:": "Armazenamento secreto:",
"ready": "pronto",
"not ready": "não está pronto",
"Subscribed lists": "Listas inscritas",
"This room is bridging messages to the following platforms. <a>Learn more.</a>": "Esta sala está integrando mensagens com as seguintes plataformas. <a>Saiba mais.</a>",
"This room isnt bridging messages to any platforms. <a>Learn more.</a>": "Esta sala não está integrando mensagens com nenhuma plataforma. <a>Saiba mais.</a>",
"Bridges": "Integrações",
"Error changing power level requirement": "Houve um erro ao alterar o nível de permissão do contato",
"An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "Ocorreu um erro ao alterar os níveis de permissão da sala. Certifique-se de que você tem o nível suficiente e tente novamente.",
"An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "Ocorreu um erro ao alterar o nível de permissão de um contato. Certifique-se de que você tem o nível suficiente e tente novamente.",
"Remove messages sent by others": "Remover mensagens enviadas por outros",
"To link to this room, please add an address.": "Para criar um link para esta sala, antes adicione um endereço.",
"Explore community rooms": "Explorar salas da comunidade",
"Explore public rooms": "Explorar salas públicas",
"Can't see what youre looking for?": "Não consegue encontrar o que está procurando?",
"Explore all public rooms": "Explorar todas as salas públicas",
"%(count)s results|other": "%(count)s resultados",
"%(count)s results|one": "%(count)s resultado",
"%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.": "%(errcode)s apareceu ao tentar entrar na sala. Se você recebeu essa mensagem por engano, <issueLink>envie um relatório de erro</issueLink>.",
"Not encrypted": "Não criptografada",
"About": "Sobre a sala",
"Pin to room": "Fixar na sala",
"You can only pin 2 widgets at a time": "Você só pode fixar 2 widgets ao mesmo tempo",
"Ignored attempt to disable encryption": "A tentativa de desativar a criptografia foi ignorada",
"Message Actions": "Ações da mensagem",
"Join the conference at the top of this room": "Entre na chamada em grupo no topo desta sala",
"Join the conference from the room information card on the right": "Participe da chamada em grupo, clicando no botão de informações da sala, à direita da tela",
"Video conference ended by %(senderName)s": "Chamada de vídeo em grupo encerrada por %(senderName)s",
"Video conference updated by %(senderName)s": "Chamada de vídeo em grupo atualizada por %(senderName)s",
"Video conference started by %(senderName)s": "Chamada de vídeo em grupo iniciada por %(senderName)s",
"Preparing to download logs": "Preparando os relatórios para download",
"Download logs": "Baixar relatórios",
"Use this when referencing your community to others. The community ID cannot be changed.": "Use esta informação para indicar a sua comunidade para outras pessoas. O ID da comunidade não pode ser alterado.",
"You can change this later if needed.": "Você poderá alterar esta informação posteriormente, se for necessário.",
"Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Salas privadas são encontradas e acessadas apenas por meio de convite. Por sua vez, salas públicas são encontradas e acessadas por qualquer pessoa.",
"Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Salas privadas são encontradas e acessadas apenas por meio de convite. Por sua vez, salas públicas são encontradas e acessadas por qualquer integrante desta comunidade.",
"Your server requires encryption to be enabled in private rooms.": "O seu servidor demanda que a criptografia esteja ativada em salas privadas.",
"You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Você pode ativar essa opção se a sala for usada apenas para colaboração dentre equipes internas em seu servidor local. Essa opção não poderá ser alterado mais tarde.",
"You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Você pode desativar essa opção se a sala for usada para colaboração dentre equipes externas que possuem seu próprio servidor local. Isso não poderá ser alterado mais tarde.",
"Create a room in %(communityName)s": "Criar uma sala em %(communityName)s",
"Block anyone not part of %(serverName)s from ever joining this room.": "Bloquear pessoas externas ao servidor %(serverName)s de conseguirem entrar nesta sala.",
"Confirm your account deactivation by using Single Sign On to prove your identity.": "Prove a sua identidade por meio do seu Acesso único, para confirmar a desativação da sua conta.",
"There was an error updating your community. The server is unable to process your request.": "Houve um erro ao atualizar a sua comunidade. O servidor não conseguiu processar a sua solicitação.",
"Update community": "Atualizar a comunidade",
"To continue, use Single Sign On to prove your identity.": "Para continuar, use o Acesso único para provar a sua identidade.",
"May include members not in %(communityName)s": "Pode incluir integrantes externos à %(communityName)s",
"Start a conversation with someone using their name or username (like <userId/>).": "Comece uma conversa, a partir do nome ou nome de usuário de alguém (por exemplo: <userId/>).",
"This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>": "Ninguém será convidado para %(communityName)s. Para convidar alguém para %(communityName)s, clique <a>aqui</a>",
"Go": "Próximo",
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.": "Convide alguém a partir do nome ou nome de usuário (por exemplo: <userId/>) ou <a>compartilhe esta sala</a>.",
"Confirm by comparing the following with the User Settings in your other session:": "Para confirmar, compare a seguinte informação com aquela apresentada em sua outra sessão:",
"Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Atualizar esta sala irá fechar a instância atual da sala e, em seu lugar, criar uma sala atualizada com o mesmo nome. Para oferecer a melhor experiência possível aos integrantes da sala, nós iremos:",
"You're all caught up.": "Tudo em dia.",
"Your area is experiencing difficulties connecting to the internet.": "A sua região está com dificuldade de acesso à internet.",
"A connection error occurred while trying to contact the server.": "Um erro ocorreu na conexão do Element com o servidor.",
"Unable to set up keys": "Não foi possível configurar as chaves",
"Unpin": "Desafixar",
"Cannot create rooms in this community": "Não foi possível criar salas nesta comunidade",
"You do not have permission to create rooms in this community.": "Você não tem permissão para criar salas nesta comunidade.",
"Youre all caught up": "Tudo em dia",
"Explore rooms in %(communityName)s": "Explore as salas em %(communityName)s",
"Create community": "Criar comunidade",
"Failed to find the general chat for this community": "Houve uma falha para encontrar a conversa principal desta comunidade",
"Community settings": "Configurações da comunidade",
"User settings": "Configurações do usuário",
"Community and user menu": "Comunidade e menu de usuário",
"Sign in instead": "Fazer login",
"Failed to get autodiscovery configuration from server": "Houve uma falha para obter do servidor a configuração de encontrar contatos",
"If you've joined lots of rooms, this might take a while": "Se você participa em muitas salas, isso pode demorar um pouco",
"Unable to query for supported registration methods.": "Não foi possível consultar as opções de registro suportadas.",
"Registration has been disabled on this homeserver.": "O registro de contas foi desativado neste servidor local.",
"Continue with previous account": "Continuar com a conta anterior",
"This requires the latest %(brand)s on your other devices:": "Esta funcionalidade requer o %(brand)s mais recente em seus outros aparelhos:",
"Emoji Autocomplete": "Preenchimento automático de emoji",
"Room Autocomplete": "Preenchimento automático de sala",
"User Autocomplete": "Preenchimento automático de usuário",
"Enter a recovery passphrase": "Digite uma frase de recuperação",
"Great! This recovery passphrase looks strong enough.": "Ótimo! Essa frase de recuperação é forte o suficiente.",
"Please enter your recovery passphrase a second time to confirm.": "Digite a sua frase de recuperação uma segunda vez para confirmar, por favor.",
"Repeat your recovery passphrase...": "Digite a sua frase de recuperação novamente...",
"Keep a copy of it somewhere secure, like a password manager or even a safe.": "Mantenha uma cópia em algum lugar seguro, como em um gerenciador de senhas ou até mesmo em um cofre.",
"Unable to query secret storage status": "Não foi possível obter o status do armazenamento secreto",
"Set a Security Phrase": "Defina uma frase de segurança",
"Confirm Security Phrase": "Confirme a frase de segurança",
"Unable to set up secret storage": "Não foi possível definir o armazenamento secreto",
"Recovery Method Removed": "Opção de recuperação removida",
"Not currently indexing messages for any room.": "Atualmente, mensagens de nenhuma sala estão sendo armazenadas.",
"Currently indexing: %(currentRoom)s": "Armazenando no momento: %(currentRoom)s",
"Indexed messages:": "Mensagens armazenadas:",
"Indexed rooms:": "Salas armazenadas:",
"Message downloading sleep time(ms)": "Tempo de espera entre o download de mensagens (ms)",
"Clear room list filter field": "Limpar o campo de busca de salas",
"Previous/next unread room or DM": "Anterior/próxima mensagem ou sala não lida",
"Previous/next room or DM": "Anterior/próxima mensagem ou sala",
"Toggle the top left menu": "Alternar o menu superior esquerdo",
"Activate selected button": "Apertar no botão selecionado",
"Toggle right panel": "Alternar o painel na direita",
"Toggle this dialog": "Alternar esta janela",
"Move autocomplete selection up/down": "Alternar para cima/baixo a opção do preenchimento automático",
"Cancel autocomplete": "Cancelar o preenchimento automático",
"Offline encrypted messaging using dehydrated devices": "Envio de mensagens criptografadas offline, usando dispositivos específicos",
"Calling...": "Chamando...",
"Call connecting...": "Iniciando chamada...",
"Starting camera...": "Iniciando a câmera...",
"Starting microphone...": "Iniciando o microfone...",
"(their device couldn't start the camera / microphone)": "(o aparelho não conseguiu iniciar a câmera/microfone)",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s alterou a lista de controle de acesso do servidor para esta sala.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s definiu a lista de controle de acesso do servidor para esta sala.",
"The call could not be established": "Não foi possível iniciar a chamada",
"The other party declined the call.": "O contato recusou a chamada.",
"%(senderName)s declined the call.": "%(senderName)s recusou a chamada.",
"(an error occurred)": "(ocorreu um erro)",
"(connection failed)": "(a conexão falhou)",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Todos os servidores foram banidos desta sala! Esta sala não pode mais ser utilizada.",
"Call Declined": "Chamada recusada"
}

View file

@ -2517,5 +2517,10 @@
"End conference": "Завершить конференцию",
"This will end the conference for everyone. Continue?": "Это завершит конференцию для всех. Продолжить?",
"Failed to save your profile": "Не удалось сохранить ваш профиль",
"The operation could not be completed": "Операция не может быть выполнена"
"The operation could not be completed": "Операция не может быть выполнена",
"Calling...": "Звонок…",
"Call connecting...": "Устанавливается соединение…",
"Starting camera...": "Запуск камеры…",
"Starting microphone...": "Запуск микрофона…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Все серверы запрещены к участию! Эта комната больше не может быть использована."
}

View file

@ -2518,5 +2518,21 @@
"This version of %(brand)s does not support viewing some encrypted files": "Ky version i %(brand)s nuk mbulon parjen për disa kartela të fshehtëzuara",
"This version of %(brand)s does not support searching encrypted messages": "Ky version i %(brand)s nuk mbulon kërkimin në mesazhe të fshehtëzuar",
"Cannot create rooms in this community": "Smund të krijohen dhoma në këtë bashkësi",
"You do not have permission to create rooms in this community.": "Skeni leje të krijoni dhoma në këtë bashkësi."
"You do not have permission to create rooms in this community.": "Skeni leje të krijoni dhoma në këtë bashkësi.",
"Failed to save your profile": "Su arrit të ruhej profili juaj",
"The operation could not be completed": "Veprimi su plotësua dot",
"Starting microphone...": "Po vihet mikrofoni në punë…",
"Starting camera...": "Po vihet kamera në punë…",
"Call connecting...": "Po bëhet lidhja për thirrje…",
"Calling...": "Po thirret…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Janë dëbuar nga pjesëmarrja krejt shërbyesit! Kjo dhomë smund të përdoret më.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ndryshoi ACL-ra shërbyesi për këtë dhomë.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s caktoi ACL-ra shërbyesi për këtë dhomë.",
"%(senderName)s declined the call.": "%(senderName)s hodhi poshtë thirrjen.",
"(an error occurred)": "(ndodhi një gabim)",
"(their device couldn't start the camera / microphone)": "(pajisja e tyre svuri dot në punë kamerën / mikrofonin)",
"(connection failed)": "(dështoi lidhja)",
"The call could not be established": "Thirrja su nis dot",
"The other party declined the call.": "Pala tjetër hodhi poshtë thirrjen.",
"Call Declined": "Thirrja u Hodh Poshtë"
}

View file

@ -2457,5 +2457,19 @@
"This version of %(brand)s does not support viewing some encrypted files": "Den här versionen av %(brand)s stöder inte visning av vissa krypterade filer",
"This version of %(brand)s does not support searching encrypted messages": "Den här versionen av %(brand)s stöder inte sökning bland krypterade meddelanden",
"Cannot create rooms in this community": "Kan inte skapa rum i den här gemenskapen",
"You do not have permission to create rooms in this community.": "Du har inte behörighet att skapa rum i den här gemenskapen."
"You do not have permission to create rooms in this community.": "Du har inte behörighet att skapa rum i den här gemenskapen.",
"Calling...": "Ringer…",
"Call connecting...": "Samtal ansluts…",
"Starting camera...": "Startar kamera…",
"Starting microphone...": "Startar mikrofon…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Alla servrar har bannats från att delta! Det här rummet kan inte längre användas.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ändrade server-ACL:erna för det här rummet.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s ställde in server-ACL:er för det här rummet.",
"%(senderName)s declined the call.": "%(senderName)s avböjde samtalet.",
"(an error occurred)": "(ett fel inträffade)",
"(their device couldn't start the camera / microphone)": "(deras enhet kunde inte starta kameran/mikrofonen)",
"(connection failed)": "(anslutning misslyckad)",
"The call could not be established": "Samtalet kunde inte etableras",
"The other party declined the call.": "Den andra parten avböjde samtalet.",
"Call Declined": "Samtal avböjt"
}

View file

@ -222,7 +222,7 @@
"Whether or not you're using the Richtext mode of the Rich Text Editor": "Чи використовуєте ви режим форматованого тексту у редакторі Rich Text Editor",
"Your homeserver's URL": "URL адреса вашого домашнього сервера",
"Failed to verify email address: make sure you clicked the link in the email": "Не вдалось перевірити адресу електронної пошти: переконайтесь, що ви перейшли за посиланням у листі",
"The platform you're on": "Використовувана платформа",
"The platform you're on": "Платформа, на якій ви працюєте",
"e.g. %(exampleValue)s": "напр. %(exampleValue)s",
"Every page you use in the app": "Кожна сторінка, яку ви використовуєте в програмі",
"e.g. <CurrentPageURL>": "напр. <CurrentPageURL>",
@ -1229,5 +1229,40 @@
"Cancel autocomplete": "Скасувати самодоповнення",
"Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Журнали зневадження містять дані використання застосунку, включно з вашим користувацьким ім’ям, ідентифікаторами або псевдонімами відвіданих вами кімнат або груп, а також іменами інших користувачів. Вони не містять повідомлень.",
"Confirm your account deactivation by using Single Sign On to prove your identity.": "Підтвердьте знедіяння вашого облікового запису через Single Sign On щоб підтвердити вашу особу.",
"This account has been deactivated.": "Цей обліковий запис було знедіяно."
"This account has been deactivated.": "Цей обліковий запис було знедіяно.",
"End conference": "Завершити конференцію",
"This will end the conference for everyone. Continue?": "Це завершить конференцію для всіх. Продовжити?",
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Додає ( ͡° ͜ʖ ͡°) на початку текстового повідомлення",
"about a day ago": "близько доби тому",
"%(name)s (%(userId)s)": "%(name)s (%(userId)s)",
"Unexpected server error trying to leave the room": "Виникла неочікувана помилка серверу під час спроби залишити кімнату",
"Unknown App": "Невідомий додаток",
"Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.": "Відправляти <UsageDataLink>анонімну статистику користування</UsageDataLink>, що дозволяє нам покращувати %(brand)s. Це використовує <PolicyLink>кукі</PolicyLink>.",
"Set up Secure Backup": "Налаштувати захищене резервне копіювання",
"Safeguard against losing access to encrypted messages & data": "Захист від втрати доступу до зашифрованих повідомлень та даних",
"The person who invited you already left the room.": "Особа, що вас запросила, вже залишила кімнату.",
"The person who invited you already left the room, or their server is offline.": "Особа, що вас запросила вже залишила кімнату, або її сервер відімкнено.",
"Change notification settings": "Змінити налаштування сповіщень",
"Render simple counters in room header": "Показувати звичайні лічильники у заголовку кімнати",
"Send typing notifications": "Надсилати сповіщення про набирання тексту",
"Use a system font": "Використовувати системний шрифт",
"System font name": "Ім’я системного шрифту",
"Allow Peer-to-Peer for 1:1 calls": "Дозволити Peer-to-Peer для дзвінків 1:1",
"Enable widget screenshots on supported widgets": "Увімкнути скріншоти віджетів для віджетів, що підтримуються",
"Prompt before sending invites to potentially invalid matrix IDs": "Запитувати перед надсиланням запрошень на потенційно недійсні matrix ID",
"Order rooms by name": "Сортувати кімнати за назвою",
"Low bandwidth mode": "Режим для низької пропускної здатності",
"Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Дозволити резервний сервер допоміжних викликів turn.matrix.org якщо ваш домашній сервер не пропонує такого (ваша IP-адреса буде розкрита для здійснення дзвінка)",
"Send read receipts for messages (requires compatible homeserver to disable)": "Надсилати мітки прочитання повідомлень (необхідний сумісний домашній сервер для відімкнення)",
"How fast should messages be downloaded.": "Як швидко повідомлення повинні завантажуватися.",
"Enable experimental, compact IRC style layout": "Увімкнути експериментальне, компактне компонування IRC",
"Uploading logs": "Відвантаження журналів",
"Downloading logs": "Завантаження журналів",
"My Ban List": "Мій список блокувань",
"This is your list of users/servers you have blocked - don't leave the room!": "Це ваш список користувачів/серверів, які ви заблокували не залишайте кімнату!",
"Incoming call": "Вхідний виклик",
"The other party cancelled the verification.": "Друга сторона скасувала звірення.",
"Verified!": "Звірено!",
"You've successfully verified this user.": "Ви успішно звірили цього користувача.",
"Got It": "Зрозуміло"
}

View file

@ -1529,10 +1529,10 @@
"Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "备份有来自 ID 为 %(deviceId)s 的<verify>未知</verify>会话的签名",
"Backup has a <validity>valid</validity> signature from this session": "备份有来自此会话的<validity>有效</validity>签名",
"Backup has an <validity>invalid</validity> signature from this session": "备份有来自此会话的<validity>无效</validity>签名",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "备份有来自<verify>已验证</verify>会话 <device></device> 的<validity>有效</validity>签名",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有来自<verify>未验证</verify>会话 <device></device> 的<validity>无效</validity>签名",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "备份有来自<verify>已验证</verify>会话 <device></device> 的<validity>无效</validity>签名",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有来自<verify>未验证的</verify>会话 <device></device> 的 <validity>无效</validity>签名",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "备份有一个<validity>有效的签名</validity>,它来自<verify>已验证</verify>会话<device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有一个<validity>有效的</validity>签名,它来自<verify>未验证</verify>会话<device>\n</device>",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "备份有一个<validity>无效的</validity>签名,它来自<verify>已验证</verify>会话<device>\n</device>",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有一个<validity>无效的</validity>签名,它来自<verify>未验证的</verify>会话<device>\n</device>",
"Backup is not signed by any of your sessions": "备份没有被您的任何一个会话签名",
"This backup is trusted because it has been restored on this session": "此备份是受信任的因为它被恢复到了此会话上",
"Backup key stored: ": "存储的备份密钥: ",
@ -1553,12 +1553,12 @@
"Disconnect identity server": "断开身份服务器连接",
"Disconnect from the identity server <idserver />?": "从身份服务器 <idserver /> 断开连接吗?",
"Disconnect": "断开连接",
"You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> is currently offline or cannot be reached.": "在断开连接之前,您应该从身份服务器 <idserver /> <b>删除您的个人信息</b>。不幸的是,身份服务器 <idserver /> 现在为离线状态或不能到达。",
"You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> is currently offline or cannot be reached.": "断开连接前,你应当<b>删除你的个人信息</b>从身份服务器<idserver />。\n不幸的是身份服务器<idserver />当前处于离线状态或无法访问。",
"You should:": "您应该:",
"contact the administrators of identity server <idserver />": "联系身份服务器 <idserver /> 的管理员",
"wait and try again later": "等待并稍后重试",
"Disconnect anyway": "仍然断开连接",
"You are still <b>sharing your personal data</b> on the identity server <idserver />.": "您仍然在身份服务器 <idserver /> 上<b>共享您的个人信息</b>。",
"You are still <b>sharing your personal data</b> on the identity server <idserver />.": "您仍然在<b>分享您的个人信息</b>在身份服务器上<idserver />。",
"We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐您在断开连接前从身份服务器上删除您的邮箱地址和电话号码。",
"Identity Server (%(server)s)": "身份服务器(%(server)s",
"not stored": "未存储",
@ -2287,7 +2287,7 @@
"Alt": "Alt",
"Alt Gr": "Alt Gr",
"Shift": "Shift",
"Super": "Super",
"Super": "",
"Ctrl": "Ctrl",
"New line": "换行",
"Jump to start/end of the composer": "跳转到编辑器的开始/结束",

View file

@ -2527,5 +2527,19 @@
"Offline encrypted messaging using dehydrated devices": "使用乾淨裝置的離線加密訊息",
"Failed to save your profile": "儲存您的設定檔失敗",
"The operation could not be completed": "無法完成操作",
"Remove messages sent by others": "移除其他人傳送的訊息"
"Remove messages sent by others": "移除其他人傳送的訊息",
"Calling...": "正在通話……",
"Call connecting...": "正在連線通話……",
"Starting camera...": "正在開啟攝影機……",
"Starting microphone...": "正在開啟麥克風……",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 所有伺服器都被禁止加入! 這間聊天室無法使用。",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s 為此房間更改了伺服器的存取控制列表。",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s 為此房間設置了伺服器的存取控制列表。",
"%(senderName)s declined the call.": "%(senderName)s 拒絕了通話。",
"(an error occurred)": "(遇到錯誤)",
"(their device couldn't start the camera / microphone)": "(他們的裝置無法開啟攝影機/麥克風)",
"(connection failed)": "(連線失敗)",
"The call could not be established": "無法建立通話",
"The other party declined the call.": "對方拒絕了電話。",
"Call Declined": "通話已拒絕"
}

View file

@ -105,10 +105,13 @@ export default abstract class BaseEventIndexManager {
/**
* Initialize the event index for the given user.
*
* @param {string} userId The event that should be added to the index.
* @param {string} deviceId The profile of the event sender at the
*
* @return {Promise} A promise that will resolve when the event index is
* initialized.
*/
async initEventIndex(): Promise<void> {
async initEventIndex(userId: string, deviceId: string): Promise<void> {
throw new Error("Unimplemented");
}

View file

@ -21,6 +21,7 @@ limitations under the License.
import PlatformPeg from "../PlatformPeg";
import EventIndex from "../indexing/EventIndex";
import {MatrixClientPeg} from "../MatrixClientPeg";
import SettingsStore from '../settings/SettingsStore';
import {SettingLevel} from "../settings/SettingLevel";
@ -70,9 +71,13 @@ class EventIndexPeg {
async initEventIndex() {
const index = new EventIndex();
const indexManager = PlatformPeg.get().getEventIndexingManager();
const client = MatrixClientPeg.get();
const userId = client.getUserId();
const deviceId = client.getDeviceId();
try {
await indexManager.initEventIndex();
await indexManager.initEventIndex(userId, deviceId);
const userVersion = await indexManager.getUserVersion();
const eventIndexIsEmpty = await indexManager.isEventIndexEmpty();
@ -83,7 +88,7 @@ class EventIndexPeg {
await indexManager.closeEventIndex();
await this.deleteEventIndex();
await indexManager.initEventIndex();
await indexManager.initEventIndex(userId, deviceId);
await indexManager.setUserVersion(INDEX_VERSION);
}

View file

@ -120,7 +120,7 @@ export class IntegrationManagers {
if (!data) return;
const uiUrl = w.content['url'];
const apiUrl = data['api_url'];
const apiUrl = data['api_url'] as string;
if (!apiUrl || !uiUrl) return;
const manager = new IntegrationManagerInstance(

View file

@ -96,7 +96,7 @@ function safeCounterpartTranslate(text: string, options?: object) {
return translated;
}
interface IVariables {
export interface IVariables {
count?: number;
[key: string]: number | string;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,9 +16,16 @@ limitations under the License.
import FixedDistributor from "./fixed";
import ResizeItem from "../item";
import Resizer, {IConfig} from "../resizer";
import Sizer from "../sizer";
class CollapseItem extends ResizeItem {
notifyCollapsed(collapsed) {
export interface ICollapseConfig extends IConfig {
toggleSize: number;
onCollapsed?(collapsed: boolean, id: string, element: HTMLElement): void;
}
class CollapseItem extends ResizeItem<ICollapseConfig> {
notifyCollapsed(collapsed: boolean) {
const callback = this.resizer.config.onCollapsed;
if (callback) {
callback(collapsed, this.id, this.domNode);
@ -26,18 +33,20 @@ class CollapseItem extends ResizeItem {
}
}
export default class CollapseDistributor extends FixedDistributor {
static createItem(resizeHandle, resizer, sizer) {
export default class CollapseDistributor extends FixedDistributor<ICollapseConfig, CollapseItem> {
static createItem(resizeHandle: HTMLDivElement, resizer: Resizer<ICollapseConfig>, sizer: Sizer) {
return new CollapseItem(resizeHandle, resizer, sizer);
}
constructor(item, config) {
private readonly toggleSize: number;
private isCollapsed = false;
constructor(item: CollapseItem) {
super(item);
this.toggleSize = config && config.toggleSize;
this.isCollapsed = false;
this.toggleSize = item.resizer?.config?.toggleSize;
}
resize(newSize) {
public resize(newSize: number) {
const isCollapsedSize = newSize < this.toggleSize;
if (isCollapsedSize && !this.isCollapsed) {
this.isCollapsed = true;

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +16,7 @@ limitations under the License.
import ResizeItem from "../item";
import Sizer from "../sizer";
import Resizer, {IConfig} from "../resizer";
/**
distributors translate a moving cursor into
@ -27,29 +28,42 @@ they have two methods:
within the container bounding box. For internal use.
This method usually ends up calling `resize` once the start offset is subtracted.
*/
export default class FixedDistributor {
static createItem(resizeHandle, resizer, sizer) {
export default class FixedDistributor<C extends IConfig, I extends ResizeItem<any> = ResizeItem<C>> {
static createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem {
return new ResizeItem(resizeHandle, resizer, sizer);
}
static createSizer(containerElement, vertical, reverse) {
static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer {
return new Sizer(containerElement, vertical, reverse);
}
constructor(item) {
this.item = item;
private readonly beforeOffset: number;
constructor(public readonly item: I) {
this.beforeOffset = item.offset();
}
resize(size) {
public get size() {
return this.item.getSize();
}
public set size(size: string) {
this.item.setRawSize(size);
}
public resize(size: number) {
this.item.setSize(size);
}
resizeFromContainerOffset(offset) {
public resizeFromContainerOffset(offset: number) {
this.resize(offset - this.beforeOffset);
}
start() {}
public start() {
this.item.start();
}
finish() {}
public finish() {
this.item.finish();
}
}

View file

@ -0,0 +1,49 @@
/*
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 Sizer from "../sizer";
import FixedDistributor from "./fixed";
import {IConfig} from "../resizer";
class PercentageSizer extends Sizer {
public start(item: HTMLElement) {
if (this.vertical) {
item.style.minHeight = null;
} else {
item.style.minWidth = null;
}
}
public finish(item: HTMLElement) {
const parent = item.offsetParent as HTMLElement;
if (!parent) return;
if (this.vertical) {
const p = ((item.offsetHeight / parent.offsetHeight) * 100).toFixed(2) + "%";
item.style.minHeight = p;
item.style.height = p;
} else {
const p = ((item.offsetWidth / parent.offsetWidth) * 100).toFixed(2) + "%";
item.style.minWidth = p;
item.style.width = p;
}
}
}
export default class PercentageDistributor extends FixedDistributor<IConfig> {
static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean) {
return new PercentageSizer(containerElement, vertical, reverse);
}
}

View file

@ -1,5 +1,4 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
@ -15,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export FixedDistributor from "./distributors/fixed";
export CollapseDistributor from "./distributors/collapse";
export Resizer from "./resizer";
export {default as FixedDistributor} from "./distributors/fixed";
export {default as PercentageDistributor} from "./distributors/percentage";
export {default as CollapseDistributor} from "./distributors/collapse";
export {default as Resizer} from "./resizer";

View file

@ -1,107 +0,0 @@
/*
Copyright 2019 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.
*/
export default class ResizeItem {
constructor(handle, resizer, sizer) {
const id = handle.getAttribute("data-id");
const reverse = resizer.isReverseResizeHandle(handle);
const domNode = reverse ? handle.nextElementSibling : handle.previousElementSibling;
this.domNode = domNode;
this.id = id;
this.reverse = reverse;
this.resizer = resizer;
this.sizer = sizer;
}
_copyWith(handle, resizer, sizer) {
const Ctor = this.constructor;
return new Ctor(handle, resizer, sizer);
}
_advance(forwards) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ?
this.domNode.previousElementSibling :
this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
if (moveNext) {
handle = handle.nextElementSibling;
} else {
handle = handle.previousElementSibling;
}
} while (handle && !this.resizer.isResizeHandle(handle));
if (handle) {
const nextHandle = this._copyWith(handle, this.resizer, this.sizer);
nextHandle.reverse = this.reverse;
return nextHandle;
}
}
next() {
return this._advance(true);
}
previous() {
return this._advance(false);
}
size() {
return this.sizer.getItemSize(this.domNode);
}
offset() {
return this.sizer.getItemOffset(this.domNode);
}
setSize(size) {
this.sizer.setItemSize(this.domNode, size);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
}
}
clearSize() {
this.sizer.clearItemSize(this.domNode);
const callback = this.resizer.config.onResized;
if (callback) {
callback(null, this.id, this.domNode);
}
}
first() {
const firstHandle = Array.from(this.domNode.parentElement.children).find(el => {
return this.resizer.isResizeHandle(el);
});
if (firstHandle) {
return this._copyWith(firstHandle, this.resizer, this.sizer);
}
}
last() {
const lastHandle = Array.from(this.domNode.parentElement.children).reverse().find(el => {
return this.resizer.isResizeHandle(el);
});
if (lastHandle) {
return this._copyWith(lastHandle, this.resizer, this.sizer);
}
}
}

125
src/resizer/item.ts Normal file
View file

@ -0,0 +1,125 @@
/*
Copyright 2019 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 Resizer, {IConfig} from "./resizer";
import Sizer from "./sizer";
export default class ResizeItem<C extends IConfig = IConfig> {
public readonly domNode: HTMLElement;
protected readonly id: string;
protected reverse: boolean;
constructor(
handle: HTMLElement,
public readonly resizer: Resizer<C>,
public readonly sizer: Sizer,
) {
this.reverse = resizer.isReverseResizeHandle(handle);
this.domNode = <HTMLElement>(this.reverse ? handle.nextElementSibling : handle.previousElementSibling);
this.id = handle.getAttribute("data-id");
}
private copyWith(handle: HTMLElement, resizer: Resizer, sizer: Sizer) {
const Ctor = this.constructor as typeof ResizeItem;
return new Ctor(handle, resizer, sizer);
}
private advance(forwards: boolean) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ? this.domNode.previousElementSibling : this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
if (moveNext) {
handle = handle.nextElementSibling;
} else {
handle = handle.previousElementSibling;
}
} while (handle && !this.resizer.isResizeHandle(<HTMLElement>handle));
if (handle) {
const nextHandle = this.copyWith(<HTMLElement>handle, this.resizer, this.sizer);
nextHandle.reverse = this.reverse;
return nextHandle;
}
}
public next() {
return this.advance(true);
}
public previous() {
return this.advance(false);
}
public size() {
return this.sizer.getItemSize(this.domNode);
}
public offset() {
return this.sizer.getItemOffset(this.domNode);
}
public start() {
this.sizer.start(this.domNode);
}
public finish() {
this.sizer.finish(this.domNode);
}
public getSize() {
return this.sizer.getDesiredItemSize(this.domNode);
}
public setRawSize(size: string) {
this.sizer.setItemSize(this.domNode, size);
}
public setSize(size: number) {
this.setRawSize(`${Math.round(size)}px`);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
}
}
public clearSize() {
this.sizer.clearItemSize(this.domNode);
const callback = this.resizer.config.onResized;
if (callback) {
callback(null, this.id, this.domNode);
}
}
public first() {
const firstHandle = Array.from(this.domNode.parentElement.children).find(el => {
return this.resizer.isResizeHandle(<HTMLElement>el);
});
if (firstHandle) {
return this.copyWith(<HTMLElement>firstHandle, this.resizer, this.sizer);
}
}
public last() {
const lastHandle = Array.from(this.domNode.parentElement.children).reverse().find(el => {
return this.resizer.isResizeHandle(<HTMLElement>el);
});
if (lastHandle) {
return this.copyWith(<HTMLElement>lastHandle, this.resizer, this.sizer);
}
}
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 - 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,86 +14,105 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/*
classNames:
import {throttle} from "lodash";
import FixedDistributor from "./distributors/fixed";
import ResizeItem from "./item";
import Sizer from "./sizer";
interface IClassNames {
// class on resize-handle
handle: string
handle?: string;
// class on resize-handle
reverse: string
reverse?: string;
// class on resize-handle
vertical: string
vertical?: string;
// class on container
resizing: string
*/
resizing?: string;
}
export interface IConfig {
onResizeStart?(): void;
onResizeStop?(): void;
onResized?(size: number, id: string, element: HTMLElement): void;
}
export default class Resizer<C extends IConfig = IConfig> {
private classNames: IClassNames;
export default class Resizer {
// TODO move vertical/horizontal to config option/container class
// as it doesn't make sense to mix them within one container/Resizer
constructor(container, distributorCtor, config) {
constructor(
public container: HTMLElement,
private readonly distributorCtor: {
new(item: ResizeItem): FixedDistributor<C, any>;
createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem;
createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer;
},
public readonly config?: C,
) {
if (!container) {
throw new Error("Resizer requires a non-null `container` arg");
}
this.container = container;
this.distributorCtor = distributorCtor;
this.config = config;
this.classNames = {
handle: "resizer-handle",
reverse: "resizer-reverse",
vertical: "resizer-vertical",
resizing: "resizer-resizing",
};
this._onMouseDown = this._onMouseDown.bind(this);
}
setClassNames(classNames) {
public setClassNames(classNames: IClassNames) {
this.classNames = classNames;
}
attach() {
this.container.addEventListener("mousedown", this._onMouseDown, false);
public attach() {
this.container.addEventListener("mousedown", this.onMouseDown, false);
window.addEventListener("resize", this.onResize);
}
detach() {
this.container.removeEventListener("mousedown", this._onMouseDown, false);
public detach() {
this.container.removeEventListener("mousedown", this.onMouseDown, false);
window.removeEventListener("resize", this.onResize);
}
/**
Gives the distributor for a specific resize handle, as if you would have started
to drag that handle. Can be used to manipulate the size of an item programmatically.
@param {number} handleIndex the index of the resize handle in the container
@return {Distributor} a new distributor for the given handle
@return {FixedDistributor} a new distributor for the given handle
*/
forHandleAt(handleIndex) {
const handles = this._getResizeHandles();
public forHandleAt(handleIndex: number): FixedDistributor<C> {
const handles = this.getResizeHandles();
const handle = handles[handleIndex];
if (handle) {
const {distributor} = this._createSizerAndDistributor(handle);
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
}
}
forHandleWithId(id) {
const handles = this._getResizeHandles();
public forHandleWithId(id: string): FixedDistributor<C> {
const handles = this.getResizeHandles();
const handle = handles.find((h) => h.getAttribute("data-id") === id);
if (handle) {
const {distributor} = this._createSizerAndDistributor(handle);
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
}
}
isReverseResizeHandle(el) {
public isReverseResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.reverse);
}
isResizeHandle(el) {
public isResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.handle);
}
_onMouseDown(event) {
private onMouseDown = (event: MouseEvent) => {
// use closest in case the resize handle contains
// child dom nodes that can be the target
const resizeHandle = event.target && event.target.closest(`.${this.classNames.handle}`);
const resizeHandle = event.target && (<HTMLDivElement>event.target).closest(`.${this.classNames.handle}`);
if (!resizeHandle || resizeHandle.parentElement !== this.container) {
return;
}
@ -109,7 +127,7 @@ export default class Resizer {
this.config.onResizeStart();
}
const {sizer, distributor} = this._createSizerAndDistributor(resizeHandle);
const {sizer, distributor} = this.createSizerAndDistributor(<HTMLDivElement>resizeHandle);
distributor.start();
const onMouseMove = (event) => {
@ -122,10 +140,10 @@ export default class Resizer {
if (this.classNames.resizing) {
this.container.classList.remove(this.classNames.resizing);
}
distributor.finish();
if (this.config.onResizeStop) {
this.config.onResizeStop();
}
distributor.finish();
body.removeEventListener("mouseup", finishResize, false);
document.removeEventListener("mouseleave", finishResize, false);
body.removeEventListener("mousemove", onMouseMove, false);
@ -133,21 +151,39 @@ export default class Resizer {
body.addEventListener("mouseup", finishResize, false);
document.addEventListener("mouseleave", finishResize, false);
body.addEventListener("mousemove", onMouseMove, false);
}
};
_createSizerAndDistributor(resizeHandle) {
private onResize = throttle(() => {
const distributors = this.getDistributors();
// relax all items if they had any overconstrained flexboxes
distributors.forEach(d => d.start());
distributors.forEach(d => d.finish());
}, 100, {trailing: true, leading: true});
public getDistributors = () => {
return this.getResizeHandles().map(handle => {
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
});
};
private createSizerAndDistributor(
resizeHandle: HTMLDivElement,
): {sizer: Sizer, distributor: FixedDistributor<any>} {
const vertical = resizeHandle.classList.contains(this.classNames.vertical);
const reverse = this.isReverseResizeHandle(resizeHandle);
const Distributor = this.distributorCtor;
const sizer = Distributor.createSizer(this.container, vertical, reverse);
const item = Distributor.createItem(resizeHandle, this, sizer);
const distributor = new Distributor(item, this.config);
const distributor = new Distributor(item);
return {sizer, distributor};
}
_getResizeHandles() {
private getResizeHandles() {
if (!this.container.children) return [];
return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(el);
});
return this.isResizeHandle(<HTMLElement>el);
}) as HTMLElement[];
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 - 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.
@ -19,18 +19,18 @@ implements DOM/CSS operations for resizing.
The sizer determines what CSS mechanism is used for sizing items, like flexbox, ...
*/
export default class Sizer {
constructor(container, vertical, reverse) {
this.container = container;
this.reverse = reverse;
this.vertical = vertical;
}
constructor(
protected readonly container: HTMLElement,
protected readonly vertical: boolean,
protected readonly reverse: boolean,
) {}
/**
@param {Element} item the dom element being resized
@return {number} how far the edge of the item is from the edge of the container
*/
getItemOffset(item) {
const offset = (this.vertical ? item.offsetTop : item.offsetLeft) - this._getOffset();
public getItemOffset(item: HTMLElement): number {
const offset = (this.vertical ? item.offsetTop : item.offsetLeft) - this.getOffset();
if (this.reverse) {
return this.getTotalSize() - (offset + this.getItemSize(item));
} else {
@ -42,41 +42,49 @@ export default class Sizer {
@param {Element} item the dom element being resized
@return {number} the width/height of an item in the container
*/
getItemSize(item) {
public getItemSize(item: HTMLElement): number {
return this.vertical ? item.offsetHeight : item.offsetWidth;
}
/** @return {number} the width/height of the container */
getTotalSize() {
public getTotalSize(): number {
return this.vertical ? this.container.offsetHeight : this.container.offsetWidth;
}
/** @return {number} container offset to offsetParent */
_getOffset() {
private getOffset(): number {
return this.vertical ? this.container.offsetTop : this.container.offsetLeft;
}
/** @return {number} container offset to document */
_getPageOffset() {
private getPageOffset(): number {
let element = this.container;
let offset = 0;
while (element) {
const pos = this.vertical ? element.offsetTop : element.offsetLeft;
offset = offset + pos;
element = element.offsetParent;
element = <HTMLElement>element.offsetParent;
}
return offset;
}
setItemSize(item, size) {
public getDesiredItemSize(item: HTMLElement) {
if (this.vertical) {
item.style.height = `${Math.round(size)}px`;
return item.style.height;
} else {
item.style.width = `${Math.round(size)}px`;
return item.style.width;
}
}
clearItemSize(item) {
public setItemSize(item: HTMLElement, size: string) {
if (this.vertical) {
item.style.height = size;
} else {
item.style.width = size;
}
}
public clearItemSize(item: HTMLElement) {
if (this.vertical) {
item.style.height = null;
} else {
@ -84,17 +92,21 @@ export default class Sizer {
}
}
public start(item: HTMLElement) {}
public finish(item: HTMLElement) {}
/**
@param {MouseEvent} event the mouse event
@return {number} the distance between the cursor and the edge of the container,
along the applicable axis (vertical or horizontal)
*/
offsetFromEvent(event) {
public offsetFromEvent(event: MouseEvent) {
const pos = this.vertical ? event.pageY : event.pageX;
if (this.reverse) {
return (this._getPageOffset() + this.getTotalSize()) - pos;
return (this.getPageOffset() + this.getTotalSize()) - pos;
} else {
return pos - this._getPageOffset();
return pos - this.getPageOffset();
}
}
}

Some files were not shown because too many files have changed in this diff Show more