Merge branch 'develop' into travis/feature/wellknown2

This commit is contained in:
Travis Ralston 2019-05-16 12:59:50 -06:00
commit 0c7aa39273
48 changed files with 1737 additions and 44 deletions

View file

@ -1,3 +1,12 @@
Changes in [1.1.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.2) (2019-05-15)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.1...v1.1.2)
* Always thumbnail for GIFs
[\#2976](https://github.com/matrix-org/matrix-react-sdk/pull/2976)
* Fix Single Sign-on
[\#2975](https://github.com/matrix-org/matrix-react-sdk/pull/2975)
Changes in [1.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.1) (2019-05-14) Changes in [1.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.1) (2019-05-14)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0...v1.1.1) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0...v1.1.1)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "1.1.1", "version": "1.1.2",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View file

@ -89,6 +89,7 @@
@import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_ManageIntegsButton.scss";
@import "./views/elements/_MemberEventListSummary.scss"; @import "./views/elements/_MemberEventListSummary.scss";
@import "./views/elements/_MessageEditor.scss";
@import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_PowerSelector.scss";
@import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_ProgressBar.scss";
@import "./views/elements/_ReplyThread.scss"; @import "./views/elements/_ReplyThread.scss";

View file

@ -0,0 +1,64 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_MessageEditor {
border-radius: 4px;
background-color: $header-panel-bg-color;
padding: 11px 13px 7px 56px;
.mx_MessageEditor_editor {
border-radius: 4px;
border: solid 1px #e9edf1;
background-color: #ffffff;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
outline: none;
span {
display: inline-block;
padding: 0 5px;
border-radius: 4px;
color: white;
}
span.user-pill, span.room-pill {
border-radius: 16px;
display: inline-block;
color: $primary-fg-color;
background-color: $other-user-pill-bg-color;
padding-left: 5px;
padding-right: 5px;
}
}
.mx_MessageEditor_buttons {
display: flex;
flex-direction: row;
justify-content: end;
padding: 5px 0;
.mx_AccessibleButton {
margin-left: 5px;
padding: 5px 40px;
}
}
.mx_MessageEditor_AutoCompleteWrapper {
position: relative;
height: 0;
}
}

View file

@ -69,6 +69,10 @@ limitations under the License.
mask-image: url('$(res)/img/reply.svg'); mask-image: url('$(res)/img/reply.svg');
} }
.mx_MessageActionBar_editButton::after {
mask-image: url('$(res)/img/edit.svg');
}
.mx_MessageActionBar_optionsButton::after { .mx_MessageActionBar_optionsButton::after {
mask-image: url('$(res)/img/icon_context.svg'); mask-image: url('$(res)/img/icon_context.svg');
} }

View file

@ -87,6 +87,11 @@ limitations under the License.
} }
} }
.mx_MemberList_invite.mx_AccessibleButton_disabled {
background-color: $greyed-fg-color;;
cursor: not-allowed;
}
.mx_MemberList_invite span { .mx_MemberList_invite span {
background-image: url('$(res)/img/feather-customised/user-add.svg'); background-image: url('$(res)/img/feather-customised/user-add.svg');
background-repeat: no-repeat; background-repeat: no-repeat;

1
res/img/edit.svg Normal file
View file

@ -0,0 +1 @@
<svg height="14.865319" viewBox="0 0 15.093 14.865319" width="15.093" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g style="fill:none;fill-rule:evenodd;stroke:#2e2f32;stroke-width:.75;stroke-linecap:round;stroke-linejoin:round;" transform="translate(-1048.2035 -582.14881)"><path d="m1055.75 596h6.75m-3.375-12.375a1.591 1.591 0 0 1 2.25 2.25l-9.375 9.375-3 .75.75-3z"/></g></svg>

After

Width:  |  Height:  |  Size: 415 B

View file

@ -1,6 +1,6 @@
<svg width="13" height="13" xmlns="http://www.w3.org/2000/svg"> <svg width="13" height="8" xmlns="http://www.w3.org/2000/svg">
<g stroke="#2E2F32" stroke-width=".75" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g stroke="#2E2F32" stroke-width=".75" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.75 4.75L12.5 8.5l-3.75 3.75"/> <path d="M3.167 1L.5 3.545l2.667 2.546"/>
<path d="M.5.25V5.5a3 3 0 0 0 3 3h9"/> <path d="M12.5 7.364V6.09c0-1.406-1.194-2.546-2.667-2.546H.5"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 311 B

View file

@ -257,11 +257,11 @@ $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1);
$message-action-bar-bg-color: $primary-bg-color; $message-action-bar-bg-color: $primary-bg-color;
$message-action-bar-fg-color: $primary-fg-color; $message-action-bar-fg-color: $primary-fg-color;
$message-action-bar-border-color: #e9edf1; $message-action-bar-border-color: #e9edf1;
$message-action-bar-hover-border-color: #b8c1d2; $message-action-bar-hover-border-color: $focus-bg-color;
$reaction-row-button-bg-color: $header-panel-bg-color; $reaction-row-button-bg-color: $header-panel-bg-color;
$reaction-row-button-border-color: #e9edf1; $reaction-row-button-border-color: #e9edf1;
$reaction-row-button-hover-border-color: #bebebe; $reaction-row-button-hover-border-color: $focus-bg-color;
$reaction-row-button-selected-bg-color: #e9fff9; $reaction-row-button-selected-bg-color: #e9fff9;
$reaction-row-button-selected-border-color: $accent-color; $reaction-row-button-selected-border-color: $accent-color;

View file

@ -369,7 +369,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
// If there's an inconsistency between account data in local storage and the // If there's an inconsistency between account data in local storage and the
// crypto store, we'll be generally confused when handling encrypted data. // crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage. // Show a modal recommending a full reset of storage.
if (results.dataInLocalStorage && !results.dataInCryptoStore) { if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog(); const signOut = await _showStorageEvictedDialog();
if (signOut) { if (signOut) {
await _clearStorage(); await _clearStorage();

View file

@ -121,6 +121,7 @@ class MatrixClientPeg {
// check that we have a version of the js-sdk which includes initCrypto // check that we have a version of the js-sdk which includes initCrypto
if (this.matrixClient.initCrypto) { if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto(); await this.matrixClient.initCrypto();
StorageManager.setCryptoInitialised(true);
} }
} catch (e) { } catch (e) {
if (e && e.name === 'InvalidCryptoStoreError') { if (e && e.name === 'InvalidCryptoStoreError') {
@ -176,6 +177,7 @@ class MatrixClientPeg {
_createClient(creds: MatrixClientCreds) { _createClient(creds: MatrixClientCreds) {
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions"); const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing");
const opts = { const opts = {
baseUrl: creds.homeserverUrl, baseUrl: creds.homeserverUrl,
@ -186,7 +188,7 @@ class MatrixClientPeg {
timelineSupport: true, timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
verificationMethods: [verificationMethods.SAS], verificationMethods: [verificationMethods.SAS],
unstableClientRelationAggregation: aggregateRelations, unstableClientRelationAggregation: aggregateRelations || enableEdits,
}; };
this.matrixClient = createMatrixClient(opts); this.matrixClient = createMatrixClient(opts);

View file

@ -450,9 +450,14 @@ module.exports = React.createClass({
_getTilesForEvent: function(prevEvent, mxEv, last) { _getTilesForEvent: function(prevEvent, mxEv, last) {
const EventTile = sdk.getComponent('rooms.EventTile'); const EventTile = sdk.getComponent('rooms.EventTile');
const MessageEditor = sdk.getComponent('elements.MessageEditor');
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = []; const ret = [];
if (this.props.editEvent && this.props.editEvent.getId() === mxEv.getId()) {
return [<MessageEditor key={mxEv.getId()} event={mxEv} />];
}
// is this a continuation of the previous message? // is this a continuation of the previous message?
let continuation = false; let continuation = false;
@ -521,12 +526,13 @@ module.exports = React.createClass({
<EventTile mxEvent={mxEv} <EventTile mxEvent={mxEv}
continuation={continuation} continuation={continuation}
isRedacted={mxEv.isRedacted()} isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
onHeightChanged={this._onHeightChanged} onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap} readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting} checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status} eventSendStatus={mxEv.replacementOrOwnStatus()}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour} isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}

View file

@ -208,6 +208,7 @@ const TimelinePanel = React.createClass({
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
MatrixClientPeg.get().on("Room.accountData", this.onAccountData); MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
MatrixClientPeg.get().on("sync", this.onSync); MatrixClientPeg.get().on("sync", this.onSync);
this._initTimeline(this.props); this._initTimeline(this.props);
@ -286,6 +287,7 @@ const TimelinePanel = React.createClass({
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
client.removeListener("Room.accountData", this.onAccountData); client.removeListener("Room.accountData", this.onAccountData);
client.removeListener("Event.decrypted", this.onEventDecrypted); client.removeListener("Event.decrypted", this.onEventDecrypted);
client.removeListener("Event.replaced", this.onEventReplaced);
client.removeListener("sync", this.onSync); client.removeListener("sync", this.onSync);
} }
}, },
@ -402,6 +404,9 @@ const TimelinePanel = React.createClass({
if (payload.action === 'ignore_state_changed') { if (payload.action === 'ignore_state_changed') {
this.forceUpdate(); this.forceUpdate();
} }
if (payload.action === "edit_event") {
this.setState({editEvent: payload.event});
}
}, },
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
@ -502,6 +507,17 @@ const TimelinePanel = React.createClass({
this.forceUpdate(); this.forceUpdate();
}, },
onEventReplaced: function(replacedEvent, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.timelineSet.room) return;
// we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation.
this.forceUpdate();
},
onRoomReceipt: function(ev, room) { onRoomReceipt: function(ev, room) {
if (this.unmounted) return; if (this.unmounted) return;
@ -1244,6 +1260,7 @@ const TimelinePanel = React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent} getRelationsForEvent={this.getRelationsForEvent}
editEvent={this.state.editEvent}
/> />
); );
}, },

View file

@ -195,13 +195,13 @@ export default class ImageView extends React.Component {
<img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" /> <img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" />
<div className="mx_ImageView_labelWrapper"> <div className="mx_ImageView_labelWrapper">
<div className="mx_ImageView_label"> <div className="mx_ImageView_label">
<AccessibleButton className="mx_ImageView_rotateCounterClockwise" onClick={ this.rotateCounterClockwise }> <AccessibleButton className="mx_ImageView_rotateCounterClockwise" title={_t("Rotate Left")} onClick={ this.rotateCounterClockwise }>
<img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" /> <img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" />
</AccessibleButton> </AccessibleButton>
<AccessibleButton className="mx_ImageView_rotateClockwise" onClick={ this.rotateClockwise }> <AccessibleButton className="mx_ImageView_rotateClockwise" title={_t("Rotate Right")} onClick={ this.rotateClockwise }>
<img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" /> <img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" />
</AccessibleButton> </AccessibleButton>
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }> <AccessibleButton className="mx_ImageView_cancel" title={_t("Close")} onClick={ this.props.onFinished }>
<img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } /> <img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } />
</AccessibleButton> </AccessibleButton>
<div className="mx_ImageView_shim"> <div className="mx_ImageView_shim">

View file

@ -0,0 +1,189 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import EditorModel from '../../../editor/model';
import {setCaretPosition} from '../../../editor/caret';
import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize';
import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
export default class MessageEditor extends React.Component {
static propTypes = {
// the message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
constructor(props, context) {
super(props, context);
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
);
this.model = new EditorModel(
parseEvent(this.props.event),
partCreator,
this._updateEditorState,
);
const room = this.context.matrixClient.getRoom(this.props.event.getRoomId());
this.state = {
autoComplete: null,
room,
};
this._editorRef = null;
this._autocompleteRef = null;
}
_updateEditorState = (caret) => {
renderModel(this._editorRef, this.model);
if (caret) {
try {
setCaretPosition(this._editorRef, this.model, caret);
} catch (err) {
console.error(err);
}
}
this.setState({autoComplete: this.model.autoComplete});
}
_onInput = (event) => {
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
this.model.update(text, event.inputType, caret);
}
_onKeyDown = (event) => {
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
if (!this.model.autoComplete) {
return;
}
const autoComplete = this.model.autoComplete;
switch (event.key) {
case "Enter":
autoComplete.onEnter(event); break;
case "ArrowUp":
autoComplete.onUpArrow(event); break;
case "ArrowDown":
autoComplete.onDownArrow(event); break;
case "Tab":
autoComplete.onTab(event); break;
case "Escape":
autoComplete.onEscape(event); break;
default:
return; // don't preventDefault on anything else
}
event.preventDefault();
}
_onCancelClicked = () => {
dis.dispatch({action: "edit_event", event: null});
}
_onSaveClicked = () => {
const newContent = {
"msgtype": "m.text",
"body": textSerialize(this.model),
};
const contentBody = {
msgtype: newContent.msgtype,
body: ` * ${newContent.body}`,
};
if (requiresHtml(this.model)) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = htmlSerialize(this.model);
contentBody.format = newContent.format;
contentBody.formatted_body = ` * ${newContent.formatted_body}`;
}
const content = Object.assign({
"m.new_content": newContent,
"m.relates_to": {
"rel_type": "m.replace",
"event_id": this.props.event.getId(),
},
}, contentBody);
const roomId = this.props.event.getRoomId();
this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null});
}
_onAutoCompleteConfirm = (completion) => {
this.model.autoComplete.onComponentConfirm(completion);
}
_onAutoCompleteSelectionChange = (completion) => {
this.model.autoComplete.onComponentSelectionChange(completion);
}
componentDidMount() {
this._updateEditorState();
const sel = document.getSelection();
const range = document.createRange();
range.selectNodeContents(this._editorRef);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
this._editorRef.focus();
}
render() {
let autoComplete;
if (this.state.autoComplete) {
const query = this.state.query;
const queryLen = query.length;
autoComplete = <div className="mx_MessageEditor_AutoCompleteWrapper">
<Autocomplete
ref={ref => this._autocompleteRef = ref}
query={query}
onConfirm={this._onAutoCompleteConfirm}
onSelectionChange={this._onAutoCompleteSelectionChange}
selection={{beginning: true, end: queryLen, start: queryLen}}
room={this.state.room}
/>
</div>;
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div className="mx_MessageEditor">
{ autoComplete }
<div
className="mx_MessageEditor_editor"
contentEditable="true"
tabIndex="1"
onInput={this._onInput}
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}
></div>
<div className="mx_MessageEditor_buttons">
<AccessibleButton kind="secondary" onClick={this._onCancelClicked}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._onSaveClicked}>{_t("Save")}</AccessibleButton>
</div>
</div>;
}
}

View file

@ -63,6 +63,11 @@ export default class ReplyThread extends React.Component {
static getParentEventId(ev) { static getParentEventId(ev) {
if (!ev || ev.isRedacted()) return; if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now
// have a `getRelation` helper on the event, and you might assume it
// could be used here for replies as well... However, the helper
// currently assumes the relation has a `rel_type`, which older replies
// do not, so this block is left as-is for now.
const mRelatesTo = ev.getWireContent()['m.relates_to']; const mRelatesTo = ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) { if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to']; const mInReplyTo = mRelatesTo['m.in_reply_to'];

View file

@ -23,7 +23,7 @@ import dis from '../../../dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { createMenu } from '../../structures/ContextualMenu'; import { createMenu } from '../../structures/ContextualMenu';
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import { isContentActionable } from '../../../utils/EventUtils'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
export default class MessageActionBar extends React.PureComponent { export default class MessageActionBar extends React.PureComponent {
static propTypes = { static propTypes = {
@ -58,6 +58,13 @@ export default class MessageActionBar extends React.PureComponent {
}); });
} }
onEditClick = (ev) => {
dis.dispatch({
action: 'edit_event',
event: this.props.mxEvent,
});
}
onOptionsClick = (ev) => { onOptionsClick = (ev) => {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = ev.target.getBoundingClientRect(); const buttonRect = ev.target.getBoundingClientRect();
@ -96,6 +103,10 @@ export default class MessageActionBar extends React.PureComponent {
return SettingsStore.isFeatureEnabled("feature_reactions"); return SettingsStore.isFeatureEnabled("feature_reactions");
} }
isEditingEnabled() {
return SettingsStore.isFeatureEnabled("feature_message_editing");
}
renderAgreeDimension() { renderAgreeDimension() {
if (!this.isReactionsEnabled()) { if (!this.isReactionsEnabled()) {
return null; return null;
@ -128,6 +139,7 @@ export default class MessageActionBar extends React.PureComponent {
let agreeDimensionReactionButtons; let agreeDimensionReactionButtons;
let likeDimensionReactionButtons; let likeDimensionReactionButtons;
let replyButton; let replyButton;
let editButton;
if (isContentActionable(this.props.mxEvent)) { if (isContentActionable(this.props.mxEvent)) {
agreeDimensionReactionButtons = this.renderAgreeDimension(); agreeDimensionReactionButtons = this.renderAgreeDimension();
@ -137,11 +149,18 @@ export default class MessageActionBar extends React.PureComponent {
onClick={this.onReplyClick} onClick={this.onReplyClick}
/>; />;
} }
if (this.isEditingEnabled() && canEditContent(this.props.mxEvent)) {
editButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
/>;
}
return <div className="mx_MessageActionBar"> return <div className="mx_MessageActionBar">
{agreeDimensionReactionButtons} {agreeDimensionReactionButtons}
{likeDimensionReactionButtons} {likeDimensionReactionButtons}
{replyButton} {replyButton}
{editButton}
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton" <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")} title={_t("Options")}
onClick={this.onOptionsClick} onClick={this.onOptionsClick}

View file

@ -89,6 +89,7 @@ module.exports = React.createClass({
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
maxImageHeight={this.props.maxImageHeight} maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId}
onHeightChanged={this.props.onHeightChanged} />; onHeightChanged={this.props.onHeightChanged} />;
}, },
}); });

View file

@ -37,6 +37,7 @@ export default class ReactionDimension extends React.PureComponent {
if (props.reactions) { if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange); props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange); props.reactions.on("Relations.redaction", this.onReactionsChange);
} }
} }
@ -44,6 +45,7 @@ export default class ReactionDimension extends React.PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.reactions !== this.props.reactions) { if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
this.props.reactions.on("Relations.redaction", this.onReactionsChange); this.props.reactions.on("Relations.redaction", this.onReactionsChange);
this.onReactionsChange(); this.onReactionsChange();
} }
@ -55,6 +57,10 @@ export default class ReactionDimension extends React.PureComponent {
"Relations.add", "Relations.add",
this.onReactionsChange, this.onReactionsChange,
); );
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener( this.props.reactions.removeListener(
"Relations.redaction", "Relations.redaction",
this.onReactionsChange, this.onReactionsChange,
@ -82,7 +88,7 @@ export default class ReactionDimension extends React.PureComponent {
if (mxEvent.isRedacted()) { if (mxEvent.isRedacted()) {
return false; return false;
} }
return mxEvent.getContent()["m.relates_to"].key === option; return mxEvent.getRelation().key === option;
}); });
if (!reactionForOption) { if (!reactionForOption) {
continue; continue;
@ -107,7 +113,11 @@ export default class ReactionDimension extends React.PureComponent {
return null; return null;
} }
const userId = MatrixClientPeg.get().getUserId(); const userId = MatrixClientPeg.get().getUserId();
return reactions.getAnnotationsBySender()[userId]; const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
} }
onOptionClick = (ev) => { onOptionClick = (ev) => {

View file

@ -34,6 +34,7 @@ export default class ReactionsRow extends React.PureComponent {
if (props.reactions) { if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange); props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange); props.reactions.on("Relations.redaction", this.onReactionsChange);
} }
@ -45,6 +46,7 @@ export default class ReactionsRow extends React.PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.reactions !== this.props.reactions) { if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
this.props.reactions.on("Relations.redaction", this.onReactionsChange); this.props.reactions.on("Relations.redaction", this.onReactionsChange);
this.onReactionsChange(); this.onReactionsChange();
} }
@ -56,6 +58,10 @@ export default class ReactionsRow extends React.PureComponent {
"Relations.add", "Relations.add",
this.onReactionsChange, this.onReactionsChange,
); );
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener( this.props.reactions.removeListener(
"Relations.redaction", "Relations.redaction",
this.onReactionsChange, this.onReactionsChange,
@ -80,7 +86,11 @@ export default class ReactionsRow extends React.PureComponent {
return null; return null;
} }
const userId = MatrixClientPeg.get().getUserId(); const userId = MatrixClientPeg.get().getUserId();
return reactions.getAnnotationsBySender()[userId]; const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
} }
render() { render() {
@ -101,7 +111,7 @@ export default class ReactionsRow extends React.PureComponent {
if (mxEvent.isRedacted()) { if (mxEvent.isRedacted()) {
return false; return false;
} }
return mxEvent.getContent()["m.relates_to"].key === content; return mxEvent.getRelation().key === content;
}); });
return <ReactionsRowButton return <ReactionsRowButton
key={content} key={content}

View file

@ -88,7 +88,10 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this._unmounted = false; this._unmounted = false;
this._applyFormatting();
},
_applyFormatting() {
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
// are still sent as plaintext URLs. If these are ever pillified in the composer, // are still sent as plaintext URLs. If these are ever pillified in the composer,
// we should be pillify them here by doing the linkifying BEFORE the pillifying. // we should be pillify them here by doing the linkifying BEFORE the pillifying.
@ -123,7 +126,11 @@ module.exports = React.createClass({
} }
}, },
componentDidUpdate: function() { componentDidUpdate: function(prevProps) {
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
if (messageWasEdited) {
this._applyFormatting();
}
this.calculateUrlPreview(); this.calculateUrlPreview();
}, },
@ -137,6 +144,7 @@ module.exports = React.createClass({
// exploit that events are immutable :) // exploit that events are immutable :)
return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.highlights !== this.props.highlights || nextProps.highlights !== this.props.highlights ||
nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink || nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview || nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextState.links !== this.state.links || nextState.links !== this.state.links ||

View file

@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component {
}; };
} }
componentWillReceiveProps(newProps, state) { componentDidMount() {
if (this.props.room.roomId !== newProps.room.roomId) { this._applyNewProps();
}
_applyNewProps(oldQuery, oldRoom) {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy(); this.autocompleter.destroy();
this.autocompleter = new Autocompleter(newProps.room); this.autocompleter = new Autocompleter(this.props.room);
} }
// Query hasn't changed so don't try to complete it // Query hasn't changed so don't try to complete it
if (newProps.query === this.props.query) { if (oldQuery === this.props.query) {
return; return;
} }
this.complete(newProps.query, newProps.selection); this.complete(this.props.query, this.props.selection);
} }
componentWillUnmount() { componentWillUnmount() {
@ -233,7 +237,8 @@ export default class Autocomplete extends React.Component {
} }
} }
componentDidUpdate() { componentDidUpdate(prevProps) {
this._applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed // this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`]; const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) { if (selectedCompletion && this.container) {
@ -298,6 +303,9 @@ Autocomplete.propTypes = {
// method invoked with range and text content when completion is confirmed // method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
// method invoked when selected (if any) completion changes
onSelectionChange: PropTypes.func,
// The room in which we're autocompleting // The room in which we're autocompleting
room: PropTypes.instanceOf(Room), room: PropTypes.instanceOf(Room),
}; };

View file

@ -779,6 +779,7 @@ module.exports = withMatrixClient(React.createClass({
{ thread } { thread }
<EventTileType ref="tile" <EventTileType ref="tile"
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}

View file

@ -589,6 +589,7 @@ module.exports = withMatrixClient(React.createClass({
can.kick = me.powerLevel >= powerLevels.kick; can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban; can.ban = me.powerLevel >= powerLevels.ban;
can.invite = me.powerLevel >= powerLevels.invite;
can.mute = me.powerLevel >= editPowerLevel; can.mute = me.powerLevel >= editPowerLevel;
can.modifyLevel = me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel); can.modifyLevel = me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel);
can.modifyLevelMax = me.powerLevel; can.modifyLevelMax = me.powerLevel;
@ -727,7 +728,7 @@ module.exports = withMatrixClient(React.createClass({
); );
} }
if (!member || !member.membership || member.membership === 'leave') { if (this.state.can.invite && (!member || !member.membership || member.membership === 'leave')) {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId(); const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async () => { const onInviteUserButton = async () => {
try { try {

View file

@ -449,10 +449,23 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
let inviteButton; let inviteButton;
if (room && room.getMyMembership() === 'join') { if (room && room.getMyMembership() === 'join') {
// assume we can invite until proven false
let canInvite = true;
const plEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const me = room.getMember(cli.getUserId());
if (plEvent && me) {
const content = plEvent.getContent();
if (content && content.invite > me.powerLevel) {
canInvite = false;
}
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton = inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick}> <AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!canInvite}>
<span>{ _t('Invite to this room') }</span> <span>{ _t('Invite to this room') }</span>
</AccessibleButton>; </AccessibleButton>;
} }

View file

@ -0,0 +1,97 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel {
constructor(updateCallback, getAutocompleterComponent, updateQuery) {
this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery;
this._query = null;
}
onEscape(e) {
this._getAutocompleterComponent().onEscape(e);
this._updateCallback({
replacePart: new PlainPart(this._queryPart.text),
caretOffset: this._queryOffset,
close: true,
});
}
onEnter() {
this._updateCallback({close: true});
}
onTab() {
//forceCompletion here?
}
onUpArrow() {
this._getAutocompleterComponent().onUpArrow();
}
onDownArrow() {
this._getAutocompleterComponent().onDownArrow();
}
onPartUpdate(part, offset) {
// cache the typed value and caret here
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this._queryPart = part;
this._queryOffset = offset;
this._updateQuery(part.text);
}
onComponentSelectionChange(completion) {
if (!completion) {
this._updateCallback({
replacePart: this._queryPart,
caretOffset: this._queryOffset,
});
} else {
this._updateCallback({
replacePart: this._partForCompletion(completion),
});
}
}
onComponentConfirm(completion) {
this._updateCallback({
replacePart: this._partForCompletion(completion),
close: true,
});
}
_partForCompletion(completion) {
const firstChr = completion.completionId && completion.completionId[0];
switch (firstChr) {
case "@": {
const displayName = completion.completion;
const userId = completion.completionId;
return new UserPillPart(userId, displayName);
}
case "#": {
const displayAlias = completion.completionId;
return new RoomPillPart(displayAlias);
}
// also used for emoji completion
default:
return new PlainPart(completion.completion);
}
}
}

56
src/editor/caret.js Normal file
View file

@ -0,0 +1,56 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function setCaretPosition(editor, model, caretPosition) {
const sel = document.getSelection();
sel.removeAllRanges();
const range = document.createRange();
const {parts} = model;
let lineIndex = 0;
let nodeIndex = -1;
for (let i = 0; i <= caretPosition.index; ++i) {
const part = parts[i];
if (part && part.type === "newline") {
lineIndex += 1;
nodeIndex = -1;
} else {
nodeIndex += 1;
}
}
let focusNode;
const lineNode = editor.childNodes[lineIndex];
if (lineNode) {
if (lineNode.childNodes.length === 0 && caretPosition.offset === 0) {
focusNode = lineNode;
} else {
focusNode = lineNode.childNodes[nodeIndex];
if (focusNode && focusNode.nodeType === Node.ELEMENT_NODE) {
focusNode = focusNode.childNodes[0];
}
}
}
// node not found, set caret at end
if (!focusNode) {
range.selectNodeContents(editor);
range.collapse(false);
} else {
// make sure we have a text node
range.setStart(focusNode, caretPosition.offset);
range.collapse(true);
}
sel.addRange(range);
}

76
src/editor/deserialize.js Normal file
View file

@ -0,0 +1,76 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
function parseHtmlMessage(html) {
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes);
const parts = nodes.map(n => {
switch (n.nodeType) {
case Node.TEXT_NODE:
return new PlainPart(n.nodeValue);
case Node.ELEMENT_NODE:
switch (n.nodeName) {
case "MX-REPLY":
return null;
case "A": {
const {href} = n;
const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) {
case "@": return new UserPillPart(resourceId, n.textContent);
case "#": return new RoomPillPart(resourceId, n.textContent);
default: return new PlainPart(n.textContent);
}
}
case "BR":
return new NewlinePart("\n");
default:
return new PlainPart(n.textContent);
}
default:
return null;
}
}).filter(p => !!p);
return parts;
}
export function parseEvent(event) {
const content = event.getContent();
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "");
} else {
const body = content.body || "";
const lines = body.split("\n");
const parts = lines.reduce((parts, line, i) => {
const isLast = i === lines.length - 1;
const text = new PlainPart(line);
const newLine = !isLast && new NewlinePart("\n");
if (newLine) {
return parts.concat(text, newLine);
} else {
return parts.concat(text);
}
}, []);
return parts;
}
}

78
src/editor/diff.js Normal file
View file

@ -0,0 +1,78 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function firstDiff(a, b) {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {
return i;
}
}
return compareLen;
}
function lastDiff(a, b) {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[a.length - i] !== b[b.length - i]) {
return i;
}
}
return compareLen;
}
function diffStringsAtEnd(oldStr, newStr) {
const len = Math.min(oldStr.length, newStr.length);
const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len);
if (startInCommon && oldStr.length > newStr.length) {
return {removed: oldStr.substr(len), at: len};
} else if (startInCommon && oldStr.length < newStr.length) {
return {added: newStr.substr(len), at: len};
} else {
const commonStartLen = firstDiff(oldStr, newStr);
return {
removed: oldStr.substr(commonStartLen),
added: newStr.substr(commonStartLen),
at: commonStartLen,
};
}
}
export function diffDeletion(oldStr, newStr) {
if (oldStr === newStr) {
return {};
}
const firstDiffIdx = firstDiff(oldStr, newStr);
const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1;
return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)};
}
export function diffInsertion(oldStr, newStr) {
const diff = diffDeletion(newStr, oldStr);
if (diff.removed) {
return {at: diff.at, added: diff.removed};
} else {
return diff;
}
}
export function diffAtCaret(oldValue, newValue, caretPosition) {
const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen;
const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput);
const newValueBeforeCaret = newValue.substr(0, caretPosition);
return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret);
}

84
src/editor/dom.js Normal file
View file

@ -0,0 +1,84 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) {
let node = editor.firstChild;
while (node && node !== editor) {
enterNodeCallback(node);
if (node.firstChild) {
node = node.firstChild;
} else if (node.nextSibling) {
node = node.nextSibling;
} else {
while (!node.nextSibling && node !== editor) {
node = node.parentElement;
if (node !== editor) {
leaveNodeCallback(node);
}
}
if (node !== editor) {
node = node.nextSibling;
}
}
}
}
export function getCaretOffsetAndText(editor, sel) {
let {focusNode} = sel;
const {focusOffset} = sel;
let caretOffset = focusOffset;
let foundCaret = false;
let text = "";
if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) {
focusNode = focusNode.childNodes[focusOffset - 1];
caretOffset = focusNode.textContent.length;
}
function enterNodeCallback(node) {
const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue;
if (!foundCaret) {
if (node === focusNode) {
foundCaret = true;
}
}
if (nodeText) {
if (!foundCaret) {
caretOffset += nodeText.length;
}
text += nodeText;
}
}
function leaveNodeCallback(node) {
// if this is not the last DIV (which are only used as line containers atm)
// we don't just check if there is a nextSibling because sometimes the caret ends up
// after the last DIV and it creates a newline if you type then,
// whereas you just want it to be appended to the current line
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
text += "\n";
if (!foundCaret) {
caretOffset += 1;
}
}
}
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length;
const caret = {atNodeEnd, offset: caretOffset};
return {caret, text};
}

264
src/editor/model.js Normal file
View file

@ -0,0 +1,264 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {diffAtCaret, diffDeletion} from "./diff";
export default class EditorModel {
constructor(parts, partCreator, updateCallback) {
this._parts = parts;
this._partCreator = partCreator;
this._activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
this._updateCallback = updateCallback;
}
_insertPart(index, part) {
this._parts.splice(index, 0, part);
if (this._activePartIdx >= index) {
++this._activePartIdx;
}
if (this._autoCompletePartIdx >= index) {
++this._autoCompletePartIdx;
}
}
_removePart(index) {
this._parts.splice(index, 1);
if (this._activePartIdx >= index) {
--this._activePartIdx;
}
if (this._autoCompletePartIdx >= index) {
--this._autoCompletePartIdx;
}
}
_replacePart(index, part) {
this._parts.splice(index, 1, part);
}
get parts() {
return this._parts;
}
get autoComplete() {
if (this._activePartIdx === this._autoCompletePartIdx) {
return this._autoComplete;
}
return null;
}
serializeParts() {
return this._parts.map(({type, text}) => {return {type, text};});
}
_diff(newValue, inputType, caret) {
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
// can't use caret position with drag and drop
if (inputType === "deleteByDrag") {
return diffDeletion(previousValue, newValue);
} else {
return diffAtCaret(previousValue, newValue, caret.offset);
}
}
update(newValue, inputType, caret) {
const diff = this._diff(newValue, inputType, caret);
const position = this._positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this._removeText(position, diff.removed.length);
}
let addedLen = 0;
if (diff.added) {
addedLen = this._addText(position, diff.added);
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
const newPosition = this._positionForOffset(caretOffset, true);
this._setActivePart(newPosition);
this._updateCallback(newPosition);
}
_setActivePart(pos) {
const {index} = pos;
const part = this._parts[index];
if (part) {
if (index !== this._activePartIdx) {
this._activePartIdx = index;
if (this._activePartIdx !== this._autoCompletePartIdx) {
// else try to create one
const ac = part.createAutoComplete(this._onAutoComplete);
if (ac) {
// make sure that react picks up the difference between both acs
this._autoComplete = ac;
this._autoCompletePartIdx = index;
}
}
}
// not _autoComplete, only there if active part is autocomplete part
if (this.autoComplete) {
this.autoComplete.onPartUpdate(part, pos.offset);
}
} else {
this._activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
}
}
_onAutoComplete = ({replacePart, caretOffset, close}) => {
let pos;
if (replacePart) {
this._replacePart(this._autoCompletePartIdx, replacePart);
let index = this._autoCompletePartIdx;
if (caretOffset === undefined) {
caretOffset = 0;
index += 1;
}
pos = new DocumentPosition(index, caretOffset);
}
if (close) {
this._autoComplete = null;
this._autoCompletePartIdx = null;
}
// rerender even if editor contents didn't change
// to make sure the MessageEditor checks
// model.autoComplete being empty and closes it
this._updateCallback(pos);
}
_mergeAdjacentParts(docPos) {
let prevPart = this._parts[0];
for (let i = 1; i < this._parts.length; ++i) {
let part = this._parts[i];
const isEmpty = !part.text.length;
const isMerged = !isEmpty && prevPart.merge(part);
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
this._removePart(i);
//repeat this index, as it's removed now
--i;
}
prevPart = part;
}
}
/**
* removes `len` amount of characters at `pos`.
* @param {Object} pos
* @param {Number} len
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
_removeText(pos, len) {
let {index, offset} = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
// part might be undefined here
let part = this._parts[index];
const amount = Math.min(len, part.text.length - offset);
if (part.canEdit) {
const replaceWith = part.remove(offset, amount);
if (typeof replaceWith === "string") {
this._replacePart(index, this._partCreator.createDefaultPart(replaceWith));
}
part = this._parts[index];
// remove empty part
if (!part.text.length) {
this._removePart(index);
} else {
index += 1;
}
} else {
removedOffsetDecrease += offset;
this._removePart(index);
}
len -= amount;
offset = 0;
}
return removedOffsetDecrease;
}
/**
* inserts `str` into the model at `pos`.
* @param {Object} pos
* @param {string} str
* @return {Number} how far from position (in characters) the insertion ended.
* This can be more than the length of `str` when crossing non-editable parts, which are skipped.
*/
_addText(pos, str) {
let {index} = pos;
const {offset} = pos;
let addLen = str.length;
const part = this._parts[index];
if (part) {
if (part.canEdit) {
if (part.insertAll(offset, str)) {
str = null;
} else {
const splitPart = part.split(offset);
index += 1;
this._insertPart(index, splitPart);
}
} else {
// not-editable, insert str after this part
addLen += part.text.length - offset;
index += 1;
}
}
while (str) {
const newPart = this._partCreator.createPartForInput(str);
str = newPart.appendUntilRejected(str);
this._insertPart(index, newPart);
index += 1;
}
return addLen;
}
_positionForOffset(totalOffset, atPartEnd) {
let currentOffset = 0;
const index = this._parts.findIndex(part => {
const partLen = part.text.length;
if (
(atPartEnd && (currentOffset + partLen) >= totalOffset) ||
(!atPartEnd && (currentOffset + partLen) > totalOffset)
) {
return true;
}
currentOffset += partLen;
return false;
});
return new DocumentPosition(index, totalOffset - currentOffset);
}
}
class DocumentPosition {
constructor(index, offset) {
this._index = index;
this._offset = offset;
}
get index() {
return this._index;
}
get offset() {
return this._offset;
}
}

274
src/editor/parts.js Normal file
View file

@ -0,0 +1,274 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import AutocompleteWrapperModel from "./autocomplete";
class BasePart {
constructor(text = "") {
this._text = text;
}
acceptsInsertion(chr) {
return true;
}
acceptsRemoval(position, chr) {
return true;
}
merge(part) {
return false;
}
split(offset) {
const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset);
return new PlainPart(splitText);
}
// removes len chars, or returns the plain text this part should be replaced with
// if the part would become invalid if it removed everything.
remove(offset, len) {
// validate
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
for (let i = offset; i < (len + offset); ++i) {
const chr = this.text.charAt(i);
if (!this.acceptsRemoval(i, chr)) {
return strWithRemoval;
}
}
this._text = strWithRemoval;
}
// append str, returns the remaining string if a character was rejected.
appendUntilRejected(str) {
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr)) {
this._text = this._text + str.substr(0, i);
return str.substr(i);
}
}
this._text = this._text + str;
}
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
// return whether the str was accepted or not.
insertAll(offset, str) {
for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr)) {
return false;
}
}
const beforeInsert = this._text.substr(0, offset);
const afterInsert = this._text.substr(offset);
this._text = beforeInsert + str + afterInsert;
return true;
}
createAutoComplete() {}
trim(len) {
const remaining = this._text.substr(len);
this._text = this._text.substr(0, len);
return remaining;
}
get text() {
return this._text;
}
get canEdit() {
return true;
}
toString() {
return `${this.type}(${this.text})`;
}
}
export class PlainPart extends BasePart {
acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
}
toDOMNode() {
return document.createTextNode(this.text);
}
merge(part) {
if (part.type === this.type) {
this._text = this.text + part.text;
return true;
}
return false;
}
get type() {
return "plain";
}
updateDOMNode(node) {
if (node.textContent !== this.text) {
// console.log("changing plain text from", node.textContent, "to", this.text);
node.textContent = this.text;
}
}
canUpdateDOMNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
}
class PillPart extends BasePart {
constructor(resourceId, label) {
super(label);
this.resourceId = resourceId;
}
acceptsInsertion(chr) {
return chr !== " ";
}
acceptsRemoval(position, chr) {
return position !== 0; //if you remove initial # or @, pill should become plain
}
toDOMNode() {
const container = document.createElement("span");
container.className = this.type;
container.appendChild(document.createTextNode(this.text));
return container;
}
updateDOMNode(node) {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
// console.log("changing pill text from", textNode.textContent, "to", this.text);
textNode.textContent = this.text;
}
if (node.className !== this.type) {
// console.log("turning", node.className, "into", this.type);
node.className = this.type;
}
}
canUpdateDOMNode(node) {
return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
node.childNodes[0].nodeType === Node.TEXT_NODE;
}
get canEdit() {
return false;
}
}
export class NewlinePart extends BasePart {
acceptsInsertion(chr) {
return this.text.length === 0 && chr === "\n";
}
acceptsRemoval(position, chr) {
return true;
}
toDOMNode() {
return document.createElement("br");
}
merge() {
return false;
}
updateDOMNode() {}
canUpdateDOMNode(node) {
return node.tagName === "BR";
}
get type() {
return "newline";
}
}
export class RoomPillPart extends PillPart {
constructor(displayAlias) {
super(displayAlias, displayAlias);
}
get type() {
return "room-pill";
}
}
export class UserPillPart extends PillPart {
get type() {
return "user-pill";
}
}
export class PillCandidatePart extends PlainPart {
constructor(text, autoCompleteCreator) {
super(text);
this._autoCompleteCreator = autoCompleteCreator;
}
createAutoComplete(updateCallback) {
return this._autoCompleteCreator(updateCallback);
}
acceptsInsertion(chr) {
return true;
}
acceptsRemoval(position, chr) {
return true;
}
get type() {
return "pill-candidate";
}
}
export class PartCreator {
constructor(getAutocompleterComponent, updateQuery) {
this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery);
};
}
createPartForInput(input) {
switch (input[0]) {
case "#":
case "@":
case ":":
return new PillCandidatePart("", this._autoCompleteCreator);
case "\n":
return new NewlinePart();
default:
return new PlainPart();
}
}
createDefaultPart(text) {
return new PlainPart(text);
}
}

81
src/editor/render.js Normal file
View file

@ -0,0 +1,81 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function renderModel(editor, model) {
const lines = model.parts.reduce((lines, part) => {
if (part.type === "newline") {
lines.push([]);
} else {
const lastLine = lines[lines.length - 1];
lastLine.push(part);
}
return lines;
}, [[]]);
// TODO: refactor this code, DRY it
lines.forEach((parts, i) => {
let lineContainer = editor.childNodes[i];
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
editor.removeChild(lineContainer);
lineContainer = editor.childNodes[i];
}
if (!lineContainer) {
lineContainer = document.createElement("div");
editor.appendChild(lineContainer);
}
if (parts.length) {
parts.forEach((part, j) => {
let partNode = lineContainer.childNodes[j];
while (partNode && !part.canUpdateDOMNode(partNode)) {
lineContainer.removeChild(partNode);
partNode = lineContainer.childNodes[j];
}
if (partNode && part) {
part.updateDOMNode(partNode);
} else if (part) {
lineContainer.appendChild(part.toDOMNode());
}
});
let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length);
while (surplusElementCount) {
lineContainer.removeChild(lineContainer.lastChild);
--surplusElementCount;
}
} else {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
if (!foundBR && partNode.tagName === "BR") {
foundBR = true;
} else {
lineContainer.removeChild(partNode);
}
partNode = partNode.nextSibling;
}
if (!foundBR) {
lineContainer.appendChild(document.createElement("br"));
}
}
let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length);
while (surplusElementCount) {
editor.removeChild(editor.lastChild);
--surplusElementCount;
}
});
}

43
src/editor/serialize.js Normal file
View file

@ -0,0 +1,43 @@
export function htmlSerialize(model) {
return model.parts.reduce((html, part) => {
switch (part.type) {
case "newline":
return html + "<br />";
case "plain":
case "pill-candidate":
return html + part.text;
case "room-pill":
case "user-pill":
return html + `<a href="https://matrix.to/#/${part.resourceId}">${part.text}</a>`;
}
}, "");
}
export function textSerialize(model) {
return model.parts.reduce((text, part) => {
switch (part.type) {
case "newline":
return text + "\n";
case "plain":
case "pill-candidate":
return text + part.text;
case "room-pill":
case "user-pill":
return text + `${part.resourceId}`;
}
}, "");
}
export function requiresHtml(model) {
return model.parts.some(part => {
switch (part.type) {
case "newline":
case "plain":
case "pill-candidate":
return false;
case "room-pill":
case "user-pill":
return true;
}
});
}

View file

@ -1821,5 +1821,59 @@
"Want more than a community? <a>Get your own server</a>": "Искате повече от общност? <a>Сдобийте се със собствен сървър</a>", "Want more than a community? <a>Get your own server</a>": "Искате повече от общност? <a>Сдобийте се със собствен сървър</a>",
"You are logged in to another account": "Влезли сте в друг акаунт", "You are logged in to another account": "Влезли сте в друг акаунт",
"Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Благодарим, че потвърждавате имейла си! Акаунтът, с които сте влезли тук (%(sessionUserId)s) изглежда е различен от акаунтът за който потвърждавате имейл адреса (%(verifiedUserId)s). Ако искате да влезете в акаунт %(verifiedUserId2)s, моля първо излезте.", "Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Благодарим, че потвърждавате имейла си! Акаунтът, с които сте влезли тук (%(sessionUserId)s) изглежда е различен от акаунтът за който потвърждавате имейл адреса (%(verifiedUserId)s). Ако искате да влезете в акаунт %(verifiedUserId2)s, моля първо излезте.",
"Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Промяната на паролата Ви, ще анулира всички ключове за шифроване от-край-до-край по всички Ваши устройства, правейки историята на чата нечетима. Настройте резервно копие на ключовете или експортирайте ключовете от друго устройство преди да промените паролата си." "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Промяната на паролата Ви, ще анулира всички ключове за шифроване от-край-до-край по всички Ваши устройства, правейки историята на чата нечетима. Настройте резервно копие на ключовете или експортирайте ключовете от друго устройство преди да промените паролата си.",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Дали използвате 'breadcrumbs' функцията (аватари над списъка със стаи)",
"Replying With Files": "Отговаряне с файлове",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Все още не е възможно да отговорите с файл. Искате ли да качите файла без той да бъде отговор?",
"The file '%(fileName)s' failed to upload.": "Файлът '%(fileName)s' не можа да бъде качен.",
"Room upgrade confirmation": "Потвърждение на обновяването на стаята",
"Upgrading a room can be destructive and isn't always necessary.": "Обновяването на стаята може да бъде деструктивно и не винаги е задължително.",
"Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Обновяването на стаи обикновено се препоръчва за стаи с версии считащи се за <i>нестабилни</i>. Нестабилните версии може да имат бъгове, липсващи функции или проблеми със сигурността.",
"Room upgrades usually only affect <i>server-side</i> processing of the room. If you're having problems with your Riot client, please file an issue with <issueLink />.": "Обновяванията на стаи обикновено повлияват само <i>сървърната</i> обработка. Ако имате проблем с Riot, моля съобщете за него със <issueLink />.",
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Внимание</b>: Обновяването на стаята <i>няма автоматично да прехвърли членовете в новата версия на стаята.</i> Ще изпратим съобщение в старата стая с връзка към новата - членовете на стаята ще трябва да кликнат на връзката за да влязат в новата стая.",
"Upgrade": "Обнови",
"Adds a custom widget by URL to the room": "Добавя собствено приспособление от URL в стаята",
"Please supply a https:// or http:// widget URL": "Моля, укажете https:// или http:// адрес на приспособление",
"You cannot modify widgets in this room.": "Не можете да модифицирате приспособления в тази стая.",
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s премахна покана към %(targetDisplayName)s за присъединяване в стаята.",
"Show recent room avatars above the room list": "Показвай аватари на скоро-използваните стаи над списъка със стаи",
"Enable desktop notifications for this device": "Включи известия на работния плот за това устройство",
"Enable audible notifications for this device": "Включи звукови уведомления за това устройство",
"Upgrade this room to the recommended room version": "Обнови тази стая до препоръчаната версия на стаята",
"This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "Тази стая използва версия на стая <roomVersion />, която сървърът счита за <i>нестабилна</i>.",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Обновяването на тази стая ще изключи текущата стая и ще създаде обновена стая със същото име.",
"Failed to revoke invite": "Неуспешно оттегляне на поканата",
"Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Поканата не можа да бъде оттеглена. Или има временен проблем със сървъра, или нямате достатъчно права за да оттеглите поканата.",
"Revoke invite": "Оттегли поканата",
"Invited by %(sender)s": "Поканен от %(sender)s",
"Maximize apps": "Максимизирай приложенията",
"Rotate counter-clockwise": "Завърти обратно на часовниковата стрелка",
"Rotate clockwise": "Завърти по часовниковата стрелка",
"GitHub issue": "GitHub проблем",
"Notes": "Бележки",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Моля включете допълнителни сведения, които ще помогнат за анализиране на проблема, като например: какво правихте когато възникна проблема, идентификатори на стаи, идентификатори на потребители и т.н.",
"Sign out and remove encryption keys?": "Излизане и премахване на ключовете за шифроване?",
"To help us prevent this in future, please <a>send us logs</a>.": "За да ни помогнете да предотвратим това в бъдеще, моля <a>изпратете логове</a>.",
"Missing session data": "Липсват данни за сесията",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Липсват данни за сесията, като например ключове за шифровани съобщения. За да поправите това, излезте и влезте отново, възстановявайки ключовете от резервно копие.",
"Your browser likely removed this data when running low on disk space.": "Най-вероятно браузърът Ви е премахнал тези данни поради липса на дисково пространство.",
"Upload files (%(current)s of %(total)s)": "Качване на файлове (%(current)s от %(total)s)",
"Upload files": "Качи файлове",
"Upload": "Качи",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Файлът е <b>прекалено голям</b> за да се качи. Максималният допустим размер е %(limit)s, докато този файл е %(sizeOfThisFile)s.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "Тези файлове са <b>прекалено големи</b> за да се качат. Максималният допустим размер е %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Някои файлове са <b>прекалено големи</b> за да се качат. Максималният допустим размер е %(limit)s.",
"Upload %(count)s other files|other": "Качи %(count)s други файла",
"Upload %(count)s other files|one": "Качи %(count)s друг файл",
"Cancel All": "Откажи всички",
"Upload Error": "Грешка при качване",
"A widget would like to verify your identity": "Приспособление иска да потвърди идентичността Ви",
"A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "Приспособлението от адрес %(widgetUrl)s иска да потвърди идентичността Ви. Ако позволите това, приспособлението ще може да потвърди потребителския Ви идентификатор, без да може да извършва действия с него.",
"Remember my selection for this widget": "Запомни избора ми за това приспособление",
"Deny": "Откажи",
"Riot failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "Riot не успя да вземе списъка с протоколи от сървъра. Този сървър може да е прекалено стар за да поддържа чужди мрежи.",
"Riot failed to get the public room list.": "Riot не успя да вземе списъка с публични стаи.",
"The homeserver may be unavailable or overloaded.": "Сървърът може да не е наличен или претоварен.",
"You have %(count)s unread notifications in a prior version of this room.|other": "Имате %(count)s непрочетени известия в предишна версия на тази стая.",
"You have %(count)s unread notifications in a prior version of this room.|one": "Имате %(count)s непрочетено известие в предишна версия на тази стая."
} }

View file

@ -1428,7 +1428,7 @@
"General failure": "Allgemeiner Fehler", "General failure": "Allgemeiner Fehler",
"Failed to perform homeserver discovery": "Fehler beim Aufspüren des Heimservers", "Failed to perform homeserver discovery": "Fehler beim Aufspüren des Heimservers",
"Unknown failure discovering homeserver": "Unbekannter Fehler beim Aufspüren des Heimservers", "Unknown failure discovering homeserver": "Unbekannter Fehler beim Aufspüren des Heimservers",
"Great! This passphrase looks strong enough.": "Gut! Diese Passphrase sieht start genug aus.", "Great! This passphrase looks strong enough.": "Gut! Diese Passphrase sieht stark genug aus.",
"Secure your encrypted message history with a Recovery Passphrase.": "Sichere deine sichere Nachrichtenhistorie mit einer Wiederherstellungspassphrase.", "Secure your encrypted message history with a Recovery Passphrase.": "Sichere deine sichere Nachrichtenhistorie mit einer Wiederherstellungspassphrase.",
"If you don't want encrypted message history to be available on other devices, <button>opt out</button>.": "Wenn du deine verschlüsselte Nachrichtenhistorie nicht auf anderen Geräten verfügbar haben möchtest, <button>wiederspreche</button>.", "If you don't want encrypted message history to be available on other devices, <button>opt out</button>.": "Wenn du deine verschlüsselte Nachrichtenhistorie nicht auf anderen Geräten verfügbar haben möchtest, <button>wiederspreche</button>.",
"Or, if you don't want to create a Recovery Passphrase, skip this step and <button>download a recovery key</button>.": "Oder, wenn du keine Wiederherstellungspassphrase erzeugen möchtest, überspringe diesen Schritt und <button>lade einen Wiederherstellungsschlüssel herunter</button>.", "Or, if you don't want to create a Recovery Passphrase, skip this step and <button>download a recovery key</button>.": "Oder, wenn du keine Wiederherstellungspassphrase erzeugen möchtest, überspringe diesen Schritt und <button>lade einen Wiederherstellungsschlüssel herunter</button>.",
@ -1807,5 +1807,6 @@
"Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Danke für das Verifizieren deiner E-Mail! Das Konto, mit dem du angemeldet bist (%(sessionUserId)s) scheint ein anderes zu sein als das wofür die die E-Mail verifizierst (%(verifiedUserId)s). Wenn du dich als %(verifiedUserId2)s anmelden willst, melde dich zuerst ab.", "Thank you for verifying your email! The account you're logged into here (%(sessionUserId)s) appears to be different from the account you've verified an email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, please log out first.": "Danke für das Verifizieren deiner E-Mail! Das Konto, mit dem du angemeldet bist (%(sessionUserId)s) scheint ein anderes zu sein als das wofür die die E-Mail verifizierst (%(verifiedUserId)s). Wenn du dich als %(verifiedUserId2)s anmelden willst, melde dich zuerst ab.",
"Could not load user profile": "Konnte Nutzerprofil nicht laden", "Could not load user profile": "Konnte Nutzerprofil nicht laden",
"Your Matrix account": "Dein Matrixkonto", "Your Matrix account": "Dein Matrixkonto",
"Your Matrix account on %(serverName)s": "Dein Matrixkonto auf %(serverName)s" "Your Matrix account on %(serverName)s": "Dein Matrixkonto auf %(serverName)s",
"Show recent room avatars above the room list": "Zeige die letzten Avatare über der Raumliste an (neu laden um Änderungen zu übernehmen)"
} }

View file

@ -302,6 +302,7 @@
"Show recent room avatars above the room list": "Show recent room avatars above the room list", "Show recent room avatars above the room list": "Show recent room avatars above the room list",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
"Render simple counters in room header": "Render simple counters in room header", "Render simple counters in room header": "Render simple counters in room header",
"Edit messages after they have been sent (refresh to apply changes)": "Edit messages after they have been sent (refresh to apply changes)",
"React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)", "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout", "Use compact timeline layout": "Use compact timeline layout",
@ -899,6 +900,7 @@
"Agree or Disagree": "Agree or Disagree", "Agree or Disagree": "Agree or Disagree",
"Like or Dislike": "Like or Dislike", "Like or Dislike": "Like or Dislike",
"Reply": "Reply", "Reply": "Reply",
"Edit": "Edit",
"Options": "Options", "Options": "Options",
"Attachment": "Attachment", "Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
@ -975,7 +977,6 @@
"Reload widget": "Reload widget", "Reload widget": "Reload widget",
"Popout widget": "Popout widget", "Popout widget": "Popout widget",
"Picture": "Picture", "Picture": "Picture",
"Edit": "Edit",
"Revoke widget access": "Revoke widget access", "Revoke widget access": "Revoke widget access",
"Create new room": "Create new room", "Create new room": "Create new room",
"Unblacklist": "Unblacklist", "Unblacklist": "Unblacklist",
@ -987,7 +988,9 @@
"Communities": "Communities", "Communities": "Communities",
"You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)", "You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)",
"Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s", "Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s",
"Rotate Left": "Rotate Left",
"Rotate counter-clockwise": "Rotate counter-clockwise", "Rotate counter-clockwise": "Rotate counter-clockwise",
"Rotate Right": "Rotate Right",
"Rotate clockwise": "Rotate clockwise", "Rotate clockwise": "Rotate clockwise",
"Download this file": "Download this file", "Download this file": "Download this file",
"Integrations Error": "Integrations Error", "Integrations Error": "Integrations Error",

View file

@ -1824,5 +1824,6 @@
"Riot failed to get the public room list.": "Riot-ek ezin izan du du gelen zerrenda publikoa eskuratu.", "Riot failed to get the public room list.": "Riot-ek ezin izan du du gelen zerrenda publikoa eskuratu.",
"The homeserver may be unavailable or overloaded.": "Hasiera-zerbitzaria eskuraezin edo kargatuegia egon daiteke.", "The homeserver may be unavailable or overloaded.": "Hasiera-zerbitzaria eskuraezin edo kargatuegia egon daiteke.",
"You have %(count)s unread notifications in a prior version of this room.|other": "Irakurri gabeko %(count)s jakinarazpen dituzu gela honen aurreko bertsio batean.", "You have %(count)s unread notifications in a prior version of this room.|other": "Irakurri gabeko %(count)s jakinarazpen dituzu gela honen aurreko bertsio batean.",
"You have %(count)s unread notifications in a prior version of this room.|one": "Irakurri gabeko %(count)s jakinarazpen duzu gela honen aurreko bertsio batean." "You have %(count)s unread notifications in a prior version of this room.|one": "Irakurri gabeko %(count)s jakinarazpen duzu gela honen aurreko bertsio batean.",
"Replying With Files": "Fitxategiekin erantzutea"
} }

View file

@ -1867,5 +1867,30 @@
"Riot failed to get the public room list.": "Riot na pas pu récupérer la liste des salons publics.", "Riot failed to get the public room list.": "Riot na pas pu récupérer la liste des salons publics.",
"The homeserver may be unavailable or overloaded.": "Le serveur daccueil est peut-être indisponible ou surchargé.", "The homeserver may be unavailable or overloaded.": "Le serveur daccueil est peut-être indisponible ou surchargé.",
"You have %(count)s unread notifications in a prior version of this room.|other": "Vous avez %(count)s notifications non lues dans une version précédente de ce salon.", "You have %(count)s unread notifications in a prior version of this room.|other": "Vous avez %(count)s notifications non lues dans une version précédente de ce salon.",
"You have %(count)s unread notifications in a prior version of this room.|one": "Vous avez %(count)s notification non lue dans une version précédente de ce salon." "You have %(count)s unread notifications in a prior version of this room.|one": "Vous avez %(count)s notification non lue dans une version précédente de ce salon.",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Si vous utilisez ou non la fonction « fil dariane » (les avatars au-dessus de la liste des salons)",
"Replying With Files": "Répondre avec des fichiers",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Pour le moment, il nest pas possible de répondre avec un fichier. Souhaitez-vous envoyer ce fichier sans répondre ?",
"The file '%(fileName)s' failed to upload.": "Le fichier « %(fileName)s » na pas pu être envoyé.",
"Show recent room avatars above the room list": "Afficher les avatars des salons récents au-dessus de la liste des salons",
"Rotate counter-clockwise": "Pivoter dans le sens inverse des aiguilles dune montre",
"Rotate clockwise": "Pivoter dans le sens des aiguilles dune montre",
"GitHub issue": "Rapport GitHub",
"Notes": "Notes",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Sil y a des informations supplémentaires qui pourraient nous aider à analyser le problème, comme ce que vous faisiez, lidentifiant du salon ou des utilisateurs etc, veuillez les préciser ici.",
"Sign out and remove encryption keys?": "Se déconnecter et supprimer les clés de chiffrement ?",
"To help us prevent this in future, please <a>send us logs</a>.": "Pour nous aider à éviter cela dans le futur, veuillez <a>nous envoyer les journaux</a>.",
"Missing session data": "Données de la session manquantes",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Des données de la session, dont les clés des messages chiffrés, sont manquantes. Déconnectez-vous et reconnectez-vous pour régler ce problème, en restaurant les clés depuis la sauvegarde.",
"Your browser likely removed this data when running low on disk space.": "Votre navigateur a sûrement supprimé ces données car il restait peu despace sur le disque.",
"Upload files (%(current)s of %(total)s)": "Envoi des fichiers (%(current)s sur %(total)s)",
"Upload files": "Envoyer les fichiers",
"Upload": "Envoyer",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Le fichier est <b>trop lourd</b> pour être envoyé. La taille limite est de %(limit)s mais la taille de ce fichier est de %(sizeOfThisFile)s.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "Ces fichiers sont <b>trop lourds</b> pour être envoyés. La taille limite des fichiers est de %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Certains fichiers sont <b>trop lourds</b> pour être envoyés. La taille limite des fichiers est de %(limit)s.",
"Upload %(count)s other files|other": "Envoyer %(count)s autres fichiers",
"Upload %(count)s other files|one": "Envoyer %(count)s autre fichier",
"Cancel All": "Tout annuler",
"Upload Error": "Erreur denvoi"
} }

View file

@ -772,5 +772,25 @@
"To ban users, you must be a": "उपयोगकर्ताओं को प्रतिबंधित करने के लिए, आपको होना चाहिए", "To ban users, you must be a": "उपयोगकर्ताओं को प्रतिबंधित करने के लिए, आपको होना चाहिए",
"To remove other users' messages, you must be a": "अन्य उपयोगकर्ताओं के संदेशों को हटाने के लिए, आपको होना चाहिए", "To remove other users' messages, you must be a": "अन्य उपयोगकर्ताओं के संदेशों को हटाने के लिए, आपको होना चाहिए",
"To notify everyone in the room, you must be a": "कमरे में सभी को सूचित करने के लिए, आपको होना चाहिए", "To notify everyone in the room, you must be a": "कमरे में सभी को सूचित करने के लिए, आपको होना चाहिए",
"No users have specific privileges in this room": "इस कमरे में किसी भी उपयोगकर्ता के विशेष विशेषाधिकार नहीं हैं" "No users have specific privileges in this room": "इस कमरे में किसी भी उपयोगकर्ता के विशेष विशेषाधिकार नहीं हैं",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "आप 'ब्रेडक्रंब' सुविधा का उपयोग कर रहे हैं या नहीं (कमरे की सूची के ऊपर अवतार)",
"Replying With Files": "फाइलों के साथ उत्तर",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "इस समय किसी फ़ाइल के साथ उत्तर देना संभव नहीं है। क्या आप इस फ़ाइल को बिना उत्तर दिए अपलोड करना चाहेंगे?",
"The file '%(fileName)s' failed to upload.": "फ़ाइल '%(fileName)s' अपलोड करने में विफल रही।",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "एक सादे पाठ संदेश के लिए ¯\\_(ツ)_/¯ प्रस्तुत करता है",
"Room upgrade confirmation": "रूम के उन्नयन की पुष्टि",
"Upgrading a room can be destructive and isn't always necessary.": "एक कमरे को अपग्रेड करना विनाशकारी हो सकता है और हमेशा आवश्यक नहीं होता है।",
"Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "एक कमरे के संस्करण को <i>अस्थिर</i> माना जाता है, तो आमतौर पर कमरे के उन्नयन की सिफारिश की जाती है। अस्थिर कमरे के संस्करणों में बग, लापता विशेषताएं या सुरक्षा कमजोरियां हो सकती हैं।",
"Room upgrades usually only affect <i>server-side</i> processing of the room. If you're having problems with your Riot client, please file an issue with <issueLink />.": "रूम का उन्नयन आमतौर पर केवल रूम के <i>सर्वर-साइड</i> को प्रभावित करता है। यदि आपको अपने रायट क्लाइंट के साथ समस्या हो रही है, तो कृपया <issueLink/> के साथ एक समस्या दर्ज करें।",
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>चेतावनी</b>: किसी कमरे को अपग्रेड करना <i>कमरे के सदस्यों को कमरे के नए संस्करण में स्वचालित रूप से माइग्रेट नहीं करना है।</i> हम कमरे के पुराने संस्करण में नए कमरे के लिए एक लिंक पोस्ट करेंगे। नए कमरे में शामिल होने के लिए कमरे के सदस्यों को इस लिंक पर क्लिक करना होगा।",
"Upgrade": "अपग्रेड",
"Adds a custom widget by URL to the room": "रूम में URL द्वारा एक कस्टम विजेट जोड़ता है",
"Please supply a https:// or http:// widget URL": "कृपया एक https:// या http:// विजेट URL की आपूर्ति करें",
"You cannot modify widgets in this room.": "आप इस रूम में विजेट्स को संशोधित नहीं कर सकते।",
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s ने कमरे में शामिल होने के लिए %(targetDisplayName)s के निमंत्रण को रद्द कर दिया।",
"User %(userId)s is already in the room": "उपयोगकर्ता %(userId)s पहले से ही रूम में है",
"The user must be unbanned before they can be invited.": "उपयोगकर्ता को आमंत्रित करने से पहले उन्हें प्रतिबंधित किया जाना चाहिए।",
"Show recent room avatars above the room list": "रूम की सूची के ऊपर हाल के अवतारों को दिखाएं",
"Enable desktop notifications for this device": "इस उपकरण के लिए डेस्कटॉप सूचनाएं सक्षम करें",
"Enable audible notifications for this device": "इस उपकरण के लिए श्रव्य सूचनाएँ सक्षम करें"
} }

View file

@ -961,7 +961,7 @@
"Failed to add tag %(tagName)s to room": "Nem sikerült hozzáadni a szobához ezt: %(tagName)s", "Failed to add tag %(tagName)s to room": "Nem sikerült hozzáadni a szobához ezt: %(tagName)s",
"Clear filter": "Szűrő törlése", "Clear filter": "Szűrő törlése",
"Disable Community Filter Panel": "Közösség keresési panel tiltása", "Disable Community Filter Panel": "Közösség keresési panel tiltása",
"Did you know: you can use communities to filter your Riot.im experience!": "Tudtad, hogy a Riot.im élmény fokozásához használhatsz közösségeket?", "Did you know: you can use communities to filter your Riot.im experience!": "Tudtad, hogy a Riot.im élmény fokozásához használhatsz közösségeket!",
"To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "A szűrő beállításához húzd a közösség avatarját a szűrő panel fölé a képernyő bal szélén. A szűrő panelen az avatarra kattintva bármikor leszűrheted azokat a szobákat és embereket akik a megadott közösséghez tartoznak.", "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "A szűrő beállításához húzd a közösség avatarját a szűrő panel fölé a képernyő bal szélén. A szűrő panelen az avatarra kattintva bármikor leszűrheted azokat a szobákat és embereket akik a megadott közösséghez tartoznak.",
"Your key share request has been sent - please check your other devices for key share requests.": "A kulcs megosztási kérést elküldtük - ellenőrizd a többi eszközödön a kulcs megosztási kéréseket.", "Your key share request has been sent - please check your other devices for key share requests.": "A kulcs megosztási kérést elküldtük - ellenőrizd a többi eszközödön a kulcs megosztási kéréseket.",
"Key share requests are sent to your other devices automatically. If you rejected or dismissed the key share request on your other devices, click here to request the keys for this session again.": "A kulcs megosztási kérelem automatikusan el lett küldve a többi eszközödre. Ha elutasítottad vagy törölted a kérést a másik eszközön ide kattintva újra kérheted a kulcsokat.", "Key share requests are sent to your other devices automatically. If you rejected or dismissed the key share request on your other devices, click here to request the keys for this session again.": "A kulcs megosztási kérelem automatikusan el lett küldve a többi eszközödre. Ha elutasítottad vagy törölted a kérést a másik eszközön ide kattintva újra kérheted a kulcsokat.",
@ -1175,8 +1175,8 @@
"Please forget all messages I have sent when my account is deactivated (<b>Warning:</b> this will cause future users to see an incomplete view of conversations)": "Kérlek töröld az összes általam küldött üzenetet amikor a fiókomat felfüggesztem (<b>Figyelem:</b> ez azt eredményezheti, hogy a jövőbeni felhasználók csak részleges beszélgetést látnak majd)", "Please forget all messages I have sent when my account is deactivated (<b>Warning:</b> this will cause future users to see an incomplete view of conversations)": "Kérlek töröld az összes általam küldött üzenetet amikor a fiókomat felfüggesztem (<b>Figyelem:</b> ez azt eredményezheti, hogy a jövőbeni felhasználók csak részleges beszélgetést látnak majd)",
"e.g. %(exampleValue)s": "pl. %(exampleValue)s", "e.g. %(exampleValue)s": "pl. %(exampleValue)s",
"Reload widget": "Kisalkalmazás újratöltése", "Reload widget": "Kisalkalmazás újratöltése",
"Please help improve Riot.im by sending <UsageDataLink>anonymous usage data</UsageDataLink>. This will use a cookie (please see our <PolicyLink>Cookie Policy</PolicyLink>).": "Kérlek segíts javítani a Riot.im-et azzal, hogy <UsageDataLink>anonim felhasználási adatokat</UsageDataLink> küldesz. Ez szütit (cookie) fog használni (lásd a <PolicyLink>sütire vonatkozó szabályozásunkat</PolicyLink>).", "Please help improve Riot.im by sending <UsageDataLink>anonymous usage data</UsageDataLink>. This will use a cookie (please see our <PolicyLink>Cookie Policy</PolicyLink>).": "Kérlek segíts javítani a Riot.im-et azzal, hogy <UsageDataLink>anonim felhasználási adatokat</UsageDataLink> küldesz. Ez sütit (cookie) fog használni (lásd a <PolicyLink>sütire vonatkozó szabályozásunkat</PolicyLink>).",
"Please help improve Riot.im by sending <UsageDataLink>anonymous usage data</UsageDataLink>. This will use a cookie.": "Kérlek segíts javítani a Riot.im-et azzal, hogy <UsageDataLink>anonim felhasználási adatokat</UsageDataLink> küldesz. Ez szütit (cookie) fog használni.", "Please help improve Riot.im by sending <UsageDataLink>anonymous usage data</UsageDataLink>. This will use a cookie.": "Kérlek segíts javítani a Riot.im-et azzal, hogy <UsageDataLink>anonim felhasználási adatokat</UsageDataLink> küldesz. Ez sütit (cookie) fog használni.",
"Yes, I want to help!": "Igen, segítek!", "Yes, I want to help!": "Igen, segítek!",
"Can't leave Server Notices room": "Nem lehet elhagyni a Szerver Üzenetek szobát", "Can't leave Server Notices room": "Nem lehet elhagyni a Szerver Üzenetek szobát",
"This room is used for important messages from the Homeserver, so you cannot leave it.": "Ez a szoba fontos szerverüzenetek közlésére jött létre, nem tudsz kilépni belőle.", "This room is used for important messages from the Homeserver, so you cannot leave it.": "Ez a szoba fontos szerverüzenetek közlésére jött létre, nem tudsz kilépni belőle.",
@ -1867,5 +1867,30 @@
"Riot failed to get the public room list.": "Riotnak nem sikerült beszereznie a nyilvános szoba listát.", "Riot failed to get the public room list.": "Riotnak nem sikerült beszereznie a nyilvános szoba listát.",
"The homeserver may be unavailable or overloaded.": "A Matrix szerver elérhetetlen vagy túlterhelt.", "The homeserver may be unavailable or overloaded.": "A Matrix szerver elérhetetlen vagy túlterhelt.",
"You have %(count)s unread notifications in a prior version of this room.|other": "%(count)s olvasatlan értesítésed van a régi verziójú szobában.", "You have %(count)s unread notifications in a prior version of this room.|other": "%(count)s olvasatlan értesítésed van a régi verziójú szobában.",
"You have %(count)s unread notifications in a prior version of this room.|one": "%(count)s olvasatlan értesítésed van a régi verziójú szobában." "You have %(count)s unread notifications in a prior version of this room.|one": "%(count)s olvasatlan értesítésed van a régi verziójú szobában.",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Használsz vagy nem „morzsákat” (profilképek a szobalista felett)",
"Replying With Files": "Válasz fájlokkal",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Egyenlőre nem lehet fájlal válaszolni. Szeretnéd feltölteni a fájlt úgy, hogy az nem egy válasz lesz?",
"The file '%(fileName)s' failed to upload.": "A %(fileName)s fájlt nem sikerült feltölteni.",
"Show recent room avatars above the room list": "A legfrissebb szoba profilképét mutassa a szoba lista felett",
"Rotate counter-clockwise": "Óramutató járásával ellentétesen fordít",
"Rotate clockwise": "Óramutató járásával megegyező irányba fordít",
"GitHub issue": "GitHub hibajegy",
"Notes": "Megjegyzések",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Ha a hiba felderítésében további adat is segítséget adhat, mint az, hogy mit csináltál éppen, mi a szoba-, felhasználó azonosítója, stb... itt add meg.",
"Sign out and remove encryption keys?": "Kilépés és a titkosítási kulcsok törlése?",
"To help us prevent this in future, please <a>send us logs</a>.": "Segíts abban, hogy ez később ne fordulhasson elő, kérlek küld el a <a>naplókat</a>.",
"Missing session data": "A kapcsolati adat hiányzik",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Néhány kapcsolati adat hiányzik, beleértve a titkosított üzenetek kulcsait. Lépj ki és jelentkezz vissza a hiba javításához és állítsd vissza mentésből a kulcsokat.",
"Your browser likely removed this data when running low on disk space.": "A böngésző valószínűleg törölte ezeket az adatokat amikor lecsökkent a szabad lemezterület.",
"Upload files (%(current)s of %(total)s)": "Fájlok feltöltése (%(current)s / %(total)s)",
"Upload files": "Fájlok feltöltése",
"Upload": "Feltöltés",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Ez a fájl <b>túl nagy</b>, hogy fel lehessen tölteni. A fájl méret korlát %(limit)s de a fájl %(sizeOfThisFile)s méretű.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "A fájl <b>túl nagy</b> a feltöltéshez. A fájlméret korlát %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Néhány fájl <b>túl nagy</b>, hogy fel lehessen tölteni. A fájlméret korlát %(limit)s.",
"Upload %(count)s other files|other": "Feltölt %(count)s másik fájlt",
"Upload %(count)s other files|one": "Feltölt %(count)s másik fájlt",
"Cancel All": "Mindent megszakít",
"Upload Error": "Feltöltési hiba"
} }

View file

@ -1771,5 +1771,30 @@
"Riot failed to get the public room list.": "Riot kon de lijst met openbare gesprekken niet verkrijgen.", "Riot failed to get the public room list.": "Riot kon de lijst met openbare gesprekken niet verkrijgen.",
"The homeserver may be unavailable or overloaded.": "De thuisserver is mogelijk onbereikbaar of overbelast.", "The homeserver may be unavailable or overloaded.": "De thuisserver is mogelijk onbereikbaar of overbelast.",
"You have %(count)s unread notifications in a prior version of this room.|other": "U heeft %(count)s ongelezen meldingen in een voorgaande versie van dit gesprek.", "You have %(count)s unread notifications in a prior version of this room.|other": "U heeft %(count)s ongelezen meldingen in een voorgaande versie van dit gesprek.",
"You have %(count)s unread notifications in a prior version of this room.|one": "U heeft %(count)s ongelezen melding in een voorgaande versie van dit gesprek." "You have %(count)s unread notifications in a prior version of this room.|one": "U heeft %(count)s ongelezen melding in een voorgaande versie van dit gesprek.",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Of u de 'broodkruimels'-functie al dan niet gebruikt (avatars boven de gesprekslijst)",
"Replying With Files": "Beantwoorden met bestanden",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Momenteel is het niet mogelijk om met een bestand te antwoorden. Wilt u dit bestand uploaden zonder te antwoorden?",
"The file '%(fileName)s' failed to upload.": "Het bestand %(fileName)s kon niet geüpload worden.",
"Show recent room avatars above the room list": "Recente gespreksavatars weergeven boven de gesprekslijst",
"Rotate counter-clockwise": "Tegen de klok in draaien",
"Rotate clockwise": "Met de klok mee draaien",
"GitHub issue": "GitHub-melding",
"Notes": "Opmerkingen",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Indien er extra context zou kunnen helpen om het probleem te analyseren, zoals wat u aan het doen was, relevante gespreks-IDs, gebruikers-IDs, enz., gelieve deze informatie dan hier mee te geven.",
"Sign out and remove encryption keys?": "Afmelden en versleutelingssleutels verwijderen?",
"To help us prevent this in future, please <a>send us logs</a>.": "Gelieve <a>ons logboeken te sturen</a> om dit in de toekomst te helpen voorkomen.",
"Missing session data": "Sessiegegevens ontbreken",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Sommige sessiegegevens, inclusief sleutels voor versleutelde berichten, ontbreken. Meld u af en weer aan om dit op te lossen, en herstel de sleutels uit de back-up.",
"Your browser likely removed this data when running low on disk space.": "Uw browser heeft deze gegevens mogelijk verwijderd toen de beschikbare opslagruimte vol was.",
"Upload files (%(current)s of %(total)s)": "Bestanden worden geüpload (%(current)s van %(total)s)",
"Upload files": "Bestanden uploaden",
"Upload": "Uploaden",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Dit bestand is <b>te groot</b> om te uploaden. De bestandsgroottelimiet is %(limit)s, maar dit bestand is %(sizeOfThisFile)s.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "Deze bestanden zijn <b>te groot</b> om te uploaden. De bestandsgroottelimiet is %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Sommige bestanden zijn <b>te groot</b> om te uploaden. De bestandsgroottelimiet is %(limit)s.",
"Upload %(count)s other files|other": "%(count)s overige bestanden uploaden",
"Upload %(count)s other files|one": "%(count)s overig bestand uploaden",
"Cancel All": "Alles annuleren",
"Upload Error": "Uploadfout"
} }

View file

@ -380,7 +380,7 @@
"Online": "Online", "Online": "Online",
"Unnamed room": "Namnlöst rum", "Unnamed room": "Namnlöst rum",
"World readable": "Alla kan läsa", "World readable": "Alla kan läsa",
"Guests can join": "Gäster kan bli medlem i rummet", "Guests can join": "Gäster kan gå med i rummet",
"No rooms to show": "Inga fler rum att visa", "No rooms to show": "Inga fler rum att visa",
"This phone number is already in use": "Detta telefonnummer används redan", "This phone number is already in use": "Detta telefonnummer används redan",
"The version of Riot.im": "Versionen av Riot.im", "The version of Riot.im": "Versionen av Riot.im",
@ -1607,5 +1607,59 @@
"Unable to load backup status": "Det går inte att ladda backupstatus", "Unable to load backup status": "Det går inte att ladda backupstatus",
"Guest": "Gäst", "Guest": "Gäst",
"Could not load user profile": "Kunde inte ladda användarprofil", "Could not load user profile": "Kunde inte ladda användarprofil",
"Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Om du ändrar ditt lösenord återställs eventuella krypteringsnycklar på alla dina enheter, vilket gör att krypterad chatthistorik inte kan läsas. Aktivera nyckelsäkerhetskopiering eller exportera dina rumsnycklar från en annan enhet innan du återställer ditt lösenord." "Changing your password will reset any end-to-end encryption keys on all of your devices, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another device before resetting your password.": "Om du ändrar ditt lösenord återställs eventuella krypteringsnycklar på alla dina enheter, vilket gör att krypterad chatthistorik inte kan läsas. Aktivera nyckelsäkerhetskopiering eller exportera dina rumsnycklar från en annan enhet innan du återställer ditt lösenord.",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Om du använder 'breadcrumbs' eller inte (avatarer ovanför rumslistan)",
"Replying With Files": "Svarar med filer",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Just nu är det inte möjligt att svara med en fil. Vill du ladda upp filen utan att svara?",
"The file '%(fileName)s' failed to upload.": "Filen '%(fileName)s' kunde inte laddas upp.",
"Room upgrade confirmation": "Bekräfta rumsuppgradering",
"Upgrading a room can be destructive and isn't always necessary.": "Uppgradering av ett rum kan vara destruktivt och är inte alltid nödvändigt.",
"Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Rumsuppgraderingar rekommenderas vanligtvis när en rumversion anses vara <i>instabil</i>. Instabila rumsversioner kan ha fel, sakna funktioner eller ha säkerhetsproblem.",
"Room upgrades usually only affect <i>server-side</i> processing of the room. If you're having problems with your Riot client, please file an issue with <issueLink />.": "",
"Upgrade": "Uppgradera",
"Close button should minimize window to tray": "Stängknappen ska minimera fönstret till systemfältet",
"Composer": "Meddelandefält",
"Key backup": "Nyckelsäkerhetskopiering",
"Never lose encrypted messages": "Förlora aldrig krypterade meddelanden",
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Säkerhetskopiera dina nycklar på ett säkert sätt för att undvika att förlora dem. <a>Läs mer.</a>",
"Failed to load group members": "Det gick inte att ladda gruppmedlemmar",
"Maximize apps": "Maximera appar",
"Join": "Gå med",
"Rotate counter-clockwise": "Rotera moturs",
"Rotate clockwise": "Rotera medurs",
"Power level": "Behörighetsnivå",
"Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Det gick inte att hitta profiler för de Matrix-IDn som anges nedan - vill du bjuda in dem ändå?",
"GitHub issue": "GitHub-ärende",
"Notes": "Noteringar",
"You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.": "Du har tidigare använt Riot på %(host)s med lazy loading av medlemmar aktiverat. I den här versionen är lazy loading inaktiverat. Eftersom den lokala cachen inte är kompatibel mellan dessa två inställningar behöver Riot synkronisera om ditt konto.",
"If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Om den andra versionen av Riot fortfarande är öppen i en annan flik, stäng den eftersom användning av Riot på samma värd med både lazy loading aktiverad och inaktiverad samtidigt kommer att orsaka problem.",
"Incompatible local cache": "Inkompatibel lokal cache",
"Clear cache and resync": "Töm cache och synkronisera om",
"Riot now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "Riot använder nu 3-5 gånger mindre minne, genom att bara ladda information om andra användare när det behövs. Vänta medan vi återsynkroniserar med servern!",
"I don't want my encrypted messages": "Jag vill inte ha mina krypterade meddelanden",
"Manually export keys": "Exportera nycklar manuellt",
"You'll lose access to your encrypted messages": "Du kommer att förlora åtkomst till dina krypterade meddelanden",
"Are you sure you want to sign out?": "Är du säker på att du vill logga ut?",
"If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "Om du stöter på några fel eller har feedback du vill dela, vänligen meddela oss på GitHub.",
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "För att undvika dubbla ärenden, vänligen <existingIssuesLink>granska befintliga ärenden</existingIssuesLink> först (och lägg till +1) eller <newIssueLink>skapa ett nytt ärende</newIssueLink> om du inte hittar det.",
"Report bugs & give feedback": "Rapportera fel och ge feedback",
"Go back": "Gå tillbaka",
"Room Settings - %(roomName)s": "Rumsinställningar - %(roomName)s",
"Sign out and remove encryption keys?": "Logga ut och ta bort krypteringsnycklar?",
"A username can only contain lower case letters, numbers and '=_-./'": "Ett användarnamn får endast innehålla små bokstäver, siffror och '=_-./'",
"Checking...": "Kontrollerar...",
"To help us prevent this in future, please <a>send us logs</a>.": "För att hjälpa oss att förhindra detta i framtiden, vänligen <a>skicka oss loggar</a>.",
"Missing session data": "Sessionsdata saknas",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Vissa sessionsdata, inklusive krypteringsnycklar för meddelanden, saknas. Logga ut och logga in för att åtgärda detta genom återställning av nycklarna från säkerhetskopia.",
"Your browser likely removed this data when running low on disk space.": "Din webbläsare har troligen tagit bort dessa data när det blev ont om diskutrymme.",
"Upload files (%(current)s of %(total)s)": "Ladda upp filer (%(current)s av %(total)s)",
"Upload files": "Ladda upp filer",
"Upload": "Ladda upp",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Den här filen är <b>för stor</b> för att ladda upp. Filstorleksgränsen är %(limit)s men den här filen är %(sizeOfThisFile)s.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "Dessa filer är <b>för stora</b> för att laddas upp. Filstorleksgränsen är %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Vissa filer är <b>för stora</b> för att laddas upp. Filstorleksgränsen är %(limit)s.",
"Upload %(count)s other files|other": "Ladda upp %(count)s andra filer",
"Upload %(count)s other files|one": "Ladda upp %(count)s annan fil",
"Cancel All": "Avbryt alla",
"Upload Error": "Uppladdningsfel"
} }

View file

@ -1862,5 +1862,30 @@
"Riot failed to get the public room list.": "Riot 取得公開聊天室清單失敗。", "Riot failed to get the public room list.": "Riot 取得公開聊天室清單失敗。",
"The homeserver may be unavailable or overloaded.": "家伺服器似乎不可用或超載。", "The homeserver may be unavailable or overloaded.": "家伺服器似乎不可用或超載。",
"You have %(count)s unread notifications in a prior version of this room.|other": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。", "You have %(count)s unread notifications in a prior version of this room.|other": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。",
"You have %(count)s unread notifications in a prior version of this room.|one": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。" "You have %(count)s unread notifications in a prior version of this room.|one": "您在此聊天室的先前版本有 %(count)s 個未讀的通知。",
"Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "不論您是否使用「麵包屑」功能(大頭貼在聊天室清單上)",
"Replying With Files": "以檔案回覆",
"At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "此時無法使用檔案回覆。您想要上傳此檔案而不回覆嗎?",
"The file '%(fileName)s' failed to upload.": "檔案「%(fileName)s」上傳失敗。",
"Show recent room avatars above the room list": "在聊天室清單上顯示聊天室大頭貼",
"Rotate counter-clockwise": "逆時針旋轉",
"Rotate clockwise": "順時針旋轉",
"GitHub issue": "GitHub 議題",
"Notes": "註記",
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "如果有其他有助於釐清問題的情境,如您當時正在做什麼,聊天室 ID、使用者 ID 等等,請在這裡加入這些資訊。",
"Sign out and remove encryption keys?": "登出並移除加密金鑰?",
"To help us prevent this in future, please <a>send us logs</a>.": "要協助我們讓這個問題不再發生,請<a>將紀錄檔傳送給我們</a>。",
"Missing session data": "遺失工作階段資料",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "某些工作階段資料遺失了,其中包含加密訊息金鑰。登出再登入並從備份中復原金鑰可以修復這個問題。",
"Your browser likely removed this data when running low on disk space.": "當硬碟空間不足時,您的瀏覽器可能會移除這些資料。",
"Upload files (%(current)s of %(total)s)": "上傳檔案 (%(total)s 中的 %(current)s)",
"Upload files": "上傳檔案",
"Upload": "上傳",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "這個檔案<b>太大了</b>,所以沒辦法上傳。檔案大小限制為 %(limit)s 但這個檔案大小是 %(sizeOfThisFile)s。",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "這些檔案<b>太大了</b>,所以沒辦法上傳。檔案大小限制為 %(limit)s。",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "某些檔案<b>太大了</b>,所以沒辦法上傳。檔案大小限制為 %(limit)s。",
"Upload %(count)s other files|other": "上傳 %(count)s 個其他檔案",
"Upload %(count)s other files|one": "上傳 %(count)s 個其他檔案",
"Cancel All": "取消全部",
"Upload Error": "上傳錯誤"
} }

View file

@ -118,6 +118,12 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
"feature_message_editing": {
isFeature: true,
displayName: _td("Edit messages after they have been sent (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_reactions": { "feature_reactions": {
isFeature: true, isFeature: true,
displayName: _td("React to messages with emoji (refresh to apply changes)"), displayName: _td("React to messages with emoji (refresh to apply changes)"),

View file

@ -45,6 +45,7 @@ export default function shouldHideEvent(ev) {
// Hide redacted events // Hide redacted events
if (ev.isRedacted() && !isEnabled('showRedactions')) return true; if (ev.isRedacted() && !isEnabled('showRedactions')) return true;
if (ev.isRelation("m.replace")) return true;
const eventDiff = memberEventDiff(ev); const eventDiff = memberEventDiff(ev);

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { EventStatus } from 'matrix-js-sdk'; import { EventStatus } from 'matrix-js-sdk';
import MatrixClientPeg from '../MatrixClientPeg';
/** /**
* Returns whether an event should allow actions like reply, reactions, edit, etc. * Returns whether an event should allow actions like reply, reactions, edit, etc.
@ -43,3 +44,8 @@ export function isContentActionable(mxEvent) {
return false; return false;
} }
export function canEditContent(mxEvent) {
return isContentActionable(mxEvent) &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
}

View file

@ -50,11 +50,15 @@ export async function checkConsistency() {
let dataInLocalStorage = false; let dataInLocalStorage = false;
let dataInCryptoStore = false; let dataInCryptoStore = false;
let cryptoInited = false;
let healthy = true; let healthy = true;
if (localStorage) { if (localStorage) {
dataInLocalStorage = localStorage.length > 0; dataInLocalStorage = localStorage.length > 0;
log(`Local storage contains data? ${dataInLocalStorage}`); log(`Local storage contains data? ${dataInLocalStorage}`);
cryptoInited = localStorage.getItem("mx_crypto_initialised");
log(`Crypto initialised? ${cryptoInited}`);
} else { } else {
healthy = false; healthy = false;
error("Local storage cannot be used on this browser"); error("Local storage cannot be used on this browser");
@ -84,10 +88,11 @@ export async function checkConsistency() {
track("Crypto store disabled"); track("Crypto store disabled");
} }
if (dataInLocalStorage && !dataInCryptoStore) { if (dataInLocalStorage && cryptoInited && !dataInCryptoStore) {
healthy = false; healthy = false;
error( error(
"Data exists in local storage but not in crypto store. " + "Data exists in local storage and crypto is marked as initialised " +
" but no data found in crypto store. " +
"IndexedDB storage has likely been evicted by the browser!", "IndexedDB storage has likely been evicted by the browser!",
); );
track("Crypto store evicted"); track("Crypto store evicted");
@ -104,6 +109,7 @@ export async function checkConsistency() {
return { return {
dataInLocalStorage, dataInLocalStorage,
dataInCryptoStore, dataInCryptoStore,
cryptoInited,
healthy, healthy,
}; };
} }
@ -155,3 +161,17 @@ export function trackStores(client) {
}); });
} }
} }
/**
* Sets whether crypto has ever been successfully
* initialised on this client.
* StorageManager uses this to determine whether indexeddb
* has been wiped by the browser: this flag is saved to localStorage
* and if it is true and not crypto data is found, an error is
* presented to the user.
*
* @param {bool} cryptoInited True if crypto has been set up
*/
export function setCryptoInitialised(cryptoInited) {
localStorage.setItem("mx_crypto_initialised", cryptoInited);
}