Merge remote-tracking branch 'origin/develop' into florianduros/fix-white-black-theme-switch

This commit is contained in:
Florian Duros 2022-10-06 10:08:02 +02:00
commit ba783b8441
No known key found for this signature in database
GPG Key ID: 9700AA5870258A0B
131 changed files with 4015 additions and 1215 deletions

View File

@ -27,9 +27,9 @@ jobs:
run: "yarn run lint:types"
- name: Switch js-sdk to release mode
working-directory: node_modules/matrix-js-sdk
run: |
scripts/ci/js-sdk-to-release.js
cd node_modules/matrix-js-sdk
scripts/switch_package_to_release.js
yarn install
yarn run build:compile
yarn run build:types

View File

@ -1,3 +1,18 @@
Changes in [3.57.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.57.0) (2022-09-28)
=====================================================================================================
## 🐛 Bug Fixes
* Bump IDB crypto store version ([\#2705](https://github.com/matrix-org/matrix-js-sdk/pull/2705)).
Changes in [3.56.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.56.0) (2022-09-28)
=====================================================================================================
## 🔒 Security
* Fix for [CVE-2022-39249](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39249)
* Fix for [CVE-2022-39250](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39250)
* Fix for [CVE-2022-39251](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39251)
* Fix for [CVE-2022-39236](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39236)
Changes in [3.55.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.55.0) (2022-09-20)
===============================================================================================================

View File

@ -125,7 +125,7 @@ Cypress.Commands.add("startDM", (name: string) => {
.should("have.focus")
.type("Hey!{enter}");
cy.contains(".mx_EventTile_body", "Hey!");
cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name);
cy.contains(".mx_RoomSublist[aria-label=People]", name);
});
describe("Spotlight", () => {
@ -365,7 +365,10 @@ describe("Spotlight", () => {
cy.spotlightSearch().clear().type(bot1.getUserId());
cy.wait(1000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 2);
cy.spotlightResults().eq(0).should("contain", groupDm.name);
cy.contains(
".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option",
groupDm.name,
);
});
// Search for ByteBot by id, should return group DM and user
@ -374,7 +377,10 @@ describe("Spotlight", () => {
cy.spotlightSearch().clear().type(bot2.getUserId());
cy.wait(1000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 2);
cy.spotlightResults().eq(0).should("contain", groupDm.name);
cy.contains(
".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option",
groupDm.name,
);
});
});
});

View File

@ -0,0 +1,121 @@
/*
Copyright 2022 Oliver Sand
Copyright 2022 Nordeck IT + Consulting GmbH.
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 { IWidget } from "matrix-widget-api";
import { SynapseInstance } from "../../plugins/synapsedocker";
const ROOM_NAME = 'Test Room';
const WIDGET_ID = "fake-widget";
const WIDGET_HTML = `
<html lang="en">
<head>
<title>Fake Widget</title>
</head>
<body>
Hello World
</body>
</html>
`;
describe('Widget Layout', () => {
let widgetUrl: string;
let synapse: SynapseInstance;
let roomId: string;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Sally");
});
cy.serveHtmlFile(WIDGET_HTML).then(url => {
widgetUrl = url;
});
cy.createRoom({
name: ROOM_NAME,
}).then((id) => {
roomId = id;
// setup widget via state event
cy.getClient().then(async matrixClient => {
const content: IWidget = {
id: WIDGET_ID,
creatorUserId: 'somebody',
type: 'widget',
name: 'widget',
url: widgetUrl,
};
await matrixClient.sendStateEvent(roomId, 'im.vector.modular.widgets', content, WIDGET_ID);
}).as('widgetEventSent');
// set initial layout
cy.getClient().then(async matrixClient => {
const content = {
widgets: {
[WIDGET_ID]: {
container: 'top', index: 1, width: 100, height: 0,
},
},
};
await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, "");
}).as('layoutEventSent');
});
cy.all([
cy.get<string>("@widgetEventSent"),
cy.get<string>("@layoutEventSent"),
]).then(() => {
// open the room
cy.viewRoomByName(ROOM_NAME);
});
});
afterEach(() => {
cy.stopSynapse(synapse);
cy.stopWebServers();
});
it('manually resize the height of the top container layout', () => {
cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250);
cy.get('.mx_AppsContainer_resizerHandle')
.trigger('mousedown')
.trigger('mousemove', { clientX: 0, clientY: 550, force: true })
.trigger('mouseup', { clientX: 0, clientY: 550, force: true });
cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400);
});
it('programatically resize the height of the top container layout', () => {
cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250);
cy.getClient().then(async matrixClient => {
const content = {
widgets: {
[WIDGET_ID]: {
container: 'top', index: 1, width: 100, height: 100,
},
},
};
await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, "");
});
cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400);
});
});

View File

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "3.55.0",
"version": "3.57.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -61,6 +61,7 @@
"@sentry/browser": "^6.11.0",
"@sentry/tracing": "^6.11.0",
"@types/geojson": "^7946.0.8",
"@types/ua-parser-js": "^0.7.36",
"await-lock": "^2.1.0",
"blurhash": "^1.1.3",
"browser-request": "^0.3.3",
@ -112,6 +113,7 @@
"rfc4648": "^1.4.0",
"sanitize-html": "^2.3.2",
"tar-js": "^0.3.0",
"ua-parser-js": "^1.0.2",
"url": "^0.11.0",
"what-input": "^5.2.10",
"zxcvbn": "^4.4.2"

View File

@ -34,8 +34,9 @@
@import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss";
@import "./components/views/settings/devices/_DeviceSecurityCard.pcss";
@import "./components/views/settings/devices/_DeviceTile.pcss";
@import "./components/views/settings/devices/_DeviceType.pcss";
@import "./components/views/settings/devices/_DeviceTypeIcon.pcss";
@import "./components/views/settings/devices/_FilteredDeviceList.pcss";
@import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss";
@import "./components/views/settings/devices/_SecurityRecommendations.pcss";
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
@ -209,6 +210,7 @@
@import "./views/elements/_Validation.pcss";
@import "./views/emojipicker/_EmojiPicker.pcss";
@import "./views/location/_LocationPicker.pcss";
@import "./views/messages/_CallEvent.pcss";
@import "./views/messages/_CreateEvent.pcss";
@import "./views/messages/_DateSeparator.pcss";
@import "./views/messages/_DisambiguatedProfile.pcss";
@ -263,6 +265,7 @@
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";
@import "./views/rooms/_LiveContentSummary.pcss";
@import "./views/rooms/_MemberInfo.pcss";
@import "./views/rooms/_MemberList.pcss";
@import "./views/rooms/_MessageComposer.pcss";
@ -284,7 +287,6 @@
@import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomTileCallSummary.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
@import "./views/rooms/_SearchBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss";
@ -346,6 +348,7 @@
@import "./views/user-onboarding/_UserOnboardingTask.pcss";
@import "./views/verification/_VerificationShowSas.pcss";
@import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss";
@import "./views/voip/_CallDuration.pcss";
@import "./views/voip/_CallView.pcss";
@import "./views/voip/_DialPad.pcss";
@import "./views/voip/_DialPadContextMenu.pcss";

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_DeviceType {
.mx_DeviceTypeIcon {
flex: 0 0 auto;
position: relative;
margin-right: $spacing-8;
@ -22,7 +22,7 @@ limitations under the License.
padding: 0 $spacing-8 $spacing-8 0;
}
.mx_DeviceType_deviceIcon {
.mx_DeviceTypeIcon_deviceIcon {
--background-color: $system;
--icon-color: $secondary-content;
@ -36,12 +36,12 @@ limitations under the License.
background-color: var(--background-color);
}
.mx_DeviceType_selected .mx_DeviceType_deviceIcon {
.mx_DeviceTypeIcon_selected .mx_DeviceTypeIcon_deviceIcon {
--background-color: $primary-content;
--icon-color: $background;
}
.mx_DeviceType_verificationIcon {
.mx_DeviceTypeIcon_verificationIcon {
position: absolute;
bottom: 0;
right: 0;

View File

@ -20,32 +20,12 @@ limitations under the License.
}
}
.mx_FilteredDeviceList_header {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
width: 100%;
height: 48px;
padding: 0 $spacing-16;
margin-bottom: $spacing-32;
background-color: $system;
border-radius: 8px;
color: $secondary-content;
}
.mx_FilteredDeviceList_headerLabel {
flex: 1 1 100%;
}
.mx_FilteredDeviceList_list {
list-style-type: none;
display: grid;
grid-gap: $spacing-16;
margin: 0;
padding: 0 $spacing-8;
padding: 0 $spacing-16;
}
.mx_FilteredDeviceList_listItem {
@ -62,3 +42,7 @@ limitations under the License.
text-align: center;
margin-bottom: $spacing-32;
}
.mx_FilteredDeviceList_headerButton {
flex-shrink: 0;
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2022 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.
*/
.mx_FilteredDeviceListHeader {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
gap: $spacing-16;
width: 100%;
height: 48px;
padding: 0 $spacing-16;
margin-bottom: $spacing-32;
background-color: $system;
border-radius: 8px;
color: $secondary-content;
}
.mx_FilteredDeviceListHeader_label {
flex: 1 1 100%;
}

View File

@ -139,7 +139,8 @@ limitations under the License.
&.mx_AccessibleButton_kind_link,
&.mx_AccessibleButton_kind_link_inline,
&.mx_AccessibleButton_kind_danger_inline {
&.mx_AccessibleButton_kind_danger_inline,
&.mx_AccessibleButton_kind_content_inline {
font-size: inherit;
font-weight: normal;
line-height: inherit;
@ -155,8 +156,13 @@ limitations under the License.
color: $alert;
}
&.mx_AccessibleButton_kind_content_inline {
color: $primary-content;
}
&.mx_AccessibleButton_kind_link_inline,
&.mx_AccessibleButton_kind_danger_inline {
&.mx_AccessibleButton_kind_danger_inline,
&.mx_AccessibleButton_kind_content_inline {
display: inline;
}

View File

@ -22,6 +22,7 @@ limitations under the License.
display: inline-flex;
flex-direction: row-reverse;
vertical-align: middle;
margin: 0 -1px; /* to cancel out the border on the edges */
/* Overlap the children */
> * + * {

View File

@ -0,0 +1,77 @@
/*
Copyright 2022 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.
*/
.mx_CallEvent_wrapper {
display: flex;
width: 100%;
}
.mx_CallEvent {
padding: 12px;
box-sizing: border-box;
min-height: 60px;
max-width: 600px;
width: 100%;
background-color: $system;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-8;
.mx_CallEvent_title {
font-size: $font-15px;
line-height: 24px; /* in px to match the avatar */
}
&.mx_CallEvent_inactive .mx_CallEvent_title::before {
display: inline-block;
vertical-align: middle;
content: '';
background-color: $secondary-content;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
mask-size: 16px;
width: 16px;
height: 16px;
margin-right: 8px;
}
&.mx_CallEvent_active .mx_CallEvent_title {
font-weight: 600;
}
> .mx_BaseAvatar {
align-self: flex-start;
}
> .mx_CallEvent_infoRows {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
> .mx_CallDuration {
padding: $spacing-4;
}
> .mx_CallEvent_button {
box-sizing: border-box;
min-width: 120px;
}
}

View File

@ -523,7 +523,8 @@ limitations under the License.
max-width: 100%;
}
.mx_LegacyCallEvent_wrapper {
.mx_LegacyCallEvent_wrapper,
.mx_CallEvent_wrapper {
justify-content: center;
}
}

View File

@ -14,21 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_RoomTileCallSummary {
.mx_RoomTileCallSummary_text {
.mx_LiveContentSummary {
color: $secondary-content;
.mx_LiveContentSummary_text {
&::before {
display: inline-block;
vertical-align: text-bottom;
content: '';
background-color: $secondary-content;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
mask-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
}
&.mx_RoomTileCallSummary_text_active {
&.mx_LiveContentSummary_text_video::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
&.mx_LiveContentSummary_text_active {
color: $accent;
&::before {
@ -37,7 +42,7 @@ limitations under the License.
}
}
.mx_RoomTileCallSummary_participants::before {
.mx_LiveContentSummary_participants::before {
display: inline-block;
vertical-align: text-bottom;
content: '';

View File

@ -58,7 +58,7 @@ limitations under the License.
min-height: 35px;
padding: 0 $spacing-8;
.mx_DeviceType {
.mx_DeviceTypeIcon {
/* hide the new device type in legacy device list
for backwards compat reasons */
display: none;

View File

@ -0,0 +1,20 @@
/*
Copyright 2022 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.
*/
.mx_CallDuration {
color: $secondary-content;
font-size: $font-12px;
}

View File

@ -1,17 +0,0 @@
#!/usr/bin/env node
const fsProm = require('fs/promises');
const PKGJSON = 'node_modules/matrix-js-sdk/package.json';
async function main() {
const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8'));
for (const field of ['main', 'typings']) {
if (pkgJson["matrix_lib_"+field] !== undefined) {
pkgJson[field] = pkgJson["matrix_lib_"+field];
}
}
await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2));
}
main();

View File

@ -49,3 +49,8 @@ export type KeysWithObjectShape<Input> = {
? (Input[P] extends Array<unknown> ? never : P)
: never;
}[keyof Input];
export type KeysStartingWith<Input extends object, Str extends string> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X
}[keyof Input];

View File

@ -40,6 +40,10 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { isLoggedIn } from "./utils/login";
import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg";
import { recordClientInformation } from "./utils/device/clientInformation";
import SettingsStore, { CallbackFn } from "./settings/SettingsStore";
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
@ -60,6 +64,8 @@ export default class DeviceListener {
// The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>();
private running = false;
private shouldRecordClientInformation = false;
private deviceClientInformationSettingWatcherRef: string | undefined;
public static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
@ -76,8 +82,15 @@ export default class DeviceListener {
MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData);
MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents);
this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn');
this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting(
'deviceClientInformationOptIn',
null,
this.onRecordClientInformationSettingChange,
);
this.dispatcherRef = dis.register(this.onAction);
this.recheck();
this.recordClientInformation();
}
public stop() {
@ -95,6 +108,9 @@ export default class DeviceListener {
MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
if (this.deviceClientInformationSettingWatcherRef) {
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
}
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
this.dispatcherRef = null;
@ -200,6 +216,7 @@ export default class DeviceListener {
private onAction = ({ action }: ActionPayload) => {
if (action !== Action.OnLoggedIn) return;
this.recheck();
this.recordClientInformation();
};
// The server doesn't tell us when key backup is set up, so we poll
@ -343,4 +360,33 @@ export default class DeviceListener {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
private onRecordClientInformationSettingChange: CallbackFn = (
_originalSettingName, _roomId, _level, _newLevel, newValue,
) => {
const prevValue = this.shouldRecordClientInformation;
this.shouldRecordClientInformation = !!newValue;
if (this.shouldRecordClientInformation && !prevValue) {
this.recordClientInformation();
}
};
private recordClientInformation = async () => {
if (!this.shouldRecordClientInformation) {
return;
}
try {
await recordClientInformation(
MatrixClientPeg.get(),
SdkConfig.get(),
PlatformPeg.get(),
);
} catch (error) {
// this is a best effort operation
// log the error without rethrowing
logger.error('Failed to record client information', error);
}
};
}

View File

@ -179,9 +179,6 @@ export interface IConfigOptions {
sync_timeline_limit?: number;
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
// XXX: Undocumented URL for the "Learn more about spaces" link in the "Communities don't exist" messaging.
spaces_learn_more_url?: string;
}
export interface ISsoRedirectOptions {

View File

@ -50,10 +50,20 @@ export default class MediaDeviceHandler extends EventEmitter {
return devices.some(d => Boolean(d.label));
}
/**
* Gets the available audio input/output and video input devices
* from the browser: a thin wrapper around mediaDevices.enumerateDevices()
* that also returns results by type of devices. Note that this requires
* user media permissions and an active stream, otherwise you'll get blank
* device labels.
*
* Once the Permissions API
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
* is ready for primetime, it might help make this simpler.
*
* @return Promise<IMediaDevices> The available media devices
*/
public static async getDevices(): Promise<IMediaDevices> {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const output = {

View File

@ -46,6 +46,7 @@ import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
import { localNotificationsAreSilenced } from "./utils/notifications";
/*
* Dispatches:
@ -90,8 +91,9 @@ export const Notifier = {
return TextForEvent.textForEvent(ev);
},
_displayPopupNotification: function(ev: MatrixEvent, room: Room) {
_displayPopupNotification: function(ev: MatrixEvent, room: Room): void {
const plaf = PlatformPeg.get();
const cli = MatrixClientPeg.get();
if (!plaf) {
return;
}
@ -99,6 +101,10 @@ export const Notifier = {
return;
}
if (localNotificationsAreSilenced(cli)) {
return;
}
let msg = this.notificationMessageForEvent(ev);
if (!msg) return;
@ -170,7 +176,12 @@ export const Notifier = {
};
},
_playAudioNotification: async function(ev: MatrixEvent, room: Room) {
_playAudioNotification: async function(ev: MatrixEvent, room: Room): Promise<void> {
const cli = MatrixClientPeg.get();
if (localNotificationsAreSilenced(cli)) {
return;
}
const sound = this.getSoundForRoom(room.roomId);
logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
@ -325,7 +336,7 @@ export const Notifier = {
}
const isGuest = client.isGuest();
return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() &&
!this.isEnabled() && !this._isPromptHidden();
!this.isEnabled() && !this._isPromptHidden();
},
_isPromptHidden: function() {

View File

@ -20,7 +20,6 @@ enum PageType {
HomePage = "home_page",
RoomView = "room_view",
UserView = "user_view",
LegacyGroupView = "legacy_group_view",
}
export default PageType;

View File

@ -41,7 +41,6 @@ const loggedInPageTypeMap: Record<PageType, ScreenName> = {
[PageType.HomePage]: "Home",
[PageType.RoomView]: "Room",
[PageType.UserView]: "User",
[PageType.LegacyGroupView]: "Group",
};
export default class PosthogTrackers {

View File

@ -44,7 +44,6 @@ export const DEFAULTS: IConfigOptions = {
logo: require("../res/img/element-desktop-logo.svg").default,
url: "https://element.io/get-started",
},
spaces_learn_more_url: "https://element.io/blog/spaces-blast-out-of-beta/",
};
export default class SdkConfig {

View File

@ -339,6 +339,7 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
[KeyBindingAction.FormatQuote]: {
default: {
ctrlOrCmdKey: true,
shiftKey: true,
key: Key.GREATER_THAN,
},
displayName: _td("Toggle Quote"),

View File

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2022 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,7 +16,7 @@ limitations under the License.
*/
import FileSaver from 'file-saver';
import React, { createRef } from 'react';
import React from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { logger } from "matrix-js-sdk/src/logger";
@ -23,6 +24,8 @@ import { _t } from '../../../../languageHandler';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Field from "../../../../components/views/elements/Field";
import { KeysStartingWith } from "../../../../@types/common";
enum Phase {
Edit = "edit",
@ -36,12 +39,14 @@ interface IProps extends IDialogProps {
interface IState {
phase: Phase;
errStr: string;
passphrase1: string;
passphrase2: string;
}
type AnyPassphrase = KeysStartingWith<IState, "passphrase">;
export default class ExportE2eKeysDialog extends React.Component<IProps, IState> {
private unmounted = false;
private passphrase1 = createRef<HTMLInputElement>();
private passphrase2 = createRef<HTMLInputElement>();
constructor(props: IProps) {
super(props);
@ -49,6 +54,8 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
this.state = {
phase: Phase.Edit,
errStr: null,
passphrase1: "",
passphrase2: "",
};
}
@ -59,8 +66,8 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => {
ev.preventDefault();
const passphrase = this.passphrase1.current.value;
if (passphrase !== this.passphrase2.current.value) {
const passphrase = this.state.passphrase1;
if (passphrase !== this.state.passphrase2) {
this.setState({ errStr: _t('Passphrases must match') });
return false;
}
@ -112,6 +119,12 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
return false;
};
private onPassphraseChange = (ev: React.ChangeEvent<HTMLInputElement>, phrase: AnyPassphrase) => {
this.setState({
[phrase]: ev.target.value,
} as Pick<IState, AnyPassphrase>);
};
public render(): JSX.Element {
const disableForm = (this.state.phase === Phase.Exporting);
@ -146,36 +159,25 @@ export default class ExportE2eKeysDialog extends React.Component<IProps, IState>
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase1'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this.passphrase1}
id='passphrase1'
autoFocus={true}
size={64}
type='password'
disabled={disableForm}
/>
</div>
<Field
label={_t("Enter passphrase")}
value={this.state.passphrase1}
onChange={e => this.onPassphraseChange(e, "passphrase1")}
autoFocus={true}
size={64}
type="password"
disabled={disableForm}
/>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase2'>
{ _t("Confirm passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref={this.passphrase2}
id='passphrase2'
size={64}
type='password'
disabled={disableForm}
/>
</div>
<Field
label={_t("Confirm passphrase")}
value={this.state.passphrase2}
onChange={e => this.onPassphraseChange(e, "passphrase2")}
size={64}
type="password"
disabled={disableForm}
/>
</div>
</div>
</div>

View File

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2022 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.
@ -22,6 +23,7 @@ import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryptio
import { _t } from '../../../../languageHandler';
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
import Field from "../../../../components/views/elements/Field";
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
@ -48,12 +50,12 @@ interface IState {
enableSubmit: boolean;
phase: Phase;
errStr: string;
passphrase: string;
}
export default class ImportE2eKeysDialog extends React.Component<IProps, IState> {
private unmounted = false;
private file = createRef<HTMLInputElement>();
private passphrase = createRef<HTMLInputElement>();
constructor(props: IProps) {
super(props);
@ -62,6 +64,7 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
enableSubmit: false,
phase: Phase.Edit,
errStr: null,
passphrase: "",
};
}
@ -69,16 +72,22 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
this.unmounted = true;
}
private onFormChange = (ev: React.FormEvent): void => {
private onFormChange = (): void => {
const files = this.file.current.files || [];
this.setState({
enableSubmit: (this.passphrase.current.value !== "" && files.length > 0),
enableSubmit: (this.state.passphrase !== "" && files.length > 0),
});
};
private onPassphraseChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ passphrase: ev.target.value });
this.onFormChange(); // update general form state too
};
private onFormSubmit = (ev: React.FormEvent): boolean => {
ev.preventDefault();
this.startImport(this.file.current.files[0], this.passphrase.current.value);
// noinspection JSIgnoredPromiseFromCall
this.startImport(this.file.current.files[0], this.state.passphrase);
return false;
};
@ -161,20 +170,14 @@ export default class ImportE2eKeysDialog extends React.Component<IProps, IState>
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'>
{ _t("Enter passphrase") }
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input
ref={this.passphrase}
id='passphrase'
size={64}
type='password'
onChange={this.onFormChange}
disabled={disableForm} />
</div>
<Field
label={_t("Enter passphrase")}
value={this.state.passphrase}
onChange={this.onPassphraseChange}
size={64}
type="password"
disabled={disableForm}
/>
</div>
</div>
</div>

View File

@ -1,51 +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 * as React from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import { _t } from "../../languageHandler";
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
interface IProps {
groupId: string;
}
const LegacyGroupView: React.FC<IProps> = ({ groupId }) => {
// XXX: Stealing classes from the HomePage component for CSS simplicity.
// XXX: Inline CSS because this is all temporary
const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url;
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
<div className="mx_HomePage_default_wrapper">
<h1 style={{ fontSize: '24px' }}>{ _t("That link is no longer supported") }</h1>
<p>
{ _t(
"You're trying to access a community link (%(groupId)s).<br/>" +
"Communities are no longer supported and have been replaced by spaces.<br2/>" +
"<a>Learn more about spaces here.</a>",
{ groupId },
{
br: () => <br />,
br2: () => <br />,
a: (sub) => <a href={learnMoreUrl} rel="noreferrer noopener" target="_blank">{ sub }</a>,
},
) }
</p>
</div>
</AutoHideScrollbar>;
};
export default LegacyGroupView;

View File

@ -67,7 +67,6 @@ import RightPanelStore from '../../stores/right-panel/RightPanelStore';
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import LegacyGroupView from "./LegacyGroupView";
import { IConfigOptions } from "../../IConfigOptions";
import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning';
import { UserOnboardingPage } from '../views/user-onboarding/UserOnboardingPage';
@ -103,8 +102,6 @@ interface IProps {
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
forceTimeline?: boolean; // see props on MatrixChat
currentGroupId?: string;
}
interface IState {
@ -641,10 +638,6 @@ class LoggedInView extends React.Component<IProps, IState> {
case PageTypes.UserView:
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
break;
case PageTypes.LegacyGroupView:
pageElement = <LegacyGroupView groupId={this.props.currentGroupId} />;
break;
}
const wrapperClasses = classNames({

View File

@ -1,5 +1,5 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2015-2022 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.
@ -188,8 +188,6 @@ interface IState {
currentRoomId?: string;
// If we're trying to just view a user ID (i.e. /user URL), this is it
currentUserId?: string;
// Group ID for legacy "communities don't exist" page
currentGroupId?: string;
// this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: boolean;
// Parameters used in the registration dance with the IS
@ -679,9 +677,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case 'view_legacy_group':
this.viewLegacyGroup(payload.groupId);
break;
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
Modal.createDialog(UserSettingsDialog,
@ -1023,16 +1018,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private viewLegacyGroup(groupId: string) {
this.setStateForNewView({
view: Views.LOGGED_IN,
currentRoomId: null,
currentGroupId: groupId,
});
this.notifyNewScreen('group/' + groupId);
this.setPage(PageType.LegacyGroupView);
}
private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType) {
const modal = Modal.createDialog(CreateRoomDialog, {
type,
@ -1803,12 +1788,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
userId: userId,
subAction: params.action,
});
} else if (screen.indexOf('group/') === 0) {
const groupId = screen.substring(6);
dis.dispatch({
action: 'view_legacy_group',
groupId: groupId,
});
} else {
logger.info("Ignoring showScreen for '%s'", screen);
}

View File

@ -994,7 +994,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
if (
!sendRRs
&& !cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")
&& !(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable"))
&& !(await cli.isVersionSupported("v1.4"))
) return;
try {
return await cli.sendReadReceipt(

View File

@ -26,6 +26,7 @@ type AccessibleButtonKind = | 'primary'
| 'primary_outline'
| 'primary_sm'
| 'secondary'
| 'content_inline'
| 'danger'
| 'danger_outline'
| 'danger_sm'
@ -71,7 +72,7 @@ type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T>
disabled?: boolean;
className?: string;
triggerOnMouseDown?: boolean;
onClick(e?: ButtonEvent): void | Promise<void>;
onClick: ((e: ButtonEvent) => void | Promise<void>) | null;
};
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
@ -105,9 +106,9 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
newProps["disabled"] = true;
} else {
if (triggerOnMouseDown) {
newProps.onMouseDown = onClick;
newProps.onMouseDown = onClick ?? undefined;
} else {
newProps.onClick = onClick;
newProps.onClick = onClick ?? undefined;
}
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
@ -123,7 +124,7 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
case KeyBindingAction.Enter:
e.stopPropagation();
e.preventDefault();
return onClick(e);
return onClick?.(e);
case KeyBindingAction.Space:
e.stopPropagation();
e.preventDefault();
@ -143,7 +144,7 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
case KeyBindingAction.Space:
e.stopPropagation();
e.preventDefault();
return onClick(e);
return onClick?.(e);
default:
onKeyUp?.(e);
break;

View File

@ -18,12 +18,15 @@ import React from "react";
import classNames from "classnames";
import ToggleSwitch from "./ToggleSwitch";
import { Caption } from "../typography/Caption";
interface IProps {
// The value for the toggle switch
value: boolean;
// The translated label for the switch
label: string;
// The translated caption for the switch
caption?: string;
// Whether or not to disable the toggle switch
disabled?: boolean;
// True to put the toggle in front of the label
@ -38,8 +41,14 @@ interface IProps {
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
public render() {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{ this.props.label }</span>;
const { label, caption } = this.props;
let firstPart = <span className="mx_SettingsFlag_label">
{ label }
{ caption && <>
<br />
<Caption>{ caption }</Caption>
</> }
</span>;
let secondPart = <ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}

View File

@ -0,0 +1,176 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useCallback, useContext, useMemo } from "react";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import type { ButtonEvent } from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
import FacePile from "../elements/FacePile";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
const MAX_FACES = 8;
interface ActiveCallEventProps {
mxEvent: MatrixEvent;
participants: Set<RoomMember>;
buttonText: string;
buttonKind: string;
onButtonClick: ((ev: ButtonEvent) => void) | null;
}
const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
(
{
mxEvent,
participants,
buttonText,
buttonKind,
onButtonClick,
},
ref,
) => {
const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]);
const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]);
const facePileOverflow = participants.size > facePileMembers.length;
return <div className="mx_CallEvent_wrapper" ref={ref}>
<div className="mx_CallEvent mx_CallEvent_active">
<MemberAvatar
member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
viewUserOnClick
width={24}
height={24}
/>
<div className="mx_CallEvent_infoRows">
<span className="mx_CallEvent_title">
{ _t("%(name)s started a video call", { name: senderName }) }
</span>
<LiveContentSummary
type={LiveContentType.Video}
text={_t("Video call")}
active={false}
participantCount={participants.size}
/>
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
</div>
<CallDurationFromEvent mxEvent={mxEvent} />
<AccessibleButton
className="mx_CallEvent_button"
kind={buttonKind}
disabled={onButtonClick === null}
onClick={onButtonClick}
>
{ buttonText }
</AccessibleButton>
</div>
</div>;
},
);
interface ActiveLoadedCallEventProps {
mxEvent: MatrixEvent;
call: Call;
}
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: mxEvent.getRoomId()!,
view_call: true,
metricsTrigger: undefined,
});
}, [mxEvent]);
const disconnect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
call.disconnect();
}, [call]);
const [buttonText, buttonKind, onButtonClick] = useMemo(() => {
switch (connectionState) {
case ConnectionState.Disconnected: return [_t("Join"), "primary", connect];
case ConnectionState.Connecting: return [_t("Join"), "primary", null];
case ConnectionState.Connected: return [_t("Leave"), "danger", disconnect];
case ConnectionState.Disconnecting: return [_t("Leave"), "danger", null];
}
}, [connectionState, connect, disconnect]);
return <ActiveCallEvent
ref={ref}
mxEvent={mxEvent}
participants={participants}
buttonText={buttonText}
buttonKind={buttonKind}
onButtonClick={onButtonClick}
/>;
});
interface CallEventProps {
mxEvent: MatrixEvent;
}
/**
* An event tile representing an active or historical Element call.
*/
export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
const noParticipants = useMemo(() => new Set<RoomMember>(), []);
const client = useContext(MatrixClientContext);
const call = useCall(mxEvent.getRoomId()!);
const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState
.getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!);
if ("m.terminated" in latestEvent.getContent()) {
// The call is terminated
return <div className="mx_CallEvent_wrapper" ref={ref}>
<div className="mx_CallEvent mx_CallEvent_inactive">
<span className="mx_CallEvent_title">{ _t("Video call ended") }</span>
<CallDuration delta={latestEvent.getTs() - mxEvent.getTs()} />
</div>
</div>;
}
if (call === null) {
// There should be a call, but it hasn't loaded yet
return <ActiveCallEvent
ref={ref}
mxEvent={mxEvent}
participants={noParticipants}
buttonText={_t("Join")}
buttonKind="primary"
onButtonClick={null}
/>;
}
return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call} ref={ref} />;
});

View File

@ -31,7 +31,6 @@ import Resizer from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import { useStateCallback } from "../../../hooks/useStateCallback";
import UIStore from "../../../stores/UIStore";
import { IApp } from "../../../stores/WidgetStore";
import { ActionPayload } from "../../../dispatcher/payloads";
@ -330,13 +329,8 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
defaultHeight = 280;
}
const [height, setHeight] = useStateCallback(defaultHeight, newHeight => {
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
});
return <Resizable
size={{ height: Math.min(height, maxHeight), width: undefined }}
size={{ height: Math.min(defaultHeight, maxHeight), width: undefined }}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {
@ -346,7 +340,15 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
resizeNotifier.notifyTimelineHeightChanged();
}}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
let newHeight = defaultHeight + d.height;
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(
room,
Container.Top,
newHeight,
);
resizeNotifier.stopResizing();
}}
handleWrapperClass={handleWrapperClass}

View File

@ -754,7 +754,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
[Formatting.Bold]: ctrlShortcutLabel("B"),
[Formatting.Italics]: ctrlShortcutLabel("I"),
[Formatting.Code]: ctrlShortcutLabel("E"),
[Formatting.Quote]: ctrlShortcutLabel(">"),
[Formatting.Quote]: ctrlShortcutLabel(">", true),
[Formatting.InsertLink]: ctrlShortcutLabel("L", true),
};

View File

@ -83,6 +83,7 @@ import { ReadReceiptGroup } from './ReadReceiptGroup';
import { useTooltip } from "../../../utils/useTooltip";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
import { ElementCall } from "../../../models/Call";
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -628,9 +629,11 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
}
if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal
// If the message is unauthenticated, then display a grey
// shield, otherwise if the user isn't cross-signed then
// nothing's needed
this.setState({
verified: E2EState.Normal,
verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
@ -935,7 +938,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
public render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType;
const eventType = this.props.mxEvent.getType();
const {
hasRenderer,
isBubbleMessage,
@ -997,7 +1000,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite,
mx_EventTile_continuation: isContinuation
|| eventType === EventType.CallInvite
|| ElementCall.CALL_EVENT_TYPE.matches(eventType),
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
@ -1051,8 +1056,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
avatarSize = 14;
needsSenderProfile = true;
} else if (
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
eventType === EventType.CallInvite
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File)
|| eventType === EventType.CallInvite
|| ElementCall.CALL_EVENT_TYPE.matches(eventType)
) {
// no avatar or sender profile for continuation messages and call tiles
avatarSize = 0;

View File

@ -0,0 +1,57 @@
/*
Copyright 2022 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, { FC } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
export enum LiveContentType {
Video,
// More coming soon
}
interface Props {
type: LiveContentType;
text: string;
active: boolean;
participantCount: number;
}
/**
* Summary line used to call out live, interactive content such as calls.
*/
export const LiveContentSummary: FC<Props> = ({ type, text, active, participantCount }) => (
<span className="mx_LiveContentSummary">
<span
className={classNames("mx_LiveContentSummary_text", {
"mx_LiveContentSummary_text_video": type === LiveContentType.Video,
"mx_LiveContentSummary_text_active": active,
})}
>
{ text }
</span>
{ participantCount > 0 && <>
{ " • " }
<span
className="mx_LiveContentSummary_participants"
aria-label={_t("%(count)s participants", { count: participantCount })}
>
{ participantCount }
</span>
</> }
</span>
);

View File

@ -15,12 +15,12 @@ limitations under the License.
*/
import React, { FC } from "react";
import classNames from "classnames";
import type { Call } from "../../../models/Call";
import { _t, TranslatedString } from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import { useConnectionState, useParticipants } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
interface Props {
call: Call;
@ -30,7 +30,7 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
let text: TranslatedString;
let text: string;
let active: boolean;
switch (connectionState) {
@ -49,23 +49,10 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
break;
}
return <span className="mx_RoomTileCallSummary">
<span
className={classNames(
"mx_RoomTileCallSummary_text",
{ "mx_RoomTileCallSummary_text_active": active },
)}
>
{ text }
</span>
{ participants.size ? <>
{ " · " }
<span
className="mx_RoomTileCallSummary_participants"
aria-label={_t("%(count)s participants", { count: participants.size })}
>
{ participants.size }
</span>
</> : null }
</span>;
return <LiveContentSummary
type={LiveContentType.Video}
text={text}
active={active}
participantCount={participants.size}
/>;
};

View File

@ -16,7 +16,6 @@ limitations under the License.
import React, { ReactNode } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -45,6 +44,7 @@ import { addReplyToMessageContent } from "../../../utils/Reply";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import RoomContext from "../../../contexts/RoomContext";
import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
import { createVoiceMessageContent } from "../../../utils/createVoiceMessageContent";
interface IProps {
room: Room;
@ -122,36 +122,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
try {
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
const content = {
"body": "Voice message",
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": upload.mxc,
"file": upload.encrypted,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
// MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: upload.mxc,
file: upload.encrypted,
name: "Voice message.ogg",
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
};
const content = createVoiceMessageContent(
upload.mxc,
this.state.recorder.contentType,
Math.round(this.state.recorder.durationSeconds * 1000),
this.state.recorder.contentLength,
upload.encrypted,
this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
);
attachRelation(content, relation);
if (replyToEvent) {

View File

@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi
import LogoutDialog from '../dialogs/LogoutDialog';
import DeviceTile from './devices/DeviceTile';
import SelectableDeviceTile from './devices/SelectableDeviceTile';
import { DeviceType } from '../../../utils/device/parseUserAgent';
interface IProps {
device: IMyDevice;
@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
</AccessibleButton>
</React.Fragment>;
const deviceWithVerification = {
const extendedDevice = {
...this.props.device,
isVerified: this.props.verified,
deviceType: DeviceType.Unknown,
};
if (this.props.isOwnDevice) {
@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
<div className="mx_DevicesPanel_deviceTrust">
<span className={"mx_DevicesPanel_icon mx_E2EIcon " + iconClass} />
</div>
<DeviceTile device={deviceWithVerification}>
<DeviceTile device={extendedDevice}>
{ buttons }
</DeviceTile>
</div>;
@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
return (
<div className="mx_DevicesPanel_device">
<SelectableDeviceTile device={deviceWithVerification} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
<SelectableDeviceTile device={extendedDevice} onClick={this.onDeviceToggled} isSelected={this.props.selected}>
{ buttons }
</SelectableDeviceTile>
</div>

View File

@ -18,6 +18,7 @@ import React from "react";
import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import { logger } from "matrix-js-sdk/src/logger";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -41,6 +42,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import TagComposer from "../elements/TagComposer";
import { objectClone } from "../../../utils/objects";
import { arrayDiff } from "../../../utils/arrays";
import { getLocalNotificationAccountDataEventType } from "../../../utils/notifications";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
@ -106,6 +108,7 @@ interface IState {
pushers?: IPusher[];
threepids?: IThreepid[];
deviceNotificationsEnabled: boolean;
desktopNotifications: boolean;
desktopShowBody: boolean;
audioNotifications: boolean;
@ -119,6 +122,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
this.state = {
phase: Phase.Loading,
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true,
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
@ -128,6 +132,9 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
SettingsStore.watchSetting("notificationsEnabled", null, (...[,,,, value]) =>
this.setState({ desktopNotifications: value as boolean }),
),
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[,,,, value]) => {
this.setState({ deviceNotificationsEnabled: value as boolean });
}),
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[,,,, value]) =>
this.setState({ desktopShowBody: value as boolean }),
),
@ -148,12 +155,19 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
public componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.refreshFromServer();
this.refreshFromAccountData();
}
public componentWillUnmount() {
this.settingWatchers.forEach(watcher => SettingsStore.unwatchSetting(watcher));
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) {
this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled);
}
}
private async refreshFromServer() {
try {
const newState = (await Promise.all([
@ -162,7 +176,9 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
this.refreshThreepids(),
])).reduce((p, c) => Object.assign(c, p), {});
this.setState<keyof Omit<IState, "desktopNotifications" | "desktopShowBody" | "audioNotifications">>({
this.setState<keyof Omit<IState,
"deviceNotificationsEnabled" | "desktopNotifications" | "desktopShowBody" | "audioNotifications">
>({
...newState,
phase: Phase.Ready,
});
@ -172,6 +188,22 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
}
private async refreshFromAccountData() {
const cli = MatrixClientPeg.get();
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
if (settingsEvent) {
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
await this.updateDeviceNotifications(notificationsEnabled);
}
}
private persistLocalNotificationSettings(enabled: boolean): Promise<{}> {
const cli = MatrixClientPeg.get();
return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), {
is_silenced: !enabled,
});
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.get().getPushRules();
const categories = {
@ -297,6 +329,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
}
};
private updateDeviceNotifications = async (checked: boolean) => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
this.setState({ phase: Phase.Persisting });
@ -497,7 +533,8 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
const masterSwitch = <LabelledToggleSwitch
data-test-id='notif-master-switch'
value={!this.isInhibited}
label={_t("Enable for this account")}
label={_t("Enable notifications for this account")}
caption={_t("Turn off to disable notifications on all your devices and sessions")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>;
@ -521,28 +558,36 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
{ masterSwitch }
<LabelledToggleSwitch
data-test-id='notif-setting-notificationsEnabled'
value={this.state.desktopNotifications}
onChange={this.onDesktopNotificationsChanged}
label={_t('Enable desktop notifications for this session')}
data-test-id='notif-device-switch'
value={this.state.deviceNotificationsEnabled}
label={_t("Enable notifications for this device")}
onChange={checked => this.updateDeviceNotifications(checked)}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-test-id='notif-setting-notificationBodyEnabled'
value={this.state.desktopShowBody}
onChange={this.onDesktopShowBodyChanged}
label={_t('Show message in desktop notification')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-test-id='notif-setting-audioNotificationsEnabled'
value={this.state.audioNotifications}
onChange={this.onAudioNotificationsChanged}
label={_t('Enable audible notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
{ this.state.deviceNotificationsEnabled && (<>
<LabelledToggleSwitch
data-test-id='notif-setting-notificationsEnabled'
value={this.state.desktopNotifications}
onChange={this.onDesktopNotificationsChanged}
label={_t('Enable desktop notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-test-id='notif-setting-notificationBodyEnabled'
value={this.state.desktopShowBody}
onChange={this.onDesktopShowBodyChanged}
label={_t('Show message in desktop notification')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-test-id='notif-setting-audioNotificationsEnabled'
value={this.state.audioNotifications}
onChange={this.onAudioNotificationsChanged}
label={_t('Enable audible notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
</>) }
{ emailSwitches }
</>;

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import React, { useState } from 'react';
import { _t } from '../../../../languageHandler';
@ -23,12 +24,14 @@ import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceTile from './DeviceTile';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device?: DeviceWithVerification;
device?: ExtendedDevice;
isLoading: boolean;
isSigningOut: boolean;
localNotificationSettings?: LocalNotificationSettings | undefined;
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
onVerifyCurrentDevice: () => void;
onSignOutCurrentDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
@ -38,6 +41,8 @@ const CurrentDeviceSection: React.FC<Props> = ({
device,
isLoading,
isSigningOut,
localNotificationSettings,
setPushNotifications,
onVerifyCurrentDevice,
onSignOutCurrentDevice,
saveDeviceName,
@ -63,6 +68,8 @@ const CurrentDeviceSection: React.FC<Props> = ({
{ isExpanded &&
<DeviceDetails
device={device}
localNotificationSettings={localNotificationSettings}
setPushNotifications={setPushNotifications}
isSigningOut={isSigningOut}
onVerifyDevice={onVerifyCurrentDevice}
onSignOutDevice={onSignOutCurrentDevice}

View File

@ -22,10 +22,10 @@ import Field from '../../elements/Field';
import Spinner from '../../elements/Spinner';
import { Caption } from '../../typography/Caption';
import Heading from '../../typography/Heading';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
saveDeviceName: (deviceName: string) => Promise<void>;
}

View File

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import { formatDate } from '../../../../DateUtils';
import { _t } from '../../../../languageHandler';
@ -25,20 +26,22 @@ import Spinner from '../../elements/Spinner';
import ToggleSwitch from '../../elements/ToggleSwitch';
import { DeviceDetailHeading } from './DeviceDetailHeading';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
pusher?: IPusher | undefined;
localNotificationSettings?: LocalNotificationSettings | undefined;
isSigningOut: boolean;
onVerifyDevice?: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
setPusherEnabled?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
supportsMSC3881?: boolean | undefined;
}
interface MetadataTable {
id: string;
heading?: string;
values: { label: string, value?: string | React.ReactNode }[];
}
@ -46,15 +49,17 @@ interface MetadataTable {
const DeviceDetails: React.FC<Props> = ({
device,
pusher,
localNotificationSettings,
isSigningOut,
onVerifyDevice,
onSignOutDevice,
saveDeviceName,
setPusherEnabled,
setPushNotifications,
supportsMSC3881,
}) => {
const metadata: MetadataTable[] = [
{
id: 'session',
values: [
{ label: _t('Session ID'), value: device.device_id },
{
@ -64,12 +69,43 @@ const DeviceDetails: React.FC<Props> = ({
],
},
{
id: 'application',
heading: _t('Application'),
values: [
{ label: _t('Name'), value: device.appName },
{ label: _t('Version'), value: device.appVersion },
{ label: _t('URL'), value: device.url },
],
},
{
id: 'device',
heading: _t('Device'),
values: [
{ label: _t('IP address'), value: device.last_seen_ip },
],
},
];
].map(section =>
// filter out falsy values
({ ...section, values: section.values.filter(row => !!row.value) }))
.filter(section =>
// then filter out sections with no values
section.values.length,
);
const showPushNotificationSection = !!pusher || !!localNotificationSettings;
function isPushNotificationsEnabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean {
if (pusher) return pusher[PUSHER_ENABLED.name];
if (localNotificationSettings) return !localNotificationSettings.is_silenced;
return true;
}
function isCheckboxDisabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean {
if (localNotificationSettings) return false;
if (pusher && !supportsMSC3881) return true;
return false;
}
return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}>
<section className='mx_DeviceDetails_section'>
<DeviceDetailHeading
@ -83,9 +119,10 @@ const DeviceDetails: React.FC<Props> = ({
</section>
<section className='mx_DeviceDetails_section'>
<p className='mx_DeviceDetails_sectionHeading'>{ _t('Session details') }</p>
{ metadata.map(({ heading, values }, index) => <table
{ metadata.map(({ heading, values, id }, index) => <table
className='mx_DeviceDetails_metadataTable'
key={index}
data-testid={`device-detail-metadata-${id}`}
>
{ heading &&
<thead>
@ -102,7 +139,7 @@ const DeviceDetails: React.FC<Props> = ({
</table>,
) }
</section>
{ pusher && (
{ showPushNotificationSection && (
<section
className='mx_DeviceDetails_section mx_DeviceDetails_pushNotifications'
data-testid='device-detail-push-notification'
@ -110,9 +147,9 @@ const DeviceDetails: React.FC<Props> = ({
<ToggleSwitch
// For backwards compatibility, if `enabled` is missing
// default to `true`
checked={pusher?.[PUSHER_ENABLED.name] ?? true}
disabled={!supportsMSC3881}
onChange={(checked) => setPusherEnabled?.(device.device_id, checked)}
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={checked => setPushNotifications?.(device.device_id, checked)}
aria-label={_t("Toggle push notifications on this session.")}
data-testid='device-detail-push-notification-checkbox'
/>

View File

@ -21,15 +21,16 @@ import { _t } from "../../../../languageHandler";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import Heading from "../../typography/Heading";
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
import { DeviceWithVerification } from "./types";
import { DeviceType } from "./DeviceType";
import { ExtendedDevice } from "./types";
import { DeviceTypeIcon } from "./DeviceTypeIcon";
export interface DeviceTileProps {
device: DeviceWithVerification;
device: ExtendedDevice;
isSelected?: boolean;
children?: React.ReactNode;
onClick?: () => void;
}
const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => {
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size='h4'>
{ device.display_name || device.device_id }
</Heading>;
@ -47,7 +48,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri
return formatRelativeTime(new Date(timestamp));
};
const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => {
const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => {
const isInactive = isDeviceInactive(device);
if (!isInactive) {
@ -68,7 +69,12 @@ const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }>
value ? <span data-testid={`device-metadata-${id}`}>{ value }</span> : null
);
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, onClick }) => {
const DeviceTile: React.FC<DeviceTileProps> = ({
device,
children,
isSelected,
onClick,
}) => {
const inactive = getInactiveMetadata(device);
const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`;
const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified');
@ -83,7 +89,11 @@ const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, onClick }) =>
];
return <div className="mx_DeviceTile" data-testid={`device-tile-${device.device_id}`}>
<DeviceType isVerified={device.isVerified} />
<DeviceTypeIcon
isVerified={device.isVerified}
isSelected={isSelected}
deviceType={device.deviceType}
/>
<div className="mx_DeviceTile_info" onClick={onClick}>
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">

View File

@ -21,33 +21,39 @@ import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/
import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg';
import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg';
import { _t } from '../../../../languageHandler';
import { DeviceWithVerification } from './types';
import { ExtendedDevice } from './types';
import { DeviceType } from '../../../../utils/device/parseUserAgent';
interface Props {
isVerified?: DeviceWithVerification['isVerified'];
isVerified?: ExtendedDevice['isVerified'];
isSelected?: boolean;
deviceType?: DeviceType;
}
export const DeviceType: React.FC<Props> = ({ isVerified, isSelected }) => (
<div className={classNames('mx_DeviceType', {
mx_DeviceType_selected: isSelected,
export const DeviceTypeIcon: React.FC<Props> = ({
isVerified,
isSelected,
deviceType,
}) => (
<div className={classNames('mx_DeviceTypeIcon', {
mx_DeviceTypeIcon_selected: isSelected,
})}
>
{ /* TODO(kerrya) all devices have an unknown type until PSG-650 */ }
<UnknownDeviceIcon
className='mx_DeviceType_deviceIcon'
className='mx_DeviceTypeIcon_deviceIcon'
role='img'
aria-label={_t('Unknown device type')}
/>
{
isVerified
? <VerifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'verified')}
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'verified')}
role='img'
aria-label={_t('Verified')}
/>
: <UnverifiedIcon
className={classNames('mx_DeviceType_verificationIcon', 'unverified')}
className={classNames('mx_DeviceTypeIcon_verificationIcon', 'unverified')}
role='img'
aria-label={_t('Unverified')}
/>

View File

@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
interface Props {
device: DeviceWithVerification;
device: ExtendedDevice;
onVerifyDevice?: () => void;
}

View File

@ -17,6 +17,7 @@ limitations under the License.
import React, { ForwardedRef, forwardRef } from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_DEVICE_ID } from 'matrix-js-sdk/src/@types/event';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
@ -24,35 +25,44 @@ import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropd
import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import DeviceTile from './DeviceTile';
import {
filterDevicesBySecurityRecommendation,
INACTIVE_DEVICE_AGE_DAYS,
} from './filter';
import SelectableDeviceTile from './SelectableDeviceTile';
import {
DevicesDictionary,
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
} from './types';
import { DevicesState } from './useOwnDevices';
import FilteredDeviceListHeader from './FilteredDeviceListHeader';
interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
expandedDeviceIds: DeviceWithVerification['device_id'][];
signingOutDeviceIds: DeviceWithVerification['device_id'][];
localNotificationSettings: Map<string, LocalNotificationSettings>;
expandedDeviceIds: ExtendedDevice['device_id'][];
signingOutDeviceIds: ExtendedDevice['device_id'][];
selectedDeviceIds: ExtendedDevice['device_id'][];
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void;
onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
setPusherEnabled: (deviceId: string, enabled: boolean) => Promise<void>;
onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void;
supportsMSC3881?: boolean | undefined;
}
const isDeviceSelected = (
deviceId: ExtendedDevice['device_id'],
selectedDeviceIds: ExtendedDevice['device_id'][],
) => selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
@ -139,46 +149,55 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
</div>;
const DeviceListItem: React.FC<{
device: DeviceWithVerification;
device: ExtendedDevice;
pusher?: IPusher | undefined;
localNotificationSettings?: LocalNotificationSettings | undefined;
isExpanded: boolean;
isSigningOut: boolean;
isSelected: boolean;
onDeviceExpandToggle: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
onRequestDeviceVerification?: () => void;
setPusherEnabled: (deviceId: string, enabled: boolean) => Promise<void>;
toggleSelected: () => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
supportsMSC3881?: boolean | undefined;
}> = ({
device,
pusher,
localNotificationSettings,
isExpanded,
isSigningOut,
isSelected,
onDeviceExpandToggle,
onSignOutDevice,
saveDeviceName,
onRequestDeviceVerification,
setPusherEnabled,
setPushNotifications,
toggleSelected,
supportsMSC3881,
}) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile
<SelectableDeviceTile
isSelected={isSelected}
onClick={toggleSelected}
device={device}
>
<DeviceExpandDetailsButton
isExpanded={isExpanded}
onClick={onDeviceExpandToggle}
/>
</DeviceTile>
</SelectableDeviceTile>
{
isExpanded &&
<DeviceDetails
device={device}
pusher={pusher}
localNotificationSettings={localNotificationSettings}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
saveDeviceName={saveDeviceName}
setPusherEnabled={setPusherEnabled}
setPushNotifications={setPushNotifications}
supportsMSC3881={supportsMSC3881}
/>
}
@ -192,23 +211,35 @@ export const FilteredDeviceList =
forwardRef(({
devices,
pushers,
localNotificationSettings,
filter,
expandedDeviceIds,
signingOutDeviceIds,
selectedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
saveDeviceName,
onSignOutDevices,
onRequestDeviceVerification,
setPusherEnabled,
setPushNotifications,
setSelectedDeviceIds,
supportsMSC3881,
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined {
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId));
} else {
setSelectedDeviceIds([...selectedDeviceIds, deviceId]);
}
};
const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') },
{
@ -235,20 +266,50 @@ export const FilteredDeviceList =
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
const toggleSelectAll = () => {
if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map(device => device.device_id));
}
};
return <div className='mx_FilteredDeviceList' ref={ref}>
<div className='mx_FilteredDeviceList_header'>
<span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') }
</span>
<FilterDropdown<DeviceFilterKey>
id='device-list-filter'
label={_t('Filter devices')}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t('Show')}
/>
</div>
<FilteredDeviceListHeader
selectedDeviceCount={selectedDeviceIds.length}
isAllSelected={isAllSelected}
toggleSelectAll={toggleSelectAll}
>
{ selectedDeviceIds.length
? <>
<AccessibleButton
data-testid='sign-out-selection-cta'
kind='danger_inline'
onClick={() => onSignOutDevices(selectedDeviceIds)}
className='mx_FilteredDeviceList_headerButton'
>
{ _t('Sign out') }
</AccessibleButton>
<AccessibleButton
data-testid='cancel-selection-cta'
kind='content_inline'
onClick={() => setSelectedDeviceIds([])}
className='mx_FilteredDeviceList_headerButton'
>
{ _t('Cancel') }
</AccessibleButton>
</>
: <FilterDropdown<DeviceFilterKey>
id='device-list-filter'
label={_t('Filter devices')}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t('Show')}
/>
}
</FilteredDeviceListHeader>
{ !!sortedDevices.length
? <FilterSecurityCard filter={filter} />
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
@ -258,8 +319,10 @@ export const FilteredDeviceList =
key={device.device_id}
device={device}
pusher={getPusherForDevice(device)}
localNotificationSettings={localNotificationSettings.get(device.device_id)}
isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onSignOutDevice={() => onSignOutDevices([device.device_id])}
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
@ -268,7 +331,8 @@ export const FilteredDeviceList =
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
setPusherEnabled={setPusherEnabled}
setPushNotifications={setPushNotifications}
toggleSelected={() => toggleSelection(device.device_id)}
supportsMSC3881={supportsMSC3881}
/>,
) }

View File

@ -0,0 +1,63 @@
/*
Copyright 2022 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, { HTMLProps } from 'react';
import { _t } from '../../../../languageHandler';
import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox';
import { Alignment } from '../../elements/Tooltip';
import TooltipTarget from '../../elements/TooltipTarget';
interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className'> {
selectedDeviceCount: number;
isAllSelected: boolean;
toggleSelectAll: () => void;
children?: React.ReactNode;
}
const FilteredDeviceListHeader: React.FC<Props> = ({
selectedDeviceCount,
isAllSelected,
toggleSelectAll,
children,
...rest
}) => {
const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all');
return <div className='mx_FilteredDeviceListHeader' {...rest}>
<TooltipTarget
label={checkboxLabel}
alignment={Alignment.Top}
>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isAllSelected}
onChange={toggleSelectAll}
id='device-select-all-checkbox'
data-testid='device-select-all-checkbox'
aria-label={checkboxLabel}
/>
</TooltipTarget>
<span className='mx_FilteredDeviceListHeader_label'>
{ selectedDeviceCount > 0
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })
: _t('Sessions')
}
</span>
{ children }
</div>;
};
export default FilteredDeviceListHeader;

View File

@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
DeviceWithVerification,
ExtendedDevice,
DevicesDictionary,
} from './types';
interface Props {
devices: DevicesDictionary;
currentDeviceId: DeviceWithVerification['device_id'];
currentDeviceId: ExtendedDevice['device_id'];
goToFilteredList: (filter: DeviceSecurityVariation) => void;
}
@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC<Props> = ({
currentDeviceId,
goToFilteredList,
}) => {
const devicesArray = Object.values<DeviceWithVerification>(devices);
const devicesArray = Object.values<ExtendedDevice>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,

View File

@ -32,8 +32,9 @@ const SelectableDeviceTile: React.FC<Props> = ({ children, device, isSelected, o
onChange={onClick}
className='mx_SelectableDeviceTile_checkbox'
id={`device-tile-checkbox-${device.device_id}`}
data-testid={`device-tile-checkbox-${device.device_id}`}
/>
<DeviceTile device={device} onClick={onClick}>
<DeviceTile device={device} onClick={onClick} isSelected={isSelected}>
{ children }
</DeviceTile>
</div>;

View File

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
import { ExtendedDevice, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
type DeviceFilterCondition = (device: ExtendedDevice) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
@ -32,7 +32,7 @@ const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
};
export const filterDevicesBySecurityRecommendation = (
devices: DeviceWithVerification[],
devices: ExtendedDevice[],
securityVariations: DeviceSecurityVariation[],
) => {
const activeFilters = securityVariations.map(variation => filters[variation]);

View File

@ -16,8 +16,17 @@ limitations under the License.
import { IMyDevice } from "matrix-js-sdk/src/matrix";
import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent";
export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null };
export type DevicesDictionary = Record<DeviceWithVerification['device_id'], DeviceWithVerification>;
export type ExtendedDeviceAppInfo = {
// eg Element Web
appName?: string;
appVersion?: string;
url?: string;
};
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation;
export type DevicesDictionary = Record<ExtendedDevice['device_id'], ExtendedDevice>;
export enum DeviceSecurityVariation {
Verified = 'Verified',

View File

@ -15,15 +15,29 @@ limitations under the License.
*/
import { useCallback, useContext, useEffect, useState } from "react";
import { IMyDevice, IPusher, MatrixClient, PUSHER_DEVICE_ID, PUSHER_ENABLED } from "matrix-js-sdk/src/matrix";
import {
ClientEvent,
IMyDevice,
IPusher,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixClient,
MatrixEvent,
PUSHER_DEVICE_ID,
PUSHER_ENABLED,
UNSTABLE_MSC3852_LAST_SEEN_UA,
} from "matrix-js-sdk/src/matrix";
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { logger } from "matrix-js-sdk/src/logger";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import { DevicesDictionary, DeviceWithVerification } from "./types";
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
const isDeviceVerified = (
matrixClient: MatrixClient,
@ -51,6 +65,16 @@ const isDeviceVerified = (
}
};
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => {
const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id);
return {
appName: name,
appVersion: version,
url,
};
};
const fetchDevicesWithVerification = async (
matrixClient: MatrixClient,
userId: string,
@ -64,6 +88,8 @@ const fetchDevicesWithVerification = async (
[device.device_id]: {
...device,
isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
},
}), {});
@ -77,13 +103,14 @@ export enum OwnDevicesError {
export type DevicesState = {
devices: DevicesDictionary;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise<void>;
setPusherEnabled: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise<void>;
saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise<void>;
error?: OwnDevicesError;
supportsMSC3881?: boolean | undefined;
};
@ -95,6 +122,8 @@ export const useOwnDevices = (): DevicesState => {
const [devices, setDevices] = useState<DevicesState['devices']>({});
const [pushers, setPushers] = useState<DevicesState['pushers']>([]);
const [localNotificationSettings, setLocalNotificationSettings]
= useState<DevicesState['localNotificationSettings']>(new Map<string, LocalNotificationSettings>());
const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true);
const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes!
@ -120,6 +149,19 @@ export const useOwnDevices = (): DevicesState => {
const { pushers } = await matrixClient.getPushers();
setPushers(pushers);
const notificationSettings = new Map<string, LocalNotificationSettings>();
Object.keys(devices).forEach((deviceId) => {
const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
const event = matrixClient.getAccountData(eventType);
if (event) {
notificationSettings.set(
deviceId,
event.getContent(),
);
}
});
setLocalNotificationSettings(notificationSettings);
setIsLoadingDeviceList(false);
} catch (error) {
if ((error as MatrixError).httpStatus == 404) {
@ -137,10 +179,20 @@ export const useOwnDevices = (): DevicesState => {
refreshDevices();
}, [refreshDevices]);
useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => {
const type = event.getType();
if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
const newSettings = new Map(localNotificationSettings);
const deviceId = type.slice(type.lastIndexOf(".") + 1);
newSettings.set(deviceId, event.getContent<LocalNotificationSettings>());
setLocalNotificationSettings(newSettings);
}
});
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification = isCurrentDeviceVerified && userId
? async (deviceId: DeviceWithVerification['device_id']) => {
? async (deviceId: ExtendedDevice['device_id']) => {
return await matrixClient.requestVerification(
userId,
[deviceId],
@ -149,7 +201,7 @@ export const useOwnDevices = (): DevicesState => {
: undefined;
const saveDeviceName = useCallback(
async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise<void> => {
async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise<void> => {
const device = devices[deviceId];
// no change
@ -169,32 +221,40 @@ export const useOwnDevices = (): DevicesState => {
}
}, [matrixClient, devices, refreshDevices]);
const setPusherEnabled = useCallback(
async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise<void> => {
const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId);
const setPushNotifications = useCallback(
async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise<void> => {
try {
await matrixClient.setPusher({
...pusher,
[PUSHER_ENABLED.name]: enabled,
});
await refreshDevices();
const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId);
if (pusher) {
await matrixClient.setPusher({
...pusher,
[PUSHER_ENABLED.name]: enabled,
});
} else if (localNotificationSettings.has(deviceId)) {
await matrixClient.setLocalNotificationSettings(deviceId, {
is_silenced: !enabled,
});
}
} catch (error) {
logger.error("Error setting pusher state", error);
throw new Error(_t("Failed to set pusher state"));
} finally {
await refreshDevices();
}
}, [matrixClient, pushers, refreshDevices],
}, [matrixClient, pushers, localNotificationSettings, refreshDevices],
);
return {
devices,
pushers,
localNotificationSettings,
currentDeviceId,
isLoadingDeviceList,
error,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPusherEnabled,
setPushNotifications,
supportsMSC3881,
};
};

View File

@ -125,9 +125,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
}
public async componentDidMount(): Promise<void> {
const cli = MatrixClientPeg.get();
this.setState({
disablingReadReceiptsSupported: (
await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable")
(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable"))
|| (await cli.isVersionSupported("v1.4"))
),
});
}

View File

@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
level={SettingLevel.ACCOUNT} />
) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Sessions") }</span>
<SettingsFlag
name="deviceClientInformationOptIn"
level={SettingLevel.ACCOUNT} />
</div>
</React.Fragment>;
}

View File

@ -19,29 +19,29 @@ import { MatrixClient } from 'matrix-js-sdk/src/client';
import { logger } from 'matrix-js-sdk/src/logger';
import { _t } from "../../../../../languageHandler";
import { DevicesState, useOwnDevices } from '../../devices/useOwnDevices';
import SettingsSubsection from '../../shared/SettingsSubsection';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types';
import SettingsTab from '../SettingsTab';
import MatrixClientContext from '../../../../../contexts/MatrixClientContext';
import Modal from '../../../../../Modal';
import SettingsSubsection from '../../shared/SettingsSubsection';
import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog';
import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog';
import LogoutDialog from '../../../dialogs/LogoutDialog';
import MatrixClientContext from '../../../../../contexts/MatrixClientContext';
import { useOwnDevices } from '../../devices/useOwnDevices';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
import SettingsTab from '../SettingsTab';
const useSignOut = (
matrixClient: MatrixClient,
refreshDevices: DevicesState['refreshDevices'],
onSignoutResolvedCallback: () => Promise<void>,
): {
onSignOutCurrentDevice: () => void;
onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise<void>;
signingOutDeviceIds: DeviceWithVerification['device_id'][];
onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise<void>;
signingOutDeviceIds: ExtendedDevice['device_id'][];
} => {
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const onSignOutCurrentDevice = () => {
Modal.createDialog(
@ -53,7 +53,7 @@ const useSignOut = (
);
};
const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => {
const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => {
if (!deviceIds.length) {
return;
}
@ -64,9 +64,7 @@ const useSignOut = (
deviceIds,
async (success) => {
if (success) {
// @TODO(kerrya) clear selection if was bulk deletion
// when added in PSG-659
await refreshDevices();
await onSignoutResolvedCallback();
}
setSigningOutDeviceIds(signingOutDeviceIds.filter(deviceId => !deviceIds.includes(deviceId)));
},
@ -88,16 +86,18 @@ const SessionManagerTab: React.FC = () => {
const {
devices,
pushers,
localNotificationSettings,
currentDeviceId,
isLoadingDeviceList,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPusherEnabled,
setPushNotifications,
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const [expandedDeviceIds, setExpandedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
@ -105,7 +105,7 @@ const SessionManagerTab: React.FC = () => {
const userId = matrixClient.getUserId();
const currentUserMember = userId && matrixClient.getUser(userId) || undefined;
const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId));
} else {
@ -115,7 +115,6 @@ const SessionManagerTab: React.FC = () => {
const onGoToFilteredList = (filter: DeviceSecurityVariation) => {
setFilter(filter);
// @TODO(kerrya) clear selection when added in PSG-659
clearTimeout(scrollIntoViewTimeoutRef.current);
// wait a tick for the filtered section to rerender with different height
scrollIntoViewTimeoutRef.current =
@ -137,7 +136,7 @@ const SessionManagerTab: React.FC = () => {
);
};
const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => {
const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => {
if (!requestDeviceVerification) {
return;
}
@ -153,16 +152,25 @@ const SessionManagerTab: React.FC = () => {
});
}, [requestDeviceVerification, refreshDevices, currentUserMember]);
const onSignoutResolvedCallback = async () => {
await refreshDevices();
setSelectedDeviceIds([]);
};
const {
onSignOutCurrentDevice,
onSignOutOtherDevices,
signingOutDeviceIds,
} = useSignOut(matrixClient, refreshDevices);
} = useSignOut(matrixClient, onSignoutResolvedCallback);
useEffect(() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current);
}, [scrollIntoViewTimeoutRef]);
// clear selection when filter changes
useEffect(() => {
setSelectedDeviceIds([]);
}, [filter, setSelectedDeviceIds]);
return <SettingsTab heading={_t('Sessions')}>
<SecurityRecommendations
devices={devices}
@ -171,9 +179,11 @@ const SessionManagerTab: React.FC = () => {
/>
<CurrentDeviceSection
device={currentDevice}
isSigningOut={signingOutDeviceIds.includes(currentDevice?.device_id)}
localNotificationSettings={localNotificationSettings.get(currentDeviceId)}
setPushNotifications={setPushNotifications}
isSigningOut={signingOutDeviceIds.includes(currentDeviceId)}
isLoading={isLoadingDeviceList}
saveDeviceName={(deviceName) => saveDeviceName(currentDevice?.device_id, deviceName)}
saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)}
onVerifyCurrentDevice={onVerifyCurrentDevice}
onSignOutCurrentDevice={onSignOutCurrentDevice}
/>
@ -190,15 +200,18 @@ const SessionManagerTab: React.FC = () => {
<FilteredDeviceList
devices={otherDevices}
pushers={pushers}
localNotificationSettings={localNotificationSettings}
filter={filter}
expandedDeviceIds={expandedDeviceIds}
signingOutDeviceIds={signingOutDeviceIds}
selectedDeviceIds={selectedDeviceIds}
setSelectedDeviceIds={setSelectedDeviceIds}
onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle}
onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined}
onSignOutDevices={onSignOutOtherDevices}
saveDeviceName={saveDeviceName}
setPusherEnabled={setPusherEnabled}
setPushNotifications={setPushNotifications}
ref={filteredDeviceListRef}
supportsMSC3881={supportsMSC3881}
/>

View File

@ -0,0 +1,51 @@
/*
Copyright 2022 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, { FC, useState, useEffect } from "react";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { formatCallTime } from "../../../DateUtils";
interface CallDurationProps {
delta: number;
}
/**
* A call duration counter.
*/
export const CallDuration: FC<CallDurationProps> = ({ delta }) => {
// Clock desync could lead to a negative duration, so just hide it if that happens
if (delta <= 0) return null;
return <div className="mx_CallDuration">{ formatCallTime(new Date(delta)) }</div>;
};
interface CallDurationFromEventProps {
mxEvent: MatrixEvent;
}
/**
* A call duration counter that automatically counts up, given the event that
* started the call.
*/
export const CallDurationFromEvent: FC<CallDurationFromEventProps> = ({ mxEvent }) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
return <CallDuration delta={now - mxEvent.getTs()} />;
};

View File

@ -45,7 +45,6 @@ interface DeviceButtonProps {
devices: MediaDeviceInfo[];
setDevice: (device: MediaDeviceInfo) => void;
deviceListLabel: string;
fallbackDeviceLabel: (n: number) => string;
muted: boolean;
disabled: boolean;
toggle: () => void;
@ -54,7 +53,7 @@ interface DeviceButtonProps {
}
const DeviceButton: FC<DeviceButtonProps> = ({
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
kind, devices, setDevice, deviceListLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
}) => {
const [showMenu, buttonRef, openMenu, closeMenu] = useContextMenu();
const selectDevice = useCallback((device: MediaDeviceInfo) => {
@ -67,10 +66,10 @@ const DeviceButton: FC<DeviceButtonProps> = ({
const buttonRect = buttonRef.current!.getBoundingClientRect();
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
{ devices.map((d, index) =>
{ devices.map((d) =>
<IconizedContextMenuOption
key={d.deviceId}
label={d.label || fallbackDeviceLabel(index + 1)}
label={d.label}
onClick={() => selectDevice(d)}
/>,
) }
@ -119,26 +118,8 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const videoRef = useRef<HTMLVideoElement>(null);
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
try {
const devices = await MediaDeviceHandler.getDevices();
return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
} catch (e) {
logger.warn(`Failed to get media device list`, e);
return [[], []];
}
}, [], [[], []]);
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
}, []);
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
setVideoInputId(device.deviceId);
}, []);
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
@ -151,18 +132,46 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
setVideoMuted(!videoMuted);
}, [videoMuted, setVideoMuted]);
const videoStream = useAsyncMemo(async () => {
if (videoInputId && !videoMuted) {
try {
return await navigator.mediaDevices.getUserMedia({
video: { deviceId: videoInputId },
});
} catch (e) {
logger.error(`Failed to get stream for device ${videoInputId}`, e);
}
const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => {
let previewStream: MediaStream;
try {
// We get the preview stream before requesting devices: this is because
// we need (in some browsers) an active media stream in order to get
// non-blank labels for the devices. According to the docs, we
// need a stream of each type (audio + video) if we want to enumerate
// audio & video devices, although this didn't seem to be the case
// in practice for me. We request both anyway.
// For similar reasons, we also request a stream even if video is muted,
// which could be a bit strange but allows us to get the device list
// reliably. One option could be to try & get devices without a stream,
// then try again with a stream if we get blank deviceids, but... ew.
previewStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: videoInputId },
audio: { deviceId: MediaDeviceHandler.getAudioInput() },
});
} catch (e) {
logger.error(`Failed to get stream for device ${videoInputId}`, e);
}
return null;
}, [videoInputId, videoMuted]);
const devices = await MediaDeviceHandler.getDevices();
// If video is muted, we don't actually want the stream, so we can get rid of
// it now.
if (videoMuted) {
previewStream.getTracks().forEach(t => t.stop());
previewStream = undefined;
}
return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
}, [videoInputId, videoMuted], [null, [], []]);
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
}, []);
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
setVideoInputId(device.deviceId);
}, []);
useEffect(() => {
if (videoStream) {
@ -205,7 +214,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
devices={audioInputs}
setDevice={setAudioInput}
deviceListLabel={_t("Audio devices")}
fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })}
muted={audioMuted}
disabled={connecting}
toggle={toggleAudio}
@ -217,7 +225,6 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
devices={videoInputs}
setDevice={setVideoInput}
deviceListLabel={_t("Video devices")}
fallbackDeviceLabel={n => _t("Video input %(n)s", { n })}
muted={videoMuted}
disabled={connecting}
toggle={toggleVideo}

View File

@ -158,9 +158,9 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates
"org.matrix.msc3401.call.member": 0,
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
// Make calls immutable, even to admins
"org.matrix.msc3401.call": 200,
[ElementCall.CALL_EVENT_TYPE.name]: 200,
},
users: {
// Temporarily give ourselves the power to set up a call

View File

@ -28,6 +28,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent";
import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent";
import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import RoomCreate from "../components/views/messages/RoomCreate";
@ -44,6 +45,7 @@ import HiddenBody from "../components/views/messages/HiddenBody";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile";
import { ElementCall } from "../models/Call";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps {
@ -74,6 +76,7 @@ const KeyVerificationConclFactory: Factory = (ref, props) => <MKeyVerificationCo
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (
<LegacyCallEvent ref={ref} {...props} />
);
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
const VerificationReqFactory: Factory = (ref, props) => <MKeyVerificationRequest ref={ref} {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
@ -113,6 +116,10 @@ const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomGuestAccess, TextualEventFactory],
]);
for (const evType of ElementCall.CALL_EVENT_TYPE.names) {
STATE_EVENT_TILE_TYPES.set(evType, CallEventFactory);
}
// Add all the Mjolnir stuff to the renderer too
for (const evType of ALL_RULE_TYPES) {
STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory);
@ -397,6 +404,15 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boo
return hasText(mxEvent, showHiddenEvents);
} else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) {
return Boolean(mxEvent.getContent()['predecessor']);
} else if (ElementCall.CALL_EVENT_TYPE.names.some(eventType => handler === STATE_EVENT_TILE_TYPES.get(eventType))) {
const intent = mxEvent.getContent()['m.intent'];
const prevContent = mxEvent.getPrevContent();
// If the call became unterminated or previously had invalid contents,
// then this event marks the start of the call
const newlyStarted = 'm.terminated' in prevContent
|| !('m.intent' in prevContent) || !('m.type' in prevContent);
// Only interested in events that mark the start of a non-room call
return typeof intent === 'string' && intent !== 'm.room' && newlyStarted;
} else if (handler === JSONEventFactory) {
return false;
} else {

View File

@ -1,45 +0,0 @@
/*
Copyright 2022 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 Modal from "./Modal";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import { _t } from "./languageHandler";
import SdkConfig, { DEFAULTS } from "./SdkConfig";
export function showGroupReplacedWithSpacesDialog(groupId: string) {
const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url;
Modal.createDialog(QuestionDialog, {
title: _t("That link is no longer supported"),
description: <>
<p>
{ _t(
"You're trying to access a community link (%(groupId)s).<br/>" +
"Communities are no longer supported and have been replaced by spaces.<br2/>" +
"<a>Learn more about spaces here.</a>",
{ groupId },
{
br: () => <br />,
br2: () => <br />,
a: (sub) => <a href={learnMoreUrl} rel="noreferrer noopener" target="_blank">{ sub }</a>,
},
) }
</p>
</>,
hasCancelButton: false,
});
}

View File

@ -3570,5 +3570,24 @@
"Voice broadcast": "Hlasové vysílání",
"Voice broadcast (under active development)": "Hlasové vysílání (v aktivním vývoji)",
"Element Call video rooms": "Element Call video místnosti",
"Voice broadcasts": "Hlasová vysílání"
"Voice broadcasts": "Hlasová vysílání",
"New group call experience": "Nový zážitek ze skupinových hovorů",
"You do not have permission to start voice calls": "Nemáte oprávnění k zahájení hlasových hovorů",
"There's no one here to call": "Není tu nikdo, komu zavolat",
"You do not have permission to start video calls": "Nemáte oprávnění ke spuštění videohovorů",
"Ongoing call": "Průběžný hovor",
"Video call (Element Call)": "Videohovor (Element Call)",
"Video call (Jitsi)": "Videohovor (Jitsi)",
"Live": "Živě",
"Failed to set pusher state": "Nepodařilo se nastavit stav push oznámení",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybraných relací",
"Receive push notifications on this session.": "Přijímat push oznámení v této relaci.",
"Toggle push notifications on this session.": "Přepnout push notifikace v této relaci.",
"Push notifications": "Push notifikace",
"Enable notifications for this device": "Povolit oznámení pro toto zařízení",
"Turn off to disable notifications on all your devices and sessions": "Vypnutím zakážete oznámení na všech zařízeních a relacích",
"Enable notifications for this account": "Povolit oznámení pro tento účet",
"Video call ended": "Videohovor ukončen",
"%(name)s started a video call": "%(name)s zahájil(a) videohovor",
"Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenat název, verzi a url pro snadnější rozpoznání relací ve správci relací"
}

File diff suppressed because it is too large Load Diff

View File

@ -52,8 +52,6 @@
"%(value)sh": "%(value)sh",
"%(value)sm": "%(value)sm",
"%(value)ss": "%(value)ss",
"That link is no longer supported": "That link is no longer supported",
"You're trying to access a community link (%(groupId)s).<br/>Communities are no longer supported and have been replaced by spaces.<br2/><a>Learn more about spaces here.</a>": "You're trying to access a community link (%(groupId)s).<br/>Communities are no longer supported and have been replaced by spaces.<br2/><a>Learn more about spaces here.</a>",
"Identity server has no terms of service": "Identity server has no terms of service",
"This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.",
"Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.",
@ -955,6 +953,7 @@
"System font name": "System font name",
"Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)",
"Send analytics data": "Send analytics data",
"Record the client name, version, and url to recognise sessions more easily in session manager": "Record the client name, version, and url to recognise sessions more easily in session manager",
"Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session",
"Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session",
"Enable inline URL previews by default": "Enable inline URL previews by default",
@ -1047,11 +1046,9 @@
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
"Send as message": "Send as message",
"Audio devices": "Audio devices",
"Audio input %(n)s": "Audio input %(n)s",
"Mute microphone": "Mute microphone",
"Unmute microphone": "Unmute microphone",
"Video devices": "Video devices",
"Video input %(n)s": "Video input %(n)s",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Join": "Join",
@ -1361,8 +1358,10 @@
"Messages containing keywords": "Messages containing keywords",
"Error saving notification preferences": "Error saving notification preferences",
"An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
"Enable for this account": "Enable for this account",
"Enable notifications for this account": "Enable notifications for this account",
"Turn off to disable notifications on all your devices and sessions": "Turn off to disable notifications on all your devices and sessions",
"Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
"Enable notifications for this device": "Enable notifications for this device",
"Enable desktop notifications for this session": "Enable desktop notifications for this session",
"Show message in desktop notification": "Show message in desktop notification",
"Enable audible notifications for this session": "Enable audible notifications for this session",
@ -1569,9 +1568,9 @@
"Okay": "Okay",
"Privacy": "Privacy",
"Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
"Sessions": "Sessions",
"Where you're signed in": "Where you're signed in",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.",
"Sessions": "Sessions",
"Other sessions": "Other sessions",
"For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.",
"Sidebar": "Sidebar",
@ -1716,6 +1715,9 @@
"Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with",
"Session ID": "Session ID",
"Last activity": "Last activity",
"Application": "Application",
"Version": "Version",
"URL": "URL",
"Device": "Device",
"IP address": "IP address",
"Session details": "Session details",
@ -1749,8 +1751,10 @@
"Not ready for secure messaging": "Not ready for secure messaging",
"Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Sign out": "Sign out",
"Filter devices": "Filter devices",
"Show": "Show",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected",
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
@ -1800,6 +1804,8 @@
"Show %(count)s other previews|other": "Show %(count)s other previews",
"Show %(count)s other previews|one": "Show %(count)s other preview",
"Close preview": "Close preview",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...",
"Invite to this room": "Invite to this room",
@ -1999,8 +2005,6 @@
"Video": "Video",
"Joining…": "Joining…",
"Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
"This room has already been upgraded.": "This room has already been upgraded.",
"This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.",
@ -2179,6 +2183,8 @@
"%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.",
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"%(name)s started a video call": "%(name)s started a video call",
"Video call ended": "Video call ended",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -2607,7 +2613,6 @@
"Private space (invite only)": "Private space (invite only)",
"Want to add an existing space instead?": "Want to add an existing space instead?",
"Adding...": "Adding...",
"Sign out": "Sign out",
"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": "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",
"You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.",
"Incompatible Database": "Incompatible Database",

View File

@ -3491,8 +3491,8 @@
"Share your activity and status with others.": "Jaga teistega oma olekut ja tegevusi.",
"Presence": "Olek võrgus",
"Send read receipts": "Saada lugemisteatiseid",
"Last activity": "Viimased tegevused",
"Sessions": "Sessionid",
"Last activity": "Viimati kasutusel",
"Sessions": "Sessioonid",
"Use new session manager (under active development)": "Uus sessioonihaldur (aktiivselt arendamisel)",
"Current session": "Praegune sessioon",
"Welcome": "Tere tulemast",
@ -3565,5 +3565,27 @@
"You need to be able to kick users to do that.": "Selle tegevuse jaoks peaks sul olema õigus teistele kasutajatele müksamiseks.",
"Please be aware that session names are also visible to people you communicate with": "Palun arvesta, et sessioonide nimed on näha ka kõikidele osapooltele, kellega sa suhtled",
"Rename session": "Muuda sessiooni nime",
"Element Call video rooms": "Element Call videotoad"
"Element Call video rooms": "Element Call videotoad",
"Sliding Sync configuration": "Sliding Sync konfiguratsioon",
"Voice broadcast": "Ringhäälingukõne",
"Voice broadcasts": "Ringhäälingukõned",
"Voice broadcast (under active development)": "Ringhäälingukõne (aktiivses arenduses)",
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync režiim (aktiivses arenduses, ei saa välja lülitada)",
"Enable notifications for this account": "Võta sellel kasutajakontol kasutusele teavitused",
"New group call experience": "Uus rühmakõnede lahendus",
"Video call ended": "Videokõne on lõppenud",
"%(name)s started a video call": "%(name)s algatas videokõne",
"You do not have permission to start video calls": "Sul ei ole piisavalt õigusi videokõne alustamiseks",
"You do not have permission to start voice calls": "Sul ei ole piisavalt õigusi häälkõne alustamiseks",
"There's no one here to call": "Siin ei leidu kedagi, kellele helistada",
"Ongoing call": "Kõne on pooleli",
"Video call (Element Call)": "Videokõne (Element Call)",
"Video call (Jitsi)": "Videokõne (Jitsi)",
"Failed to set pusher state": "Tõuketeavituste teenuse oleku määramine ei õnnestunud",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessioni valitud",
"Receive push notifications on this session.": "Võta tõuketeavitused selles sessioonis kasutusele.",
"Push notifications": "Tõuketeavitused",
"Toggle push notifications on this session.": "Lülita tõuketeavitused selles sessioonis sisse/välja.",
"Enable notifications for this device": "Võta teavitused selles seadmes kasutusele",
"Turn off to disable notifications on all your devices and sessions": "Välja lülitades keelad teavitused kõikides oma seadmetes ja sessioonides"
}

View File

@ -2722,7 +2722,7 @@
"Size Limit": "Taille maximale",
"Format": "Format",
"Select from the options below to export chats from your timeline": "Sélectionner les options ci-dessous pour exporter les conversations de votre historique",
"Export Chat": "Exporter la conversation privée",
"Export Chat": "Exporter la conversation",
"Exporting your data": "Export de vos données",
"Stop": "Arrêter",
"Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Êtes vous sûr de vouloir arrêter lexport de vos données ? Si vous le fait, vous devrez recommencer depuis le début.",
@ -2735,7 +2735,7 @@
"Size can only be a number between %(min)s MB and %(max)s MB": "La taille ne peut être qu'un nombre compris entre %(min)s Mo et %(max)s Mo",
"Enter a number between %(min)s and %(max)s": "Entrez un nombre entre %(min)s et %(max)s",
"In reply to <a>this message</a>": "En réponse à <a>ce message</a>",
"Export chat": "Exporter la conversation privée",
"Export chat": "Exporter la conversation",
"File Attached": "Fichier attaché",
"Error fetching file": "Erreur lors de la récupération du fichier",
"Topic: %(topic)s": "Sujet : %(topic)s",
@ -3570,5 +3570,24 @@
"Rename session": "Renommer la session",
"Voice broadcast (under active development)": "Diffusion audio (en développement)",
"Element Call video rooms": "Salons vidéo Element Call",
"Voice broadcasts": "Diffusions audio"
"Voice broadcasts": "Diffusions audio",
"You do not have permission to start voice calls": "Vous navez pas la permission de démarrer un appel audio",
"There's no one here to call": "Il ny a personne à appeler ici",
"You do not have permission to start video calls": "Vous navez pas la permission de démarrer un appel vidéo",
"Ongoing call": "Appel en cours",
"Video call (Element Call)": "Appel vidéo (Element Call)",
"Video call (Jitsi)": "Appel vidéo (Jitsi)",
"Failed to set pusher state": "Échec lors de la définition de létat push",
"Receive push notifications on this session.": "Recevoir les notifications push sur cette session.",
"Push notifications": "Notifications push",
"Toggle push notifications on this session.": "Activer/désactiver les notifications push pour cette session.",
"New group call experience": "Nouvelle expérience dappel de groupe",
"Live": "Direct",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions sélectionnées",
"Enable notifications for this device": "Activer les notifications sur cet appareil",
"Turn off to disable notifications on all your devices and sessions": "Désactiver pour ne plus afficher les notifications sur tous vos appareils et sessions",
"Enable notifications for this account": "Activer les notifications pour ce compte",
"Video call ended": "Appel vidéo terminé",
"%(name)s started a video call": "%(name)s a démarré un appel vidéo",
"Record the client name, version, and url to recognise sessions more easily in session manager": "Enregistrez le nom, la version et l'URL du client afin de reconnaitre les sessions plus facilement dans le gestionnaire de sessions"
}

View File

@ -3570,5 +3570,23 @@
"Voice broadcast (under active development)": "Hang közvetítés (aktív fejlesztés alatt)",
"Element Call video rooms": "Element Call videó szoba",
"You need to be able to kick users to do that.": "Ahhoz, hogy ezt megtedd tudnod kell kirúgni felhasználókat.",
"Voice broadcasts": "Videó közvetítés"
"Voice broadcasts": "Videó közvetítés",
"Video call ended": "Videó hívás befejeződött",
"%(name)s started a video call": "%(name)s videóhívást indított",
"You do not have permission to start voice calls": "Nincs jogosultságod hang hívást indítani",
"There's no one here to call": "Itt nincs senki akit fel lehetne hívni",
"You do not have permission to start video calls": "Nincs jogosultságod videó hívást indítani",
"Ongoing call": "Hívás folyamatban",
"Video call (Element Call)": "Videóhívás (Element Call)",
"Video call (Jitsi)": "Videóhívás (Jitsi)",
"Failed to set pusher state": "Push állapot beállítás sikertelen",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s munkamenet kiválasztva",
"Receive push notifications on this session.": "Push értesítések fogadása ebben a munkamenetben.",
"Push notifications": "Push értesítések",
"Toggle push notifications on this session.": "Push értesítések átkapcsolása ebben a munkamenetben.",
"Enable notifications for this device": "Értesítések engedélyezése ehhez az eszközhöz",
"Turn off to disable notifications on all your devices and sessions": "Kikapcsolva az eszközökön és munkamenetekben az értesítések tiltva lesznek",
"Enable notifications for this account": "Értesítések engedélyezése ehhez a fiókhoz",
"New group call experience": "Új konferenciahívás élmény",
"Live": "Élő"
}

View File

@ -649,7 +649,7 @@
"Unmute": "Suarakan",
"Historical": "Riwayat",
"Invites": "Undangan",
"Offline": "Offline",
"Offline": "Luring",
"Idle": "Idle",
"Online": "Daring",
"Anyone": "Siapa Saja",
@ -1808,9 +1808,9 @@
"Recently visited rooms": "Ruangan yang baru saja dilihat",
"Room %(name)s": "Ruangan %(name)s",
"Unknown for %(duration)s": "Tidak diketahui untuk %(duration)s",
"Offline for %(duration)s": "Offline untuk %(duration)s",
"Offline for %(duration)s": "Luring selama %(duration)s",
"Idle for %(duration)s": "Idle untuk %(duration)s",
"Online for %(duration)s": "Daring untuk %(duration)s",
"Online for %(duration)s": "Daring selama %(duration)s",
"View message": "Tampilkan pesan",
"Message didn't send. Click for info.": "Pesan tidak terkirim. Klik untuk informasi.",
"End-to-end encryption isn't enabled": "Enkripsi ujung-ke-ujung belum diaktifkan",
@ -3545,7 +3545,7 @@
"Inviting %(user)s and %(count)s others|other": "Mengundang %(user)s dan %(count)s lainnya",
"%(user)s and %(count)s others|one": "%(user)s dan 1 lainnya",
"%(user)s and %(count)s others|other": "%(user)s dan %(count)s lainnya",
"%(user1)s and %(user2)s": "%(user1)s dan%(user2)s",
"%(user1)s and %(user2)s": "%(user1)s dan %(user2)s",
"Show": "Tampilkan",
"Unknown device type": "Tipe perangkat tidak diketahui",
"Video input %(n)s": "Masukan video %(n)s",
@ -3570,5 +3570,23 @@
"Voice broadcast": "Siaran suara",
"Voice broadcast (under active development)": "Siaran suara (dalam pemgembangan aktif)",
"Element Call video rooms": "Ruangan video Element Call",
"Voice broadcasts": "Siaran suara"
"Voice broadcasts": "Siaran suara",
"You do not have permission to start voice calls": "Anda tidak memiliki izin untuk memulai panggilan suara",
"There's no one here to call": "Tidak ada siapa pun di sini untuk dipanggil",
"You do not have permission to start video calls": "Anda tidak memiliki izin untuk memulai panggilan video",
"Ongoing call": "Panggilan sedang berlangsung",
"Video call (Element Call)": "Panggilan video (Element Call)",
"Video call (Jitsi)": "Panggilan video (Jitsi)",
"New group call experience": "Pengalaman panggilan grup baru",
"Live": "Langsung",
"Failed to set pusher state": "Gagal menetapkan keadaan pendorong",
"Receive push notifications on this session.": "Terima notifikasi dorongan di sesi ini.",
"Push notifications": "Notifikasi dorongan",
"Toggle push notifications on this session.": "Aktifkan atau nonaktifkan notifikasi dorongan di sesi ini.",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sesi dipilih",
"Enable notifications for this device": "Aktifkan notifikasi untuk perangkat ini",
"Turn off to disable notifications on all your devices and sessions": "Matikan untuk menonaktifkan notifikasi pada semua perangkat dan sesi Anda",
"Enable notifications for this account": "Aktifkan notifikasi untuk akun ini",
"Video call ended": "Panggilan video berakhir",
"%(name)s started a video call": "%(name)s memulai sebuah panggilan video"
}

View File

@ -3047,9 +3047,89 @@
"Download Element": "Sækja Element",
"Find people": "Finna fólk",
"Find friends": "Finna vini",
"Spell check": "Stafsetningarathugun",
"Spell check": "Stafsetningaryfirferð",
"Saved Items": "Vistuð atriði",
"Exit fullscreen": "Fara úr fullskjásstillingu",
"Enter fullscreen": "Fara í fullskjásstillingu",
"Show spaces": "Sýna svæði"
"Show spaces": "Sýna svæði",
"Failed to set direct message tag": "Ekki tókst að stilla merki um bein skilaboð",
"<a>Give feedback</a>": "<a>Gefðu umsögn</a>",
"Check your email to continue": "Skoðaðu tölvupóstinn þinn til að halda áfram",
"Stop and close": "Hætta og loka",
"Show rooms": "Sýna spjallrásir",
"Proxy URL": "Slóð milliþjóns",
"Proxy URL (optional)": "Slóð milliþjóns (valfrjálst)",
"Checking...": "Athuga...",
"Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "Notaðu auðkennisþjón til að geta boðið með tölvupósti. <default>Notaðu sjálfgefinn auðkennisþjón (%(defaultIdentityServerName)s(</default> eða sýslaðu með þetta í <settings>stillingunum</settings>.",
"Open room": "Opin spjallrás",
"Explore account data": "Skoða aðgangsgögn",
"Create a video room": "Búa til myndspjallrás",
"Get it on F-Droid": "Ná í á F-Droid",
"Get it on Google Play": "Ná í á Google Play",
"Android": "Android",
"Download on the App Store": "Sækja á App Store forritasafni",
"%(qrCode)s or %(appLinks)s": "%(qrCode)s eða %(appLinks)s",
"iOS": "iOS",
"Download %(brand)s Desktop": "Sækja %(brand)s Desktop fyrir vinnutölvur",
"Show: Matrix rooms": "Birta: Matrix-spjallrásir",
"Remove server “%(roomServer)s”": "Fjarlægja netþjóninn “%(roomServer)s”",
"Online community members": "Meðlimi samfélags á netinu",
"Coworkers and teams": "Samstarfsmenn og teymi",
"Friends and family": "Vinir og fjölskylda",
"We'll help you get connected.": "Við munum hjálpa þér að tengjast.",
"Choose a locale": "Veldu staðfærslu",
"Help": "Hjálp",
"Click to read topic": "Smelltu til að lesa umfjöllunarefni",
"Edit topic": "Breyta umfjöllunarefni",
"Video call ended": "Mynddsímtali lauk",
"View all": "Skoða allt",
"Show": "Sýna",
"Inactive": "Óvirkt",
"All": "Allt",
"No sessions found.": "Engar setur fundust.",
"No inactive sessions found.": "Engar óvirkar setur fundust.",
"No unverified sessions found.": "Engar óstaðfestar setur fundust.",
"No verified sessions found.": "Engar staðfestar setur fundust.",
"Unverified sessions": "Óstaðfestar setur",
"Unverified session": "Óstaðfest seta",
"Verified session": "Staðfest seta",
"Unknown device type": "Óþekkt tegund tækis",
"Unverified": "Óstaðfest",
"Verified": "Staðfest",
"Toggle device details": "Víxla ítarupplýsingum tækis af/á",
"Push notifications": "Ýti-tilkynningar",
"Session details": "Nánar um setuna",
"IP address": "IP-vistfang",
"Device": "Tæki",
"Last activity": "Síðasta virkni",
"Rename session": "Endurnefna setu",
"Current session": "Núverandi seta",
"Other sessions": "Aðrar setur",
"Sessions": "Setur",
"Enable hardware acceleration (restart %(appName)s to take effect)": "Virkja vélbúnaðarhröðun (endurræstu %(appName)s til að breytingar taki gildi)",
"Your server doesn't support disabling sending read receipts.": "Netþjónninn þinn styður ekki að sending leskvittana sé gerð óvirk.",
"Share your activity and status with others.": "Deila virkni og stöðu þinni með öðrum.",
"Presence": "Viðvera",
"Deactivating your account is a permanent action — be careful!": "Að gera aðganginn þinn óvirkan er endanleg aðgerð - farðu varlega!",
"You will not receive push notifications on other devices until you sign back in to them.": "Þú munt ekki fá ýti-tilkynningar á öðrum tækjum fyrr en þú skráir þig aftur inn á þeim.",
"Your password was successfully changed.": "Það tókst að breyta lykilorðinu þínu.",
"Welcome to %(brand)s": "Velkomin í %(brand)s",
"Welcome": "Velkomin/n",
"Find and invite your friends": "Finndu og bjóddu vinum þínum",
"You made it!": "Þú hafðir það!",
"Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.": "Byrjaðu skilaboð með <code>/plain</code> til að senda án markdown og með <code>/md</code> til að senda með slíku.",
"Send read receipts": "Senda leskvittanir",
"How can I create a video room?": "Hvernig bý ég til myndspjallrás?",
"Mapbox logo": "Mapbox-táknmerki",
"Location not available": "Staðsetning ekki tiltæk",
"Find my location": "Finna staðsetningu mína",
"Live": "Beint",
"You need to be able to kick users to do that.": "Þú þarft að hafa heimild til að sparka notendum til að gera þetta.",
"Empty room (was %(oldName)s)": "Tóm spjallrás (var %(oldName)s)",
"Inviting %(user)s and %(count)s others|one": "Býð %(user)s og 1 öðrum",
"Inviting %(user)s and %(count)s others|other": "Býð %(user)s og %(count)s til viðbótar",
"Inviting %(user1)s and %(user2)s": "Býð %(user1)s og %(user2)s",
"%(user)s and %(count)s others|one": "%(user)s og 1 annar",
"%(user)s and %(count)s others|other": "%(user)s og %(count)s til viðbótar",
"%(user1)s and %(user2)s": "%(user1)s og %(user2)s"
}

View File

@ -1500,7 +1500,7 @@
"Page Down": "Pagina giù",
"Esc": "Esc",
"Enter": "Invio",
"Space": "Barra spaziatrice",
"Space": "Spazio",
"End": "Fine",
"Manually Verify by Text": "Verifica manualmente con testo",
"Interactively verify by Emoji": "Verifica interattivamente con emoji",
@ -3570,5 +3570,24 @@
"Rename session": "Rinomina sessione",
"Voice broadcast (under active development)": "Broadcast voce (in sviluppo attivo)",
"Voice broadcasts": "Broadcast voce",
"Element Call video rooms": "Stanze video di Element Call"
"Element Call video rooms": "Stanze video di Element Call",
"You do not have permission to start voice calls": "Non hai il permesso di avviare chiamate",
"There's no one here to call": "Non c'è nessuno da chiamare qui",
"You do not have permission to start video calls": "Non hai il permesso di avviare videochiamate",
"Ongoing call": "Chiamata in corso",
"Video call (Element Call)": "Videochiamata (Element Call)",
"Video call (Jitsi)": "Videochiamata (Jitsi)",
"New group call experience": "Nuova esperienza per chiamate di gruppo",
"Live": "In diretta",
"Failed to set pusher state": "Impostazione stato del push fallita",
"Receive push notifications on this session.": "Ricevi notifiche push in questa sessione.",
"Push notifications": "Notifiche push",
"Toggle push notifications on this session.": "Attiva/disattiva le notifiche push in questa sessione.",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessioni selezionate",
"Enable notifications for this device": "Attiva le notifiche per questo dispositivo",
"Turn off to disable notifications on all your devices and sessions": "Disabilita per disattivare le notifiche in tutti i dispositivi e sessioni",
"Enable notifications for this account": "Attiva le notifiche per questo account",
"Video call ended": "Videochiamata terminata",
"%(name)s started a video call": "%(name)s ha iniziato una videochiamata",
"Record the client name, version, and url to recognise sessions more easily in session manager": "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni"
}

View File

@ -747,19 +747,19 @@
"For help with using %(brand)s, click <a>here</a>.": "Para obter ajuda com o uso do %(brand)s, clique <a>aqui</a>.",
"For help with using %(brand)s, click <a>here</a> or start a chat with our bot using the button below.": "Para obter ajuda com o uso do %(brand)s, clique <a>aqui</a> ou inicie um bate-papo com nosso bot usando o botão abaixo.",
"Chat with %(brand)s Bot": "Converse com o bot do %(brand)s",
"Help & About": "Ajuda & Sobre",
"Help & About": "Ajuda e sobre",
"Bug reporting": "Relato de Erros",
"FAQ": "FAQ",
"Versions": "Versões",
"Preferences": "Preferências",
"Composer": "Campo de texto",
"Timeline": "Conversas",
"Room list": "Lista de Salas",
"Room list": "Lista de salas",
"Autocomplete delay (ms)": "Atraso no preenchimento automático (ms)",
"Ignored users": "Usuários bloqueados",
"Bulk options": "Opções em massa",
"Accept all %(invitedRooms)s invites": "Aceite todos os convites de %(invitedRooms)s",
"Security & Privacy": "Segurança & Privacidade",
"Security & Privacy": "Segurança e privacidade",
"Missing media permissions, click the button below to request.": "Permissões de mídia ausentes, clique no botão abaixo para solicitar.",
"Request media permissions": "Solicitar permissões de mídia",
"Voice & Video": "Voz e vídeo",
@ -774,7 +774,7 @@
"Change permissions": "Alterar permissões",
"Change topic": "Alterar a descrição",
"Modify widgets": "Modificar widgets",
"Default role": "Papel padrão",
"Default role": "Cargo padrão",
"Send messages": "Enviar mensagens",
"Invite users": "Convidar usuários",
"Use Single Sign On to continue": "Use \"Single Sign On\" para continuar",
@ -1120,7 +1120,7 @@
"Click the button below to confirm adding this phone number.": "Clique no botão abaixo para confirmar a adição deste número de telefone.",
"Clear notifications": "Limpar notificações",
"Enable desktop notifications for this session": "Ativar notificações na área de trabalho nesta sessão",
"Mentions & Keywords": "Apenas @menções e palavras-chave",
"Mentions & Keywords": "Menções e palavras-chave",
"Notification options": "Alterar notificações",
"Forget Room": "Esquecer Sala",
"Favourited": "Favoritado",
@ -1304,8 +1304,8 @@
"Read Marker off-screen lifetime (ms)": "Vida útil do marcador de leitura fora da tela (ms)",
"Change settings": "Alterar configurações",
"Send %(eventType)s events": "Enviar eventos de %(eventType)s",
"Roles & Permissions": "Papeis & Permissões",
"Select the roles required to change various parts of the room": "Selecione os papeis necessários para alterar várias partes da sala",
"Roles & Permissions": "Cargos e permissões",
"Select the roles required to change various parts of the room": "Selecione os cargos necessários para alterar várias partes da sala",
"Room %(name)s": "Sala %(name)s",
"No recently visited rooms": "Nenhuma sala foi visitada recentemente",
"Joining room …": "Entrando na sala…",
@ -1658,8 +1658,8 @@
"Privacy": "Privacidade",
"Room Info": "Informações da sala",
"Widgets": "Widgets",
"Edit widgets, bridges & bots": "Editar widgets, integrações & bots",
"Add widgets, bridges & bots": "Adicionar widgets, integrações & bots",
"Edit widgets, bridges & bots": "Editar widgets, integrações e bots",
"Add widgets, bridges & bots": "Adicionar widgets, integrações e bots",
"Room settings": "Configurações da sala",
"Take a picture": "Tirar uma foto",
"Use the <a>Desktop app</a> to see all encrypted files": "Use o <a>app para Computador</a> para ver todos os arquivos criptografados",
@ -1670,7 +1670,7 @@
"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",
"Safeguard against losing access to encrypted messages & data": "Proteja-se contra a perda de acesso a mensagens e 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",
@ -2699,7 +2699,7 @@
"Change space avatar": "Alterar avatar do espaço",
"You won't get any notifications": "Você não receberá nenhuma notificação",
"Get notified only with mentions and keywords as set up in your <a>settings</a>": "Receba notificações apenas com menções e palavras-chave conforme definido em suas <a>configurações</a>",
"@mentions & keywords": "@menções & palavras-chave",
"@mentions & keywords": "@menções e palavras-chave",
"Get notified for every message": "Seja notificado para cada mensagem",
"Get notifications as set up in your <a>settings</a>": "Receba notificações conforme configurado em suas <a>configurações</a>",
"This room isn't bridging messages to any platforms. <a>Learn more.</a>": "Esta sala não está conectando mensagens a nenhuma plataforma. <a> Saiba mais. </a>",
@ -2708,7 +2708,7 @@
"Show all your rooms in Home, even if they're in a space.": "Mostre todas as suas salas no Início, mesmo que elas estejam em um espaço.",
"Home is useful for getting an overview of everything.": "A página inicial é útil para obter uma visão geral de tudo.",
"Spaces to show": "Espaços para mostrar",
"Sidebar": "Barra Lateral",
"Sidebar": "Barra lateral",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Gerencie seus dispositivos conectados abaixo. O nome de um dispositivo é visível para as pessoas com quem você se comunica.",
"Where you're signed in": "Onde você está conectado",
"Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Compartilhe dados anônimos para nos ajudar a identificar problemas. Nada pessoal. Sem terceiros.",
@ -2763,5 +2763,70 @@
"Leave all rooms": "Sair de todas as salas",
"Don't leave any rooms": "Não saia de nenhuma sala",
"Jump to date": "Ir para Data",
"Command error: Unable to find rendering type (%(renderingType)s)": "Erro de comando: Não é possível manipular o tipo (%(renderingType)s)"
"Command error: Unable to find rendering type (%(renderingType)s)": "Erro de comando: Não é possível manipular o tipo (%(renderingType)s)",
"Unable to copy a link to the room to the clipboard.": "Não foi possível copiar um link da sala para a área de transferência.",
"Unable to copy room link": "Não foi possível copiar o link da sala",
"Copy room link": "Copiar link da sala",
"Public rooms": "Salas públicas",
"Room ID: %(roomId)s": "ID da sala: %(roomId)s",
"%(qrCode)s or %(appLinks)s": "%(qrCode)s ou %(appLinks)s",
"%(qrCode)s or %(emojiCompare)s": "%(qrCode)s ou %(emojiCompare)s",
"Create a video room": "Criar uma sala de vídeo",
"Create video room": "Criar sala de vídeo",
"Create room": "Criar sala",
"Android": "Android",
"iOS": "iOS",
"Add new server…": "Adicionar um novo servidor…",
"were removed %(count)s times|other": "foram removidos %(count)s vezes",
"were removed %(count)s times|one": "foram removidos",
"was removed %(count)s times|other": "foi removido %(count)s vezes",
"was removed %(count)s times|one": "foi removido",
"Last month": "Último mês",
"Last week": "Última semana",
"%(count)s participants|other": "%(count)s participantes",
"%(count)s participants|one": "1 participante",
"Saved Items": "Itens salvos",
"Add space": "Adicionar espaço",
"Video room": "Sala de vídeo",
"Private space": "Espaço privado",
"Private room": "Sala privada",
"New video room": "Nova sala de vídeo",
"New room": "Nova sala",
"Seen by %(count)s people|one": "Visto por %(count)s pessoa",
"Seen by %(count)s people|other": "Visto por %(count)s pessoas",
"Security recommendations": "Recomendações de segurança",
"Filter devices": "Filtrar dispositivos",
"Inactive sessions": "Sessões inativas",
"Unverified sessions": "Sessões não verificadas",
"Verified sessions": "Sessões verificadas",
"Unverified session": "Sessão não verificada",
"Verified session": "Sessão verificada",
"Unverified": "Não verificado",
"Verified": "Verificado",
"Session details": "Detalhes da sessão",
"IP address": "Endereço de IP",
"Device": "Dispositivo",
"Rename session": "Renomear sessão",
"Remove users": "Remover usuários",
"Other sessions": "Outras sessões",
"Sessions": "Sessões",
"Keyboard": "Teclado",
"IRC (Experimental)": "IRC (experimental)",
"Turn off camera": "Desligar câmera",
"Turn on camera": "Ligar câmera",
"Your profile": "Seu perfil",
"Find people": "Encontrar pessoas",
"Enable hardware acceleration": "Habilitar aceleração de hardware",
"Enable Markdown": "Habilitar markdown",
"Export successful!": "Exportação realizada com sucesso!",
"Generating a ZIP": "Gerando um ZIP",
"Starting export...": "Iniciando exportação...",
"Creating HTML...": "Criando HTML...",
"User is already in the space": "O usuário já está no espaço",
"User is already in the room": "O usuário já está na sala",
"User does not exist": "O usuário não existe",
"Inviting %(user1)s and %(user2)s": "Convidando %(user1)s e %(user2)s",
"%(user1)s and %(user2)s": "%(user1)s e %(user2)s",
"%(value)sh": "%(value)sh",
"%(value)sd": "%(value)sd"
}

View File

@ -215,7 +215,7 @@
"You have <a>enabled</a> URL previews by default.": "Предпросмотр ссылок по умолчанию <a>включен</a> для вас.",
"You seem to be in a call, are you sure you want to quit?": "Звонок не завершён. Уверены, что хотите выйти?",
"You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "Вы не сможете отменить это действие, так как этот пользователь получит уровень прав, равный вашему.",
"Options": "Настройки",
"Options": "Дополнительно",
"Passphrases must match": "Пароли должны совпадать",
"Passphrase must not be empty": "Пароль не должен быть пустым",
"Export room keys": "Экспорт ключей комнаты",
@ -252,7 +252,7 @@
"%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s удалил(а) аватар комнаты.",
"%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s изменил(а) аватар комнаты %(roomName)s",
"Create new room": "Создать комнату",
"Start chat": "Начать чат",
"Start chat": "Отправить личное сообщение",
"Add": "Добавить",
"Uploading %(filename)s and %(count)s others|zero": "Отправка %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Отправка %(filename)s и %(count)s другой",
@ -613,7 +613,7 @@
"Timeline": "Лента сообщений",
"Autocomplete delay (ms)": "Задержка автодополнения (мс)",
"Roles & Permissions": "Роли и права",
"Security & Privacy": "Безопасность и конфиденциальность",
"Security & Privacy": "Безопасность и приватность",
"Encryption": "Шифрование",
"Encrypted": "Зашифровано",
"Ignored users": "Игнорируемые пользователи",
@ -1098,7 +1098,7 @@
"View": "Просмотр",
"Find a room…": "Поиск комнат…",
"Find a room… (e.g. %(exampleRoom)s)": "Поиск комнат... (напр. %(exampleRoom)s)",
"Explore rooms": "Список комнат",
"Explore rooms": "Обзор комнат",
"Command Autocomplete": "Автозаполнение команды",
"Emoji Autocomplete": "Автодополнение смайлов",
"Notification Autocomplete": "Автозаполнение уведомлений",
@ -1106,11 +1106,11 @@
"User Autocomplete": "Автозаполнение пользователя",
"Quick Reactions": "Быстрая реакция",
"Frequently Used": "Часто используемые",
"Smileys & People": "Смайлики & Люди",
"Animals & Nature": "Животные & Природа",
"Food & Drink": "Еда & Напитки",
"Smileys & People": "Смайлики и люди",
"Animals & Nature": "Животные и природа",
"Food & Drink": "Еда и напитки",
"Activities": "Действия",
"Travel & Places": "Путешествия & Места",
"Travel & Places": "Путешествия и места",
"Objects": "Объекты",
"Symbols": "Символы",
"Flags": "Флаги",
@ -1135,14 +1135,14 @@
"Error upgrading room": "Ошибка обновления комнаты",
"Match system theme": "Тема системы",
"Show typing notifications": "Уведомлять о наборе текста",
"Enable desktop notifications for this session": "Включить уведомления для рабочего стола для этой сессии",
"Enable audible notifications for this session": "Включить звуковые уведомления для этой сессии",
"Enable desktop notifications for this session": "Уведомления рабочего стола для этой сессии",
"Enable audible notifications for this session": "Звуковые уведомления для этой сессии",
"Manage integrations": "Управление интеграциями",
"Direct Messages": "Личные сообщения",
"%(count)s sessions|other": "Сессий: %(count)s",
"Hide sessions": "Свернуть сессии",
"Enable 'Manage Integrations' in Settings to do this.": "Включите «Управление интеграциями» в настройках, чтобы сделать это.",
"Verify this session": "Заверить эту сессию",
"Verify this session": "Заверьте эту сессию",
"Verifies a user, session, and pubkey tuple": "Проверяет пользователя, сессию и публичные ключи",
"Session already verified!": "Сессия уже подтверждена!",
"Never send encrypted messages to unverified sessions from this session": "Никогда не отправлять зашифрованные сообщения непроверенным сессиям в этой сессии",
@ -1193,7 +1193,7 @@
"They don't match": "Они не совпадают",
"To be secure, do this in person or use a trusted way to communicate.": "Чтобы быть в безопасности, делайте это лично или используйте надежный способ связи.",
"Lock": "Заблокировать",
"Other users may not trust it": "Другие пользователи могут не доверять этому",
"Other users may not trust it": "Другие пользователи могут не доверять этой сессии",
"Upgrade": "Обновление",
"Verify": "Заверить",
"Later": "Позже",
@ -1425,7 +1425,7 @@
"Clear cross-signing keys": "Очистить ключи кросс-подписи",
"Clear all data in this session?": "Очистить все данные в этой сессии?",
"Enable end-to-end encryption": "Включить сквозное шифрование",
"Verify session": "Подтвердить сессию",
"Verify session": "Заверить сессию",
"Session name": "Название сессии",
"Session key": "Ключ сессии",
"To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.": "Чтобы сообщить о проблеме безопасности Matrix, пожалуйста, прочитайте <a>Политику раскрытия информации</a> Matrix.org.",
@ -1448,7 +1448,7 @@
"If your other sessions do not have the key for this message you will not be able to decrypt them.": "Вы не сможете расшифровать это сообщение в других сессиях, если у них нет ключа для него.",
"For extra security, verify this user by checking a one-time code on both of your devices.": "Для дополнительной безопасности подтвердите этого пользователя, сравнив одноразовый код на ваших устройствах.",
"Start verification again from their profile.": "Начните подтверждение заново в профиле пользователя.",
"Send a Direct Message": "Отправить сообщение",
"Send a Direct Message": "Отправить личное сообщение",
"Light": "Светлая",
"Dark": "Темная",
"Recent Conversations": "Недавние Диалоги",
@ -2289,7 +2289,7 @@
"Create a new room": "Создать новую комнату",
"Spaces": "Пространства",
"Space selection": "Выбор пространства",
"Edit devices": "Редактировать устройства",
"Edit devices": "Редактировать сессии",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии вбудущем.",
"Empty room": "Пустая комната",
"Suggested Rooms": "Предлагаемые комнаты",
@ -2321,8 +2321,8 @@
"Create a space": "Создать пространство",
"Delete": "Удалить",
"Jump to the bottom of the timeline when you send a message": "Перейти к нижней части временной шкалы, когда вы отправляете сообщение",
"Check your devices": "Проверьте ваши устройства",
"You have unverified logins": "У вас есть не проверенные входы в систему",
"Check your devices": "Проверить сессии",
"You have unverified logins": "У вас есть незаверенные сессии",
"This homeserver has been blocked by its administrator.": "Доступ к этому домашнему серверу заблокирован вашим администратором.",
"You're already in a call with this person.": "Вы уже разговариваете с этим человеком.",
"Already in call": "Уже в вызове",
@ -2426,7 +2426,7 @@
"If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Если вы сбросите все настройки, вы перезагрузитесь без доверенных сессий, без доверенных пользователей и, возможно, не сможете просматривать прошлые сообщения.",
"Only do this if you have no other device to complete verification with.": "Делайте это только в том случае, если у вас нет другого устройства для завершения проверки.",
"Reset everything": "Сбросить всё",
"Forgotten or lost all recovery methods? <a>Reset all</a>": "Забыли или потеряли все методы восстановления? <a>Сбросить всё</a>",
"Forgotten or lost all recovery methods? <a>Reset all</a>": "Забыли или потеряли все варианты восстановления? <a>Сбросить всё</a>",
"Settings - %(spaceName)s": "Настройки - %(spaceName)s",
"Reset event store": "Сброс хранилища событий",
"If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Если вы это сделаете, обратите внимание, что ни одно из ваших сообщений не будет удалено, но работа поиска может быть ухудшена на несколько мгновений, пока индекс не будет воссоздан",
@ -2588,7 +2588,7 @@
"Global": "Глобально",
"New keyword": "Новое ключевое слово",
"Keyword": "Ключевое слово",
"Enable email notifications for %(email)s": "Включить уведомления по электронной почте для %(email)s",
"Enable email notifications for %(email)s": "Уведомления по электронной почте для %(email)s",
"Enable for this account": "Включить для этого аккаунта",
"An error occurred whilst saving your notification preferences.": "При сохранении ваших настроек уведомлений произошла ошибка.",
"Error saving notification preferences": "Ошибка при сохранении настроек уведомлений",
@ -2640,7 +2640,7 @@
"Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Прототип \"Сообщить модераторам\". В комнатах, поддерживающих модерацию, кнопка `сообщить` позволит вам сообщать о злоупотреблениях модераторам комнаты",
"Silence call": "Тихий вызов",
"Sound on": "Звук включен",
"Review to ensure your account is safe": "Проверьте, чтобы убедиться, что ваша учетная запись в безопасности",
"Review to ensure your account is safe": "Проверьте, чтобы убедиться, что ваша учётная запись в безопасности",
"See when people join, leave, or are invited to your active room": "Просмотрите, когда люди присоединяются, уходят или приглашают в вашу активную комнату",
"See when people join, leave, or are invited to this room": "Посмотрите, когда люди присоединяются, покидают или приглашают в эту комнату",
"%(senderName)s changed the <a>pinned messages</a> for the room.": "%(senderName)s изменил(а) <a>закреплённые сообщения</a> в этой комнате.",
@ -2657,7 +2657,7 @@
"%(senderName)s removed their profile picture": "%(senderName)s удалил(а) аватар",
"%(senderName)s removed their display name (%(oldDisplayName)s)": "%(senderName)s удалил(а) отображаемое имя (%(oldDisplayName)s)",
"%(senderName)s set their display name to %(displayName)s": "%(senderName)s установил(а) отображаемое имя %(displayName)s",
"%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s изменил(а) отображаемое имя на %(displayName)s",
"%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s изменил(а) имя на %(displayName)s",
"%(senderName)s banned %(targetName)s": "%(senderName)s заблокировал(а) %(targetName)s",
"%(senderName)s banned %(targetName)s: %(reason)s": "%(senderName)s заблокировал(а) %(targetName)s: %(reason)s",
"%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s принял(а) приглашение для %(displayName)s",
@ -2710,7 +2710,7 @@
"Would you like to leave the rooms in this space?": "Хотите ли вы покинуть комнаты в этом пространстве?",
"You are about to leave <spaceName/>.": "Вы собираетесь покинуть <spaceName/>.",
"%(reactors)s reacted with %(content)s": "%(reactors)s отреагировали %(content)s",
"Message": "Сообщение",
"Message": "Отправить личное сообщение",
"Joining space …": "Присоединение к пространству…",
"Message didn't send. Click for info.": "Сообщение не отправлено. Нажмите для получения информации.",
"To join a space you'll need an invite.": "Чтобы присоединиться к пространству, вам нужно получить приглашение.",
@ -2832,7 +2832,7 @@
"Verify with another device": "Заверить с помощью другого устройства",
"Someone already has that username, please try another.": "У кого-то уже есть такое имя пользователя, пожалуйста, попробуйте другое.",
"Device verified": "Устройство заверено",
"Verify this device": "Заверить это устройство",
"Verify this device": "Заверьте эту сессию",
"Unable to verify this device": "Не удалось проверить это устройство",
"Show all threads": "Показать все обсуждения",
"Keep discussions organised with threads": "Организуйте обсуждения с помощью обсуждений",
@ -2991,7 +2991,7 @@
"Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Пространства - это способ группировки комнат и людей. Наряду с пространствами, в которых вы находитесь, вы также можете использовать некоторые предварительно созданные пространства.",
"Spaces to show": "Пространства для показа",
"Sidebar": "Боковая панель",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Управляйте устройствами, на которых вы вошли. Имя устройства видят люди, с которыми вы общаетесь.",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Управляйте сессиями, в которые вы вошли. Название сессии видят люди, с которыми вы общаетесь.",
"Where you're signed in": "Где вы вошли",
"Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Поделитесь анонимными данными, чтобы помочь нам выявить проблемы. Никаких личных данных. Никаких третьих лиц.",
"Okay": "Хорошо",
@ -3006,13 +3006,13 @@
"Rename": "Переименовать",
"Sign Out": "Выйти",
"Last seen %(date)s at %(ip)s": "Последнее посещение %(date)s на %(ip)s",
"This device": "Это устройство",
"This device": "Текущая сессия",
"You aren't signed into any other devices.": "Вы не вошли ни на каких других устройствах.",
"Sign out %(count)s selected devices|one": "Выйти из %(count)s выбранного устройства",
"Sign out %(count)s selected devices|other": "Выйти из %(count)s выбранных устройств",
"Devices without encryption support": "Устройства без поддержки шифрования",
"Unverified devices": "Незаверенные устройства",
"Verified devices": "Заверенные устройства",
"Sign out %(count)s selected devices|one": "Выйти из %(count)s выбранной сессии",
"Sign out %(count)s selected devices|other": "Выйти из %(count)s выбранных сессий",
"Devices without encryption support": "Сессии без поддержки шифрования",
"Unverified devices": "Незаверенные сессии",
"Verified devices": "Заверенные сессии",
"Select all": "Выбрать все",
"Deselect all": "Отменить выбор",
"Sign out devices|one": "Выйти из устройства",
@ -3193,7 +3193,7 @@
"You do not have permission to invite people to this space.": "Вам не разрешено приглашать людей в это пространство.",
"<b>Tip:</b> Use “%(replyInThread)s” when hovering over a message.": "<b>Совет:</b> Используйте “%(replyInThread)s” при наведении курсора на сообщение.",
"Threads help keep your conversations on-topic and easy to track.": "Обсуждения помогают поддерживать и легко отслеживать тематику бесед.",
"Do you want to enable threads anyway?": "Вы хотите включить обсуждения в любом случае?",
"Do you want to enable threads anyway?": "Вы всё равно хотите включить обсуждения?",
"Your homeserver does not currently support threads, so this feature may be unreliable. Some threaded messages may not be reliably available. <a>Learn more</a>.": "Ваш домашний сервер в настоящее время не поддерживает обсуждения, поэтому эта функция может быть ненадёжной. Некоторые обсуждения могут быть недоступны. <a>Узнать больше</a>.",
"Partial Support for Threads": "Частичная поддержка обсуждений",
"To leave, return to this page and use the “%(leaveTheBeta)s” button.": "Чтобы выйти, вернитесь на эту страницу и воспользуйтесь кнопкой “%(leaveTheBeta)s”.",
@ -3412,7 +3412,7 @@
"View live location": "Посмотреть трансляцию местоположения",
"Live location enabled": "Трансляция местоположения включена",
"<a>Give feedback</a>": "<a>Оставить отзыв</a>",
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "Если не можете найти нужную комнату, просто попросите пригласить вас или <a>создайте новую комнату</a>.",
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "Если не можете найти нужную комнату, просто попросите пригласить вас или <a>создайте новую</a>.",
"If you can't find the room you're looking for, ask for an invite or create a new room.": "Если не можете найти нужную комнату, просто попросите пригласить вас или создайте новую комнату.",
"Location sharing - pin drop": "Поделиться местоположением — произвольная метка на карте",
"Live Location Sharing (temporary implementation: locations persist in room history)": "Поделиться трансляцией местоположения (временная реализация: местоположения сохраняются в истории комнат)",
@ -3443,7 +3443,7 @@
"In %(spaceName)s and %(count)s other spaces.|one": "В %(spaceName)s и %(count)s другом пространстве.",
"In %(spaceName)s and %(count)s other spaces.|other": "В %(spaceName)s и %(count)s других пространствах.",
"In spaces %(space1Name)s and %(space2Name)s.": "В пространствах %(space1Name)s и %(space2Name)s.",
"Use new session manager (under active development)": "Использовать новый менеджер сессий (в активной разработке)",
"Use new session manager (under active development)": "Новый менеджер сессий (в активной разработке)",
"Unverified": "Не заверено",
"Verified": "Заверено",
"IP address": "IP-адрес",
@ -3530,7 +3530,7 @@
"%(user)s and %(count)s others|one": "%(user)s и ещё 1",
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Подтвердите свои сессии для более безопасного обмена сообщениями или выйдите из тех, которые более не признаёте или не используете.",
"For best security, sign out from any session that you don't recognize or use anymore.": "Для лучшей безопасности выйдите из всех сессий, которые более не признаёте или не используете.",
"Inactive for %(inactiveAgeDays)s days or longer": "Неактивна в течение %(inactiveAgeDays)s дней или дольше",
"Inactive for %(inactiveAgeDays)s days or longer": "Неактивны %(inactiveAgeDays)s дней или дольше",
"No inactive sessions found.": "Неактивные сессии не обнаружены.",
"No sessions found.": "Сессии не обнаружены.",
"Unknown device type": "Неизвестный тип устройства",
@ -3544,5 +3544,15 @@
"%(qrCode)s or %(emojiCompare)s": "%(qrCode)s или %(emojiCompare)s",
"%(qrCode)s or %(appLinks)s": "%(qrCode)s или %(appLinks)s",
"%(securityKey)s or %(recoveryFile)s": "%(securityKey)s или %(recoveryFile)s",
"%(downloadButton)s or %(copyButton)s": "%(downloadButton)s или %(copyButton)s"
"%(downloadButton)s or %(copyButton)s": "%(downloadButton)s или %(copyButton)s",
"Sign out of this session": "Выйти из этой сессии",
"Please be aware that session names are also visible to people you communicate with": "Пожалуйста, имейте в виду, что названия сессий также видны людям, с которыми вы общаетесь",
"Push notifications": "Push-уведомления",
"Receive push notifications on this session.": "Получать push-уведомления в этой сессии.",
"Toggle push notifications on this session.": "Push-уведомления для этой сессии.",
"Enable notifications for this device": "Уведомления для этой сессии",
"Enable notifications for this account": "Уведомления для этой учётной записи",
"Turn off to disable notifications on all your devices and sessions": "Выключите, чтобы отключить уведомления во всех своих сессиях",
"Failed to set pusher state": "Не удалось установить состояние push-службы",
"%(selectedDeviceCount)s sessions selected": "Выбрано сессий: %(selectedDeviceCount)s"
}

View File

@ -3570,5 +3570,23 @@
"Element Call video rooms": "Element Call video miestnosti",
"Voice broadcast": "Hlasové vysielanie",
"Voice broadcast (under active development)": "Hlasové vysielanie (v štádiu aktívneho vývoja)",
"Voice broadcasts": "Hlasové vysielania"
"Voice broadcasts": "Hlasové vysielania",
"You do not have permission to start voice calls": "Nemáte povolenie na spustenie hlasových hovorov",
"There's no one here to call": "Nie je tu nikto, komu by ste mohli zavolať",
"You do not have permission to start video calls": "Nemáte povolenie na spustenie videohovorov",
"Ongoing call": "Prebiehajúci hovor",
"Video call (Element Call)": "Videohovor (Element hovor)",
"Video call (Jitsi)": "Videohovor (Jitsi)",
"New group call experience": "Nový zážitok zo skupinových hovorov",
"Live": "Naživo",
"Failed to set pusher state": "Nepodarilo sa nastaviť stav push oznámení",
"Receive push notifications on this session.": "Prijímať push oznámenia v tejto relácii.",
"Push notifications": "Push oznámenia",
"Toggle push notifications on this session.": "Prepnúť push oznámenia v tejto relácii.",
"Enable notifications for this device": "Povoliť oznámenia pre toto zariadenie",
"Turn off to disable notifications on all your devices and sessions": "Vypnutím vypnete upozornenia na všetkých svojich zariadeniach a reláciách",
"Enable notifications for this account": "Povoliť oznámenia pre tento účet",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybratých relácií",
"Video call ended": "Videohovor ukončený",
"%(name)s started a video call": "%(name)s začal/a videohovor"
}

View File

@ -2736,7 +2736,7 @@
"Are you sure you want to exit during this export?": "Är du säker på att du vill avsluta under den här exporten?",
"In reply to <a>this message</a>": "Som svar på <a>detta meddelande</a>",
"Downloading": "Laddar ner",
"They won't be able to access whatever you're not an admin of.": "De kommer inte kunna komma åt saker du inte är admin för.",
"They won't be able to access whatever you're not an admin of.": "Personen kommer inte kunna komma åt saker du inte är admin för.",
"Ban them from specific things I'm able to": "Banna dem från specifika saker jag kan",
"Unban them from specific things I'm able to": "Avbanna dem från specifika saker jag kan",
"Ban them from everything I'm able to": "Banna dem från allt jag kan",

View File

@ -24,7 +24,7 @@
"Admin": "Адміністратор",
"Admin Tools": "Засоби адміністрування",
"No Microphones detected": "Мікрофон не виявлено",
"No Webcams detected": "Веб-камеру не виявлено",
"No Webcams detected": "Вебкамеру не виявлено",
"Favourites": "Вибрані",
"No media permissions": "Немає медіадозволів",
"You may need to manually permit %(brand)s to access your microphone/webcam": "Можливо, вам треба дозволити %(brand)s використання мікрофону/камери вручну",
@ -46,7 +46,7 @@
"Attachment": "Прикріплення",
"Banned users": "Заблоковані користувачі",
"Bans user with given id": "Блокує користувача з указаним ID",
"Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.": "Не вдається підключитись до домашнього серверу - перевірте підключення, переконайтесь, що ваш <a>SSL-сертифікат домашнього сервера</a> є довіреним і що розширення браузера не блокує запити.",
"Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.": "Не вдалося під'єднатися до домашнього сервера — перевірте з'єднання, переконайтесь, що ваш <a>SSL-сертифікат домашнього сервера</a> довірений і що розширення браузера не блокує запити.",
"Change Password": "Змінити пароль",
"%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s змінює рівень повноважень %(powerLevelDiffText)s.",
"%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s змінює назву кімнати на %(roomName)s.",
@ -172,7 +172,7 @@
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(day)s %(monthName)s %(fullYear)s %(time)s",
"Permission Required": "Потрібен дозвіл",
"You do not have permission to start a conference call in this room": "У вас немає дозволу, щоб розпочати груповий виклик у цій кімнаті",
"%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s не має дозволу надсилати вам сповіщення — будь ласка, перевірте налаштування переглядача",
"%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s не має дозволу надсилати вам сповіщення — перевірте налаштування браузера",
"%(brand)s was not given permission to send notifications - please try again": "%(brand)s не має дозволу надсилати сповіщення — будь ласка, спробуйте ще раз",
"Unable to enable Notifications": "Не вдалося увімкнути сповіщення",
"This email address was not found": "Не знайдено адресу електронної пошти",
@ -231,7 +231,7 @@
"This homeserver has exceeded one of its resource limits.": "Цей домашній сервер досягнув одного зі своїх лімітів ресурсів.",
"Please <a>contact your service administrator</a> to continue using the service.": "Будь ласка, <a>зв'яжіться з адміністратором вашого сервісу</a>, щоб продовжити користуватися цим сервісом.",
"Unable to connect to Homeserver. Retrying...": "Не вдається приєднатися до домашнього сервера. Повторення спроби...",
"Your browser does not support the required cryptography extensions": "Ваша веб-переглядачка не підтримує необхідних криптографічних функцій",
"Your browser does not support the required cryptography extensions": "Ваш браузер не підтримує необхідних криптографічних функцій",
"Not a valid %(brand)s keyfile": "Файл ключа %(brand)s некоректний",
"Authentication check failed: incorrect password?": "Помилка автентифікації: неправильний пароль?",
"Please contact your homeserver administrator.": "Будь ласка, зв'яжіться з адміністратором вашого домашнього сервера.",
@ -326,7 +326,7 @@
"Your homeserver doesn't seem to support this feature.": "Схоже, що ваш домашній сервер не підтримує цю властивість.",
"Sign out and remove encryption keys?": "Вийти та видалити ключі шифрування?",
"Clear Storage and Sign Out": "Очистити сховище та вийти",
"Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Очищення сховища вашого переглядача може усунути проблему, але воно виведе вас з системи та зробить непрочитною історію ваших зашифрованих листувань.",
"Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Очищення сховища вашого браузера може усунути проблему, але ви вийдете з системи та зробить історію вашого зашифрованого спілкування непрочитною.",
"Verification Pending": "Очікується перевірка",
"Upload files (%(current)s of %(total)s)": "Вивантажити файли (%(current)s з %(total)s)",
"Upload files": "Вивантажити файли",
@ -463,9 +463,9 @@
"%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s змінює головні та альтернативні адреси для цієї кімнати.",
"%(senderName)s changed the addresses for this room.": "%(senderName)s змінює адреси для цієї кімнати.",
"%(senderName)s placed a voice call.": "%(senderName)s розпочинає голосовий виклик.",
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s розпочинає голосовий виклик. (не підтримується цим переглядачем)",
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s розпочинає голосовий виклик. (не підтримується цим браузером)",
"%(senderName)s placed a video call.": "%(senderName)s розпочинає відеовиклик.",
"%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s розпочинає відеовиклик. (не підтримується цим переглядачем)",
"%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s розпочинає відеовиклик. (не підтримується цим браузером)",
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s відкликав(-ла) запрошення %(targetDisplayName)s приєднання до кімнати.",
"%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s вилучає правило заборони користувачів зі збігом з %(glob)s",
"%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s вилучає правило блокування кімнат зі збігом з %(glob)s",
@ -821,7 +821,7 @@
"Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Захищені повідомлення з цим користувачем є наскрізно зашифрованими та непрочитними для сторонніх осіб.",
"Securely cache encrypted messages locally for them to appear in search results.": "Безпечно локально кешувати зашифровані повідомлення щоб вони з'являлись у результатах пошуку.",
"%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with <nativeLink>search components added</nativeLink>.": "%(brand)s'ові бракує деяких складників, необхідних для безпечного локального кешування зашифрованих повідомлень. Якщо ви хочете поекспериментувати з цією властивістю, зберіть спеціальну збірку %(brand)s Desktop із <nativeLink>доданням пошукових складників</nativeLink>.",
"%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.": "%(brand)s не може безпечно локально кешувати зашифровані повідомлення під час роботи у переглядачі. Користуйтесь <desktopLink>%(brand)s Desktop</desktopLink>, в якому зашифровані повідомлення з'являються у результатах пошуку.",
"%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.": "%(brand)s не може безпечно локально кешувати зашифровані повідомлення під час роботи у браузері. Користуйтесь <desktopLink>%(brand)s для комп'ютерів</desktopLink>, в якому зашифровані повідомлення з'являються у результатах пошуку.",
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Ви впевнені? Ви втратите ваші зашифровані повідомлення якщо копія ключів не була створена коректно.",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Зашифровані повідомлення захищені наскрізним шифруванням. Лише ви та отримувачі повідомлень мають ключі для їх читання.",
"Display Name": "Показуване ім'я",
@ -1467,7 +1467,7 @@
"We sent the others, but the below people couldn't be invited to <RoomName/>": "Ми надіслали іншим, але вказаних людей, не вдалося запросити до <RoomName/>",
"Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Ваш домашній сервер намагався відхилити спробу вашого входу. Це може бути пов'язано з занадто тривалим часом входу. Повторіть спробу. Якщо це триватиме й далі, зверніться до адміністратора домашнього сервера.",
"Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Ваш домашній сервер був недоступний і вхід не виконано. Повторіть спробу. Якщо це триватиме й далі, зверніться до адміністратора свого домашнього сервера.",
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Ми попросили переглядач запам’ятати, який домашній сервер ви використовуєте, щоб дозволити вам увійти, але, на жаль, ваш переглядач забув його. Перейдіть на сторінку входу та повторіть спробу.",
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Ми попросили браузер запам’ятати, який домашній сервер ви використовуєте, щоб дозволити вам увійти, але, на жаль, ваш браузер забув його. Перейдіть на сторінку входу та повторіть спробу.",
"You've successfully verified %(deviceName)s (%(deviceId)s)!": "Ви успішно звірили %(deviceName)s (%(deviceId)s)!",
"You've successfully verified your device!": "Ви успішно звірили свій пристрій!",
"You've successfully verified %(displayName)s!": "Ви успішно звірили %(displayName)s!",
@ -1747,7 +1747,7 @@
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Будь-хто у просторі може знайти та приєднатися. <a>Укажіть, які простори можуть отримати доступ сюди.</a>",
"Currently, %(count)s spaces have access|one": "На разі простір має доступ",
"contact the administrators of identity server <idserver />": "зв'язатися з адміністратором сервера ідентифікації <idserver />",
"check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "перевірити плагіни переглядача на наявність будь-чого, що може заблокувати сервер ідентифікації (наприклад, Privacy Badger)",
"check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "перевірити плагіни браузера на наявність будь-чого, що може заблокувати сервер ідентифікації (наприклад, Privacy Badger)",
"Disconnect from the identity server <idserver />?": "Від'єднатися від сервера ідентифікації <idserver />?",
"Disconnect identity server": "Від'єднатися від сервера ідентифікації",
"Disconnect from the identity server <current /> and connect to <new /> instead?": "Від'єднатися від сервера ідентифікації <current /> й натомість під'єднатися до <new />?",
@ -2316,7 +2316,7 @@
"Link to selected message": "Посилання на вибране повідомлення",
"To help us prevent this in future, please <a>send us logs</a>.": "Щоб уникнути цього в майбутньому просимо <a>надіслати нам журнал</a>.",
"Missing session data": "Відсутні дані сеансу",
"Your browser likely removed this data when running low on disk space.": "Схоже, що ваш переглядач вилучив ці дані через брак місця на диску.",
"Your browser likely removed this data when running low on disk space.": "Схоже, що ваш браузер вилучив ці дані через брак простору на диску.",
"Be found by phone or email": "Бути знайденим за номером телефону або е-поштою",
"Find others by phone or email": "Шукати інших за номером телефону або е-поштою",
"You can't disable this later. Bridges & most bots won't work yet.": "Ви не зможете вимкнути це пізніше. Мости й більшість ботів поки не працюватимуть.",
@ -2508,7 +2508,7 @@
"Could not load user profile": "Не вдалося звантажити профіль користувача",
"Skip verification for now": "На разі пропустити звірку",
"Failed to load timeline position": "Не вдалося завантажити позицію стрічки",
"Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "З'єднуватися з домашнім сервером по HTTP, коли в рядку адреси HTTPS, не можна. Використовуйте HTTPS або <a>дозвольте небезпечні скрипти</a>.",
"Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "З'єднуватися з домашнім сервером через HTTP, коли в рядку адреси браузера введено HTTPS-адресу, не можна. Використовуйте HTTPS або <a>дозвольте небезпечні скрипти</a>.",
"This version of %(brand)s does not support viewing some encrypted files": "Ця версія %(brand)s не підтримує перегляд деяких зашифрованих файлів",
"Use the <a>Desktop app</a> to search encrypted messages": "Скористайтеся <a>стільничним застосунком</a>, щоб пошукати серед зашифрованих повідомлень",
"Use the <a>Desktop app</a> to see all encrypted files": "Скористайтеся <a>стільничним застосунком</a>, щоб переглянути всі зашифровані файли",
@ -2584,7 +2584,7 @@
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "Помилка оновлення запасних адрес кімнати. Можливо, сервер цього не дозволяє або стався тимчасовий збій.",
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "Помилка оновлення головної адреси кімнати. Можливо, сервер цього не дозволяє або стався тимчасовий збій.",
"We didn't find a microphone on your device. Please check your settings and try again.": "Мікрофона не знайдено. Перевірте налаштування й повторіть спробу.",
"We were unable to access your microphone. Please check your browser settings and try again.": "Збій доступу до вашого мікрофона. Перевірте налаштування переглядача й повторіть спробу.",
"We were unable to access your microphone. Please check your browser settings and try again.": "Збій доступу до вашого мікрофона. Перевірте налаштування браузера й повторіть спробу.",
"Invited by %(sender)s": "Запрошення від %(sender)s",
"Stickerpack": "Пакунок наліпок",
"Add some now": "Додайте які-небудь",
@ -2707,7 +2707,7 @@
"Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "Ваш новий обліковий запис (%(newAccountId)s) зареєстровано, проте ви вже ввійшли до іншого облікового запису (%(loggedInUserId)s).",
"Already have an account? <a>Sign in here</a>": "Уже маєте обліковий запис? <a>Увійдіть тут</a>",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Перш ніж надіслати журнали, <a>створіть обговорення на GitHub</a> із описом проблеми.",
"Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Нагадуємо, що ваш переглядач не підтримується, тож деякі функції можуть не працювати.",
"Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Нагадуємо, що ваш браузер не підтримується, тож деякі функції можуть не працювати.",
"Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Будь ласка, повідомте нам, що пішло не так; а ще краще створіть обговорення на GitHub із описом проблеми.",
"Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "Використовуйте сервер ідентифікації, щоб запрошувати через е-пошту. <default>Наприклад, типовий %(defaultIdentityServerName)s,</default> або інший у <settings>налаштуваннях</settings>.",
"%(oneUser)smade no changes %(count)s times|one": "%(oneUser)sнічого не змінює",
@ -2892,7 +2892,7 @@
"The server is not configured to indicate what the problem is (CORS).": "Сервер не налаштований на деталізацію суті проблеми (CORS).",
"A connection error occurred while trying to contact the server.": "Стався збій при спробі зв'язку з сервером.",
"Your area is experiencing difficulties connecting to the internet.": "У вашій місцевості зараз проблеми з інтернет-зв'язком.",
"A browser extension is preventing the request.": "Розширення переглядача заблокувало запит.",
"A browser extension is preventing the request.": "Розширення браузера заблокувало запит.",
"Your firewall or anti-virus is blocking the request.": "Ваш файрвол чи антивірус заблокував запит.",
"The server (%(serverName)s) took too long to respond.": "Сервер (%(serverName)s) не відповів у прийнятний термін.",
"Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Не вдалося отримати відповідь на деякі запити до вашого сервера. Ось деякі можливі причини.",
@ -3195,8 +3195,8 @@
"Next recently visited room or space": "Наступна недавно відвідана кімната або простір",
"Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Допомагайте нам визначати проблеми й удосконалювати %(analyticsOwner)s, надсилаючи анонімні дані про використання. Щоб розуміти, як люди використовують кілька пристроїв, ми створимо спільний для ваших пристроїв випадковий ідентифікатор.",
"You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "Використайте нетипові параметри сервера, щоб увійти в інший домашній сервер Matrix, вказавши його URL-адресу. Це дасть вам змогу використовувати %(brand)s з уже наявним у вас на іншому домашньому сервері обліковим записом Matrix.",
"%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s не отримав доступу до вашого місцеперебування. Дозвольте доступ до місцеперебування в налаштуваннях переглядача.",
"%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s у мобільних вебпереглядачах лише випробовується. Поки що кращі враження й новіші функції — в нашому вільному мобільному застосунку.",
"%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s не отримав доступу до вашого місця перебування. Дозвольте доступ до місця перебування в налаштуваннях браузера.",
"%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.": "%(brand)s у мобільних браузерах ще випробовується. Поки що кращі враження й новіші функції — у нашому вільному мобільному застосунку.",
"Settings explorer": "Налаштування оглядача",
"Explore account data": "Переглянути дані облікового запису",
"Verification explorer": "Оглядач автентифікації",
@ -3570,5 +3570,23 @@
"Voice broadcast": "Голосове мовлення",
"Voice broadcast (under active development)": "Голосове мовлення (в активній розробці)",
"Element Call video rooms": "Відео кімнати Element Call",
"Voice broadcasts": "Голосові передачі"
"Voice broadcasts": "Голосові передачі",
"You do not have permission to start voice calls": "У вас немає дозволу розпочинати голосові виклики",
"There's no one here to call": "Тут немає кого викликати",
"You do not have permission to start video calls": "У вас немає дозволу розпочинати відеовиклики",
"Ongoing call": "Поточний виклик",
"Video call (Element Call)": "Відеовиклик (Element Call)",
"Video call (Jitsi)": "Відеовиклик (Jitsi)",
"New group call experience": "Нові можливості групових викликів",
"Live": "Наживо",
"Failed to set pusher state": "Не вдалося встановити стан push-служби",
"Receive push notifications on this session.": "Отримувати push-сповіщення в цьому сеансі.",
"Push notifications": "Push-сповіщення",
"Toggle push notifications on this session.": "Увімкнути push-сповіщення для цього сеансу.",
"Enable notifications for this device": "Увімкнути сповіщення для цього пристрою",
"Turn off to disable notifications on all your devices and sessions": "Вимкніть, щоб вимкнути сповіщення для всіх ваших пристроїв і сесій",
"Enable notifications for this account": "Увімкнути сповіщення для цього облікового запису",
"Video call ended": "Відеовиклик завершено",
"%(name)s started a video call": "%(name)s розпочинає відеовиклик",
"%(selectedDeviceCount)s sessions selected": "Вибрано %(selectedDeviceCount)s сеансів"
}

View File

@ -3525,5 +3525,17 @@
"Check if you want to hide all current and future messages from this user.": "若想隐藏来自此用户的全部当前和未来的消息,请打勾。",
"Inviting %(user1)s and %(user2)s": "正在邀请 %(user1)s 与 %(user2)s",
"%(user)s and %(count)s others|one": "%(user)s 与 1 个人",
"%(user)s and %(count)s others|other": "%(user)s 与 %(count)s 个人"
"%(user)s and %(count)s others|other": "%(user)s 与 %(count)s 个人",
"Please be aware that session names are also visible to people you communicate with": "请注意,与你交流的人也能看到会话名称",
"Voice broadcast (under active development)": "语音广播(正在积极开发)",
"Voice broadcast": "语音广播",
"Element Call video rooms": "Element通话视频房间",
"Voice broadcasts": "语音广播",
"New group call experience": "新的群通话体验",
"Video call (Jitsi)": "视频通话Jitsi",
"Video call (Element Call)": "视频通话Element通话",
"Ongoing call": "正在进行的通话",
"You do not have permission to start video calls": "你没有权限开始视频通话",
"There's no one here to call": "这里没有人可以打电话",
"You do not have permission to start voice calls": "你没有权限开始语音通话"
}

View File

@ -3570,5 +3570,23 @@
"Voice broadcast": "音訊廣播",
"Voice broadcast (under active development)": "語音廣播(正在活躍開發中)",
"Element Call video rooms": "Element Call 視訊聊天室",
"Voice broadcasts": "音訊廣播"
"Voice broadcasts": "音訊廣播",
"You do not have permission to start voice calls": "您無權開始音訊通話",
"There's no one here to call": "這裡沒有人可以通話",
"You do not have permission to start video calls": "您無權開始視訊通話",
"Ongoing call": "正在進行通話",
"Video call (Element Call)": "視訊通話 (Element Call)",
"Video call (Jitsi)": "視訊通話 (Jitsi)",
"New group call experience": "全新的群組通話體驗",
"Live": "即時",
"Failed to set pusher state": "設定推播程式狀態失敗",
"Receive push notifications on this session.": "在此工作階段接收推播通知。",
"Push notifications": "推播通知",
"Toggle push notifications on this session.": "在此工作階段切換推播通知。",
"Enable notifications for this device": "為此裝置啟用通知",
"Turn off to disable notifications on all your devices and sessions": "關閉以停用您所有裝置與工作階段上的通知",
"Enable notifications for this account": "為此帳號啟用通知",
"%(selectedDeviceCount)s sessions selected": "已選取 %(selectedDeviceCount)s 個工作階段",
"Video call ended": "視訊通話已結束",
"%(name)s started a video call": "%(name)s 開始了視訊通話"
}

View File

@ -30,13 +30,11 @@ import dis from './dispatcher/dispatcher';
import { Action } from './dispatcher/actions';
import { ViewUserPayload } from './dispatcher/payloads/ViewUserPayload';
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { showGroupReplacedWithSpacesDialog } from "./group_helpers";
export enum Type {
URL = "url",
UserId = "userid",
RoomAlias = "roomalias",
GroupId = "groupid",
}
// Linkify stuff doesn't type scanner/parser/utils properly :/
@ -115,11 +113,6 @@ function onUserClick(event: MouseEvent, userId: string) {
});
}
function onGroupClick(event: MouseEvent, groupId: string) {
event.preventDefault();
showGroupReplacedWithSpacesDialog(groupId);
}
function onAliasClick(event: MouseEvent, roomAlias: string) {
event.preventDefault();
dis.dispatch<ViewRoomPayload>({
@ -192,15 +185,6 @@ export const options = {
onAliasClick(e, alias);
},
};
case Type.GroupId:
return {
// @ts-ignore see https://linkify.js.org/docs/options.html
click: function(e: MouseEvent) {
const groupId = parsePermalink(href).groupId;
onGroupClick(e, groupId);
},
};
}
},
@ -208,7 +192,6 @@ export const options = {
switch (type) {
case Type.RoomAlias:
case Type.UserId:
case Type.GroupId:
default: {
return tryTransformEntityToPermalink(href);
}
@ -255,17 +238,6 @@ registerPlugin(Type.RoomAlias, ({ scanner, parser, utils }) => {
});
});
registerPlugin(Type.GroupId, ({ scanner, parser, utils }) => {
const token = scanner.tokens.PLUS as '+';
matrixOpaqueIdLinkifyParser({
scanner,
parser,
utils,
token,
name: Type.GroupId,
});
});
registerPlugin(Type.UserId, ({ scanner, parser, utils }) => {
const token = scanner.tokens.AT as '@';
matrixOpaqueIdLinkifyParser({

View File

@ -771,8 +771,8 @@ export class ElementCall extends Call {
): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.deviceId ?? null,
videoInput: videoInput?.deviceId ?? null,
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);

View File

@ -438,6 +438,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE,
labsGroup: LabGroup.VoiceAndVideo,
displayName: _td("New group call experience"),
controller: new ReloadOnChangeController(),
default: false,
},
"feature_location_share_live": {
@ -739,6 +740,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Send analytics data'),
default: null,
},
"deviceClientInformationOptIn": {
supportedLevels: [SettingLevel.ACCOUNT],
displayName: _td(
`Record the client name, version, and url ` +
`to recognise sessions more easily in session manager`,
),
default: false,
},
"FTUE.useCaseSelection": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null,
@ -790,6 +799,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
default: false,
controller: new NotificationsEnabledController(),
},
"deviceNotificationsEnabled": {
supportedLevels: [SettingLevel.DEVICE],
default: true,
},
"notificationSound": {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: false,

View File

@ -434,7 +434,6 @@ export class StopGapWidgetDriver extends WidgetDriver {
}
const {
originalEvent,
events,
nextBatch,
prevBatch,
@ -451,7 +450,6 @@ export class StopGapWidgetDriver extends WidgetDriver {
});
return {
originalEvent: originalEvent?.getEffectiveEvent(),
chunk: events.map(e => e.getEffectiveEvent()),
nextBatch,
prevBatch,

View File

@ -18,9 +18,16 @@ import { _t } from "../languageHandler";
import Notifier from "../Notifier";
import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { getLocalNotificationAccountDataEventType } from "../utils/notifications";
const onAccept = () => {
Notifier.setEnabled(true);
const cli = MatrixClientPeg.get();
const eventType = getLocalNotificationAccountDataEventType(cli.deviceId);
cli.setAccountData(eventType, {
is_silenced: false,
});
};
const onReject = () => {

View File

@ -23,6 +23,7 @@ import SettingsStore from "../settings/SettingsStore";
import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils";
import { ElementCall } from "../models/Call";
export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): {
isInfoMessage: boolean;
@ -61,9 +62,8 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool
(eventType === EventType.RoomEncryption) ||
(factory === JitsiEventFactory)
);
const isLeftAlignedBubbleMessage = (
!isBubbleMessage &&
eventType === EventType.CallInvite
const isLeftAlignedBubbleMessage = !isBubbleMessage && (
eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType)
);
let isInfoMessage = (
!isBubbleMessage &&

View File

@ -0,0 +1,64 @@
/*
Copyright 2022 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 { IEncryptedFile, MsgType } from "matrix-js-sdk/src/matrix";
/**
* @param {string} mxc MXC URL of the file
* @param {string} mimetype
* @param {number} duration Duration in milliseconds
* @param {number} size
* @param {number[]} [waveform]
* @param {IEncryptedFile} [file] Encrypted file
*/
export const createVoiceMessageContent = (
mxc: string,
mimetype: string,
duration: number,
size: number,
file?: IEncryptedFile,
waveform?: number[],
) => {
return {
"body": "Voice message",
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": mxc,
"file": file,
"info": {
duration,
mimetype,
size,
},
// MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
file,
name: "Voice message.ogg",
mimetype,
size,
},
"org.matrix.msc1767.audio": {
duration,
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform,
},
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
};
};

View File

@ -0,0 +1,86 @@
/*
Copyright 2022 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 { MatrixClient } from "matrix-js-sdk/src/client";
import BasePlatform from "../../BasePlatform";
import { IConfigOptions } from "../../IConfigOptions";
export type DeviceClientInformation = {
name?: string;
version?: string;
url?: string;
};
const formatUrl = (): string | undefined => {
// don't record url for electron clients
if (window.electron) {
return undefined;
}
// strip query-string and fragment from uri
const url = new URL(window.location.href);
return [
url.host,
url.pathname.replace(/\/$/, ""), // Remove trailing slash if present
].join("");
};
export const getClientInformationEventType = (deviceId: string): string =>
`io.element.matrix_client_information.${deviceId}`;
/**
* Record extra client information for the current device
* https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md
*/
export const recordClientInformation = async (
matrixClient: MatrixClient,
sdkConfig: IConfigOptions,
platform: BasePlatform,
): Promise<void> => {
const deviceId = matrixClient.getDeviceId();
const { brand } = sdkConfig;
const version = await platform.getAppVersion();
const type = getClientInformationEventType(deviceId);
const url = formatUrl();
await matrixClient.setAccountData(type, {
name: brand,
version,
url,
});
};
const sanitizeContentString = (value: unknown): string | undefined =>
value && typeof value === 'string' ? value : undefined;
export const getDeviceClientInformation = (matrixClient: MatrixClient, deviceId: string): DeviceClientInformation => {
const event = matrixClient.getAccountData(getClientInformationEventType(deviceId));
if (!event) {
return {};
}
const { name, version, url } = event.getContent();
return {
name: sanitizeContentString(name),
version: sanitizeContentString(version),
url: sanitizeContentString(url),
};
};

View File

@ -0,0 +1,113 @@
/*
Copyright 2022 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 UAParser from 'ua-parser-js';
export enum DeviceType {
Desktop = 'Desktop',
Mobile = 'Mobile',
Web = 'Web',
Unknown = 'Unknown',
}
export type ExtendedDeviceInformation = {
deviceType: DeviceType;
// eg Google Pixel 6
deviceModel?: string;
// eg Android 11
deviceOperatingSystem?: string;
// eg Firefox 1.1.0
client?: string;
};
// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)
const IOS_KEYWORD = "; iOS ";
const BROWSER_KEYWORD = "Mozilla/";
const getDeviceType = (
userAgent: string,
device: UAParser.IDevice,
browser: UAParser.IBrowser,
operatingSystem: UAParser.IOS,
): DeviceType => {
if (browser.name === 'Electron') {
return DeviceType.Desktop;
}
if (!!browser.name) {
return DeviceType.Web;
}
if (
device.type === 'mobile' ||
operatingSystem.name?.includes('Android') ||
userAgent.indexOf(IOS_KEYWORD) > -1
) {
return DeviceType.Mobile;
}
return DeviceType.Unknown;
};
/**
* Some mobile model and OS strings are not recognised
* by the UA parsing library
* check they exist by hand
*/
const checkForCustomValues = (userAgent: string): {
customDeviceModel?: string;
customDeviceOS?: string;
} => {
if (userAgent.includes(BROWSER_KEYWORD)) {
return {};
}
const mightHaveDevice = userAgent.includes('(');
if (!mightHaveDevice) {
return {};
}
const deviceInfoSegments = userAgent.substring(userAgent.indexOf('(') + 1).split('; ');
const customDeviceModel = deviceInfoSegments[0] || undefined;
const customDeviceOS = deviceInfoSegments[1] || undefined;
return { customDeviceModel, customDeviceOS };
};
const concatenateNameAndVersion = (name?: string, version?: string): string | undefined =>
name && [name, version].filter(Boolean).join(' ');
export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => {
if (!userAgent) {
return {
deviceType: DeviceType.Unknown,
};
}
const parser = new UAParser(userAgent);
const browser = parser.getBrowser();
const device = parser.getDevice();
const operatingSystem = parser.getOS();
const deviceOperatingSystem = concatenateNameAndVersion(operatingSystem.name, operatingSystem.version);
const deviceModel = concatenateNameAndVersion(device.vendor, device.model);
const client = concatenateNameAndVersion(browser.name, browser.major || browser.version);
const { customDeviceModel, customDeviceOS } = checkForCustomValues(userAgent);
const deviceType = getDeviceType(userAgent, device, browser, operatingSystem);
return {
deviceType,
deviceModel: deviceModel || customDeviceModel,
deviceOperatingSystem: deviceOperatingSystem || customDeviceOS,
client,
};
};

View File

@ -0,0 +1,29 @@
/*
Copyright 2022 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 { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event";
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
import { MatrixClient } from "matrix-js-sdk/src/client";
export function getLocalNotificationAccountDataEventType(deviceId: string): string {
return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
}
export function localNotificationsAreSilenced(cli: MatrixClient): boolean {
const eventType = getLocalNotificationAccountDataEventType(cli.deviceId);
const event = cli.getAccountData(eventType);
return event?.getContent<LocalNotificationSettings>()?.is_silenced ?? true;
}

View File

@ -43,17 +43,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor {
return `${this.elementUrl}/#/user/${userId}`;
}
forGroup(groupId: string): string {
return `${this.elementUrl}/#/group/${groupId}`;
}
forEntity(entityId: string): string {
if (entityId[0] === '!' || entityId[0] === '#') {
return this.forRoom(entityId);
} else if (entityId[0] === '@') {
return this.forUser(entityId);
} else if (entityId[0] === '+') {
return this.forGroup(entityId);
} else throw new Error("Unrecognized entity");
}
@ -107,8 +101,6 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor {
const eventId = parts.length > 2 ? parts.slice(2).join('/') : "";
const via = query.split(/&?via=/).filter(p => !!p);
return PermalinkParts.forEvent(entity, eventId, via);
} else if (entityType === 'group') {
return PermalinkParts.forGroup(entity);
} else {
throw new Error("Unknown entity type in permalink");
}

View File

@ -51,10 +51,6 @@ export default class MatrixSchemePermalinkConstructor extends PermalinkConstruct
return `matrix:${this.encodeEntity(userId)}`;
}
forGroup(groupId: string): string {
throw new Error("Deliberately not implemented");
}
forEntity(entityId: string): string {
return `matrix:${this.encodeEntity(entityId)}`;
}

View File

@ -39,10 +39,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor {
return `${baseUrl}/#/${userId}`;
}
forGroup(groupId: string): string {
return `${baseUrl}/#/${groupId}`;
}
forEntity(entityId: string): string {
return `${baseUrl}/#/${entityId}`;
}
@ -82,8 +78,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor {
const via = query.split(/&?via=/g).filter(p => !!p);
return PermalinkParts.forEvent(entity, eventId, via);
} else if (entity[0] === '+') {
return PermalinkParts.forGroup(entity);
} else {
throw new Error("Unknown entity type in permalink");
}

View File

@ -27,10 +27,6 @@ export default class PermalinkConstructor {
throw new Error("Not implemented");
}
forGroup(groupId: string): string {
throw new Error("Not implemented");
}
forUser(userId: string): string {
throw new Error("Not implemented");
}
@ -55,30 +51,24 @@ export class PermalinkParts {
eventId: string;
userId: string;
viaServers: string[];
groupId: string;
constructor(roomIdOrAlias: string, eventId: string, userId: string, groupId: string, viaServers: string[]) {
constructor(roomIdOrAlias: string, eventId: string, userId: string, viaServers: string[]) {
this.roomIdOrAlias = roomIdOrAlias;
this.eventId = eventId;
this.userId = userId;
this.groupId = groupId;
this.viaServers = viaServers;
}
static forUser(userId: string): PermalinkParts {
return new PermalinkParts(null, null, userId, null, null);
}
static forGroup(groupId: string): PermalinkParts {
return new PermalinkParts(null, null, null, groupId, null);
return new PermalinkParts(null, null, userId, null);
}
static forRoom(roomIdOrAlias: string, viaServers: string[] = []): PermalinkParts {
return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers);
return new PermalinkParts(roomIdOrAlias, null, null, viaServers);
}
static forEvent(roomId: string, eventId: string, viaServers: string[] = []): PermalinkParts {
return new PermalinkParts(roomId, eventId, null, null, viaServers);
return new PermalinkParts(roomId, eventId, null, viaServers);
}
get primaryEntityId(): string {

View File

@ -295,10 +295,6 @@ export function makeRoomPermalink(roomId: string): string {
return permalinkCreator.forShareableRoom();
}
export function makeGroupPermalink(groupId: string): string {
return getPermalinkConstructor().forGroup(groupId);
}
export function isPermalinkHost(host: string): boolean {
// Always check if the permalink is a spec permalink (callers are likely to call
// parsePermalink after this function).
@ -319,7 +315,6 @@ export function tryTransformEntityToPermalink(entity: string): string {
// Check to see if it is a bare entity for starters
if (entity[0] === '#' || entity[0] === '!') return makeRoomPermalink(entity);
if (entity[0] === '@') return makeUserPermalink(entity);
if (entity[0] === '+') return makeGroupPermalink(entity);
if (entity.slice(0, 7) === "matrix:") {
try {
@ -332,8 +327,6 @@ export function tryTransformEntityToPermalink(entity: string): string {
pl += new MatrixToPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers);
}
return pl;
} else if (permalinkParts.groupId) {
return matrixtoBaseUrl + `/#/${permalinkParts.groupId}`;
} else if (permalinkParts.userId) {
return matrixtoBaseUrl + `/#/${permalinkParts.userId}`;
}
@ -381,8 +374,6 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string {
}
} else if (permalinkParts.userId) {
permalink = `#/user/${permalinkParts.userId}`;
} else if (permalinkParts.groupId) {
permalink = `#/group/${permalinkParts.groupId}`;
} // else not a valid permalink for our purposes - do not handle
}
} catch (e) {
@ -410,7 +401,6 @@ export function getPrimaryPermalinkEntity(permalink: string): string {
if (!permalinkParts) return null; // not processable
if (permalinkParts.userId) return permalinkParts.userId;
if (permalinkParts.roomIdOrAlias) return permalinkParts.roomIdOrAlias;
if (permalinkParts.groupId) return permalinkParts.groupId;
} catch (e) {
// no entity - not a permalink
}

View File

@ -18,6 +18,7 @@ limitations under the License.
import { EventEmitter } from "events";
import { mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio
import { isSecretStorageBeingAccessed } from "../src/SecurityManager";
import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions";
import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel";
import { mockPlatformPeg } from "./test-utils";
// don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger");
@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(),
}));
const deviceId = 'my-device-id';
class MockClient extends EventEmitter {
isGuest = jest.fn();
getUserId = jest.fn();
getKeyBackupVersion = jest.fn().mockResolvedValue(undefined);
getRooms = jest.fn().mockReturnValue([]);
@ -57,6 +64,8 @@ class MockClient extends EventEmitter {
downloadKeys = jest.fn();
isRoomEncrypted = jest.fn();
getClientWellKnown = jest.fn();
getDeviceId = jest.fn().mockReturnValue(deviceId);
setAccountData = jest.fn();
}
const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick);
@ -75,8 +84,12 @@ describe('DeviceListener', () => {
beforeEach(() => {
jest.resetAllMocks();
mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue('1.2.3'),
});
mockClient = new MockClient();
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});
const createAndStart = async (): Promise<DeviceListener> => {
@ -86,6 +99,115 @@ describe('DeviceListener', () => {
return instance;
};
describe('client information', () => {
it('watches device client information setting', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting');
const deviceListener = await createAndStart();
expect(watchSettingSpy).toHaveBeenCalledWith(
'deviceClientInformationOptIn', null, expect.any(Function),
);
deviceListener.stop();
expect(unwatchSettingSpy).toHaveBeenCalled();
});
describe('when device client information feature is enabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockImplementation(
settingName => settingName === 'deviceClientInformationOptIn',
);
});
it('saves client information on start', async () => {
await createAndStart();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('catches error and logs when saving client information fails', async () => {
const errorLogSpy = jest.spyOn(logger, 'error');
const error = new Error('oups');
mockClient.setAccountData.mockRejectedValue(error);
// doesn't throw
await createAndStart();
expect(errorLogSpy).toHaveBeenCalledWith(
'Failed to record client information',
error,
);
});
it('saves client information on logged in action', async () => {
const instance = await createAndStart();
mockClient.setAccountData.mockClear();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});
describe('when device client information feature is disabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});
it('does not save client information on start', async () => {
await createAndStart();
expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('does not save client information on logged in action', async () => {
const instance = await createAndStart();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('saves client information after setting is enabled', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
await createAndStart();
const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0];
expect(settingName).toEqual('deviceClientInformationOptIn');
expect(roomId).toBeNull();
callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true);
await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});
});
describe('recheck', () => {
it('does nothing when cross signing feature is not supported', async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);

85
test/Notifier-test.ts Normal file
View File

@ -0,0 +1,85 @@
/*
Copyright 2022 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import Notifier from "../src/Notifier";
import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications";
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils";
describe("Notifier", () => {
let MockPlatform;
let accountDataStore = {};
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue("@bob:example.org"),
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = new MatrixEvent({
type: eventType,
content,
});
}),
});
const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
const roomId = "!room1:server";
const testEvent = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: {},
});
const testRoom = mkRoom(mockClient, roomId);
beforeEach(() => {
accountDataStore = {};
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn(),
});
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
});
describe("_displayPopupNotification", () => {
it.each([
{ silenced: true, count: 0 },
{ silenced: false, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced });
Notifier._displayPopupNotification(testEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count);
});
});
describe("_playAudioNotification", () => {
it.each([
{ silenced: true, count: 0 },
{ silenced: false, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => {
// It's not ideal to only look at whether this function has been called
// but avoids starting to look into DOM stuff
Notifier.getSoundForRoom = jest.fn();
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced });
Notifier._playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
});
});
});

View File

@ -0,0 +1,150 @@
/*
Copyright 2022 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 { render, screen, act, cleanup, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { mocked, Mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
useMockedCalls,
MockedCall,
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
} from "../../../test-utils";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { CallEvent as UnwrappedCallEvent } from "../../../../src/components/views/messages/CallEvent";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallStore } from "../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../src/models/Call";
const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent);
describe("CallEvent", () => {
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } });
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let call: MockedCall;
let widget: Widget;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
stubClient();
client = mocked(MatrixClientPeg.get());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation(
userId => [alice, bob].find(member => member.userId === userId) ?? null,
);
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(
store => setupAsyncStoreWithClient(store, client),
));
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.get(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(async () => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
});
const renderEvent = () => { render(<CallEvent mxEvent={call.event} />); };
it("shows a message and duration if the call was ended", () => {
jest.advanceTimersByTime(90000);
call.destroy();
renderEvent();
screen.getByText("Video call ended");
screen.getByText("1m 30s");
});
it("shows placeholder info if the call isn't loaded yet", () => {
jest.spyOn(CallStore.instance, "get").mockReturnValue(null);
jest.advanceTimersByTime(90000);
renderEvent();
screen.getByText("@alice:example.org started a video call");
expect(screen.getByRole("button", { name: "Join" })).toHaveAttribute("aria-disabled", "true");
});
it("shows call details and connection controls if the call is loaded", async () => {
jest.advanceTimersByTime(90000);
call.participants = new Set([alice, bob]);
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 participants");
screen.getByText("1m 30s");
// Test that the join button works
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}));
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.connect());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
await waitFor(() => screen.getByRole("button", { name: "Join" }));
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
});

View File

@ -57,7 +57,7 @@ describe("<VoiceRecordComposerTile/>", () => {
durationSeconds: 1337,
contentType: "audio/ogg",
getPlayback: () => ({
thumbnailWaveform: [],
thumbnailWaveform: [1.4, 2.5, 3.6],
}),
} as unknown as VoiceRecording;
voiceRecordComposerTile = mount(<VoiceRecordComposerTile {...props} />);
@ -88,7 +88,11 @@ describe("<VoiceRecordComposerTile/>", () => {
"msgtype": MsgType.Audio,
"org.matrix.msc1767.audio": {
"duration": 1337000,
"waveform": [],
"waveform": [
1434,
2560,
3686,
],
},
"org.matrix.msc1767.file": {
"file": undefined,

View File

@ -15,7 +15,14 @@ limitations under the License.
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount, ReactWrapper } from 'enzyme';
import { IPushRule, IPushRules, RuleId, IPusher } from 'matrix-js-sdk/src/matrix';
import {
IPushRule,
IPushRules,
RuleId,
IPusher,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixEvent,
} from 'matrix-js-sdk/src/matrix';
import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids';
import { act } from 'react-dom/test-utils';
@ -67,6 +74,17 @@ describe('<Notifications />', () => {
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getRooms: jest.fn().mockReturnValue([]),
getAccountData: jest.fn().mockImplementation(eventType => {
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: false,
},
});
}
}),
setAccountData: jest.fn(),
});
mockClient.getPushRules.mockResolvedValue(pushRules);
@ -117,6 +135,7 @@ describe('<Notifications />', () => {
const component = await getComponentAndWait();
expect(findByTestId(component, 'notif-master-switch').length).toBeTruthy();
expect(findByTestId(component, 'notif-device-switch').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-notificationsEnabled').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-notificationBodyEnabled').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-audioNotificationsEnabled').length).toBeTruthy();

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