Merge branch 'develop' into dialog-a11y

This commit is contained in:
Peter Vágner 2017-12-14 11:04:59 +01:00
commit 9f5857a7cc
17 changed files with 481 additions and 231 deletions

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -58,6 +59,7 @@ import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import dis from './dispatcher'; import dis from './dispatcher';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
global.mxCalls = { global.mxCalls = {
//room_id: MatrixCall //room_id: MatrixCall
@ -97,19 +99,54 @@ function pause(audioId) {
} }
} }
function _reAttemptCall(call) {
if (call.direction === 'outbound') {
dis.dispatch({
action: 'place_call',
room_id: call.roomId,
type: call.type,
});
} else {
call.answer();
}
}
function _setCallListeners(call) { function _setCallListeners(call) {
call.on("error", function(err) { call.on("error", function(err) {
console.error("Call error: %s", err); console.error("Call error: %s", err);
console.error(err.stack); console.error(err.stack);
call.hangup(); if (err.code === 'unknown_devices') {
_setCallState(undefined, call.roomId, "ended"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
});
call.on('send_event_error', function(err) { Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
if (err.name === "UnknownDeviceError") { title: _t('Call Failed'),
dis.dispatch({ description: _t(
action: 'unknown_device_error', "There are unknown devices in this room: "+
err: err, "if you proceed without verifying them, it will be "+
room: MatrixClientPeg.get().getRoom(call.roomId), "possible for someone to eavesdrop on your call."
),
button: _t('Review Devices'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
showUnknownDeviceDialogForCalls(
MatrixClientPeg.get(),
room,
() => {
_reAttemptCall(call);
},
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
);
}
},
});
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Call Failed'),
description: err.message,
}); });
} }
}); });
@ -179,7 +216,6 @@ function _setCallState(call, roomId, status) {
function _onAction(payload) { function _onAction(payload) {
function placeCall(newCall) { function placeCall(newCall) {
_setCallListeners(newCall); _setCallListeners(newCall);
_setCallState(newCall, newCall.roomId, "ringback");
if (payload.type === 'voice') { if (payload.type === 'voice') {
newCall.placeVoiceCall(); newCall.placeVoiceCall();
} else if (payload.type === 'video') { } else if (payload.type === 'video') {

View file

@ -44,13 +44,6 @@ module.exports = {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148 // https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')'); console.log('Resend got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: room,
});
}
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
@ -60,9 +53,5 @@ module.exports = {
}, },
removeFromQueue: function(event) { removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({
action: 'message_send_cancelled',
event: event,
});
}, },
}; };

View file

@ -76,6 +76,35 @@ class ScalarAuthClient {
return defer.promise; return defer.promise;
} }
getScalarPageTitle(url) {
const defer = Promise.defer();
let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
request({
method: 'GET',
uri: scalarPageLookupUrl,
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body) {
defer.reject(new Error("Missing page title in response"));
} else {
let title = "";
if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
title = body.page_title_cache_item.cached_title;
}
defer.resolve(title);
}
});
return defer.promise;
}
getScalarInterfaceUrlForRoom(roomId, screen, id) { getScalarInterfaceUrlForRoom(roomId, screen, id) {
let url = SdkConfig.get().integrations_ui_url; let url = SdkConfig.get().integrations_ui_url;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "?scalar_token=" + encodeURIComponent(this.scalarToken);

View file

@ -366,6 +366,22 @@ function getWidgets(event, roomId) {
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
} }
function getRoomEncState(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
sendResponse(event, roomIsEncrypted);
}
function setPlumbingState(event, roomId, status) { function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
@ -593,6 +609,9 @@ const onMessage = function(event) {
} else if (event.data.action === "get_widgets") { } else if (event.data.action === "get_widgets") {
getWidgets(event, roomId); getWidgets(event, roomId);
return; return;
} else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId);
return;
} else if (event.data.action === "can_send_event") { } else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId); canSendEvent(event, roomId);
return; return;

View file

@ -1,51 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher';
import sdk from './index';
import Modal from './Modal';
let isDialogOpen = false;
const onAction = function(payload) {
if (payload.action === 'unknown_device_error' && !isDialogOpen) {
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
isDialogOpen = true;
Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
devices: payload.err.devices,
room: payload.room,
onFinished: (r) => {
isDialogOpen = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, 'mx_Dialog_unknownDevice');
}
};
let ref = null;
export function startListening() {
ref = dis.register(onAction);
}
export function stopListening() {
if (ref) {
dis.unregister(ref);
ref = null;
}
}

View file

@ -164,7 +164,7 @@ function stopListening() {
function addEndpoint(widgetId, endpointUrl) { function addEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl); const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) { if (!u || !u.protocol || !u.host) {
console.warn("Invalid origin"); console.warn("Invalid origin:", endpointUrl);
return; return;
} }

View file

@ -40,7 +40,6 @@ require('../../stores/LifecycleStore');
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import createRoom from "../../createRoom"; import createRoom from "../../createRoom";
import * as UDEHandler from '../../UnknownDeviceErrorHandler';
import KeyRequestHandler from '../../KeyRequestHandler'; import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler'; import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
@ -295,7 +294,6 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
UDEHandler.startListening();
this.focusComposer = false; this.focusComposer = false;
@ -361,7 +359,6 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
Lifecycle.stopMatrixClient(); Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
UDEHandler.stopListening();
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
}, },
@ -1142,6 +1139,37 @@ module.exports = React.createClass({
room.setBlacklistUnverifiedDevices(blacklistEnabled); room.setBlacklistUnverifiedDevices(blacklistEnabled);
} }
}); });
cli.on("crypto.warning", (type) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
switch (type) {
case 'CRYPTO_WARNING_ACCOUNT_MIGRATED':
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Cryptography data migrated'),
description: _t(
"A one-off migration of cryptography data has been performed. "+
"End-to-end encryption will not work if you go back to an older "+
"version of Riot. If you need to use end-to-end cryptography on "+
"an older version, log out of Riot first. To retain message history, "+
"export and re-import your keys.",
),
});
break;
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Old cryptography data detected'),
description: _t(
"Data from an older version of Riot has been detected. "+
"This will have caused end-to-end cryptography to malfunction "+
"in the older version. End-to-end encrypted messages exchanged "+
"recently whilst using the older version may not be decryptable "+
"in this version. This may also cause messages exchanged with this "+
"version to fail. If you experience problems, log out and back in "+
"again. To retain message history, export and re-import your keys.",
),
});
break;
}
});
}, },
/** /**
@ -1398,13 +1426,6 @@ module.exports = React.createClass({
cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => {
dis.dispatch({action: 'message_sent'}); dis.dispatch({action: 'message_sent'});
}, (err) => { }, (err) => {
if (err.name === 'UnknownDeviceError') {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: cli.getRoom(roomId),
});
}
dis.dispatch({action: 'message_send_failed'}); dis.dispatch({action: 'message_send_failed'});
}); });
}, },

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,16 +16,26 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sdk from '../../index'; import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping'; import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar'; import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import { showUnknownDeviceDialogForMessages } from '../../cryptodevices';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2; const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
});
};
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomStatusBar', displayName: 'RoomStatusBar',
@ -35,9 +46,6 @@ module.exports = React.createClass({
// the number of messages which have arrived since we've been scrolled up // the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number, numUnreadMessages: React.PropTypes.number,
// string to display when there are messages in the room which had errors on send
unsentMessageError: React.PropTypes.string,
// this is true if we are fully scrolled-down, and are looking at // this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline. // the end of the live timeline.
atEndOfLiveTimeline: React.PropTypes.bool, atEndOfLiveTimeline: React.PropTypes.bool,
@ -98,12 +106,14 @@ module.exports = React.createClass({
return { return {
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize(); this._checkSize();
}, },
@ -118,6 +128,7 @@ module.exports = React.createClass({
if (client) { if (client) {
client.removeListener("sync", this.onSyncStateChange); client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping); client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
} }
}, },
@ -136,6 +147,26 @@ module.exports = React.createClass({
}); });
}, },
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
},
_onShowDevicesClick: function() {
showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
},
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
if (room.roomId !== this.props.room.roomId) return;
this.setState({
unsentMessages: getUnsentMessages(this.props.room),
});
},
// Check whether current size is greater than 0, if yes call props.onVisible // Check whether current size is greater than 0, if yes call props.onVisible
_checkSize: function() { _checkSize: function() {
if (this.props.onVisible && this._getSize()) { if (this.props.onVisible && this._getSize()) {
@ -155,7 +186,7 @@ module.exports = React.createClass({
this.props.sentMessageAndIsAlone this.props.sentMessageAndIsAlone
) { ) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.props.unsentMessageError) { } else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
} }
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
@ -241,6 +272,61 @@ module.exports = React.createClass({
return avatars; return avatars;
}, },
_getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
let content;
const hasUDE = unsentMessages.some((m) => {
return m.error && m.error.name === "UnknownDeviceError";
});
if (hasUDE) {
title = _t("Message not sent due to unknown devices being present");
content = _t(
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.",
{},
{
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
} else {
if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = unsentMessages[0].error.data.error;
} else {
title = _t("Some of your messages have not been sent.");
}
content = _t("<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.",
{},
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>;
},
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent: function() { _getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
@ -263,28 +349,8 @@ module.exports = React.createClass({
); );
} }
if (this.props.unsentMessageError) { if (this.state.unsentMessages.length > 0) {
return ( return this._getUnsentMessageContent();
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ this.props.unsentMessageError }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{
_t("<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.",
{},
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this.props.onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this.props.onCancelAllClick}>{ sub }</a>,
},
) }
</div>
</div>
);
} }
// unread count trumps who is typing since the unread count is only // unread count trumps who is typing since the unread count is only
@ -342,7 +408,6 @@ module.exports = React.createClass({
return null; return null;
}, },
render: function() { render: function() {
const content = this._getContent(); const content = this._getContent();
const indicator = this._getIndicator(this.state.usersTyping.length > 0); const indicator = this._getIndicator(this.state.usersTyping.length > 0);

View file

@ -26,7 +26,6 @@ const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import Promise from 'bluebird'; import Promise from 'bluebird';
const classNames = require("classnames"); const classNames = require("classnames");
const Matrix = require("matrix-js-sdk");
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
const MatrixClientPeg = require("../../MatrixClientPeg"); const MatrixClientPeg = require("../../MatrixClientPeg");
@ -34,7 +33,6 @@ const ContentMessages = require("../../ContentMessages");
const Modal = require("../../Modal"); const Modal = require("../../Modal");
const sdk = require('../../index'); const sdk = require('../../index');
const CallHandler = require('../../CallHandler'); const CallHandler = require('../../CallHandler');
const Resend = require("../../Resend");
const dis = require("../../dispatcher"); const dis = require("../../dispatcher");
const Tinter = require("../../Tinter"); const Tinter = require("../../Tinter");
const rate_limited_func = require('../../ratelimitedfunc'); const rate_limited_func = require('../../ratelimitedfunc');
@ -110,7 +108,6 @@ module.exports = React.createClass({
draggingFile: false, draggingFile: false,
searching: false, searching: false,
searchResults: null, searchResults: null,
unsentMessageError: '',
callState: null, callState: null,
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
@ -202,7 +199,6 @@ module.exports = React.createClass({
if (initial) { if (initial) {
newState.room = MatrixClientPeg.get().getRoom(newState.roomId); newState.room = MatrixClientPeg.get().getRoom(newState.roomId);
if (newState.room) { if (newState.room) {
newState.unsentMessageError = this._getUnsentMessageError(newState.room);
newState.showApps = this._shouldShowApps(newState.room); newState.showApps = this._shouldShowApps(newState.room);
this._onRoomLoaded(newState.room); this._onRoomLoaded(newState.room);
} }
@ -462,11 +458,6 @@ module.exports = React.createClass({
case 'message_send_failed': case 'message_send_failed':
case 'message_sent': case 'message_sent':
this._checkIfAlone(this.state.room); this._checkIfAlone(this.state.room);
// no break; to intentionally fall through
case 'message_send_cancelled':
this.setState({
unsentMessageError: this._getUnsentMessageError(this.state.room),
});
break; break;
case 'notifier_enabled': case 'notifier_enabled':
case 'upload_failed': case 'upload_failed':
@ -711,35 +702,6 @@ module.exports = React.createClass({
this.setState({isAlone: joinedMembers.length === 1}); this.setState({isAlone: joinedMembers.length === 1});
}, },
_getUnsentMessageError: function(room) {
const unsentMessages = this._getUnsentMessages(room);
if (!unsentMessages.length) return "";
if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error &&
unsentMessages[0].error.name !== "UnknownDeviceError"
) {
return unsentMessages[0].error.data.error;
}
for (const event of unsentMessages) {
if (!event.error || event.error.name !== "UnknownDeviceError") {
return _t("Some of your messages have not been sent.");
}
}
return _t("Message not sent due to unknown devices being present");
},
_getUnsentMessages: function(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
});
},
_updateConfCallNotification: function() { _updateConfCallNotification: function() {
const room = this.state.room; const room = this.state.room;
if (!room || !this.props.ConferenceHandler) { if (!room || !this.props.ConferenceHandler) {
@ -784,14 +746,6 @@ module.exports = React.createClass({
} }
}, },
onResendAllClick: function() {
Resend.resendUnsentEvents(this.state.room);
},
onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.state.room);
},
onInviteButtonClick: function() { onInviteButtonClick: function() {
// call AddressPickerDialog // call AddressPickerDialog
dis.dispatch({ dis.dispatch({
@ -935,11 +889,7 @@ module.exports = React.createClass({
file, this.state.room.roomId, MatrixClientPeg.get(), file, this.state.room.roomId, MatrixClientPeg.get(),
).done(undefined, (error) => { ).done(undefined, (error) => {
if (error.name === "UnknownDeviceError") { if (error.name === "UnknownDeviceError") {
dis.dispatch({ // Let the staus bar handle this
action: 'unknown_device_error',
err: error,
room: this.state.room,
});
return; return;
} }
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1571,12 +1521,9 @@ module.exports = React.createClass({
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
unsentMessageError={this.state.unsentMessageError}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline} atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
sentMessageAndIsAlone={this.state.isAlone} sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall} hasActiveCall={inCall}
onResendAllClick={this.onResendAllClick}
onCancelAllClick={this.onCancelAllClick}
onInviteClick={this.onInviteButtonClick} onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick} onStopWarningClick={this.onStopAloneWarningClick}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
@ -22,6 +24,14 @@ import Resend from '../../../Resend';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
function markAllDevicesKnown(devices) {
Object.keys(devices).forEach((userId) => {
Object.keys(devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
});
});
}
function DeviceListEntry(props) { function DeviceListEntry(props) {
const {userId, device} = props; const {userId, device} = props;
@ -38,10 +48,10 @@ function DeviceListEntry(props) {
} }
DeviceListEntry.propTypes = { DeviceListEntry.propTypes = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// deviceinfo // deviceinfo
device: React.PropTypes.object.isRequired, device: PropTypes.object.isRequired,
}; };
@ -61,10 +71,10 @@ function UserUnknownDeviceList(props) {
} }
UserUnknownDeviceList.propTypes = { UserUnknownDeviceList.propTypes = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// map from deviceid -> deviceinfo // map from deviceid -> deviceinfo
userDevices: React.PropTypes.object.isRequired, userDevices: PropTypes.object.isRequired,
}; };
@ -83,7 +93,7 @@ function UnknownDeviceList(props) {
UnknownDeviceList.propTypes = { UnknownDeviceList.propTypes = {
// map from userid -> deviceid -> deviceinfo // map from userid -> deviceid -> deviceinfo
devices: React.PropTypes.object.isRequired, devices: PropTypes.object.isRequired,
}; };
@ -91,28 +101,63 @@ export default React.createClass({
displayName: 'UnknownDeviceDialog', displayName: 'UnknownDeviceDialog',
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: PropTypes.object.isRequired,
// map from userid -> deviceid -> deviceinfo // map from userid -> deviceid -> deviceinfo or null if devices are not yet loaded
devices: React.PropTypes.object.isRequired, devices: PropTypes.object,
onFinished: React.PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
// Label for the button that marks all devices known and tries the send again
sendAnywayLabel: PropTypes.string.isRequired,
// Label for the button that to send the event if you've verified all devices
sendLabel: PropTypes.string.isRequired,
// function to retry the request once all devices are verified / known
onSend: PropTypes.func.isRequired,
}, },
componentDidMount: function() { componentWillMount: function() {
// Given we've now shown the user the unknown device, it is no longer MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged);
// unknown to them. Therefore mark it as 'known'. },
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
});
});
// XXX: temporary logging to try to diagnose componentWillUnmount: function() {
// https://github.com/vector-im/riot-web/issues/3148 if (MatrixClientPeg.get()) {
console.log('Opening UnknownDeviceDialog'); MatrixClientPeg.get().removeListener("deviceVerificationChanged", this._onDeviceVerificationChanged);
}
},
_onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) {
if (this.props.devices[userId] && this.props.devices[userId][deviceId]) {
// XXX: Mutating props :/
this.props.devices[userId][deviceId] = deviceInfo;
this.forceUpdate();
}
},
_onDismissClicked: function() {
this.props.onFinished();
},
_onSendAnywayClicked: function() {
markAllDevicesKnown(this.props.devices);
this.props.onFinished();
this.props.onSend();
},
_onSendClicked: function() {
this.props.onFinished();
this.props.onSend();
}, },
render: function() { render: function() {
if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
let warning; let warning;
if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) { if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) {
warning = ( warning = (
@ -133,15 +178,30 @@ export default React.createClass({
); );
} }
let haveUnknownDevices = false;
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
const device = this.props.devices[userId][deviceId];
if (device.isUnverified() && !device.isKnown()) {
haveUnknownDevices = true;
}
});
});
let sendButton;
if (haveUnknownDevices) {
sendButton = <button onClick={this._onSendAnywayClicked}>
{ this.props.sendAnywayLabel }
</button>;
} else {
sendButton = <button onClick={this._onSendClicked}>
{ this.props.sendLabel }
</button>;
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<BaseDialog className='mx_UnknownDeviceDialog' <BaseDialog className='mx_UnknownDeviceDialog'
onFinished={() => { onFinished={this.props.onFinished}
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by escape");
this.props.onFinished();
}}
title={_t('Room contains unknown devices')} title={_t('Room contains unknown devices')}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
@ -155,21 +215,11 @@ export default React.createClass({
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbar> </GeminiScrollbar>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
{sendButton}
<button className="mx_Dialog_primary" autoFocus={true} <button className="mx_Dialog_primary" autoFocus={true}
onClick={() => { onClick={this._onDismissClicked}
this.props.onFinished(); >
Resend.resendUnsentEvents(this.props.room); {_t("Dismiss")}
}}>
{ _t("Send anyway") }
</button>
<button className="mx_Dialog_primary"
onClick={() => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by OK");
this.props.onFinished();
}}>
OK
</button> </button>
</div> </div>
</BaseDialog> </BaseDialog>

View file

@ -52,11 +52,13 @@ export default React.createClass({
userId: React.PropTypes.string.isRequired, userId: React.PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget // UserId of the entity that added / modified the widget
creatorUserId: React.PropTypes.string, creatorUserId: React.PropTypes.string,
waitForIframeLoad: React.PropTypes.bool,
}, },
getDefaultProps() { getDefaultProps() {
return { return {
url: "", url: "",
waitForIframeLoad: true,
}; };
}, },
@ -71,7 +73,7 @@ export default React.createClass({
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return { return {
initialising: true, // True while we are mangling the widget URL initialising: true, // True while we are mangling the widget URL
loading: true, // True while the iframe content is loading loading: this.props.waitForIframeLoad, // True while the iframe content is loading
widgetUrl: this._addWurlParams(newProps.url), widgetUrl: this._addWurlParams(newProps.url),
widgetPermissionId: widgetPermissionId, widgetPermissionId: widgetPermissionId,
// Assume that widget has permission to load if we are the user who // Assume that widget has permission to load if we are the user who
@ -79,7 +81,7 @@ export default React.createClass({
hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId, hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId,
error: null, error: null,
deleting: false, deleting: false,
widgetPageTitle: null, widgetPageTitle: newProps.widgetPageTitle,
}; };
}, },
@ -196,6 +198,11 @@ export default React.createClass({
widgetUrl: u.format(), widgetUrl: u.format(),
initialising: false, initialising: false,
}); });
// Fetch page title from remote content if not already set
if (!this.state.widgetPageTitle && params.url) {
this._fetchWidgetTitle(params.url);
}
}, (err) => { }, (err) => {
console.error("Failed to get scalar_token", err); console.error("Failed to get scalar_token", err);
this.setState({ this.setState({
@ -215,10 +222,14 @@ export default React.createClass({
if (nextProps.url !== this.props.url) { if (nextProps.url !== this.props.url) {
this._getNewState(nextProps); this._getNewState(nextProps);
this.setScalarToken(); this.setScalarToken();
} else if (nextProps.show && !this.props.show) { } else if (nextProps.show && !this.props.show && this.props.waitForIframeLoad) {
this.setState({ this.setState({
loading: true, loading: true,
}); });
} else if (nextProps.widgetPageTitle !== this.props.widgetPageTitle) {
this.setState({
widgetPageTitle: nextProps.widgetPageTitle,
});
} }
}, },
@ -299,12 +310,16 @@ export default React.createClass({
/** /**
* Set remote content title on AppTile * Set remote content title on AppTile
* @param {string} title Title string to set on the AppTile * @param {string} url Url to check for title
*/ */
_updateWidgetTitle(title) { _fetchWidgetTitle(url) {
if (title) { this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
this.setState({widgetPageTitle: null}); if (widgetPageTitle) {
} this.setState({widgetPageTitle: widgetPageTitle});
}
}, (err) =>{
console.error("Failed to get page title", err);
});
}, },
// Widget labels to render, depending upon user permissions // Widget labels to render, depending upon user permissions
@ -430,13 +445,24 @@ export default React.createClass({
deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
} }
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg');
return ( return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}> <div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}> <div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
<b>{ this.formatAppTileName() }</b> <span className="mx_AppTileMenuBarTitle">
{ this.state.widgetPageTitle && ( <TintableSvgButton
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span> src={windowStateIcon}
) } className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Minimize apps')}
width="10"
height="10"
/>
<b>{ this.formatAppTileName() }</b>
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && (
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span>
) }
</span>
<span className="mx_AppTileMenuBarWidgets"> <span className="mx_AppTileMenuBarWidgets">
{ /* Edit widget */ } { /* Edit widget */ }
{ showEditButton && <TintableSvgButton { showEditButton && <TintableSvgButton

View file

@ -478,7 +478,7 @@ module.exports = React.createClass({
} }
const toggleButton = ( const toggleButton = (
<div className={"mx_MemberEventListSummary_toggle"} onClick={this._toggleSummary}> <div className={"mx_MemberEventListSummary_toggle"} onClick={this._toggleSummary}>
{ expanded ? 'collapse' : 'expand' } { expanded ? _t('collapse') : _t('expand') }
</div> </div>
); );

View file

@ -253,7 +253,7 @@ module.exports = React.createClass({
return ( return (
<div> <div>
<h3>Addresses</h3> <h3>{ _t('Addresses') }</h3>
<div className="mx_RoomSettings_aliasLabel"> <div className="mx_RoomSettings_aliasLabel">
{ _t('The main address for this room is') }: { canonical_alias_section } { _t('The main address for this room is') }: { canonical_alias_section }
</div> </div>

View file

@ -133,14 +133,17 @@ module.exports = React.createClass({
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '', '$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
}; };
app.id = appId;
app.name = app.name || app.type;
if (app.data) { if (app.data) {
Object.keys(app.data).forEach((key) => { Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key]; params['$' + key] = app.data[key];
}); });
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
} }
app.id = appId;
app.name = app.name || app.type;
app.url = this.encodeUri(app.url, params); app.url = this.encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null; app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
@ -224,6 +227,8 @@ module.exports = React.createClass({
userId={this.props.userId} userId={this.props.userId}
show={this.props.showApps} show={this.props.showApps}
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
/>); />);
}); });

View file

@ -74,13 +74,6 @@ function onSendMessageFailed(err, room) {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148 // https://github.com/vector-im/riot-web/issues/3148
console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: room,
});
}
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
}); });

104
src/cryptodevices.js Normal file
View file

@ -0,0 +1,104 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Resend from './Resend';
import sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
/**
* Gets all crypto devices in a room that are marked neither known
* nor verified.
*
* @param {MatrixClient} matrixClient A MatrixClient
* @param {Room} room js-sdk room object representing the room
* @return {Promise} A promise which resolves to a map userId->deviceId->{@link
* module:crypto~DeviceInfo|DeviceInfo}.
*/
export function getUnknownDevicesForRoom(matrixClient, room) {
const roomMembers = room.getJoinedMembers().map((m) => {
return m.userId;
});
return matrixClient.downloadKeys(roomMembers, false).then((devices) => {
const unknownDevices = {};
// This is all devices in this room, so find the unknown ones.
Object.keys(devices).forEach((userId) => {
Object.keys(devices[userId]).map((deviceId) => {
const device = devices[userId][deviceId];
if (device.isUnverified() && !device.isKnown()) {
if (unknownDevices[userId] === undefined) {
unknownDevices[userId] = {};
}
unknownDevices[userId][deviceId] = device;
}
});
});
return unknownDevices;
});
}
/**
* Show the UnknownDeviceDialog for a given room. The dialog will inform the user
* that messages they sent to this room have not been sent due to unknown devices
* being present.
*
* @param {MatrixClient} matrixClient A MatrixClient
* @param {Room} room js-sdk room object representing the room
*/
export function showUnknownDeviceDialogForMessages(matrixClient, room) {
getUnknownDevicesForRoom(matrixClient, room).then((unknownDevices) => {
const onSendClicked = () => {
Resend.resendUnsentEvents(room);
};
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
Modal.createTrackedDialog('Unknown Device Dialog', '', UnknownDeviceDialog, {
room: room,
devices: unknownDevices,
sendAnywayLabel: _t("Send anyway"),
sendLabel: _t("Send"),
onSend: onSendClicked,
}, 'mx_Dialog_unknownDevice');
});
}
/**
* Show the UnknownDeviceDialog for a given room. The dialog will inform the user
* that a call they tried to place or answer in the room couldn't be placed or
* answered due to unknown devices being present.
*
* @param {MatrixClient} matrixClient A MatrixClient
* @param {Room} room js-sdk room object representing the room
* @param {func} sendAnyway Function called when the 'call anyway' or 'call'
* button is pressed. This should attempt to place or answer the call again.
* @param {string} sendAnywayLabel Label for the button displayed to retry the call
* when unknown devices are still present (eg. "Call Anyway")
* @param {string} sendLabel Label for the button displayed to retry the call
* after all devices have been verified (eg. "Call")
*/
export function showUnknownDeviceDialogForCalls(matrixClient, room, sendAnyway, sendAnywayLabel, sendLabel) {
getUnknownDevicesForRoom(matrixClient, room).then((unknownDevices) => {
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
Modal.createTrackedDialog('Unknown Device Dialog', '', UnknownDeviceDialog, {
room: room,
devices: unknownDevices,
sendAnywayLabel: sendAnywayLabel,
sendLabel: sendLabel,
onSend: sendAnyway,
}, 'mx_Dialog_unknownDevice');
});
}

View file

@ -2,6 +2,13 @@
"This email address is already in use": "This email address is already in use", "This email address is already in use": "This email address is already in use",
"This phone number is already in use": "This phone number is already in use", "This phone number is already in use": "This phone number is already in use",
"Failed to verify email address: make sure you clicked the link in the email": "Failed to verify email address: make sure you clicked the link in the email", "Failed to verify email address: make sure you clicked the link in the email": "Failed to verify email address: make sure you clicked the link in the email",
"Call Failed": "Call Failed",
"There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.",
"Review Devices": "Review Devices",
"Call Anyway": "Call Anyway",
"Answer Anyway": "Answer Anyway",
"Call": "Call",
"Answer": "Answer",
"Call Timeout": "Call Timeout", "Call Timeout": "Call Timeout",
"The remote side failed to pick up": "The remote side failed to pick up", "The remote side failed to pick up": "The remote side failed to pick up",
"Unable to capture screen": "Unable to capture screen", "Unable to capture screen": "Unable to capture screen",
@ -156,6 +163,8 @@
"%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing",
"Failure to create room": "Failure to create room", "Failure to create room": "Failure to create room",
"Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.",
"Send anyway": "Send anyway",
"Send": "Send",
"Unnamed Room": "Unnamed Room", "Unnamed Room": "Unnamed Room",
"Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions",
"Not a valid Riot keyfile": "Not a valid Riot keyfile", "Not a valid Riot keyfile": "Not a valid Riot keyfile",
@ -448,6 +457,7 @@
"not specified": "not specified", "not specified": "not specified",
"not set": "not set", "not set": "not set",
"Remote addresses for this room:": "Remote addresses for this room:", "Remote addresses for this room:": "Remote addresses for this room:",
"Addresses": "Addresses",
"The main address for this room is": "The main address for this room is", "The main address for this room is": "The main address for this room is",
"Local addresses for this room:": "Local addresses for this room:", "Local addresses for this room:": "Local addresses for this room:",
"This room has no local addresses": "This room has no local addresses", "This room has no local addresses": "This room has no local addresses",
@ -613,6 +623,8 @@
"%(items)s and %(count)s others|other": "%(items)s and %(count)s others", "%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
"%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(count)s others|one": "%(items)s and one other",
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
"collapse": "collapse",
"expand": "expand",
"Custom of %(powerLevel)s": "Custom of %(powerLevel)s", "Custom of %(powerLevel)s": "Custom of %(powerLevel)s",
"Custom level": "Custom level", "Custom level": "Custom level",
"Room directory": "Room directory", "Room directory": "Room directory",
@ -693,7 +705,6 @@
"Room contains unknown devices": "Room contains unknown devices", "Room contains unknown devices": "Room contains unknown devices",
"\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.", "\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.",
"Unknown devices": "Unknown devices", "Unknown devices": "Unknown devices",
"Send anyway": "Send anyway",
"Private Chat": "Private Chat", "Private Chat": "Private Chat",
"Public Chat": "Public Chat", "Public Chat": "Public Chat",
"Custom": "Custom", "Custom": "Custom",
@ -749,6 +760,10 @@
"Failed to leave room": "Failed to leave room", "Failed to leave room": "Failed to leave room",
"Signed Out": "Signed Out", "Signed Out": "Signed Out",
"For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.", "For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.",
"Cryptography data migrated": "Cryptography data migrated",
"A one-off migration of cryptography data has been performed. End-to-end encryption will not work if you go back to an older version of Riot. If you need to use end-to-end cryptography on an older version, log out of Riot first. To retain message history, export and re-import your keys.": "A one-off migration of cryptography data has been performed. End-to-end encryption will not work if you go back to an older version of Riot. If you need to use end-to-end cryptography on an older version, log out of Riot first. To retain message history, export and re-import your keys.",
"Old cryptography data detected": "Old cryptography data detected",
"Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.",
"Logout": "Logout", "Logout": "Logout",
"Your Communities": "Your Communities", "Your Communities": "Your Communities",
"Error whilst fetching joined communities": "Error whilst fetching joined communities", "Error whilst fetching joined communities": "Error whilst fetching joined communities",
@ -758,17 +773,19 @@
"To join an existing community you'll have to know its community identifier; this will look something like <i>+example:matrix.org</i>.": "To join an existing community you'll have to know its community identifier; this will look something like <i>+example:matrix.org</i>.", "To join an existing community you'll have to know its community identifier; this will look something like <i>+example:matrix.org</i>.": "To join an existing community you'll have to know its community identifier; this will look something like <i>+example:matrix.org</i>.",
"You have no visible notifications": "You have no visible notifications", "You have no visible notifications": "You have no visible notifications",
"Scroll to bottom of page": "Scroll to bottom of page", "Scroll to bottom of page": "Scroll to bottom of page",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.": "<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.",
"Some of your messages have not been sent.": "Some of your messages have not been sent.",
"<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.",
"Warning": "Warning",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.",
"%(count)s new messages|other": "%(count)s new messages", "%(count)s new messages|other": "%(count)s new messages",
"%(count)s new messages|one": "%(count)s new message", "%(count)s new messages|one": "%(count)s new message",
"Active call": "Active call", "Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"Some of your messages have not been sent.": "Some of your messages have not been sent.",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"Failed to upload file": "Failed to upload file", "Failed to upload file": "Failed to upload file",
"Server may be unavailable, overloaded, or the file too big": "Server may be unavailable, overloaded, or the file too big", "Server may be unavailable, overloaded, or the file too big": "Server may be unavailable, overloaded, or the file too big",
"Search failed": "Search failed", "Search failed": "Search failed",