1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-11-17 17:42:41 +03:00

Merge remote-tracking branch 'origin/develop' into hs/bridge-info-pretty

This commit is contained in:
Half-Shot
2020-01-28 11:22:02 +00:00
124 changed files with 4572 additions and 1159 deletions

View File

@@ -19,9 +19,10 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import {Filter} from 'matrix-js-sdk';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler';
/*
@@ -29,6 +30,9 @@ import { _t } from '../../languageHandler';
*/
const FilePanel = createReactClass({
displayName: 'FilePanel',
// This is used to track if a decrypted event was a live event and should be
// added to the timeline.
decryptingEvents: new Set(),
propTypes: {
roomId: PropTypes.string.isRequired,
@@ -40,42 +44,147 @@ const FilePanel = createReactClass({
};
},
componentDidMount: function() {
this.updateTimelineSet(this.props.roomId);
onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
if (room.roomId !== this.props.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
} else {
this.addEncryptedLiveEvent(ev);
}
},
updateTimelineSet: function(roomId) {
onEventDecrypted(ev, err) {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
if (!this.decryptingEvents.delete(eventId)) return;
if (err) return;
this.addEncryptedLiveEvent(ev);
},
addEncryptedLiveEvent(ev, toStartOfTimeline) {
if (!this.state.timelineSet) return;
const timeline = this.state.timelineSet.getLiveTimeline();
if (ev.getType() !== "m.room.message") return;
if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) {
return;
}
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
}
},
async componentDidMount() {
const client = MatrixClientPeg.get();
await this.updateTimelineSet(this.props.roomId);
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
// The timelineSets filter makes sure that encrypted events that contain
// URLs never get added to the timeline, even if they are live events.
// These methods are here to manually listen for such events and add
// them despite the filter's best efforts.
//
// We do this only for encrypted rooms and if an event index exists,
// this could be made more general in the future or the filter logic
// could be fixed.
if (EventIndexPeg.get() !== null) {
client.on('Room.timeline', this.onRoomTimeline.bind(this));
client.on('Event.decrypted', this.onEventDecrypted.bind(this));
}
},
componentWillUnmount() {
const client = MatrixClientPeg.get();
if (client === null) return;
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
if (EventIndexPeg.get() !== null) {
client.removeListener('Room.timeline', this.onRoomTimeline.bind(this));
client.removeListener('Event.decrypted', this.onEventDecrypted.bind(this));
}
},
async fetchFileEventsServer(room) {
const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
},
},
);
const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
return timelineSet;
},
onPaginationRequest(timelineWindow, direction, limit) {
const client = MatrixClientPeg.get();
const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId;
const room = client.getRoom(roomId);
// We override the pagination request for encrypted rooms so that we ask
// the event index to fulfill the pagination request. Asking the server
// to paginate won't ever work since the server can't correctly filter
// out events containing URLs
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit);
} else {
return timelineWindow.paginate(direction, limit);
}
},
async updateTimelineSet(roomId: string) {
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
const eventIndex = EventIndexPeg.get();
this.noRoom = !room;
if (room) {
const filter = new Matrix.Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
},
},
);
let timelineSet;
// FIXME: we shouldn't be doing this every time we change room - see comment above.
client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
(filterId)=>{
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
this.setState({ timelineSet: timelineSet });
},
(error)=>{
console.error("Failed to get or create file panel filter", error);
},
);
try {
timelineSet = await this.fetchFileEventsServer(room);
// If this room is encrypted the file panel won't be populated
// correctly since the defined filter doesn't support encrypted
// events and the server can't check if encrypted events contain
// URLs.
//
// This is where our event index comes into place, we ask the
// event index to populate the timelineSet for us. This call
// will add 10 events to the live timeline of the set. More can
// be requested using pagination.
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
const timeline = timelineSet.getLiveTimeline();
await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10);
}
this.setState({ timelineSet: timelineSet });
} catch (error) {
console.error("Failed to get or create file panel filter", error);
}
} else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
}
@@ -111,6 +220,7 @@ const FilePanel = createReactClass({
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
onPaginationRequest={this.onPaginationRequest}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}

View File

@@ -20,7 +20,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import * as sdk from '../../index';

View File

@@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
if (!this.focusedElement) return;
switch (ev.key) {
case Key.TAB:
this._onMoveFocus(ev, ev.shiftKey);
break;
case Key.ARROW_UP:
this._onMoveFocus(ev, true, true);
break;

View File

@@ -89,12 +89,15 @@ export const VIEWS = {
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY: 6,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP: 7,
// we are logged in with an active matrix client.
LOGGED_IN: 7,
LOGGED_IN: 8,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT: 8,
SOFT_LOGOUT: 9,
};
// Actions that are redirected through the onboarding process prior to being
@@ -253,6 +256,9 @@ export default createReactClass({
// logout page.
Lifecycle.loadSession({});
}
this._accountPassword = null;
this._accountPasswordTimer = null;
},
componentDidMount: function() {
@@ -349,6 +355,8 @@ export default createReactClass({
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
},
componentWillUpdate: function(props, state) {
@@ -657,7 +665,9 @@ export default createReactClass({
if (
!Lifecycle.isSoftLogout() &&
this.state.view !== VIEWS.LOGIN &&
this.state.view !== VIEWS.COMPLETE_SECURITY
this.state.view !== VIEWS.REGISTER &&
this.state.view !== VIEWS.COMPLETE_SECURITY &&
this.state.view !== VIEWS.E2E_SETUP
) {
this._onLoggedIn();
}
@@ -961,9 +971,9 @@ export default createReactClass({
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
const [shouldCreate, createOpts] = await modal.finished;
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
createRoom({createOpts});
createRoom(opts);
}
},
@@ -1453,7 +1463,6 @@ export default createReactClass({
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
cli.on("crypto.verification.request", request => {
console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId());
if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
@@ -1725,6 +1734,10 @@ export default createReactClass({
this.showScreen("forgot_password");
},
onRegisterFlowComplete: function(credentials, password) {
return this.onUserCompletedLoginFlow(credentials, password);
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) {
return Lifecycle.setLoggedIn(credentials);
@@ -1813,7 +1826,14 @@ export default createReactClass({
this._loggedInView = ref;
},
async onUserCompletedLoginFlow(credentials) {
async onUserCompletedLoginFlow(credentials, password) {
this._accountPassword = password;
// self-destruct the password after 5mins
if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
this._accountPasswordTimer = setTimeout(() => {
this._accountPassword = null;
this._accountPasswordTimer = null;
}, 60 * 5 * 1000);
// Wait for the client to be logged in (but not started)
// which is enough to ask the server about account data.
const loggedIn = new Promise(resolve => {
@@ -1827,7 +1847,7 @@ export default createReactClass({
});
// Create and start the client in the background
Lifecycle.setLoggedIn(credentials);
const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
await loggedIn;
const cli = MatrixClientPeg.get();
@@ -1848,12 +1868,20 @@ export default createReactClass({
if (masterKeyInStorage) {
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
} else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// This will only work if the feature is set to 'enable' in the config,
// since it's too early in the lifecycle for users to have turned the
// labs flag on.
this.setStateForNewView({ view: VIEWS.E2E_SETUP });
} else {
this._onLoggedIn();
}
return setLoggedInPromise;
},
onCompleteSecurityFinished() {
// complete security / e2e setup has finished
onCompleteSecurityE2eSetupFinished() {
this._onLoggedIn();
},
@@ -1873,7 +1901,15 @@ export default createReactClass({
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
view = (
<CompleteSecurity
onFinished={this.onCompleteSecurityFinished}
onFinished={this.onCompleteSecurityE2eSetupFinished}
/>
);
} else if (this.state.view === VIEWS.E2E_SETUP) {
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this._accountPassword}
/>
);
} else if (this.state.view === VIEWS.POST_REGISTRATION) {
@@ -1940,7 +1976,7 @@ export default createReactClass({
email={this.props.startingFragmentQueryParams.email}
brand={this.props.config.brand}
makeRegistrationUrl={this._makeRegistrationUrl}
onLoggedIn={this.onRegistered}
onLoggedIn={this.onRegisterFlowComplete}
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()}

View File

@@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler";
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
// turn this on for drop & drag console debugging galore
const debug = false;
@@ -141,10 +142,6 @@ export default class RoomSubList extends React.PureComponent {
onHeaderKeyDown = (ev) => {
switch (ev.key) {
case Key.TAB:
// Prevent LeftPanel handling Tab if focus is on the sublist header itself
ev.stopPropagation();
break;
case Key.ARROW_LEFT:
// On ARROW_LEFT collapse the room sublist
if (!this.state.hidden && !this.props.forceExpand) {
@@ -263,33 +260,6 @@ export default class RoomSubList extends React.PureComponent {
const subListNotifCount = subListNotifications.count;
const subListNotifHighlight = subListNotifications.highlight;
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
let title;
@@ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent {
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
const len = this.props.list.length + this.props.extraTiles.length;
let chevron;
if (len) {
@@ -327,25 +286,81 @@ export default class RoomSubList extends React.PureComponent {
chevron = (<div className={chevronClasses} />);
}
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onClick={this.onClick}
className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed}
inputRef={this._headerButton}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
return <RovingTabIndexWrapper inputRef={this._headerButton}>
{({onFocus, isActive, ref}) => {
const tabIndex = isActive ? 0 : -1;
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onNotifBadgeClick}
aria-label={_t("Jump to first unread room.")}
>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onInviteBadgeClick}
aria-label={_t("Jump to first invite.")}
>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onFocus={onFocus}
tabIndex={tabIndex}
inputRef={ref}
onClick={this.onClick}
className="mx_RoomSubList_label"
aria-expanded={!isCollapsed}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
} }
</RovingTabIndexWrapper>;
}
checkOverflow = () => {

View File

@@ -766,7 +766,7 @@ export default createReactClass({
onUserVerificationChanged: function(userId, _trustStatus) {
const room = this.state.room;
if (!room.currentState.getMember(userId)) {
if (!room || !room.currentState.getMember(userId)) {
return;
}
this._updateE2EStatus(room);
@@ -796,6 +796,7 @@ export default createReactClass({
return;
}
// Duplication between here and _updateE2eStatus in RoomTile
/* At this point, the user has encryption on and cross-signing on */
const e2eMembers = await room.getEncryptionTargetMembers();
const verified = [];
@@ -810,12 +811,12 @@ export default createReactClass({
debuglog("e2e verified", verified, "unverified", unverified);
/* Check all verified user devices. */
for (const userId of verified) {
for (const userId of [...verified, cli.getUserId()]) {
const devices = await cli.getStoredDevicesForUser(userId);
const allDevicesVerified = devices.every(({deviceId}) => {
return cli.checkDeviceTrust(userId, deviceId).isVerified();
const anyDeviceNotVerified = devices.some(({deviceId}) => {
return !cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (!allDevicesVerified) {
if (anyDeviceNotVerified) {
this.setState({
e2eStatus: "warning",
});
@@ -1367,6 +1368,41 @@ export default createReactClass({
});
},
onRejectAndIgnoreClick: async function() {
this.setState({
rejecting: true,
});
const cli = MatrixClientPeg.get();
try {
const myMember = this.state.room.getMember(cli.getUserId());
const inviteEvent = myMember.events.member;
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk
await cli.setIgnoredUsers(ignoredUsers);
await cli.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
this.setState({
rejecting: false,
});
} catch (error) {
console.error("Failed to reject invite: %s", error);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
title: _t("Failed to reject invite"),
description: msg,
});
self.setState({
rejecting: false,
rejectError: error,
});
}
},
onRejectThreepidInviteButtonClicked: function(ev) {
// We can reject 3pid invites in the same way that we accept them,
// using /leave rather than /join. In the short term though, we
@@ -1671,9 +1707,11 @@ export default createReactClass({
return (
<div className="mx_RoomView">
<ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}

View File

@@ -877,11 +877,14 @@ export default createReactClass({
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
// list-style-type: none; is no longer a list
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children }
</ol>
</div>

View File

@@ -133,9 +133,11 @@ export default createReactClass({
return null;
}
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
(<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
// show a shorter placeholder when blurred, if requested

View File

@@ -94,6 +94,10 @@ const TimelinePanel = createReactClass({
// callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func,
// callback which is called when we wish to paginate the timeline
// window.
onPaginationRequest: PropTypes.func,
// maximum number of events to show in a timeline
timelineCap: PropTypes.number,
@@ -338,6 +342,14 @@ const TimelinePanel = createReactClass({
}
},
onPaginationRequest(timelineWindow, direction, size) {
if (this.props.onPaginationRequest) {
return this.props.onPaginationRequest(timelineWindow, direction, size);
} else {
return timelineWindow.paginate(direction, size);
}
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
if (!this._shouldPaginate()) return Promise.resolve(false);
@@ -360,7 +372,7 @@ const TimelinePanel = createReactClass({
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
this.setState({[paginatingKey]: true});
return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => {
return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then((r) => {
if (this.unmounted) { return; }
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);

View File

@@ -23,9 +23,11 @@ export default class ToastContainer extends React.Component {
constructor() {
super();
this.state = {toasts: ToastStore.sharedInstance().getToasts()};
}
componentDidMount() {
// Start listening here rather than in componentDidMount because
// toasts may dismiss themselves in their didMount if they find
// they're already irrelevant by the time they're mounted, and
// our own componentDidMount is too late.
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
}

View File

@@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {TopLeftMenu} from '../views/context_menus/TopLeftMenu';
import TopLeftMenu from '../views/context_menus/TopLeftMenu';
import BaseAvatar from '../views/avatars/BaseAvatar';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import * as Avatar from '../../Avatar';

View File

@@ -35,7 +35,21 @@ export default class CompleteSecurity extends React.Component {
this.state = {
phase: PHASE_INTRO,
// this serves dual purpose as the object for the request logic and
// the presence of it insidicating that we're in 'verify mode'.
// Because of the latter, it lives in the state.
verificationRequest: null,
};
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest);
}
componentWillUnmount() {
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("crypto.verification.request", this.onVerificationRequest);
}
}
onStartClick = async () => {
@@ -44,14 +58,38 @@ export default class CompleteSecurity extends React.Component {
await accessSecretStorage(async () => {
await cli.checkOwnCrossSigningTrust();
});
this.setState({
phase: PHASE_DONE,
});
if (cli.getCrossSigningId()) {
this.setState({
phase: PHASE_DONE,
});
}
} catch (e) {
// this will throw if the user hits cancel, so ignore
}
}
onVerificationRequest = (request) => {
if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return;
if (this.state.verificationRequest) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
}
request.on("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: request,
});
}
onVerificationRequestChange = () => {
if (this.state.verificationRequest.cancelled) {
this.state.verificationRequest.off("change", this.onVerificationRequestChange);
this.setState({
verificationRequest: null,
});
}
}
onSkipClick = () => {
this.setState({
phase: PHASE_CONFIRM_SKIP,
@@ -74,8 +112,7 @@ export default class CompleteSecurity extends React.Component {
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
@@ -85,7 +122,13 @@ export default class CompleteSecurity extends React.Component {
let icon;
let title;
let body;
if (phase === PHASE_INTRO) {
if (this.state.verificationRequest) {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
body = <IncomingSasDialog verifier={this.state.verificationRequest.verifier}
onFinished={this.props.onFinished}
/>;
} else if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security");
body = (
@@ -161,8 +204,7 @@ export default class CompleteSecurity extends React.Component {
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<CompleteSecurityBody>
<h2 className="mx_CompleteSecurity_header">
{icon}
{title}
@@ -170,7 +212,7 @@ export default class CompleteSecurity extends React.Component {
<div className="mx_CompleteSecurity_body">
{body}
</div>
</AuthBody>
</CompleteSecurityBody>
</AuthPage>
);
}

View File

@@ -0,0 +1,50 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import AsyncWrapper from '../../../AsyncWrapper';
import * as sdk from '../../../index';
export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
accountPassword: PropTypes.string,
};
constructor() {
super();
// awkwardly indented because https://github.com/eslint/eslint/issues/11310
this._createStorageDialogPromise =
import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog");
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
return (
<AuthPage>
<CompleteSecurityBody>
<AsyncWrapper prom={this._createStorageDialogPromise}
hasCancel={false}
onFinished={this.props.onFinished}
accountPassword={this.props.accountPassword}
/>
</CompleteSecurityBody>
</AuthPage>
);
}
}

View File

@@ -58,6 +58,11 @@ export default createReactClass({
displayName: 'Login',
propTypes: {
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
// If true, the component will consider itself busy.
@@ -181,7 +186,7 @@ export default createReactClass({
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data);
this.props.onLoggedIn(data, password);
}, (error) => {
if (this._unmounted) {
return;

View File

@@ -45,7 +45,13 @@ export default createReactClass({
displayName: 'Registration',
propTypes: {
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
@@ -348,7 +354,7 @@ export default createReactClass({
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
}, this.state.formVals.password);
this._setupPushers(cli);
// we're still busy until we get unmounted: don't show the registration form again

View File

@@ -62,7 +62,7 @@ export default createReactClass({
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
let protocol = global.location.protocol;
if (protocol === "vector:") {
if (protocol !== "http:") {
protocol = "https:";
}
const scriptTag = document.createElement('script');

View File

@@ -0,0 +1,27 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
export default class CompleteSecurityBody extends React.PureComponent {
render() {
return <div className="mx_CompleteSecurityBody">
{ this.props.children }
</div>;
}
}

View File

@@ -641,7 +641,7 @@ const AuthEntryComponents = [
TermsAuthEntry,
];
export function getEntryComponentForLoginType(loginType) {
export default function getEntryComponentForLoginType(loginType) {
for (const c of AuthEntryComponents) {
if (c.LOGIN_TYPE == loginType) {
return c;

View File

@@ -306,7 +306,7 @@ export default createReactClass({
return (
<div>
<MenuItem className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings}>
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" alt="" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/feather-customised/settings.svg")} width="15" height="15" alt="" />
{ _t('Settings') }
</MenuItem>
</div>

View File

@@ -27,7 +27,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {MenuItem} from "../../structures/ContextMenu";
import * as sdk from "../../../index";
export class TopLeftMenu extends React.Component {
export default class TopLeftMenu extends React.Component {
static propTypes = {
displayName: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,

View File

@@ -65,6 +65,9 @@ export default createReactClass({
// Title for the dialog.
title: PropTypes.node.isRequired,
// Path to an icon to put in the header
headerImage: PropTypes.string,
// children should be the content of the dialog
children: PropTypes.node,
@@ -110,6 +113,13 @@ export default createReactClass({
);
}
let headerImage;
if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
alt=""
/>;
}
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<FocusLock
@@ -135,6 +145,7 @@ export default createReactClass({
'mx_Dialog_headerWithButton': !!this.props.headerButton,
})}>
<div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
{headerImage}
{ this.props.title }
</div>
{ this.props.headerButton }

View File

@@ -1,5 +1,6 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -44,13 +45,13 @@ export default createReactClass({
},
_roomCreateOptions() {
const createOpts = {};
const opts = {};
const createOpts = opts.createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
createOpts.visibility = "public";
createOpts.preset = "public_chat";
// to prevent createRoom from enabling guest access
createOpts['initial_state'] = [];
opts.guestAccess = false;
const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
@@ -61,7 +62,7 @@ export default createReactClass({
if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false};
}
return createOpts;
return opts;
},
componentDidMount() {

View File

@@ -33,6 +33,7 @@ import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
@@ -337,19 +338,31 @@ export default class InviteDialog extends React.PureComponent {
const recents = [];
for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.includes(userId)) continue;
if (excludedTargetIds.includes(userId)) {
console.warn(`[Invite:Recents] Excluding ${userId} from recents`);
continue;
}
const room = rooms[userId];
const member = room.getMember(userId);
if (!member) continue; // just skip people who don't have memberships for some reason
if (!member) {
// just skip people who don't have memberships for some reason
console.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`);
continue;
}
const lastEventTs = room.timeline && room.timeline.length
? room.timeline[room.timeline.length - 1].getTs()
: 0;
if (!lastEventTs) continue; // something weird is going on with this room
if (!lastEventTs) {
// something weird is going on with this room
console.warn(`[Invite:Recents] ${userId} (${room.roomId}) has a weird last timestamp: ${lastEventTs}`);
continue;
}
recents.push({userId, user: member, lastActive: lastEventTs});
}
if (!recents) console.warn("[Invite:Recents] No recents to suggest!");
// Sort the recents by last active to save us time later
recents.sort((a, b) => b.lastActive - a.lastActive);
@@ -493,7 +506,7 @@ export default class InviteDialog extends React.PureComponent {
return false;
}
_startDm = () => {
_startDm = async () => {
this.setState({busy: true});
const targetIds = this.state.targets.map(t => t.userId);
@@ -510,14 +523,31 @@ export default class InviteDialog extends React.PureComponent {
return;
}
const createRoomOptions = {};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const client = MatrixClientPeg.get();
const usersToDevicesMap = await client.downloadKeys(targetIds);
const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => {
// `devices` is an object of the form { deviceId: deviceInfo, ... }.
return Object.keys(devices).length > 0;
});
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
}
}
// Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
let createRoomPromise = Promise.resolve();
if (targetIds.length === 1) {
createRoomPromise = createRoom({dmUserId: targetIds[0]});
createRoomOptions.dmUserId = targetIds[0];
createRoomPromise = createRoom(createRoomOptions);
} else {
// Create a boring room and try to invite the targets manually.
createRoomPromise = createRoom().then(roomId => {
createRoomPromise = createRoom(createRoomOptions).then(roomId => {
return inviteMultipleToRoom(roomId, targetIds);
}).then(result => {
if (this._shouldAbortAfterInviteError(result)) {
@@ -586,13 +616,36 @@ export default class InviteDialog extends React.PureComponent {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(async () => {
MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1 && r.results.length === 0) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list
r.results.push({
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
}
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
@@ -672,11 +725,16 @@ export default class InviteDialog extends React.PureComponent {
};
_toggleMember = (member: Member) => {
let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
if (idx >= 0) targets.splice(idx, 1);
else targets.push(member);
this.setState({targets});
if (idx >= 0) {
targets.splice(idx, 1);
} else {
targets.push(member);
filterText = ""; // clear the filter when the user accepts a suggestion
}
this.setState({targets, filterText});
};
_removeMember = (member: Member) => {
@@ -876,7 +934,7 @@ export default class InviteDialog extends React.PureComponent {
key={"input"}
rows={1}
onChange={this._updateFilter}
defaultValue={this.state.filterText}
value={this.state.filterText}
ref={this._editorRef}
onPaste={this._onPaste}
/>
@@ -944,7 +1002,7 @@ export default class InviteDialog extends React.PureComponent {
title = _t("Direct Messages");
helpText = _t(
"If you can't find someone, ask them for their username, or share your " +
"If you can't find someone, ask them for their username, share your " +
"username (%(userId)s) or <a>profile link</a>.",
{userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
@@ -970,7 +1028,7 @@ export default class InviteDialog extends React.PureComponent {
title={title}
>
<div className='mx_InviteDialog_content'>
<p>{helpText}</p>
<p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'>
{this._renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'>
@@ -987,8 +1045,10 @@ export default class InviteDialog extends React.PureComponent {
</div>
{this._renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
{this._renderSection('recents')}
{this._renderSection('suggestions')}
<div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')}
{this._renderSection('suggestions')}
</div>
</div>
</BaseDialog>
);

View File

@@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
/*
* A dialog for reporting an event.
@@ -95,6 +97,15 @@ export default class ReportEventDialog extends PureComponent {
);
}
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<BaseDialog
className="mx_BugReportDialog"
@@ -110,7 +121,7 @@ export default class ReportEventDialog extends PureComponent {
"administrator will not be able to read the message text or view any files or images.")
}
</p>
{adminMessage}
<Field
id="mx_ReportEventDialog_reason"
className="mx_ReportEventDialog_reason"

View File

@@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk';
@@ -32,6 +33,16 @@ const RESTORE_TYPE_SECRET_STORAGE = 2;
* Dialog for restoring e2e keys from a backup and the user's recovery key
*/
export default class RestoreKeyBackupDialog extends React.PureComponent {
static propTypes = {
// if false, will close the dialog as soon as the restore completes succesfully
// default: true
showSummary: PropTypes.bool,
};
defaultProps = {
showSummary: true,
};
constructor(props) {
super(props);
this.state = {
@@ -96,6 +107,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo,
);
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({
loading: false,
recoverInfo,
@@ -119,6 +134,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
);
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({
loading: false,
recoverInfo,
@@ -253,6 +272,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
title = _t("Error");
content = _t("No backup found!");
} else if (this.state.recoverInfo) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
title = _t("Backup Restored");
let failedToDecrypt;
if (this.state.recoverInfo.total > this.state.recoverInfo.imported) {
@@ -264,6 +284,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
content = <div>
<p>{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}</p>
{failedToDecrypt}
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
focus={true}
/>
</div>;
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');

View File

@@ -34,12 +34,19 @@ export default createReactClass({
// A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node,
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit: PropTypes.bool,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func.isRequired,
onPrimaryButtonClick: PropTypes.func,
// should there be a cancel button? default: true
hasCancel: PropTypes.bool,
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass: PropTypes.node,
// onClick handler for the cancel button.
onCancel: PropTypes.func,
@@ -69,16 +76,26 @@ export default createReactClass({
primaryButtonClassName += " " + this.props.primaryButtonClass;
}
let cancelButton;
if (this.props.cancelButton || this.props.hasCancel) {
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}>
cancelButton = <button
// important: the default type is 'submit' and this button comes before the
// primary in the DOM so will get form submissions unless we make it not a submit.
type="button"
onClick={this._onCancelClick}
className={this.props.cancelButtonClass}
disabled={this.props.disabled}
>
{ this.props.cancelButton || _t("Cancel") }
</button>;
}
return (
<div className="mx_Dialog_buttons">
{ cancelButton }
{ this.props.children }
<button className={primaryButtonClassName}
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
className={primaryButtonClassName}
onClick={this.props.onPrimaryButtonClick}
autoFocus={this.props.focus}
disabled={this.props.disabled || this.props.primaryDisabled}

View File

@@ -0,0 +1,56 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import {replaceableComponent} from "../../../../utils/replaceableComponent";
import * as qs from "qs";
import QRCode from "qrcode-react";
@replaceableComponent("views.elements.crypto.VerificationQRCode")
export default class VerificationQRCode extends React.PureComponent {
static propTypes = {
// Common for all kinds of QR codes
keys: PropTypes.array.isRequired, // array of [Key ID, Base64 Key] pairs
action: PropTypes.string.isRequired,
keyholderUserId: PropTypes.string.isRequired,
// User verification use case only
secret: PropTypes.string,
otherUserKey: PropTypes.string, // Base64 key being verified
requestEventId: PropTypes.string,
};
static defaultProps = {
action: "verify",
};
render() {
const query = {
request: this.props.requestEventId,
action: this.props.action,
other_user_key: this.props.otherUserKey,
secret: this.props.secret,
};
for (const key of this.props.keys) {
query[`key_${key[0]}`] = key[1];
}
const uri = `https://matrix.to/#/${this.props.keyholderUserId}?${qs.stringify(query)}`;
return <QRCode value={uri} size={256} logoWidth={48} logo={require("../../../../../res/img/matrix-m.svg")} />;
}
}

View File

@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import * as recent from './recent';
import * as recent from '../../../emojipicker/recent';
import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji";
export const CATEGORY_HEADER_HEIGHT = 22;

View File

@@ -1,35 +0,0 @@
/*
Copyright 2019 Tulir Asokan <tulir@maunium.net>
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.
*/
const REACTION_COUNT = JSON.parse(window.localStorage.mx_reaction_count || '{}');
let sorted = null;
export function add(emoji) {
const [count] = REACTION_COUNT[emoji] || [0];
REACTION_COUNT[emoji] = [count + 1, Date.now()];
window.localStorage.mx_reaction_count = JSON.stringify(REACTION_COUNT);
sorted = null;
}
export function get(limit = 24) {
if (sorted === null) {
sorted = Object.entries(REACTION_COUNT)
.sort(([, [count1, date1]], [, [count2, date2]]) =>
count2 === count1 ? date2 - date1 : count2 - count1)
.map(([emoji, count]) => emoji);
}
return sorted.slice(0, limit);
}

View File

@@ -26,6 +26,7 @@ import classNames from 'classnames';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
// XXX this class copies a lot from RoomTile.js
export default createReactClass({
@@ -127,7 +128,8 @@ export default createReactClass({
'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed,
});
const label = <div title={this.props.group.groupId} className={nameClasses} dir="auto">
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
const label = <div title={this.props.group.groupId} className={nameClasses} tabIndex={-1} dir="auto">
{ groupName }
</div>;
@@ -137,16 +139,6 @@ export default createReactClass({
});
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
const badge = (
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
>
{ badgeContent }
</ContextMenuButton>
);
let tooltip;
if (this.props.collapsed && this.state.hover) {
@@ -171,22 +163,37 @@ export default createReactClass({
}
return <React.Fragment>
<AccessibleButton
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile_avatar">
{ av }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div>
{ tooltip }
</AccessibleButton>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile_avatar">
{ av }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
tabIndex={isActive ? 0 : -1}
>
{ badgeContent }
</ContextMenuButton>
</div>
{ tooltip }
</AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu }
</React.Fragment>;

View File

@@ -93,7 +93,7 @@ export default class MKeyVerificationConclusion extends React.Component {
}
if (title) {
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent);
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId());
const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", {
mx_KeyVerification_icon_verified: request.done,
});

View File

@@ -85,7 +85,7 @@ export default class MKeyVerificationRequest extends React.Component {
if (userId === myUserId) {
return _t("You accepted");
} else {
return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent)});
return _t("%(name)s accepted", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())});
}
}
@@ -95,7 +95,7 @@ export default class MKeyVerificationRequest extends React.Component {
if (userId === myUserId) {
return _t("You cancelled");
} else {
return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent)});
return _t("%(name)s cancelled", {name: getNameForEventRoom(userId, this.props.mxEvent.getRoomId())});
}
}
@@ -128,10 +128,11 @@ export default class MKeyVerificationRequest extends React.Component {
}
if (!request.initiatedByMe) {
const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
title = (<div className="mx_KeyVerification_title">{
_t("%(name)s wants to verify", {name: getNameForEventRoom(request.requestingUserId, mxEvent)})}</div>);
_t("%(name)s wants to verify", {name})}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(request.requestingUserId, mxEvent)}</div>);
userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
if (request.requested && !request.observeOnly) {
stateNode = (<div className="mx_KeyVerification_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
@@ -142,7 +143,7 @@ export default class MKeyVerificationRequest extends React.Component {
title = (<div className="mx_KeyVerification_title">{
_t("You sent a verification request")}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(request.receivingUserId, mxEvent)}</div>);
userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
}
if (title) {

View File

@@ -82,7 +82,7 @@ const _getE2EStatus = (cli, userId, devices) => {
return "warning";
};
function openDMForUser(matrixClient, userId) {
async function openDMForUser(matrixClient, userId) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
const room = matrixClient.getRoom(roomId);
@@ -100,9 +100,27 @@ function openDMForUser(matrixClient, userId) {
action: 'view_room',
room_id: lastActiveRoom.roomId,
});
} else {
createRoom({dmUserId: userId});
return;
}
const createRoomOptions = {
dmUserId: userId,
};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before.
// If so, enable encryption in the new room.
const usersToDevicesMap = await matrixClient.downloadKeys([userId]);
const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => {
// `devices` is an object of the form { deviceId: deviceInfo, ... }.
return Object.keys(devices).length > 0;
});
if (allHaveDeviceKeys) {
createRoomOptions.encryption = true;
}
}
createRoom(createRoomOptions);
}
function useIsEncrypted(cli, room) {
@@ -1219,10 +1237,9 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
let closeButton;
if (onClose) {
closeButton = <AccessibleButton
className="mx_UserInfo_cancel"
onClick={onClose}
title={_t('Close')} />;
closeButton = <AccessibleButton className="mx_UserInfo_cancel" onClick={onClose} title={_t('Close')}>
<div />
</AccessibleButton>;
}
const memberDetails = (
@@ -1308,15 +1325,18 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
userTrust.isVerified();
const isMe = user.userId === cli.getUserId();
let verifyButton;
if (!userVerified && !isMe) {
if (isRoomEncrypted && !userVerified && !isMe) {
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
{_t("Verify")}
</AccessibleButton>;
}
const devicesSection = <DevicesSection
loading={devices === undefined}
devices={devices} userId={user.userId} />;
let devicesSection;
if (isRoomEncrypted) {
devicesSection = <DevicesSection
loading={devices === undefined}
devices={devices} userId={user.userId} />;
}
const securitySection = (
<div className="mx_UserInfo_container">
@@ -1335,32 +1355,32 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
return (
<div className="mx_UserInfo" role="tabpanel">
{ closeButton }
{ avatarElement }
<div className="mx_UserInfo_container">
<div className="mx_UserInfo_profile">
<div>
<h2 aria-label={displayName}>
{ e2eIcon }
{ displayName }
</h2>
</div>
<div>{ user.userId }</div>
<div className="mx_UserInfo_profileStatus">
{presenceLabel}
{statusLabel}
</div>
</div>
</div>
{ memberDetails && <div className="mx_UserInfo_container mx_UserInfo_memberDetailsContainer">
<div className="mx_UserInfo_memberDetails">
{ memberDetails }
</div>
</div> }
<AutoHideScrollbar className="mx_UserInfo_scrollContainer">
{ closeButton }
{ avatarElement }
<div className="mx_UserInfo_container">
<div className="mx_UserInfo_profile">
<div>
<h2 aria-label={displayName}>
{ e2eIcon }
{ displayName }
</h2>
</div>
<div>{ user.userId }</div>
<div className="mx_UserInfo_profileStatus">
{presenceLabel}
{statusLabel}
</div>
</div>
</div>
{ memberDetails && <div className="mx_UserInfo_container mx_UserInfo_memberDetailsContainer">
<div className="mx_UserInfo_memberDetails">
{ memberDetails }
</div>
</div> }
{ securitySection }
<UserOptionsSection
devices={devices}

View File

@@ -17,6 +17,9 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../index';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
export default class VerificationPanel extends React.PureComponent {
constructor(props) {
@@ -36,7 +39,8 @@ export default class VerificationPanel extends React.PureComponent {
renderStatus() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner');
const {request} = this.props;
const {request: req} = this.props;
const request: VerificationRequest = req;
if (request.requested) {
return (<p>Waiting for {request.otherUserId} to accept ... <Spinner /></p>);
@@ -44,6 +48,24 @@ export default class VerificationPanel extends React.PureComponent {
const verifyButton = <AccessibleButton kind="primary" onClick={this._startSAS}>
Verify by emoji
</AccessibleButton>;
const crossSigningInfo = MatrixClientPeg.get().getStoredCrossSigningForUser(request.otherUserId);
const myKeyId = MatrixClientPeg.get().getCrossSigningId();
if (request.requestEvent && request.requestEvent.getId() && crossSigningInfo) {
const qrCodeKeys = [
[MatrixClientPeg.get().getDeviceId(), MatrixClientPeg.get().getDeviceEd25519Key()],
[myKeyId, myKeyId],
];
const qrCode = <VerificationQRCode
keyholderUserId={MatrixClientPeg.get().getUserId()}
requestEventId={request.requestEvent.getId()}
otherUserKey={crossSigningInfo.getId("master")}
secret={request.encodedSharedSecret}
keys={qrCodeKeys}
/>;
return (<p>{request.otherUserId} is ready, start {verifyButton} or have them scan: {qrCode}</p>);
}
return (<p>{request.otherUserId} is ready, start {verifyButton}</p>);
} else if (request.started) {
if (this.state.sasWaitingForOtherParty) {

View File

@@ -209,6 +209,7 @@ export default class BasicMessageEditor extends React.Component {
const range = getRangeForSelection(this._editorRef, model, selection);
const selectedParts = range.parts.map(p => p.serialize());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
event.clipboardData.setData("text/plain", text); // so plain copy/paste works
if (type === "cut") {
// Remove the text, updating the model as appropriate
this._modifiedFlag = true;

View File

@@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,76 +15,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import PropTypes from "prop-types";
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from '../../../settings/SettingsStore';
export default function(props) {
const { isUser } = props;
const isNormal = props.status === "normal";
const isWarning = props.status === "warning";
const isVerified = props.status === "verified";
const e2eIconClasses = classNames({
import {_t, _td} from '../../../languageHandler';
import {useFeatureEnabled} from "../../../hooks/useSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip";
export const E2E_STATE = {
VERIFIED: "verified",
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
};
const crossSigningUserTitles = {
[E2E_STATE.WARNING]: _td("This user has not verified all of their devices."),
[E2E_STATE.NORMAL]: _td("You have not verified this user. This user has verified all of their devices."),
[E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their devices."),
};
const crossSigningRoomTitles = {
[E2E_STATE.WARNING]: _td("Someone is using an unknown device"),
[E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"),
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"),
};
const legacyUserTitles = {
[E2E_STATE.WARNING]: _td("Some devices for this user are not trusted"),
[E2E_STATE.VERIFIED]: _td("All devices for this user are trusted"),
};
const legacyRoomTitles = {
[E2E_STATE.WARNING]: _td("Some devices in this encrypted room are not trusted"),
[E2E_STATE.VERIFIED]: _td("All devices in this encrypted room are trusted"),
};
const E2EIcon = ({isUser, status, className, size, onClick}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
mx_E2EIcon: true,
mx_E2EIcon_warning: isWarning,
mx_E2EIcon_normal: isNormal,
mx_E2EIcon_verified: isVerified,
}, props.className);
mx_E2EIcon_warning: status === E2E_STATE.WARNING,
mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
}, className);
let e2eTitle;
const crossSigning = SettingsStore.isFeatureEnabled("feature_cross_signing");
const crossSigning = useFeatureEnabled("feature_cross_signing");
if (crossSigning && isUser) {
if (isWarning) {
e2eTitle = _t(
"This user has not verified all of their devices.",
);
} else if (isNormal) {
e2eTitle = _t(
"You have not verified this user. " +
"This user has verified all of their devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"You have verified this user. " +
"This user has verified all of their devices.",
);
}
e2eTitle = crossSigningUserTitles[status];
} else if (crossSigning && !isUser) {
if (isWarning) {
e2eTitle = _t(
"Some users in this encrypted room are not verified by you or " +
"they have not verified their own devices.",
);
} else if (isVerified) {
e2eTitle = _t(
"All users in this encrypted room are verified by you and " +
"they have verified their own devices.",
);
}
e2eTitle = crossSigningRoomTitles[status];
} else if (!crossSigning && isUser) {
if (isWarning) {
e2eTitle = _t("Some devices for this user are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices for this user are trusted");
}
e2eTitle = legacyUserTitles[status];
} else if (!crossSigning && !isUser) {
if (isWarning) {
e2eTitle = _t("Some devices in this encrypted room are not trusted");
} else if (isVerified) {
e2eTitle = _t("All devices in this encrypted room are trusted");
}
e2eTitle = legacyRoomTitles[status];
}
let style = null;
if (props.size) {
style = {width: `${props.size}px`, height: `${props.size}px`};
let style;
if (size) {
style = {width: `${size}px`, height: `${size}px`};
}
const icon = (<div className={e2eIconClasses} style={style} title={e2eTitle} />);
if (props.onClick) {
return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
} else {
return icon;
const onMouseOver = () => setHover(true);
const onMouseOut = () => setHover(false);
let tip;
if (hover) {
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} />;
}
}
if (onClick) {
return (
<AccessibleButton
onClick={onClick}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
className={classes}
style={style}
>
{ tip }
</AccessibleButton>
);
}
return <div onMouseOver={onMouseOver} onMouseOut={onMouseOut} className={classes} style={style}>
{ tip }
</div>;
};
E2EIcon.propTypes = {
isUser: PropTypes.bool,
status: PropTypes.oneOf(Object.values(E2E_STATE)),
className: PropTypes.string,
size: PropTypes.number,
onClick: PropTypes.func,
};
export default E2EIcon;

View File

@@ -33,6 +33,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@@ -235,6 +236,7 @@ export default createReactClass({
this._suppressReadReceiptAnimation = false;
const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
@@ -260,6 +262,7 @@ export default createReactClass({
componentWillUnmount: function() {
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
@@ -282,18 +285,56 @@ export default createReactClass({
}
},
onUserVerificationChanged: function(userId, _trustStatus) {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
_verifyEvent: async function(mxEvent) {
if (!mxEvent.isEncrypted()) {
return;
}
// If we directly trust the device, short-circuit here
const verified = await this.context.isEventSenderVerified(mxEvent);
if (verified) {
this.setState({
verified: E2E_STATE.VERIFIED,
}, () => {
// Decryption may have caused a change in size
this.props.onHeightChanged();
});
return;
}
// If cross-signing is off, the old behaviour is to scream at the user
// as if they've done something wrong, which they haven't
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
this.setState({
verified: E2E_STATE.WARNING,
}, this.props.onHeightChanged);
return;
}
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
this.setState({
verified: E2E_STATE.NORMAL,
}, this.props.onHeightChanged);
return;
}
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
}, this.props.onHeightChanged); // Decryption may have cause a change in size
return;
}
this.setState({
verified: verified,
}, () => {
// Decryption may have caused a change in size
this.props.onHeightChanged();
});
verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
},
_propsEqual: function(objA, objB) {
@@ -473,8 +514,12 @@ export default createReactClass({
// event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) {
if (this.state.verified) {
if (this.state.verified === E2E_STATE.NORMAL) {
return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) {
return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
return (<E2ePadlockUnknown />);
} else {
return (<E2ePadlockUnverified />);
}
@@ -527,6 +572,7 @@ export default createReactClass({
console.error("EventTile attempted to get relations for an event without an ID");
// Use event's special `toJSON` method to log key data.
console.log(JSON.stringify(this.props.mxEvent, null, 4));
console.trace("Stacktrace for https://github.com/vector-im/riot-web/issues/11120");
}
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
},
@@ -604,8 +650,9 @@ export default createReactClass({
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === true,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
@@ -901,6 +948,12 @@ function E2ePadlockUnencrypted(props) {
);
}
function E2ePadlockUnknown(props) {
return (
<E2ePadlock title={_t("Encrypted by a deleted device")} icon="unknown" {...props} />
);
}
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,

View File

@@ -0,0 +1,51 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
export default class InviteOnlyIcon extends React.Component {
constructor() {
super();
this.state = {
hover: false,
};
}
onHoverStart = () => {
this.setState({hover: true});
};
onHoverEnd = () => {
this.setState({hover: false});
};
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
let tooltip;
if (this.state.hover) {
tooltip = <Tooltip className="mx_InviteOnlyIcon_tooltip" label={_t("Invite only")} dir="auto" />;
}
return (<div className="mx_InviteOnlyIcon"
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>
{ tooltip }
</div>);
}
}

View File

@@ -26,6 +26,7 @@ import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@@ -168,7 +169,6 @@ export default class MessageComposer extends React.Component {
constructor(props) {
super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
@@ -182,11 +182,6 @@ export default class MessageComposer extends React.Component {
}
componentDidMount() {
// N.B. using 'event' rather than 'RoomEvents' otherwise the crypto handler
// for 'event' fires *after* 'RoomEvent', and our room won't have yet been
// marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
@@ -210,7 +205,6 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent);
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
}
if (this._roomStoreToken) {
@@ -218,13 +212,6 @@ export default class MessageComposer extends React.Component {
}
}
onEvent(event) {
if (event.getType() !== 'm.room.encryption') return;
if (event.getRoomId() !== this.props.room.roomId) return;
// TODO: put (encryption state??) in state
this.forceUpdate();
}
_onRoomStateEvents(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId) return;
@@ -282,18 +269,33 @@ export default class MessageComposer extends React.Component {
}
renderPlaceholderText() {
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
if (this.state.isQuoting) {
if (roomIsEncrypted) {
return _t('Send an encrypted reply…');
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply…');
}
} else {
return _t('Send a reply (unencrypted)…');
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message…');
}
}
} else {
if (roomIsEncrypted) {
return _t('Send an encrypted message…');
if (this.state.isQuoting) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
return _t('Send a reply (unencrypted)…');
}
} else {
return _t('Send a message (unencrypted)…');
if (this.props.e2eStatus) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message (unencrypted)…');
}
}
}
}

View File

@@ -363,7 +363,7 @@ export default class RoomBreadcrumbs extends React.Component {
}
let dmIndicator;
if (this._isDmRoom(r.room)) {
if (this._isDmRoom(r.room) && !SettingsStore.isFeatureEnabled("feature_cross_signing")) {
dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomBreadcrumbs_dmIndicator"

View File

@@ -31,7 +31,9 @@ import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import DMRoomMap from '../../../utils/DMRoomMap';
import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
export default createReactClass({
displayName: 'RoomHeader',
@@ -160,13 +162,16 @@ export default createReactClass({
<E2EIcon status={this.props.e2eStatus} /> :
undefined;
const dmUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
const joinRules = this.props.room && this.props.room.currentState.getStateEvents("m.room.join_rules", "");
const joinRule = joinRules && joinRules.getContent().join_rule;
const joinRuleClass = classNames("mx_RoomHeader_PrivateIcon",
{"mx_RoomHeader_isPrivate": joinRule === "invite"});
const privateIcon = SettingsStore.isFeatureEnabled("feature_cross_signing") ?
<div className={joinRuleClass} /> :
undefined;
let privateIcon;
// Don't show an invite-only icon for DMs. Users know they're invite-only.
if (!dmUserId && SettingsStore.isFeatureEnabled("feature_cross_signing")) {
if (joinRule == "invite") {
privateIcon = <InviteOnlyIcon />;
}
}
if (this.props.onCancelClick) {
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
@@ -310,8 +315,7 @@ export default createReactClass({
return (
<div className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ e2eIcon }
<div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
{ privateIcon }
{ name }
{ topicElement }

View File

@@ -39,6 +39,7 @@ import * as sdk from "../../../index";
import * as Receipt from "../../../utils/Receipt";
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@@ -718,7 +719,7 @@ export default createReactClass({
},
{
list: this.state.lists['im.vector.fake.direct'],
label: _t('People'),
label: _t('Direct Messages'),
tagName: "im.vector.fake.direct",
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
@@ -776,19 +777,22 @@ export default createReactClass({
const subListComponents = this._mapSubListProps(subLists);
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, ...props} = this.props; // eslint-disable-line
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
return (
<div
{...props}
ref={this._collectResizeContainer}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
{ subListComponents }
</div>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => <div
{...props}
onKeyDown={onKeyDownHandler}
ref={this._collectResizeContainer}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
{ subListComponents }
</div> }
</RovingTabIndexProvider>
);
},
});

View File

@@ -49,6 +49,7 @@ export default createReactClass({
propTypes: {
onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName
@@ -282,6 +283,7 @@ export default createReactClass({
render: function() {
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false;
let darkStyle = false;
@@ -292,6 +294,7 @@ export default createReactClass({
let secondaryActionHandler;
let secondaryActionLabel;
let footer;
const extraComponents = [];
const messageCase = this._getMessageCase();
switch (messageCase) {
@@ -469,6 +472,14 @@ export default createReactClass({
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Reject");
secondaryActionHandler = this.props.onRejectClick;
if (this.props.onRejectAndIgnoreClick) {
extraComponents.push(
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
{ _t("Reject & Ignore user") }
</AccessibleButton>,
);
}
break;
}
case MessageCase.ViewingRoom: {
@@ -505,8 +516,6 @@ export default createReactClass({
}
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let subTitleElements;
if (subTitle) {
if (!Array.isArray(subTitle)) {
@@ -554,6 +563,7 @@ export default createReactClass({
</div>
<div className="mx_RoomPreviewBar_actions">
{ secondaryButton }
{ extraComponents }
{ primaryButton }
</div>
<div className="mx_RoomPreviewBar_footer">

View File

@@ -32,6 +32,11 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import {_t} from "../../../languageHandler";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
import E2EIcon from './E2EIcon';
import InviteOnlyIcon from './InviteOnlyIcon';
// eslint-disable-next-line camelcase
import rate_limited_func from '../../../ratelimitedfunc';
export default createReactClass({
displayName: 'RoomTile',
@@ -69,6 +74,7 @@ export default createReactClass({
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(),
e2eStatus: null,
});
},
@@ -101,6 +107,83 @@ export default createReactClass({
return statusUser._unstable_statusMessage;
},
onRoomStateMember: function(ev, state, member) {
// we only care about leaving users
// because trust state will change if someone joins a megolm session anyway
if (member.membership !== "leave") {
return;
}
// ignore members in other rooms
if (member.roomId !== this.props.room.roomId) {
return;
}
this._updateE2eStatus();
},
onUserVerificationChanged: function(userId, _trustStatus) {
if (!this.props.room.getMember(userId)) {
// Not in this room
return;
}
this._updateE2eStatus();
},
onRoomTimeline: function(ev, room) {
if (!room) return;
if (room.roomId != this.props.room.roomId) return;
if (ev.getType() !== "m.room.encryption") return;
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
this.onFindingRoomToBeEncrypted();
},
onFindingRoomToBeEncrypted: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("userTrustStatusChanged", this.onUserVerificationChanged);
this._updateE2eStatus();
},
_updateE2eStatus: async function() {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(this.props.room.roomId)) {
return;
}
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
// Duplication between here and _updateE2eStatus in RoomView
const e2eMembers = await this.props.room.getEncryptionTargetMembers();
const verified = [];
const unverified = [];
e2eMembers.map(({userId}) => userId)
.filter((userId) => userId !== cli.getUserId())
.forEach((userId) => {
(cli.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId);
});
/* Check all verified user devices. */
for (const userId of [...verified, cli.getUserId()]) {
const devices = await cli.getStoredDevicesForUser(userId);
const allDevicesVerified = devices.every(({deviceId}) => {
return cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (!allDevicesVerified) {
this.setState({
e2eStatus: "warning",
});
return;
}
}
this.setState({
e2eStatus: unverified.length === 0 ? "verified" : "normal",
});
},
onRoomName: function(room) {
if (room !== this.props.room) return;
this.setState({
@@ -150,10 +233,19 @@ export default createReactClass({
},
componentDidMount: function() {
/* We bind here rather than in the definition because otherwise we wind up with the
method only being callable once every 500ms across all instances, which would be wrong */
this._updateE2eStatus = rate_limited_func(this._updateE2eStatus, 500);
const cli = MatrixClientPeg.get();
cli.on("accountData", this.onAccountData);
cli.on("Room.name", this.onRoomName);
cli.on("RoomState.events", this.onJoinRule);
if (cli.isRoomEncrypted(this.props.room.roomId)) {
this.onFindingRoomToBeEncrypted();
} else {
cli.on("Room.timeline", this.onRoomTimeline);
}
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction);
@@ -171,6 +263,9 @@ export default createReactClass({
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
cli.removeListener("RoomState.events", this.onJoinRule);
cli.removeListener("RoomState.members", this.onRoomStateMember);
cli.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
cli.removeListener("Room.timeline", this.onRoomTimeline);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef);
@@ -317,7 +412,6 @@ export default createReactClass({
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
'mx_RoomTile_isPrivate': this.state.joinRule == "invite" && !dmUserId,
});
const avatarClasses = classNames({
@@ -352,7 +446,8 @@ export default createReactClass({
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
label = <div title={name} className={nameClasses} dir="auto">{ name }</div>;
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
label = <div title={name} className={nameClasses} tabIndex={-1} dir="auto">{ name }</div>;
} else if (this.state.hover) {
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
@@ -383,7 +478,9 @@ export default createReactClass({
let dmIndicator;
let dmOnline;
if (dmUserId) {
/* Post-cross-signing we don't show DM indicators at all, instead relying on user
context to let them know when that is. */
if (dmUserId && !SettingsStore.isFeatureEnabled("feature_cross_signing")) {
dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomTile_dm"
@@ -428,40 +525,54 @@ export default createReactClass({
let privateIcon = null;
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
privateIcon = <div className="mx_RoomTile_PrivateIcon" />;
if (this.state.joinRule == "invite" && !dmUserId) {
privateIcon = <InviteOnlyIcon />;
}
}
let e2eIcon = null;
if (this.state.e2eStatus) {
e2eIcon = <E2EIcon status={this.state.e2eStatus} className="mx_RoomTile_e2eIcon" />;
}
return <React.Fragment>
<AccessibleButton
tabIndex="0"
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
{ privateIcon }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ dmOnline }
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
{ e2eIcon }
</div>
</div>
{ privateIcon }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ dmOnline }
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu }
</React.Fragment>;

View File

@@ -24,6 +24,8 @@ import {
containsEmote,
stripEmoteCommand,
unescapeMessage,
startsWith,
stripPrefix,
} from '../../../editor/serialize';
import {CommandPartCreator} from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
@@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread";
import {parseEvent} from '../../../editor/deserialize';
import {findEditableEvent} from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
import {processCommandInput} from '../../../SlashCommands';
import {getCommand} from '../../../SlashCommands';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler';
@@ -56,11 +58,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
}
}
function createMessageContent(model, permalinkCreator) {
// exported for tests
export function createMessageContent(model, permalinkCreator) {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model);
const repliedToEvent = RoomViewStore.getQuotingEvent();
@@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command") {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
return true;
}
}
return false;
}
async _runSlashCommand() {
_getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
@@ -196,50 +203,86 @@ export default class SendMessageComposer extends React.Component {
}
return text + part.text;
}, "");
const cmd = processCommandInput(this.props.room.roomId, commandText);
return [getCommand(this.props.room.roomId, commandText), commandText];
}
if (cmd) {
let error = cmd.error;
if (cmd.promise) {
try {
await cmd.promise;
} catch (err) {
error = err;
}
async _runSlashCommand(fn) {
const cmd = fn();
let error = cmd.error;
if (cmd.promise) {
try {
await cmd.promise;
} catch (err) {
error = err;
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!cmd.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!cmd.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
console.log("Command success.");
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
} else {
console.log("Command success.");
}
}
_sendMessage() {
async _sendMessage() {
if (this.model.isEmpty) {
return;
}
let shouldSend = true;
if (!containsEmote(this.model) && this._isSlashCommand()) {
this._runSlashCommand();
} else {
const [cmd, commandText] = this._getSlashCommand();
if (cmd) {
shouldSend = false;
this._runSlashCommand(cmd);
} else {
// ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"),
description: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
const isReply = !!RoomViewStore.getQuotingEvent();
const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator);
@@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
});
}
}
this.sendHistoryManager.save(this.model);
// clear composer
this.model.reset([]);

View File

@@ -0,0 +1,187 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import {formatBytes, formatCountLong} from "../../../utils/FormattingUtils";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
export default class EventIndexPanel extends React.Component {
constructor() {
super();
this.state = {
enabling: false,
eventIndexSize: 0,
roomCount: 0,
eventIndexingEnabled:
SettingsStore.getValueAt(SettingLevel.DEVICE, 'enableEventIndexing'),
};
}
async updateCurrentRoom(room) {
const eventIndex = EventIndexPeg.get();
const stats = await eventIndex.getStats();
this.setState({
eventIndexSize: stats.size,
roomCount: stats.roomCount,
});
}
componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom.bind(this));
}
}
async componentWillMount(): void {
this.updateState();
}
async updateState() {
const eventIndex = EventIndexPeg.get();
const eventIndexingEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, 'enableEventIndexing');
const enabling = false;
let eventIndexSize = 0;
let roomCount = 0;
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom.bind(this));
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
roomCount = stats.roomCount;
}
this.setState({
enabling,
eventIndexSize,
roomCount,
eventIndexingEnabled,
});
}
_onManage = async () => {
Modal.createTrackedDialogAsync('Message search', 'Message search',
import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'),
{
onFinished: () => {},
}, null, /* priority = */ false, /* static = */ true,
);
}
_onEnable = async () => {
this.setState({
enabling: true,
});
await EventIndexPeg.initEventIndex();
await EventIndexPeg.get().addInitialCheckpoints();
await EventIndexPeg.get().startCrawler();
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, true);
await this.updateState();
}
render() {
let eventIndexingSettings = null;
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
if (EventIndexPeg.get() !== null) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them " +
"to appear in search results, using ")
} {formatBytes(this.state.eventIndexSize, 0)}
{_t( " to store messages from ")}
{formatCountLong(this.state.roomCount)} {_t("rooms.")}
</div>
<div>
<AccessibleButton kind="primary" onClick={this._onManage}>
{_t("Manage")}
</AccessibleButton>
</div>
</div>
);
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them to " +
"appear in search results.")}
</div>
<div>
<AccessibleButton kind="primary" disabled={this.state.enabling}
onClick={this._onEnable}>
{_t("Enable")}
</AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />}
</div>
</div>
);
} else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) {
const nativeLink = (
"https://github.com/vector-im/riot-web/blob/develop/" +
"docs/native-node-modules.md#" +
"adding-seshat-for-search-in-e2e-encrypted-rooms"
);
eventIndexingSettings = (
<div>
{
_t( "Riot is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " +
"experiment with this feature, build a custom Riot Desktop " +
"with <nativeLink>search components added</nativeLink>.",
{},
{
'nativeLink': (sub) => <a href={nativeLink} target="_blank"
rel="noopener">{sub}</a>,
},
)
}
</div>
);
} else {
eventIndexingSettings = (
<div>
{
_t( "Riot can't securely cache encrypted messages locally " +
"while running in a web browser. Use <riotLink>Riot Desktop</riotLink> " +
"for encrypted messages to appear in search results.",
{},
{
'riotLink': (sub) => <a href="https://riot.im/download/desktop"
target="_blank" rel="noopener">{sub}</a>,
},
)
}
</div>
);
}
return eventIndexingSettings;
}
}

View File

@@ -70,7 +70,16 @@ export default class GeneralUserSettingsTab extends React.Component {
const cli = MatrixClientPeg.get();
const serverSupportsSeparateAddAndBind = await cli.doesServerSupportSeparateAddAndBind();
this.setState({serverSupportsSeparateAddAndBind});
const capabilities = await cli.getCapabilities(); // this is cached
const changePasswordCap = capabilities['m.change_password'];
// You can change your password so long as the capability isn't explicitly disabled. The implicit
// behaviour is you can change your password when the capability is missing or has not-false as
// the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap['enabled'] !== false;
this.setState({serverSupportsSeparateAddAndBind, canChangePassword});
this._getThreepidState();
}
@@ -280,7 +289,7 @@ export default class GeneralUserSettingsTab extends React.Component {
const PhoneNumbers = sdk.getComponent("views.settings.account.PhoneNumbers");
const Spinner = sdk.getComponent("views.elements.Spinner");
const passwordChangeForm = (
let passwordChangeForm = (
<ChangePassword
className="mx_GeneralUserSettingsTab_changePassword"
rowClassName=""
@@ -314,11 +323,18 @@ export default class GeneralUserSettingsTab extends React.Component {
threepidSection = <Spinner />;
}
let passwordChangeText = _t("Set a new account password...");
if (!this.state.canChangePassword) {
// Just don't show anything if you can't do anything.
passwordChangeText = null;
passwordChangeForm = null;
}
return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_accountSection">
<span className="mx_SettingsTab_subheading">{_t("Account")}</span>
<p className="mx_SettingsTab_subsectionText">
{_t("Set a new account password...")}
{passwordChangeText}
</p>
{passwordChangeForm}
{threepidSection}

View File

@@ -21,15 +21,10 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom";
import packageJson from "../../../../../../package.json";
import Modal from "../../../../../Modal";
import * as sdk from "../../../../../";
import PlatformPeg from "../../../../../PlatformPeg";
// if this looks like a release, use the 'version' from package.json; else use
// the git sha. Prepend version with v, to look like riot-web version
const REACT_SDK_VERSION = 'dist' in packageJson ? packageJson.version : packageJson.gitHead || '<local>';
// Simple method to help prettify GH Release Tags and Commit Hashes.
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
const ghVersionLabel = function(repo, token='') {
@@ -188,9 +183,6 @@ export default class HelpUserSettingsTab extends React.Component {
);
}
const reactSdkVersion = REACT_SDK_VERSION !== '<local>'
? ghVersionLabel('matrix-org/matrix-react-sdk', REACT_SDK_VERSION)
: REACT_SDK_VERSION;
const vectorVersion = this.state.vectorVersion
? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
: 'unknown';
@@ -243,7 +235,6 @@ export default class HelpUserSettingsTab extends React.Component {
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
<div className='mx_SettingsTab_subsectionText'>
{_t("matrix-react-sdk version:")} {reactSdkVersion}<br />
{_t("riot-web version:")} {vectorVersion}<br />
{_t("olm version:")} {olmVersion}<br />
{updateButton}

View File

@@ -170,6 +170,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
return (
<div className="mx_SettingsTab mx_PreferencesUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Preferences")}</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}

View File

@@ -242,6 +242,7 @@ export default class SecurityUserSettingsTab extends React.Component {
render() {
const DevicesPanel = sdk.getComponent('views.settings.DevicesPanel');
const SettingsFlag = sdk.getComponent('views.elements.SettingsFlag');
const EventIndexPanel = sdk.getComponent('views.settings.EventIndexPanel');
const KeyBackupPanel = sdk.getComponent('views.settings.KeyBackupPanel');
const keyBackup = (
@@ -253,6 +254,16 @@ export default class SecurityUserSettingsTab extends React.Component {
</div>
);
let eventIndex;
if (SettingsStore.isFeatureEnabled("feature_event_indexing")) {
eventIndex = (
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Message search")}</span>
<EventIndexPanel />
</div>
);
}
// XXX: There's no such panel in the current cross-signing designs, but
// it's useful to have for testing the feature. If there's no interest
// in having advanced details here once all flows are implemented, we
@@ -281,6 +292,7 @@ export default class SecurityUserSettingsTab extends React.Component {
</div>
</div>
{keyBackup}
{eventIndex}
{crossSigning}
{this._renderCurrentDeviceInfo()}
<div className='mx_SettingsTab_section'>

View File

@@ -32,7 +32,7 @@ export default class VerifySessionToast extends React.PureComponent {
DeviceListener.sharedInstance().dismissVerification(this.props.deviceId);
};
_onVerifyClick = async () => {
_onReviewClick = async () => {
const cli = MatrixClientPeg.get();
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
@@ -47,10 +47,10 @@ export default class VerifySessionToast extends React.PureComponent {
render() {
const FormButton = sdk.getComponent("elements.FormButton");
return (<div>
<div className="mx_Toast_description">{_t("Other users may not trust it")}</div>
<div className="mx_Toast_description">{_t("Review & verify your new session")}</div>
<div className="mx_Toast_buttons" aria-live="off">
<FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} />
<FormButton label={_t("Verify")} onClick={this._onVerifyClick} />
<FormButton label={_t("Review")} onClick={this._onReviewClick} />
</div>
</div>);
}

View File

@@ -0,0 +1,68 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import DeviceListener from '../../../DeviceListener';
import { accessSecretStorage } from '../../../CrossSigningManager';
export default class SetupEncryptionToast extends React.PureComponent {
static propTypes = {
toastKey: PropTypes.string.isRequired,
kind: PropTypes.oneOf(['set_up_encryption', 'verify_this_session', 'upgrade_encryption']).isRequired,
};
_onLaterClick = () => {
DeviceListener.sharedInstance().dismissEncryptionSetup();
};
_onSetupClick = async () => {
accessSecretStorage();
};
getDescription() {
switch (this.props.kind) {
case 'set_up_encryption':
case 'upgrade_encryption':
return _t('Verify your other devices easier');
case 'verify_this_session':
return _t('Other users may not trust it');
}
}
getSetupCaption() {
switch (this.props.kind) {
case 'set_up_encryption':
case 'upgrade_encryption':
return _t('Upgrade');
case 'verify_this_session':
return _t('Verify');
}
}
render() {
const FormButton = sdk.getComponent("elements.FormButton");
return (<div>
<div className="mx_Toast_description">{this.getDescription()}</div>
<div className="mx_Toast_buttons" aria-live="off">
<FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} />
<FormButton label={this.getSetupCaption()} onClick={this._onSetupClick} />
</div>
</div>);
}
}

View File

@@ -23,6 +23,7 @@ import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
import dis from "../../../dispatcher";
import ToastStore from "../../../stores/ToastStore";
import Modal from "../../../Modal";
export default class VerificationRequestToast extends React.PureComponent {
constructor(props) {
@@ -38,6 +39,13 @@ export default class VerificationRequestToast extends React.PureComponent {
this.setState({counter});
}, 1000);
request.on("change", this._checkRequestIsPending);
// We should probably have a separate class managing the active verification toasts,
// rather than monitoring this in the toast component itself, since we'll get problems
// like the toasdt not going away when the verification is cancelled unless it's the
// one on the top (ie. the one that's mounted).
// As a quick & dirty fix, check the toast is still relevant when it mounts (this prevents
// a toast hanging around after logging in if you did a verification as part of login).
this._checkRequestIsPending();
}
componentWillUnmount() {
@@ -65,22 +73,27 @@ export default class VerificationRequestToast extends React.PureComponent {
accept = async () => {
ToastStore.sharedInstance().dismissToast(this.props.toastKey);
const {request} = this.props;
const {event} = request;
// no room id for to_device requests
if (event.getRoomId()) {
dis.dispatch({
action: 'view_room',
room_id: event.getRoomId(),
should_peek: false,
});
}
try {
await request.accept();
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest: request},
});
if (request.channel.roomId) {
dis.dispatch({
action: 'view_room',
room_id: request.channel.roomId,
should_peek: false,
});
await request.accept();
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest: request},
});
} else if (request.channel.deviceId && request.verifier) {
// show to_device verifications in dialog still
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier: request.verifier,
}, null, /* priority = */ false, /* static = */ true);
}
} catch (err) {
console.error(err.message);
}
@@ -89,13 +102,13 @@ export default class VerificationRequestToast extends React.PureComponent {
render() {
const FormButton = sdk.getComponent("elements.FormButton");
const {request} = this.props;
const {event} = request;
const userId = request.otherUserId;
let nameLabel = event.getRoomId() ? userLabelForEventRoom(userId, event) : userId;
const roomId = request.channel.roomId;
let nameLabel = roomId ? userLabelForEventRoom(userId, roomId) : userId;
// for legacy to_device verification requests
if (nameLabel === userId) {
const client = MatrixClientPeg.get();
const user = client.getUser(event.getSender());
const user = client.getUser(userId);
if (user && user.displayName) {
nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId});
}