Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into rxl881/snapshot
This commit is contained in:
commit
f8f8bc469e
85 changed files with 3694 additions and 513 deletions
|
@ -31,7 +31,6 @@ import GroupStoreCache from '../../stores/GroupStoreCache';
|
|||
import GroupStore from '../../stores/GroupStore';
|
||||
import FlairStore from '../../stores/FlairStore';
|
||||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
|
@ -671,6 +670,20 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onJoinClick: function() {
|
||||
this.setState({membershipBusy: true});
|
||||
this._matrixClient.joinGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error joining room', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to join community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_onLeaveClick: function() {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Leave Group', '', QuestionDialog, {
|
||||
|
@ -687,9 +700,9 @@ export default React.createClass({
|
|||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error leaving room', '', ErrorDialog, {
|
||||
Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to leave room"),
|
||||
description: _t("Unable to leave community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
@ -707,8 +720,21 @@ export default React.createClass({
|
|||
});
|
||||
|
||||
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
|
||||
const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ?
|
||||
<div className="mx_GroupView_changeDelayWarning">
|
||||
{ _t(
|
||||
'Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> ' +
|
||||
'might not be seen by other users for up to 30 minutes.',
|
||||
{},
|
||||
{
|
||||
'bold1': (sub) => <b> { sub } </b>,
|
||||
'bold2': (sub) => <b> { sub } </b>,
|
||||
},
|
||||
) }
|
||||
</div> : <div />;
|
||||
return <div className={groupSettingsSectionClasses}>
|
||||
{ header }
|
||||
{ changeDelayWarning }
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
</div>;
|
||||
|
@ -847,9 +873,8 @@ export default React.createClass({
|
|||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
const group = this._matrixClient.getGroup(this.props.groupId);
|
||||
if (!group) return null;
|
||||
|
||||
if (group.myMembership === 'invite') {
|
||||
if (group && group.myMembership === 'invite') {
|
||||
if (this.state.membershipBusy || this.state.inviterProfileBusy) {
|
||||
return <div className="mx_GroupView_membershipSection">
|
||||
<Spinner />
|
||||
|
@ -890,33 +915,72 @@ export default React.createClass({
|
|||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
} else if (group.myMembership === 'join' && this.state.editing) {
|
||||
const leaveButtonTooltip = this.state.isUserPrivileged ?
|
||||
}
|
||||
|
||||
let membershipContainerExtraClasses;
|
||||
let membershipButtonExtraClasses;
|
||||
let membershipButtonTooltip;
|
||||
let membershipButtonText;
|
||||
let membershipButtonOnClick;
|
||||
|
||||
// User is not in the group
|
||||
if ((!group || group.myMembership === 'leave') &&
|
||||
this.state.summary &&
|
||||
this.state.summary.profile &&
|
||||
Boolean(this.state.summary.profile.is_joinable)
|
||||
) {
|
||||
membershipButtonText = _t("Join this community");
|
||||
membershipButtonOnClick = this._onJoinClick;
|
||||
|
||||
membershipButtonExtraClasses = 'mx_GroupView_joinButton';
|
||||
membershipContainerExtraClasses = 'mx_GroupView_membershipSection_leave';
|
||||
} else if (
|
||||
group &&
|
||||
group.myMembership === 'join' &&
|
||||
this.state.editing
|
||||
) {
|
||||
membershipButtonText = _t("Leave this community");
|
||||
membershipButtonOnClick = this._onLeaveClick;
|
||||
membershipButtonTooltip = this.state.isUserPrivileged ?
|
||||
_t("You are an administrator of this community") :
|
||||
_t("You are a member of this community");
|
||||
const leaveButtonClasses = classnames({
|
||||
"mx_RoomHeader_textButton": true,
|
||||
"mx_GroupView_textButton": true,
|
||||
"mx_GroupView_leaveButton": true,
|
||||
"mx_RoomHeader_textButton_danger": this.state.isUserPrivileged,
|
||||
});
|
||||
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_joined">
|
||||
<div className="mx_GroupView_membershipSubSection">
|
||||
{ /* Empty div for flex alignment */ }
|
||||
<div />
|
||||
<div className="mx_GroupView_membership_buttonContainer">
|
||||
<AccessibleButton
|
||||
className={leaveButtonClasses}
|
||||
onClick={this._onLeaveClick}
|
||||
title={leaveButtonTooltip}
|
||||
>
|
||||
{ _t("Leave") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
membershipButtonExtraClasses = {
|
||||
'mx_GroupView_leaveButton': true,
|
||||
'mx_RoomHeader_textButton_danger': this.state.isUserPrivileged,
|
||||
};
|
||||
membershipContainerExtraClasses = 'mx_GroupView_membershipSection_joined';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const membershipButtonClasses = classnames([
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
membershipButtonExtraClasses,
|
||||
);
|
||||
|
||||
const membershipContainerClasses = classnames(
|
||||
'mx_GroupView_membershipSection',
|
||||
membershipContainerExtraClasses,
|
||||
);
|
||||
|
||||
return <div className={membershipContainerClasses}>
|
||||
<div className="mx_GroupView_membershipSubSection">
|
||||
{ /* Empty div for flex alignment */ }
|
||||
<div />
|
||||
<div className="mx_GroupView_membership_buttonContainer">
|
||||
<AccessibleButton
|
||||
className={membershipButtonClasses}
|
||||
onClick={membershipButtonOnClick}
|
||||
title={membershipButtonTooltip}
|
||||
>
|
||||
{ membershipButtonText }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
_getLongDescriptionNode: function() {
|
||||
|
@ -962,6 +1026,7 @@ export default React.createClass({
|
|||
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
|
||||
return <Spinner />;
|
||||
|
@ -1112,9 +1177,9 @@ export default React.createClass({
|
|||
{ rightButtons }
|
||||
</div>
|
||||
</div>
|
||||
<GeminiScrollbar className="mx_GroupView_body">
|
||||
<GeminiScrollbarWrapper className="mx_GroupView_body">
|
||||
{ bodyNodes }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.error) {
|
||||
|
|
|
@ -374,7 +374,7 @@ const LoggedInView = React.createClass({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='mx_MatrixChat_wrapper'>
|
||||
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers}>
|
||||
{ topBar }
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div className={bodyClasses}>
|
||||
|
|
|
@ -171,6 +171,10 @@ export default React.createClass({
|
|||
register_hs_url: null,
|
||||
register_is_url: null,
|
||||
register_id_sid: null,
|
||||
|
||||
// When showing Modal dialogs we need to set aria-hidden on the root app element
|
||||
// and disable it when there are no dialogs
|
||||
hideToSRUsers: false,
|
||||
};
|
||||
return s;
|
||||
},
|
||||
|
@ -287,6 +291,8 @@ export default React.createClass({
|
|||
this.handleResize();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
|
||||
this._pageChanging = false;
|
||||
|
||||
// check we have the right tint applied for this theme.
|
||||
// N.B. we don't call the whole of setTheme() here as we may be
|
||||
// racing with the theme CSS download finishing from index.js
|
||||
|
@ -364,13 +370,58 @@ export default React.createClass({
|
|||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentWillUpdate: function(props, state) {
|
||||
if (this.shouldTrackPageChange(this.state, state)) {
|
||||
this.startPageChangeTimer();
|
||||
}
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps, prevState) {
|
||||
if (this.shouldTrackPageChange(prevState, this.state)) {
|
||||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
this.focusComposer = false;
|
||||
}
|
||||
},
|
||||
|
||||
startPageChangeTimer() {
|
||||
// This shouldn't happen because componentWillUpdate and componentDidUpdate
|
||||
// are used.
|
||||
if (this._pageChanging) {
|
||||
console.warn('MatrixChat.startPageChangeTimer: timer already started');
|
||||
return;
|
||||
}
|
||||
this._pageChanging = true;
|
||||
performance.mark('riot_MatrixChat_page_change_start');
|
||||
},
|
||||
|
||||
stopPageChangeTimer() {
|
||||
if (!this._pageChanging) {
|
||||
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
|
||||
return;
|
||||
}
|
||||
this._pageChanging = false;
|
||||
performance.mark('riot_MatrixChat_page_change_stop');
|
||||
performance.measure(
|
||||
'riot_MatrixChat_page_change_delta',
|
||||
'riot_MatrixChat_page_change_start',
|
||||
'riot_MatrixChat_page_change_stop',
|
||||
);
|
||||
performance.clearMarks('riot_MatrixChat_page_change_start');
|
||||
performance.clearMarks('riot_MatrixChat_page_change_stop');
|
||||
const measurement = performance.getEntriesByName('riot_MatrixChat_page_change_delta').pop();
|
||||
return measurement.duration;
|
||||
},
|
||||
|
||||
shouldTrackPageChange(prevState, state) {
|
||||
return prevState.currentRoomId !== state.currentRoomId ||
|
||||
prevState.view !== state.view ||
|
||||
prevState.page_type !== state.page_type;
|
||||
},
|
||||
|
||||
setStateForNewView: function(state) {
|
||||
if (state.view === undefined) {
|
||||
throw new Error("setStateForNewView with no view!");
|
||||
|
@ -608,6 +659,16 @@ export default React.createClass({
|
|||
case 'send_event':
|
||||
this.onSendEvent(payload.room_id, payload.event);
|
||||
break;
|
||||
case 'aria_hide_main_app':
|
||||
this.setState({
|
||||
hideToSRUsers: true,
|
||||
});
|
||||
break;
|
||||
case 'aria_unhide_main_app':
|
||||
this.setState({
|
||||
hideToSRUsers: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1171,18 +1232,6 @@ export default React.createClass({
|
|||
cli.on("crypto.warning", (type) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
switch (type) {
|
||||
case 'CRYPTO_WARNING_ACCOUNT_MIGRATED':
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Cryptography data migrated'),
|
||||
description: _t(
|
||||
"A one-off migration of cryptography data has been performed. "+
|
||||
"End-to-end encryption will not work if you go back to an older "+
|
||||
"version of Riot. If you need to use end-to-end cryptography on "+
|
||||
"an older version, log out of Riot first. To retain message history, "+
|
||||
"export and re-import your keys.",
|
||||
),
|
||||
});
|
||||
break;
|
||||
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Old cryptography data detected'),
|
||||
|
@ -1339,7 +1388,6 @@ export default React.createClass({
|
|||
if (this.props.onNewScreen) {
|
||||
this.props.onNewScreen(screen);
|
||||
}
|
||||
Analytics.trackPageChange();
|
||||
},
|
||||
|
||||
onAliasClick: function(event, alias) {
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
|
@ -63,6 +62,8 @@ export default withMatrixClient(React.createClass({
|
|||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const GroupTile = sdk.getComponent("groups.GroupTile");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
|
||||
let content;
|
||||
let contentHeader;
|
||||
|
@ -73,7 +74,7 @@ export default withMatrixClient(React.createClass({
|
|||
});
|
||||
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
|
||||
content = groupNodes.length > 0 ?
|
||||
<GeminiScrollbar>
|
||||
<GeminiScrollbarWrapper>
|
||||
<div className="mx_MyGroups_microcopy">
|
||||
<p>
|
||||
{ _t(
|
||||
|
@ -92,7 +93,7 @@ export default withMatrixClient(React.createClass({
|
|||
<div className="mx_MyGroups_joinedGroups">
|
||||
{ groupNodes }
|
||||
</div>
|
||||
</GeminiScrollbar> :
|
||||
</GeminiScrollbarWrapper> :
|
||||
<div className="mx_MyGroups_placeholder">
|
||||
{ _t(
|
||||
"You're not currently a member of any communities.",
|
||||
|
|
|
@ -17,9 +17,9 @@ limitations under the License.
|
|||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
import PropTypes from 'prop-types';
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
import Promise from 'bluebird';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import sdk from '../../index.js';
|
||||
|
||||
const DEBUG_SCROLL = false;
|
||||
// var DEBUG_SCROLL = true;
|
||||
|
@ -224,7 +224,7 @@ module.exports = React.createClass({
|
|||
onResize: function() {
|
||||
this.props.onResize();
|
||||
this.checkScroll();
|
||||
this.refs.geminiPanel.forceUpdate();
|
||||
if (this._gemScroll) this._gemScroll.forceUpdate();
|
||||
},
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
|
@ -665,14 +665,25 @@ module.exports = React.createClass({
|
|||
throw new Error("ScrollPanel._getScrollNode called when unmounted");
|
||||
}
|
||||
|
||||
return this.refs.geminiPanel.scrollbar.getViewElement();
|
||||
if (!this._gemScroll) {
|
||||
// Likewise, we should have the ref by this point, but if not
|
||||
// turn the NPE into something meaningful.
|
||||
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
|
||||
}
|
||||
|
||||
return this._gemScroll.scrollbar.getViewElement();
|
||||
},
|
||||
|
||||
_collectGeminiScroll: function(gemScroll) {
|
||||
this._gemScroll = gemScroll;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
// 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.
|
||||
return (<GeminiScrollbar autoshow={true} ref="geminiPanel"
|
||||
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
|
||||
onScroll={this.onScroll} onResize={this.onResize}
|
||||
className={this.props.className} style={this.props.style}>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
|
@ -680,7 +691,7 @@ module.exports = React.createClass({
|
|||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import TagOrderStore from '../../stores/TagOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
|
@ -101,6 +100,9 @@ const TagPanel = React.createClass({
|
|||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
|
||||
const tags = this.state.orderedTags.map((tag, index) => {
|
||||
return <DNDTagTile
|
||||
|
@ -112,12 +114,10 @@ const TagPanel = React.createClass({
|
|||
});
|
||||
|
||||
const clearButton = this.state.selectedTags.length > 0 ?
|
||||
<img
|
||||
src="img/icons-close.svg"
|
||||
<TintableSvg src="img/icons-close.svg" width="24" height="24"
|
||||
alt={_t("Clear filter")}
|
||||
title={_t("Clear filter")}
|
||||
width="24"
|
||||
height="24" /> :
|
||||
/> :
|
||||
<div />;
|
||||
|
||||
return <div className="mx_TagPanel">
|
||||
|
@ -125,7 +125,7 @@ const TagPanel = React.createClass({
|
|||
{ clearButton }
|
||||
</AccessibleButton>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
<GeminiScrollbar
|
||||
<GeminiScrollbarWrapper
|
||||
className="mx_TagPanel_scroller"
|
||||
autoshow={true}
|
||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
||||
|
@ -146,7 +146,7 @@ const TagPanel = React.createClass({
|
|||
</div>
|
||||
) }
|
||||
</Droppable>
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
<div className="mx_TagPanel_createGroupButton">
|
||||
<GroupsButton tooltip={true} />
|
||||
|
|
|
@ -624,6 +624,7 @@ var TimelinePanel = React.createClass({
|
|||
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
|
||||
dis.dispatch({
|
||||
action: 'on_room_read',
|
||||
roomId: this.props.timelineSet.room.roomId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,6 @@ import Promise from 'bluebird';
|
|||
const packageJson = require('../../../package.json');
|
||||
const UserSettingsStore = require('../../UserSettingsStore');
|
||||
const CallMediaHandler = require('../../CallMediaHandler');
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
const Email = require('../../email');
|
||||
const AddThreepid = require('../../AddThreepid');
|
||||
const SdkConfig = require('../../SdkConfig');
|
||||
|
@ -795,11 +794,18 @@ module.exports = React.createClass({
|
|||
}
|
||||
return (
|
||||
<div>
|
||||
<h3>{ _t("Bug Report") }</h3>
|
||||
<h3>{ _t("Debug Logs Submission") }</h3>
|
||||
<div className="mx_UserSettings_section">
|
||||
<p>{ _t("Found a bug?") }</p>
|
||||
<p>{
|
||||
_t( "If you've submitted a bug via GitHub, debug logs can help " +
|
||||
"us track down the problem. Debug logs contain application " +
|
||||
"usage data including your username, the IDs or aliases of " +
|
||||
"the rooms or groups you have visited and the usernames of " +
|
||||
"other users. They do not contian messages.",
|
||||
)
|
||||
}</p>
|
||||
<button className="mx_UserSettings_button danger"
|
||||
onClick={this._onBugReportClicked}>{ _t('Report it') }
|
||||
onClick={this._onBugReportClicked}>{ _t('Submit debug logs') }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1111,6 +1117,7 @@ module.exports = React.createClass({
|
|||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const Notifications = sdk.getComponent("settings.Notifications");
|
||||
const EditableText = sdk.getComponent('elements.EditableText');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
const avatarUrl = (
|
||||
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
|
||||
|
@ -1206,8 +1213,9 @@ module.exports = React.createClass({
|
|||
onCancelClick={this.props.onClose}
|
||||
/>
|
||||
|
||||
<GeminiScrollbar className="mx_UserSettings_body"
|
||||
autoshow={true}>
|
||||
<GeminiScrollbarWrapper
|
||||
className="mx_UserSettings_body"
|
||||
autoshow={true}>
|
||||
|
||||
<h3>{ _t("Profile") }</h3>
|
||||
|
||||
|
@ -1320,7 +1328,7 @@ module.exports = React.createClass({
|
|||
|
||||
{ this._renderDeactivateAccount() }
|
||||
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -82,7 +83,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onClientSync(syncState, prevState) {
|
||||
onClientSync: function(syncState, prevState) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
|
|
|
@ -48,12 +48,33 @@ module.exports = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(newProps) {
|
||||
this.setState({
|
||||
urls: this.getImageUrls(newProps),
|
||||
});
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev) {
|
||||
if (ev.getRoomId() !== this.props.room.roomId ||
|
||||
ev.getType() !== 'm.room.avatar'
|
||||
) return;
|
||||
|
||||
this.setState({
|
||||
urls: this.getImageUrls(this.props),
|
||||
});
|
||||
},
|
||||
|
||||
getImageUrls: function(props) {
|
||||
return [
|
||||
ContentRepo.getHttpUriForMxc(
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
|
@ -37,9 +38,6 @@ export default React.createClass({
|
|||
// onFinished callback to call when Escape is pressed
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
|
||||
// callback to call when Enter is pressed
|
||||
onEnterPressed: PropTypes.func,
|
||||
|
||||
// called when a key is pressed
|
||||
onKeyDown: PropTypes.func,
|
||||
|
||||
|
@ -52,6 +50,10 @@ export default React.createClass({
|
|||
|
||||
// children should be the content of the dialog
|
||||
children: PropTypes.node,
|
||||
|
||||
// Id of content element
|
||||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId: React.PropTypes.string,
|
||||
},
|
||||
|
||||
childContextTypes: {
|
||||
|
@ -76,12 +78,6 @@ export default React.createClass({
|
|||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.props.onFinished();
|
||||
} else if (e.keyCode === KeyCode.ENTER) {
|
||||
if (this.props.onEnterPressed) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.props.onEnterPressed(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -93,17 +89,28 @@ export default React.createClass({
|
|||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
return (
|
||||
<div onKeyDown={this._onKeyDown} className={this.props.className}>
|
||||
<FocusTrap onKeyDown={this._onKeyDown}
|
||||
className={this.props.className}
|
||||
role="dialog"
|
||||
aria-labelledby='mx_BaseDialog_title'
|
||||
// This should point to a node describing the dialog.
|
||||
// If we were about to completelly follow this recommendation we'd need to
|
||||
// make all the components relying on BaseDialog to be aware of it.
|
||||
// So instead we will use the whole content as the description.
|
||||
// Description comes first and if the content contains more text,
|
||||
// AT users can skip its presentation.
|
||||
aria-describedby={this.props.contentId}
|
||||
>
|
||||
<AccessibleButton onClick={this._onCancelClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
>
|
||||
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
|
||||
</AccessibleButton>
|
||||
<div className={'mx_Dialog_title ' + this.props.titleClass}>
|
||||
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
|
||||
{ this.props.title }
|
||||
</div>
|
||||
{ this.props.children }
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -59,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
);
|
||||
tiles.push(
|
||||
<RoomTile key={room.roomId} room={room}
|
||||
transparent={true}
|
||||
collapsed={false}
|
||||
selected={false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
|
@ -128,7 +129,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
</div>
|
||||
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
|
||||
</AccessibleButton>;
|
||||
content = <div className="mx_Dialog_content">
|
||||
content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ _t('You already have existing direct chats with this user:') }
|
||||
<div className="mx_ChatCreateOrReuseDialog_tiles">
|
||||
{ this.state.tiles }
|
||||
|
@ -146,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
if (this.state.busyProfile) {
|
||||
profile = <Spinner />;
|
||||
} else if (this.state.profileError) {
|
||||
profile = <div className="error">
|
||||
profile = <div className="error" role="alert">
|
||||
Unable to load profile information for { this.props.userId }
|
||||
</div>;
|
||||
} else {
|
||||
|
@ -162,14 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
content = <div>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>
|
||||
{ _t('Click on the button below to start chatting!') }
|
||||
</p>
|
||||
{ profile }
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t('Start Chatting')}
|
||||
onPrimaryButtonClick={this.props.onNewDMClick} />
|
||||
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -178,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
<BaseDialog className='mx_ChatCreateOrReuseDialog'
|
||||
onFinished={this.props.onFinished.bind(false)}
|
||||
title={title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -114,10 +114,10 @@ export default React.createClass({
|
|||
|
||||
return (
|
||||
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div id="mx_Dialog_content" className="mx_Dialog_content">
|
||||
<div className="mx_ConfirmUserActionDialog_avatar">
|
||||
{ avatar }
|
||||
</div>
|
||||
|
|
|
@ -112,7 +112,7 @@ export default React.createClass({
|
|||
// XXX: We should catch errcodes and give sensible i18ned messages for them,
|
||||
// rather than displaying what the server gives us, but synapse doesn't give
|
||||
// any yet.
|
||||
createErrorNode = <div className="error">
|
||||
createErrorNode = <div className="error" role="alert">
|
||||
<div>{ _t('Something went wrong whilst creating your community') }</div>
|
||||
<div>{ this.state.createError.message }</div>
|
||||
</div>;
|
||||
|
@ -120,7 +120,6 @@ export default React.createClass({
|
|||
|
||||
return (
|
||||
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this._onFormSubmit}
|
||||
title={_t('Create Community')}
|
||||
>
|
||||
<form onSubmit={this._onFormSubmit}>
|
||||
|
|
|
@ -45,30 +45,31 @@ export default React.createClass({
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return (
|
||||
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={_t('Create Room')}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_CreateRoomDialog_label">
|
||||
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<details className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
|
||||
<div>
|
||||
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
|
||||
<label htmlFor="checkbox">
|
||||
{ _t('Block users on other matrix homeservers from joining this room') }
|
||||
<br />
|
||||
({ _t('This setting cannot be changed later!') })
|
||||
</label>
|
||||
<form onSubmit={this.onOk}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_CreateRoomDialog_label">
|
||||
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<details className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
|
||||
<div>
|
||||
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
|
||||
<label htmlFor="checkbox">
|
||||
{ _t('Block users on other matrix homeservers from joining this room') }
|
||||
<br />
|
||||
({ _t('This setting cannot be changed later!') })
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</form>
|
||||
<DialogButtons primaryButton={_t('Create Room')}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.onCancel} />
|
||||
|
|
|
@ -52,22 +52,18 @@ export default React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
if (this.props.focus) {
|
||||
this.refs.button.focus();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={this.props.title || _t('Error')}>
|
||||
<div className="mx_Dialog_content">
|
||||
title={this.props.title || _t('Error')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ this.props.description || _t('An error has occurred.') }
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}>
|
||||
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
|
||||
{ this.props.button || _t('OK') }
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -73,11 +73,12 @@ export default React.createClass({
|
|||
let content;
|
||||
if (this.state.authError) {
|
||||
content = (
|
||||
<div>
|
||||
<div>{ this.state.authError.message || this.state.authError.toString() }</div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
|
||||
<br />
|
||||
<AccessibleButton onClick={this._onDismissClick}
|
||||
className="mx_UserSettings_button"
|
||||
autoFocus="true"
|
||||
>
|
||||
{ _t("Dismiss") }
|
||||
</AccessibleButton>
|
||||
|
@ -85,7 +86,7 @@ export default React.createClass({
|
|||
);
|
||||
} else {
|
||||
content = (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<InteractiveAuth ref={this._collectInteractiveAuth}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authData={this.props.authData}
|
||||
|
@ -100,6 +101,7 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_InteractiveAuthDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -126,11 +126,11 @@ export default React.createClass({
|
|||
text = _t(text, {displayName: displayName});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<p>{ text }</p>
|
||||
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this._onVerifyClicked}>
|
||||
<button onClick={this._onVerifyClicked} autoFocus="true">
|
||||
{ _t('Start verification') }
|
||||
</button>
|
||||
<button onClick={this._onShareClicked}>
|
||||
|
@ -154,7 +154,7 @@ export default React.createClass({
|
|||
content = this._renderContent();
|
||||
} else {
|
||||
content = (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<p>{ _t('Loading device info...') }</p>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
@ -165,6 +165,7 @@ export default React.createClass({
|
|||
<BaseDialog className='mx_KeyShareRequestDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('Encryption key request')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -60,10 +60,10 @@ export default React.createClass({
|
|||
}
|
||||
return (
|
||||
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ this.props.description }
|
||||
</div>
|
||||
<DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
|
|
|
@ -30,6 +30,12 @@ export default React.createClass({
|
|||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
if (this.refs.bugreportLink) {
|
||||
this.refs.bugreportLink.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_sendBugReport: function() {
|
||||
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
|
||||
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
|
||||
|
@ -50,16 +56,20 @@ export default React.createClass({
|
|||
{ _t(
|
||||
"Otherwise, <a>click here</a> to send a bug report.",
|
||||
{},
|
||||
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
|
||||
{ 'a': (sub) => <a ref="bugreportLink" onClick={this._sendBugReport}
|
||||
key="bugreport" href='#'>{ sub }</a> },
|
||||
) }
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const shouldFocusContinueButton =!(bugreport==true);
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={_t('Unable to restore session')}>
|
||||
<div className="mx_Dialog_content">
|
||||
title={_t('Unable to restore session')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{ _t("We encountered an error trying to restore your previous session. If " +
|
||||
"you continue, you will need to log in again, and encrypted chat " +
|
||||
"history will be unreadable.") }</p>
|
||||
|
@ -71,7 +81,7 @@ export default React.createClass({
|
|||
{ bugreport }
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Continue anyway")}
|
||||
onPrimaryButtonClick={this._continueClicked}
|
||||
onPrimaryButtonClick={this._continueClicked} focus={shouldFocusContinueButton}
|
||||
onCancel={this.props.onFinished} />
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -41,9 +41,6 @@ export default React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
},
|
||||
|
||||
onEmailAddressChanged: function(value) {
|
||||
this.setState({
|
||||
emailAddress: value,
|
||||
|
@ -131,6 +128,7 @@ export default React.createClass({
|
|||
|
||||
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
|
||||
className="mx_SetEmailDialog_email_input"
|
||||
autoFocus="true"
|
||||
placeholder={_t("Email address")}
|
||||
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
|
||||
blurToCancel={false}
|
||||
|
@ -140,9 +138,10 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_SetEmailDialog"
|
||||
onFinished={this.onCancelled}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>
|
||||
<p id='mx_Dialog_content'>
|
||||
{ _t('This will allow you to reset your password and receive notifications.') }
|
||||
</p>
|
||||
{ emailInput }
|
||||
|
|
|
@ -235,14 +235,14 @@ export default React.createClass({
|
|||
"error": Boolean(this.state.usernameError),
|
||||
"success": usernameAvailable,
|
||||
});
|
||||
usernameIndicator = <div className={usernameIndicatorClasses}>
|
||||
usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
|
||||
{ usernameAvailable ? _t('Username available') : this.state.usernameError }
|
||||
</div>;
|
||||
}
|
||||
|
||||
let authErrorIndicator = null;
|
||||
if (this.state.authError) {
|
||||
authErrorIndicator = <div className="error">
|
||||
authErrorIndicator = <div className="error" role="alert">
|
||||
{ this.state.authError }
|
||||
</div>;
|
||||
}
|
||||
|
@ -254,8 +254,9 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_SetMxIdDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('To get started, please pick a username!')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<div className="mx_SetMxIdDialog_input_group">
|
||||
<input type="text" ref="input_value" value={this.state.username}
|
||||
autoFocus={true}
|
||||
|
|
|
@ -61,17 +61,18 @@ export default React.createClass({
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return (
|
||||
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_TextInputDialog_label">
|
||||
<label htmlFor="textinput"> { this.props.description } </label>
|
||||
<form onSubmit={this.onOk}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_TextInputDialog_label">
|
||||
<label htmlFor="textinput"> { this.props.description } </label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DialogButtons primaryButton={this.props.button}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.onCancel} />
|
||||
|
|
|
@ -146,6 +146,7 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
if (this.props.devices === null) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
|
@ -189,8 +190,9 @@ export default React.createClass({
|
|||
<BaseDialog className='mx_UnknownDeviceDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('Room contains unknown devices')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<GeminiScrollbar autoshow={false} className="mx_Dialog_content">
|
||||
<GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<h4>
|
||||
{ _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }
|
||||
</h4>
|
||||
|
@ -198,7 +200,7 @@ export default React.createClass({
|
|||
{ _t("Unknown devices") }:
|
||||
|
||||
<UnknownDeviceList devices={this.props.devices} />
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
<DialogButtons primaryButton={sendButtonLabel}
|
||||
onPrimaryButtonClick={sendButtonOnClick}
|
||||
onCancel={this._onDismissClicked} />
|
||||
|
|
|
@ -17,6 +17,8 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { KeyCode } from '../../../Keyboard';
|
||||
|
||||
/**
|
||||
* AccessibleButton is a generic wrapper for any element that should be treated
|
||||
* as a button. Identifies the element as a button, setting proper tab
|
||||
|
@ -28,8 +30,34 @@ import PropTypes from 'prop-types';
|
|||
export default function AccessibleButton(props) {
|
||||
const {element, onClick, children, ...restProps} = props;
|
||||
restProps.onClick = onClick;
|
||||
// We need to consume enter onKeyDown and space onKeyUp
|
||||
// otherwise we are risking also activating other keyboard focusable elements
|
||||
// that might receive focus as a result of the AccessibleButtonClick action
|
||||
// It's because we are using html buttons at a few places e.g. inside dialogs
|
||||
// And divs which we report as role button to assistive technologies.
|
||||
// Browsers handle space and enter keypresses differently and we are only adjusting to the
|
||||
// inconsistencies here
|
||||
restProps.onKeyDown = function(e) {
|
||||
if (e.keyCode === KeyCode.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return onClick(e);
|
||||
}
|
||||
if (e.keyCode === KeyCode.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
restProps.onKeyUp = function(e) {
|
||||
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
|
||||
if (e.keyCode === KeyCode.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return onClick(e);
|
||||
}
|
||||
if (e.keyCode === KeyCode.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
restProps.tabIndex = restProps.tabIndex || "0";
|
||||
restProps.role = "button";
|
||||
|
|
33
src/components/views/elements/GeminiScrollbarWrapper.js
Normal file
33
src/components/views/elements/GeminiScrollbarWrapper.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
|
||||
function GeminiScrollbarWrapper(props) {
|
||||
// Enable forceGemini so that gemini is always enabled. This is
|
||||
// to avoid future issues where a feature is implemented without
|
||||
// doing QA on every OS/browser combination.
|
||||
//
|
||||
// By default GeminiScrollbar allows native scrollbars to be used
|
||||
// on macOS. Use forceGemini to enable Gemini's non-native
|
||||
// scrollbars on all OSs.
|
||||
return <GeminiScrollbar ref={props.wrappedRef} forceGemini={true} {...props}>
|
||||
{ props.children }
|
||||
</GeminiScrollbar>;
|
||||
}
|
||||
export default GeminiScrollbarWrapper;
|
||||
|
|
@ -25,7 +25,6 @@ import { _t } from '../../../languageHandler';
|
|||
import { GroupMemberType } from '../../../groups';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'GroupMemberInfo',
|
||||
|
@ -180,9 +179,10 @@ module.exports = React.createClass({
|
|||
);
|
||||
|
||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent('elements.GeminiScrollbarWrapper');
|
||||
return (
|
||||
<div className="mx_MemberInfo">
|
||||
<GeminiScrollbar autoshow={true}>
|
||||
<GeminiScrollbarWrapper autoshow={true}>
|
||||
<AccessibleButton className="mx_MemberInfo_cancel"onClick={this._onCancel}>
|
||||
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
|
||||
</AccessibleButton>
|
||||
|
@ -199,7 +199,7 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
|
||||
{ adminTools }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -18,7 +18,6 @@ import React from 'react';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const INITIAL_LOAD_NUM_MEMBERS = 30;
|
||||
|
@ -134,6 +133,7 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
if (this.state.fetching || this.state.fetchingInvitedMembers) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return (<div className="mx_MemberList">
|
||||
|
@ -162,10 +162,10 @@ export default React.createClass({
|
|||
return (
|
||||
<div className="mx_MemberList">
|
||||
{ inputBox }
|
||||
<GeminiScrollbar autoshow={true} className="mx_MemberList_outerWrapper">
|
||||
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_outerWrapper">
|
||||
{ joined }
|
||||
{ invited }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -22,7 +22,6 @@ import Modal from '../../../Modal';
|
|||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'GroupRoomInfo',
|
||||
|
@ -157,6 +156,7 @@ module.exports = React.createClass({
|
|||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
if (this.state.groupRoomRemoveLoading || !this.state.groupRoom) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <div className="mx_MemberInfo">
|
||||
|
@ -216,7 +216,7 @@ module.exports = React.createClass({
|
|||
const avatar = <BaseAvatar name={groupRoomName} width={36} height={36} url={avatarUrl} />;
|
||||
return (
|
||||
<div className="mx_MemberInfo">
|
||||
<GeminiScrollbar autoshow={true}>
|
||||
<GeminiScrollbarWrapper autoshow={true}>
|
||||
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this._onCancel}>
|
||||
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
|
||||
</AccessibleButton>
|
||||
|
@ -233,7 +233,7 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
|
||||
{ adminTools }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -17,7 +17,6 @@ import React from 'react';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const INITIAL_LOAD_NUM_ROOMS = 30;
|
||||
|
@ -120,16 +119,17 @@ export default React.createClass({
|
|||
</form>
|
||||
);
|
||||
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
const TruncatedList = sdk.getComponent("elements.TruncatedList");
|
||||
return (
|
||||
<div className="mx_GroupRoomList">
|
||||
{ inputBox }
|
||||
<GeminiScrollbar autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
|
||||
<GeminiScrollbarWrapper autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
|
||||
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile}>
|
||||
{ this.makeGroupRoomTiles(this.state.searchQuery) }
|
||||
</TruncatedList>
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -128,12 +128,22 @@ export const PasswordAuthEntry = React.createClass({
|
|||
);
|
||||
}
|
||||
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("To continue, please enter your password.") }</p>
|
||||
<p>{ _t("Password:") }</p>
|
||||
<form onSubmit={this._onSubmit}>
|
||||
<label htmlFor="passwordField">{ _t("Password:") }</label>
|
||||
<input
|
||||
name="passwordField"
|
||||
ref="passwordField"
|
||||
className={passwordBoxClass}
|
||||
onChange={this._onPasswordFieldChange}
|
||||
|
@ -143,9 +153,7 @@ export const PasswordAuthEntry = React.createClass({
|
|||
{ submitButtonOrSpinner }
|
||||
</div>
|
||||
</form>
|
||||
<div className="error">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -180,14 +188,22 @@ export const RecaptchaAuthEntry = React.createClass({
|
|||
|
||||
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
|
||||
const sitePublicKey = this.props.stageParams.public_key;
|
||||
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CaptchaForm sitePublicKey={sitePublicKey}
|
||||
onCaptchaResponse={this._onCaptchaResponse}
|
||||
/>
|
||||
<div className="error">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -372,6 +388,14 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
|
||||
mx_UserSettings_button: true, // XXX button classes
|
||||
});
|
||||
let errorSection;
|
||||
if (this.state.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("A text message has been sent to %(msisdn)s",
|
||||
|
@ -385,6 +409,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
|
||||
value={this.state.token}
|
||||
onChange={this._onTokenChange}
|
||||
aria-label={ _t("Code")}
|
||||
/>
|
||||
<br />
|
||||
<input type="submit" value={_t("Submit")}
|
||||
|
@ -392,9 +417,7 @@ export const MsisdnAuthEntry = React.createClass({
|
|||
disabled={!enableSubmit}
|
||||
/>
|
||||
</form>
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
{errorSection}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -427,6 +450,12 @@ export const FallbackAuthEntry = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
focus: function() {
|
||||
if (this.refs.fallbackButton) {
|
||||
this.refs.fallbackButton.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_onShowFallbackClick: function() {
|
||||
const url = this.props.matrixClient.getFallbackAuthUrl(
|
||||
this.props.loginType,
|
||||
|
@ -445,12 +474,18 @@ export const FallbackAuthEntry = React.createClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<div>
|
||||
<a onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
|
||||
<div className="error">
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<a ref="fallbackButton" onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
|
||||
{errorSection}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -337,7 +337,7 @@ module.exports = React.createClass({
|
|||
|
||||
_addCodeCopyButton() {
|
||||
// Add 'copy' buttons to pre blocks
|
||||
ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre').forEach((p) => {
|
||||
Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
|
||||
const button = document.createElement("span");
|
||||
button.className = "mx_EventTile_copyButton";
|
||||
button.onclick = (e) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -32,7 +33,11 @@ const PRESENCE_CLASS = {
|
|||
};
|
||||
|
||||
|
||||
function presenceClassForMember(presenceState, lastActiveAgo) {
|
||||
function presenceClassForMember(presenceState, lastActiveAgo, showPresence) {
|
||||
if (showPresence === false) {
|
||||
return 'mx_EntityTile_online_beenactive';
|
||||
}
|
||||
|
||||
// offline is split into two categories depending on whether we have
|
||||
// a last_active_ago for them.
|
||||
if (presenceState == 'offline') {
|
||||
|
@ -64,6 +69,7 @@ const EntityTile = React.createClass({
|
|||
shouldComponentUpdate: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
suppressOnHover: PropTypes.bool,
|
||||
showPresence: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
@ -75,6 +81,7 @@ const EntityTile = React.createClass({
|
|||
presenceLastTs: 0,
|
||||
showInviteButton: false,
|
||||
suppressOnHover: false,
|
||||
showPresence: true,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -99,7 +106,7 @@ const EntityTile = React.createClass({
|
|||
|
||||
render: function() {
|
||||
const presenceClass = presenceClassForMember(
|
||||
this.props.presenceState, this.props.presenceLastActiveAgo,
|
||||
this.props.presenceState, this.props.presenceLastActiveAgo, this.props.showPresence,
|
||||
);
|
||||
|
||||
let mainClassName = "mx_EntityTile ";
|
||||
|
@ -114,15 +121,21 @@ const EntityTile = React.createClass({
|
|||
|
||||
mainClassName += " mx_EntityTile_hover";
|
||||
const PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
|
||||
let presenceLabel = null;
|
||||
let nameClasses = 'mx_EntityTile_name';
|
||||
if (this.props.showPresence) {
|
||||
presenceLabel = <PresenceLabel activeAgo={activeAgo}
|
||||
currentlyActive={this.props.presenceCurrentlyActive}
|
||||
presenceState={this.props.presenceState} />;
|
||||
nameClasses += ' mx_EntityTile_name_hover';
|
||||
}
|
||||
nameEl = (
|
||||
<div className="mx_EntityTile_details">
|
||||
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12" />
|
||||
<EmojiText element="div" className="mx_EntityTile_name mx_EntityTile_name_hover" dir="auto">
|
||||
<EmojiText element="div" className={nameClasses} dir="auto">
|
||||
{ name }
|
||||
</EmojiText>
|
||||
<PresenceLabel activeAgo={activeAgo}
|
||||
currentlyActive={this.props.presenceCurrentlyActive}
|
||||
presenceState={this.props.presenceState} />
|
||||
{presenceLabel}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -153,7 +153,17 @@ module.exports = withMatrixClient(React.createClass({
|
|||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {menu: false, allReadAvatars: false, verified: null};
|
||||
return {
|
||||
// Whether the context menu is being displayed.
|
||||
menu: false,
|
||||
// Whether all read receipts are being displayed. If not, only display
|
||||
// a truncation of them.
|
||||
allReadAvatars: false,
|
||||
// Whether the event's sender has been verified.
|
||||
verified: null,
|
||||
// Whether onRequestKeysClick has been called since mounting.
|
||||
previouslyRequestedKeys: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
|
@ -394,6 +404,19 @@ module.exports = withMatrixClient(React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
onRequestKeysClick: function() {
|
||||
this.setState({
|
||||
// Indicate in the UI that the keys have been requested (this is expected to
|
||||
// be reset if the component is mounted in the future).
|
||||
previouslyRequestedKeys: true,
|
||||
});
|
||||
|
||||
// Cancel any outgoing key request for this event and resend it. If a response
|
||||
// is received for the request with the required keys, the event could be
|
||||
// decrypted successfully.
|
||||
this.props.matrixClient.cancelAndResendEventRoomKeyRequest(this.props.mxEvent);
|
||||
},
|
||||
|
||||
onPermalinkClicked: function(e) {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||
|
@ -460,6 +483,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
|
||||
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
|
||||
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
|
||||
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
|
||||
|
||||
const classes = classNames({
|
||||
mx_EventTile: true,
|
||||
|
@ -476,7 +500,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
menu: this.state.menu,
|
||||
mx_EventTile_verified: this.state.verified == true,
|
||||
mx_EventTile_unverified: this.state.verified == false,
|
||||
mx_EventTile_bad: msgtype === 'm.bad.encrypted',
|
||||
mx_EventTile_bad: isEncryptionFailure,
|
||||
mx_EventTile_emote: msgtype === 'm.emote',
|
||||
mx_EventTile_redacted: isRedacted,
|
||||
});
|
||||
|
@ -536,6 +560,40 @@ module.exports = withMatrixClient(React.createClass({
|
|||
const timestamp = this.props.mxEvent.getTs() ?
|
||||
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
|
||||
|
||||
const keyRequestHelpText =
|
||||
<div className="mx_EventTile_keyRequestInfo_tooltip_contents">
|
||||
<p>
|
||||
{ this.state.previouslyRequestedKeys ?
|
||||
_t( 'Your key share request has been sent - please check your other devices ' +
|
||||
'for key share requests.') :
|
||||
_t( 'Key share requests are sent to your other devices automatically. If you ' +
|
||||
'rejected or dismissed the key share request on your other devices, click ' +
|
||||
'here to request the keys for this session again.')
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
{ _t( 'If your other devices do not have the key for this message you will not ' +
|
||||
'be able to decrypt them.')
|
||||
}
|
||||
</p>
|
||||
</div>;
|
||||
const keyRequestInfoContent = this.state.previouslyRequestedKeys ?
|
||||
_t('Key request sent.') :
|
||||
_t(
|
||||
'<requestLink>Re-request encryption keys</requestLink> from your other devices.',
|
||||
{},
|
||||
{'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a>},
|
||||
);
|
||||
|
||||
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
|
||||
const keyRequestInfo = isEncryptionFailure ?
|
||||
<div className="mx_EventTile_keyRequestInfo">
|
||||
<span className="mx_EventTile_keyRequestInfo_text">
|
||||
{ keyRequestInfoContent }
|
||||
</span>
|
||||
<ToolTipButton helpText={keyRequestHelpText} />
|
||||
</div> : null;
|
||||
|
||||
switch (this.props.tileShape) {
|
||||
case 'notif': {
|
||||
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
@ -629,6 +687,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
onWidgetLoad={this.props.onWidgetLoad} />
|
||||
{ keyRequestInfo }
|
||||
{ editButton }
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -39,8 +39,8 @@ import Unread from '../../../Unread';
|
|||
import { findReadReceiptFromUserId } from '../../../utils/Receipt';
|
||||
import withMatrixClient from '../../../wrappers/withMatrixClient';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
module.exports = withMatrixClient(React.createClass({
|
||||
displayName: 'MemberInfo',
|
||||
|
@ -754,6 +754,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
|
||||
tiles.push(
|
||||
<RoomTile key={room.roomId} room={room}
|
||||
transparent={true}
|
||||
collapsed={false}
|
||||
selected={false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
|
@ -860,10 +861,24 @@ module.exports = withMatrixClient(React.createClass({
|
|||
const powerLevelEvent = room ? room.currentState.getStateEvents("m.room.power_levels", "") : null;
|
||||
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
|
||||
|
||||
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
|
||||
const hsUrl = this.props.matrixClient.baseUrl;
|
||||
let showPresence = true;
|
||||
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
|
||||
showPresence = enablePresenceByHsUrl[hsUrl];
|
||||
}
|
||||
|
||||
let presenceLabel = null;
|
||||
if (showPresence) {
|
||||
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
|
||||
presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo}
|
||||
currentlyActive={presenceCurrentlyActive}
|
||||
presenceState={presenceState} />;
|
||||
}
|
||||
|
||||
let roomMemberDetails = null;
|
||||
if (this.props.member.roomId) { // is in room
|
||||
const PowerSelector = sdk.getComponent('elements.PowerSelector');
|
||||
const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
|
||||
roomMemberDetails = <div>
|
||||
<div className="mx_MemberInfo_profileField">
|
||||
{ _t("Level:") } <b>
|
||||
|
@ -876,18 +891,17 @@ module.exports = withMatrixClient(React.createClass({
|
|||
</b>
|
||||
</div>
|
||||
<div className="mx_MemberInfo_profileField">
|
||||
<PresenceLabel activeAgo={presenceLastActiveAgo}
|
||||
currentlyActive={presenceCurrentlyActive}
|
||||
presenceState={presenceState} />
|
||||
{presenceLabel}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
|
||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||
return (
|
||||
<div className="mx_MemberInfo">
|
||||
<GeminiScrollbar autoshow={true}>
|
||||
<GeminiScrollbarWrapper autoshow={true}>
|
||||
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}> <img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
|
||||
<div className="mx_MemberInfo_avatar">
|
||||
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
|
||||
|
@ -911,7 +925,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
{ this._renderDevices() }
|
||||
|
||||
{ spinner }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,9 +18,9 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
const MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||
const sdk = require('../../../index');
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
const rate_limited_func = require('../../../ratelimitedfunc');
|
||||
const CallHandler = require("../../../CallHandler");
|
||||
|
||||
|
@ -59,6 +59,14 @@ module.exports = React.createClass({
|
|||
// the information contained in presence events).
|
||||
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
|
||||
// cli.on("Room.timeline", this.onRoomTimeline);
|
||||
|
||||
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
|
||||
const hsUrl = MatrixClientPeg.get().baseUrl;
|
||||
|
||||
this._showPresence = true;
|
||||
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
|
||||
this._showPresence = enablePresenceByHsUrl[hsUrl];
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -345,7 +353,7 @@ module.exports = React.createClass({
|
|||
const memberList = members.map((userId) => {
|
||||
const m = this.memberDict[userId];
|
||||
return (
|
||||
<MemberTile key={userId} member={m} ref={userId} />
|
||||
<MemberTile key={userId} member={m} ref={userId} showPresence={this._showPresence} />
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -358,7 +366,10 @@ module.exports = React.createClass({
|
|||
if (membership === "invite") {
|
||||
const EntityTile = sdk.getComponent("rooms.EntityTile");
|
||||
memberList.push(...this._getPending3PidInvites().map((e) => {
|
||||
return <EntityTile key={e.getStateKey()} name={e.getContent().display_name} suppressOnHover={true} />;
|
||||
return <EntityTile key={e.getStateKey()}
|
||||
name={e.getContent().display_name}
|
||||
suppressOnHover={true}
|
||||
/>;
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -383,6 +394,7 @@ module.exports = React.createClass({
|
|||
|
||||
render: function() {
|
||||
const TruncatedList = sdk.getComponent("elements.TruncatedList");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
let invitedSection = null;
|
||||
if (this._getChildCountInvited() > 0) {
|
||||
|
@ -411,14 +423,14 @@ module.exports = React.createClass({
|
|||
return (
|
||||
<div className="mx_MemberList">
|
||||
{ inputBox }
|
||||
<GeminiScrollbar autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper">
|
||||
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper">
|
||||
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtJoined}
|
||||
createOverflowElement={this._createOverflowTileJoined}
|
||||
getChildren={this._getChildrenJoined}
|
||||
getChildCount={this._getChildCountJoined}
|
||||
/>
|
||||
{ invitedSection }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -30,6 +30,13 @@ module.exports = React.createClass({
|
|||
|
||||
propTypes: {
|
||||
member: PropTypes.any.isRequired, // RoomMember
|
||||
showPresence: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
showPresence: true,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -99,7 +106,7 @@ module.exports = React.createClass({
|
|||
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
|
||||
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
|
||||
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
|
||||
name={name} powerStatus={powerStatus} />
|
||||
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} />
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -185,10 +185,21 @@ module.exports = React.createClass({
|
|||
|
||||
let title;
|
||||
if (this.props.timestamp) {
|
||||
title = _t(
|
||||
"Seen by %(userName)s at %(dateTime)s",
|
||||
{userName: this.props.member.userId, dateTime: formatDate(new Date(this.props.timestamp), this.props.showTwelveHour)},
|
||||
);
|
||||
const dateString = formatDate(new Date(this.props.timestamp), this.props.showTwelveHour);
|
||||
if (this.props.member.userId === this.props.member.rawDisplayName) {
|
||||
title = _t(
|
||||
"Seen by %(userName)s at %(dateTime)s",
|
||||
{userName: this.props.member.userId,
|
||||
dateTime: dateString},
|
||||
);
|
||||
} else {
|
||||
title = _t(
|
||||
"Seen by %(displayName)s (%(userName)s) at %(dateTime)s",
|
||||
{displayName: this.props.member.rawDisplayName,
|
||||
userName: this.props.member.userId,
|
||||
dateTime: dateString},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -20,7 +20,6 @@ const React = require("react");
|
|||
const ReactDOM = require("react-dom");
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
const MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||
const CallHandler = require('../../../CallHandler');
|
||||
const dis = require("../../../dispatcher");
|
||||
|
@ -77,9 +76,7 @@ module.exports = React.createClass({
|
|||
|
||||
cli.on("Room", this.onRoom);
|
||||
cli.on("deleteRoom", this.onDeleteRoom);
|
||||
cli.on("Room.name", this.onRoomName);
|
||||
cli.on("Room.receipt", this.onRoomReceipt);
|
||||
cli.on("RoomState.events", this.onRoomStateEvents);
|
||||
cli.on("RoomMember.name", this.onRoomMemberName);
|
||||
cli.on("Event.decrypted", this.onEventDecrypted);
|
||||
cli.on("accountData", this.onAccountData);
|
||||
|
@ -161,12 +158,6 @@ module.exports = React.createClass({
|
|||
});
|
||||
}
|
||||
break;
|
||||
case 'on_room_read':
|
||||
// Force an update because the notif count state is too deep to cause
|
||||
// an update. This forces the local echo of reading notifs to be
|
||||
// reflected by the RoomTiles.
|
||||
this.forceUpdate();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -177,9 +168,7 @@ module.exports = React.createClass({
|
|||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
|
||||
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
|
||||
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
|
||||
|
@ -243,14 +232,6 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onRoomName: function(room) {
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev, state) {
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
||||
onRoomMemberName: function(ev, member) {
|
||||
this._delayedRefreshRoomList();
|
||||
},
|
||||
|
@ -369,7 +350,7 @@ module.exports = React.createClass({
|
|||
|
||||
return Boolean(isRoomVisible[taggedRoom.roomId]);
|
||||
});
|
||||
|
||||
|
||||
if (filteredRooms.length > 0 || tagName.match(STANDARD_TAGS_REGEX)) {
|
||||
filteredLists[tagName] = filteredRooms;
|
||||
}
|
||||
|
@ -526,7 +507,8 @@ module.exports = React.createClass({
|
|||
onShowMoreRooms: function() {
|
||||
// kick gemini in the balls to get it to wake up
|
||||
// XXX: uuuuuuugh.
|
||||
this.refs.gemscroll.forceUpdate();
|
||||
if (!this._gemScroll) return;
|
||||
this._gemScroll.forceUpdate();
|
||||
},
|
||||
|
||||
_getEmptyContent: function(section) {
|
||||
|
@ -617,13 +599,18 @@ module.exports = React.createClass({
|
|||
return ret;
|
||||
},
|
||||
|
||||
_collectGemini(gemScroll) {
|
||||
this._gemScroll = gemScroll;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const RoomSubList = sdk.getComponent('structures.RoomSubList');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
const self = this;
|
||||
return (
|
||||
<GeminiScrollbar className="mx_RoomList_scrollbar"
|
||||
autoshow={true} onScroll={self._whenScrolling} ref="gemscroll">
|
||||
<GeminiScrollbarWrapper className="mx_RoomList_scrollbar"
|
||||
autoshow={true} onScroll={self._whenScrolling} wrappedRef={this._collectGemini}>
|
||||
<div className="mx_RoomList">
|
||||
<RoomSubList list={[]}
|
||||
extraTiles={this._makeGroupInviteTiles()}
|
||||
|
@ -729,7 +716,7 @@ module.exports = React.createClass({
|
|||
searchFilter={self.props.searchFilter}
|
||||
onShowMoreRooms={self.onShowMoreRooms} />
|
||||
</div>
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -704,8 +704,10 @@ module.exports = React.createClass({
|
|||
{ Object.keys(userLevels).map(function(user, i) {
|
||||
return (
|
||||
<li className="mx_RoomSettings_userLevel" key={user}>
|
||||
{ _t("%(user)s is a", {user: user}) }
|
||||
<PowerSelector value={userLevels[user]} disabled={true} />
|
||||
{ _t("%(user)s is a %(userRole)s", {
|
||||
user: user,
|
||||
userRole: <PowerSelector value={userLevels[user]} disabled={true} />,
|
||||
}) }
|
||||
</li>
|
||||
);
|
||||
}) }
|
||||
|
|
|
@ -21,6 +21,7 @@ const React = require('react');
|
|||
const ReactDOM = require("react-dom");
|
||||
import PropTypes from 'prop-types';
|
||||
const classNames = require('classnames');
|
||||
import dis from '../../../dispatcher';
|
||||
const MatrixClientPeg = require('../../../MatrixClientPeg');
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
const sdk = require('../../../index');
|
||||
|
@ -41,6 +42,8 @@ module.exports = React.createClass({
|
|||
collapsed: PropTypes.bool.isRequired,
|
||||
unread: PropTypes.bool.isRequired,
|
||||
highlight: PropTypes.bool.isRequired,
|
||||
// If true, apply mx_RoomTile_transparent class
|
||||
transparent: PropTypes.bool,
|
||||
isInvite: PropTypes.bool.isRequired,
|
||||
incomingCall: PropTypes.object,
|
||||
},
|
||||
|
@ -56,7 +59,9 @@ module.exports = React.createClass({
|
|||
hover: false,
|
||||
badgeHover: false,
|
||||
menuDisplayed: false,
|
||||
roomName: this.props.room.name,
|
||||
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
|
||||
});
|
||||
},
|
||||
|
@ -79,6 +84,20 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onRoomTimeline: function(ev, room) {
|
||||
if (room !== this.props.room) return;
|
||||
this.setState({
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
});
|
||||
},
|
||||
|
||||
onRoomName: function(room) {
|
||||
if (room !== this.props.room) return;
|
||||
this.setState({
|
||||
roomName: this.props.room.name,
|
||||
});
|
||||
},
|
||||
|
||||
onAccountData: function(accountDataEvent) {
|
||||
if (accountDataEvent.getType() == 'm.push_rules') {
|
||||
this.setState({
|
||||
|
@ -87,6 +106,21 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
switch (payload.action) {
|
||||
// XXX: slight hack in order to zero the notification count when a room
|
||||
// is read. Ideally this state would be given to this via props (as we
|
||||
// do with `unread`). This is still better than forceUpdating the entire
|
||||
// RoomList when a room is read.
|
||||
case 'on_room_read':
|
||||
if (payload.roomId !== this.props.room.roomId) break;
|
||||
this.setState({
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_onActiveRoomChange: function() {
|
||||
this.setState({
|
||||
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
|
||||
|
@ -95,15 +129,46 @@ module.exports = React.createClass({
|
|||
|
||||
componentWillMount: function() {
|
||||
MatrixClientPeg.get().on("accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||
MatrixClientPeg.get().on("Room.name", this.onRoomName);
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
|
||||
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
|
||||
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
|
||||
}
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(props) {
|
||||
// XXX: This could be a lot better - this makes the assumption that
|
||||
// the notification count may have changed when the properties of
|
||||
// the room tile change.
|
||||
this.setState({
|
||||
notificationCount: this.props.room.getUnreadNotificationCount(),
|
||||
});
|
||||
},
|
||||
|
||||
// Do a simple shallow comparison of props and state to avoid unnecessary
|
||||
// renders. The assumption made here is that only state and props are used
|
||||
// in rendering this component and children.
|
||||
//
|
||||
// RoomList is frequently made to forceUpdate, so this decreases number of
|
||||
// RoomTile renderings.
|
||||
shouldComponentUpdate: function(newProps, newState) {
|
||||
if (Object.keys(newProps).some((k) => newProps[k] !== this.props[k])) {
|
||||
return true;
|
||||
}
|
||||
if (Object.keys(newState).some((k) => newState[k] !== this.state[k])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
onClick: function(ev) {
|
||||
|
@ -172,7 +237,7 @@ module.exports = React.createClass({
|
|||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const me = this.props.room.currentState.members[myUserId];
|
||||
|
||||
const notificationCount = this.props.room.getUnreadNotificationCount();
|
||||
const notificationCount = this.state.notificationCount;
|
||||
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
|
||||
|
||||
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge();
|
||||
|
@ -188,6 +253,7 @@ module.exports = React.createClass({
|
|||
'mx_RoomTile_invited': (me && me.membership == 'invite'),
|
||||
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
|
||||
'mx_RoomTile_noBadges': !badges,
|
||||
'mx_RoomTile_transparent': this.props.transparent,
|
||||
});
|
||||
|
||||
const avatarClasses = classNames({
|
||||
|
@ -199,9 +265,7 @@ module.exports = React.createClass({
|
|||
'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menuDisplayed,
|
||||
});
|
||||
|
||||
// XXX: We should never display raw room IDs, but sometimes the
|
||||
// room name js sdk gives is undefined (cannot repro this -- k)
|
||||
let name = this.props.room.name || this.props.room.roomId;
|
||||
let name = this.state.roomName;
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
let badge;
|
||||
|
|
|
@ -19,7 +19,6 @@ const MatrixClientPeg = require("../../../MatrixClientPeg");
|
|||
const Modal = require("../../../Modal");
|
||||
const sdk = require("../../../index");
|
||||
import { _t } from '../../../languageHandler';
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
|
||||
// A list capable of displaying entities which conform to the SearchableEntity
|
||||
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
|
||||
|
@ -164,11 +163,12 @@ const SearchableEntityList = React.createClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
list = (
|
||||
<GeminiScrollbar autoshow={true}
|
||||
<GeminiScrollbarWrapper autoshow={true}
|
||||
className="mx_SearchableEntityList_listWrapper">
|
||||
{ list }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue