Merge branch 'develop' into matthew/low_bandwidth

This commit is contained in:
Travis Ralston 2019-05-30 19:42:09 -06:00
commit d81804e0fe
589 changed files with 37701 additions and 15344 deletions

View file

@ -144,12 +144,16 @@ module.exports = React.createClass({
_launchManageIntegrations: function() {
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, 'mx_IntegrationsManager');
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, 'mx_IntegrationsManager');
}, (err) => {
console.error('Error ensuring a valid scalar_token exists', err);
});
},
onClickAddWidget: function(e) {

View file

@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component {
};
}
componentWillReceiveProps(newProps, state) {
if (this.props.room.roomId !== newProps.room.roomId) {
componentDidMount() {
this._applyNewProps();
}
_applyNewProps(oldQuery, oldRoom) {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(newProps.room);
this.autocompleter = new Autocompleter(this.props.room);
}
// Query hasn't changed so don't try to complete it
if (newProps.query === this.props.query) {
if (oldQuery === this.props.query) {
return;
}
this.complete(newProps.query, newProps.selection);
this.complete(this.props.query, this.props.selection);
}
componentWillUnmount() {
@ -233,7 +237,8 @@ export default class Autocomplete extends React.Component {
}
}
componentDidUpdate() {
componentDidUpdate(prevProps) {
this._applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
@ -251,8 +256,6 @@ export default class Autocomplete extends React.Component {
}
render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 1;
const renderedCompletions = this.state.completions.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, i) => {
@ -277,7 +280,7 @@ export default class Autocomplete extends React.Component {
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<EmojiText element="div" className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</EmojiText>
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
{ completionResult.provider.renderCompletions(completions) }
</div>
) : null;
@ -298,6 +301,9 @@ Autocomplete.propTypes = {
// method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired,
// method invoked when selected (if any) completion changes
onSelectionChange: PropTypes.func,
// The room in which we're autocompleting
room: PropTypes.instanceOf(Room),
};

View file

@ -122,7 +122,9 @@ module.exports = React.createClass({
const severity = ev.getContent().severity || "normal";
const stateKey = ev.getStateKey();
if (title && value && severity) {
// We want a non-empty title but can accept falsey values (e.g.
// zero)
if (title && value !== undefined) {
counters.push({
"title": title,
"value": value,
@ -211,35 +213,33 @@ module.exports = React.createClass({
const severity = counter.severity;
const stateKey = counter.stateKey;
if (title && value && severity) {
let span = <span>{ title }: { value }</span>
if (link) {
span = (
<a href={link} target="_blank" rel="noopener">
{ span }
</a>
);
}
let span = <span>{ title }: { value }</span>
if (link) {
span = (
<span
className="m_RoomView_auxPanel_stateViews_span"
data-severity={severity}
key={ "x-" + stateKey }
>
{span}
</span>
);
counters.push(span);
counters.push(
<span
className="m_RoomView_auxPanel_stateViews_delim"
key={"delim" + idx}
> </span>
<a href={link} target="_blank" rel="noopener">
{ span }
</a>
);
}
span = (
<span
className="m_RoomView_auxPanel_stateViews_span"
data-severity={severity}
key={ "x-" + stateKey }
>
{span}
</span>
);
counters.push(span);
counters.push(
<span
className="m_RoomView_auxPanel_stateViews_delim"
key={"delim" + idx}
> </span>
);
});
if (counters.length > 0) {

View file

@ -16,6 +16,7 @@ limitations under the License.
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
export default function(props) {
const isWarning = props.status === "warning";
@ -35,5 +36,10 @@ export default function(props) {
_t("All devices for this user are trusted") :
_t("All devices in this encrypted room are trusted");
}
return (<div className={e2eIconClasses} title={e2eTitle} />);
const icon = (<div className={e2eIconClasses} title={e2eTitle} />);
if (props.onClick) {
return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
} else {
return icon;
}
}

View file

@ -19,11 +19,10 @@ limitations under the License.
const React = require('react');
import PropTypes from 'prop-types';
const MatrixClientPeg = require('../../../MatrixClientPeg');
const sdk = require('../../../index');
import AccessibleButton from '../elements/AccessibleButton';
import { _t } from '../../../languageHandler';
import classNames from "classnames";
const PRESENCE_CLASS = {
@ -97,62 +96,55 @@ const EntityTile = React.createClass({
return this.props.shouldComponentUpdate(nextProps, nextState);
},
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
render: function() {
const mainClassNames = {
"mx_EntityTile": true,
"mx_EntityTile_noHover": this.props.suppressOnHover,
};
if (this.props.className) mainClassNames[this.props.className] = true;
const presenceClass = presenceClassForMember(
this.props.presenceState, this.props.presenceLastActiveAgo, this.props.showPresence,
);
mainClassNames[presenceClass] = true;
let mainClassName = "mx_EntityTile ";
mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : "");
let nameEl;
const {name} = this.props;
const EmojiText = sdk.getComponent('elements.EmojiText');
if (this.state.hover && !this.props.suppressOnHover) {
if (!this.props.suppressOnHover) {
const activeAgo = this.props.presenceLastActiveAgo ?
(Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)) : -1;
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';
}
if (this.props.subtextLabel) {
presenceLabel = <span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>;
}
nameEl = (
<div className="mx_EntityTile_details">
<EmojiText element="div" className={nameClasses} dir="auto">
<div className="mx_EntityTile_name" dir="auto">
{ name }
</EmojiText>
</div>
{presenceLabel}
</div>
);
} else if (this.props.subtextLabel) {
nameEl = (
<div className="mx_EntityTile_details">
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">
<div className="mx_EntityTile_name" dir="auto">
{name}
</EmojiText>
</div>
<span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>
</div>
);
} else {
nameEl = (
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">{ name }</EmojiText>
<div className="mx_EntityTile_name" dir="auto">{ name }</div>
);
}
@ -183,17 +175,19 @@ const EntityTile = React.createClass({
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<AccessibleButton className={mainClassName} title={this.props.title}
onClick={this.props.onClick} onMouseEnter={this.mouseEnter}
onMouseLeave={this.mouseLeave}>
<div className="mx_EntityTile_avatar">
{ av }
{ power }
</div>
{ nameEl }
{ inviteButton }
</AccessibleButton>
<div ref={(c) => this.container = c} >
<AccessibleButton className={classNames(mainClassNames)} title={this.props.title}
onClick={this.props.onClick}>
<div className="mx_EntityTile_avatar">
{ av }
{ power }
</div>
{ nameEl }
{ inviteButton }
</AccessibleButton>
</div>
);
},
});

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
import ReplyThread from "../elements/ReplyThread";
const React = require('react');
@ -30,9 +29,7 @@ const sdk = require('../../../index');
const TextForEvent = require('../../../TextForEvent');
import withMatrixClient from '../../../wrappers/withMatrixClient';
const ContextualMenu = require('../../structures/ContextualMenu');
import dis from '../../../dispatcher';
import {makeEventPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
@ -63,6 +60,9 @@ const stateEventTileTypes = {
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
'm.room.guest_access': 'messages.TextualEvent',
'm.room.related_groups': 'messages.TextualEvent',
};
function getHandlerTile(ev) {
@ -127,7 +127,7 @@ module.exports = withMatrixClient(React.createClass({
isSelectedEvent: PropTypes.bool,
/* callback called when dynamic content in events are loaded */
onWidgetLoad: PropTypes.func,
onHeightChanged: PropTypes.func,
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */
readReceipts: PropTypes.arrayOf(React.PropTypes.object),
@ -159,19 +159,25 @@ module.exports = withMatrixClient(React.createClass({
// show twelve hour timestamps
isTwelveHour: PropTypes.bool,
// helper function to access relations for this event
getRelationsForEvent: PropTypes.func,
// whether to show reactions for this event
showReactions: PropTypes.bool,
},
getDefaultProps: function() {
return {
// no-op function because onWidgetLoad is optional yet some sub-components assume its existence
onWidgetLoad: function() {},
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {},
};
},
getInitialState: function() {
return {
// Whether the context menu is being displayed.
menu: false,
// Whether the action bar is focused.
actionBarFocused: false,
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: false,
@ -179,6 +185,8 @@ module.exports = withMatrixClient(React.createClass({
verified: null,
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(),
};
},
@ -190,9 +198,12 @@ module.exports = withMatrixClient(React.createClass({
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
this.props.matrixClient.on("deviceVerificationChanged",
this.onDeviceVerificationChanged);
const client = this.props.matrixClient;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
if (this.props.showReactions && SettingsStore.isFeatureEnabled("feature_reactions")) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
}
},
componentWillReceiveProps: function(nextProps) {
@ -215,13 +226,16 @@ module.exports = withMatrixClient(React.createClass({
const client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions && SettingsStore.isFeatureEnabled("feature_reactions")) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
}
},
/** called when the event is decrypted after we show it.
*/
_onDecrypted: function() {
// we need to re-verify the sending device.
// (we call onWidgetLoad in _verifyEvent to handle the case where decryption
// (we call onHeightChanged in _verifyEvent to handle the case where decryption
// has caused a change in size of the event tile)
this._verifyEvent(this.props.mxEvent);
this.forceUpdate();
@ -243,7 +257,7 @@ module.exports = withMatrixClient(React.createClass({
verified: verified,
}, () => {
// Decryption may have caused a change in size
this.props.onWidgetLoad();
this.props.onHeightChanged();
});
},
@ -307,31 +321,6 @@ module.exports = withMatrixClient(React.createClass({
return actions.tweaks.highlight;
},
onEditClicked: function(e) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const self = this;
const {tile, replyThread} = this.refs;
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
onFinished: function() {
self.setState({menu: false});
},
});
this.setState({menu: true});
},
toggleAllReadAvatars: function() {
this.setState({
allReadAvatars: !this.state.allReadAvatars,
@ -483,6 +472,42 @@ module.exports = withMatrixClient(React.createClass({
return null;
},
onActionBarFocusChange(focused) {
this.setState({
actionBarFocused: focused,
});
},
getTile() {
return this.refs.tile;
},
getReplyThread() {
return this.refs.replyThread;
},
getReactions() {
if (
!this.props.showReactions ||
!this.props.getRelationsForEvent ||
!SettingsStore.isFeatureEnabled("feature_reactions")
) {
return null;
}
const eventId = this.props.mxEvent.getId();
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
},
_onReactionsCreated(relationType, eventType) {
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return;
}
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
this.setState({
reactions: this.getReactions(),
});
},
render: function() {
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
const SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -499,7 +524,10 @@ module.exports = withMatrixClient(React.createClass({
eventType !== 'm.room.message' && eventType !== 'm.sticker' && eventType != 'm.room.create'
);
const tileHandler = getHandlerTile(this.props.mxEvent);
let tileHandler = getHandlerTile(this.props.mxEvent);
if (!tileHandler && SettingsStore.getValue("showHiddenEventsInTimeline")) {
tileHandler = "messages.ViewSourceEvent";
}
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
@ -519,6 +547,7 @@ module.exports = withMatrixClient(React.createClass({
const classes = classNames({
mx_EventTile: true,
mx_EventTile_isEditing: this.props.isEditing,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
@ -529,7 +558,7 @@ module.exports = withMatrixClient(React.createClass({
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: this.state.verified === true,
mx_EventTile_unverified: this.state.verified === false,
mx_EventTile_bad: isEncryptionFailure,
@ -537,7 +566,10 @@ module.exports = withMatrixClient(React.createClass({
mx_EventTile_redacted: isRedacted,
});
const permalink = makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId());
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
const readAvatars = this.getReadAvatars();
@ -592,9 +624,15 @@ module.exports = withMatrixClient(React.createClass({
}
}
const editButton = (
<span className="mx_EventTile_editButton" title={_t("Options")} onClick={this.onEditClicked} />
);
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !this.props.isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange}
/> : undefined;
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -633,16 +671,24 @@ module.exports = withMatrixClient(React.createClass({
<ToolTipButton helpText={keyRequestHelpText} />
</div> : null;
let reactionsRow;
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactionsRow = <ReactionsRow
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
/>;
}
switch (this.props.tileShape) {
case 'notif': {
const EmojiText = sdk.getComponent('elements.EmojiText');
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<EmojiText element="a" href={permalink} onClick={this.onPermalinkClicked}>
<a href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
</EmojiText>
</a>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
@ -657,7 +703,7 @@ module.exports = withMatrixClient(React.createClass({
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
onHeightChanged={this.props.onHeightChanged} />
</div>
</div>
);
@ -672,7 +718,7 @@ module.exports = withMatrixClient(React.createClass({
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />
onHeightChanged={this.props.onHeightChanged} />
</div>
<a
className="mx_EventTile_senderDetailsLink"
@ -690,6 +736,15 @@ module.exports = withMatrixClient(React.createClass({
case 'reply':
case 'reply_preview': {
let thread;
if (this.props.tileShape === 'reply_preview') {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
'replyThread',
);
}
return (
<div className={classes}>
{ avatar }
@ -699,21 +754,24 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{
this.props.tileShape === 'reply_preview'
&& ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread')
}
{ thread }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onWidgetLoad={this.props.onWidgetLoad}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false} />
</div>
</div>
);
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
'replyThread',
);
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
@ -725,18 +783,21 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{ ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') }
{ thread }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
isEditing={this.props.isEditing}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
onHeightChanged={this.props.onHeightChanged} />
{ keyRequestInfo }
{ editButton }
{ reactionsRow }
{ actionBar }
</div>
{
// The avatar goes after the event tile as it's absolutly positioned to be over the
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
// the need for further z-indexing chaos)
}
@ -771,29 +832,31 @@ module.exports.haveTileForEvent = function(e) {
function E2ePadlockUndecryptable(props) {
return (
<E2ePadlock title={_t("Undecryptable")} icon="undecryptable" />
<E2ePadlock title={_t("Undecryptable")} icon="undecryptable" {...props} />
);
}
function E2ePadlockUnverified(props) {
return (
<E2ePadlock title={_t("Encrypted by an unverified device")} icon="unverified" />
<E2ePadlock title={_t("Encrypted by an unverified device")} icon="unverified" {...props} />
);
}
function E2ePadlockUnencrypted(props) {
return (
<E2ePadlock title={_t("Unencrypted message")} icon="unencrypted" />
<E2ePadlock title={_t("Unencrypted message")} icon="unencrypted" {...props} />
);
}
function E2ePadlock(props) {
if (SettingsStore.getValue("alwaysShowEncryptionIcons")) {
return <div
className={`mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${props.icon}`}
title={props.title} onClick={props.onClick} />;
return (<div
className={`mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${props.icon}`}
title={props.title} onClick={props.onClick} />);
} else {
return <div className="mx_EventTile_e2eIcon mx_EventTile_e2eIcon_hidden" onClick={props.onClick} />;
return (<div
className={`mx_EventTile_e2eIcon mx_EventTile_e2eIcon_hidden mx_EventTile_e2eIcon_${props.icon}`}
onClick={props.onClick} />);
}
}

View file

@ -16,19 +16,15 @@ limitations under the License.
'use strict';
const React = require('react');
import React from 'react';
import PropTypes from 'prop-types';
import { linkifyElement } from '../../../HtmlUtils';
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
const ImageUtils = require('../../../ImageUtils');
const Modal = require('../../../Modal');
const linkify = require('linkifyjs');
const linkifyElement = require('linkifyjs/element');
const linkifyMatrix = require('../../../linkify-matrix');
linkifyMatrix(linkify);
module.exports = React.createClass({
displayName: 'LinkPreviewWidget',
@ -36,7 +32,7 @@ module.exports = React.createClass({
link: PropTypes.string.isRequired, // the URL being previewed
mxEvent: PropTypes.object.isRequired, // the Event associated with the preview
onCancelClick: PropTypes.func, // called when the preview's cancel ('hide') button is clicked
onWidgetLoad: PropTypes.func, // called when the preview's contents has loaded
onHeightChanged: PropTypes.func, // called when the preview's contents has loaded
},
getInitialState: function() {
@ -53,22 +49,22 @@ module.exports = React.createClass({
}
this.setState(
{ preview: res },
this.props.onWidgetLoad,
this.props.onHeightChanged,
);
}, (error)=>{
console.error("Failed to get preview for " + this.props.link + " " + error);
console.error("Failed to get URL preview: " + error);
}).done();
},
componentDidMount: function() {
if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options);
linkifyElement(this.refs.description);
}
},
componentDidUpdate: function() {
if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options);
linkifyElement(this.refs.description);
}
},

View file

@ -30,7 +30,7 @@ export default class MemberDeviceInfo extends React.Component {
mx_MemberDeviceInfo_icon_unverified: this.props.device.isUnverified(),
});
const indicator = (<div className={iconClasses} />);
const deviceName = this.props.device.ambiguous ?
const deviceName = (this.props.device.ambiguous || this.props.showDeviceId) ?
(this.props.device.getDisplayName() ? this.props.device.getDisplayName() : "") + " (" + this.props.device.deviceId + ")" :
this.props.device.getDisplayName();

View file

@ -44,6 +44,7 @@ import SdkConfig from '../../../SdkConfig';
import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore";
import E2EIcon from "./E2EIcon";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
module.exports = withMatrixClient(React.createClass({
displayName: 'MemberInfo',
@ -588,6 +589,7 @@ module.exports = withMatrixClient(React.createClass({
can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban;
can.invite = me.powerLevel >= powerLevels.invite;
can.mute = me.powerLevel >= editPowerLevel;
can.modifyLevel = me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel);
can.modifyLevelMax = me.powerLevel;
@ -726,7 +728,7 @@ module.exports = withMatrixClient(React.createClass({
);
}
if (!member || !member.membership || member.membership === 'leave') {
if (this.state.can.invite && (!member || !member.membership || member.membership === 'leave')) {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async () => {
try {
@ -734,8 +736,8 @@ module.exports = withMatrixClient(React.createClass({
// we're only inviting one user.
const inviter = new MultiInviter(roomId);
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(userId) !== "invited")
throw new Error(inviter.getErrorText(userId));
if (inviter.getCompletionState(member.userId) !== "invited")
throw new Error(inviter.getErrorText(member.userId));
});
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
@ -941,24 +943,29 @@ module.exports = withMatrixClient(React.createClass({
}
let roomMemberDetails = null;
let e2eIconElement;
if (this.props.member.roomId) { // is in room
const PowerSelector = sdk.getComponent('elements.PowerSelector');
roomMemberDetails = <div>
<div className="mx_MemberInfo_profileField">
{ _t("Level:") } <b>
<PowerSelector controlled={true}
value={parseInt(this.props.member.powerLevel)}
maxValue={this.state.can.modifyLevelMax}
disabled={!this.state.can.modifyLevel}
usersDefault={powerLevelUsersDefault}
onChange={this.onPowerChange} />
</b>
<PowerSelector
value={parseInt(this.props.member.powerLevel)}
maxValue={this.state.can.modifyLevelMax}
disabled={!this.state.can.modifyLevel}
usersDefault={powerLevelUsersDefault}
onChange={this.onPowerChange} />
</div>
<div className="mx_MemberInfo_profileField">
{presenceLabel}
{statusLabel}
</div>
</div>;
const isEncrypted = this.props.matrixClient.isRoomEncrypted(this.props.member.roomId);
if (this.state.e2eStatus && isEncrypted) {
e2eIconElement = (<E2EIcon status={this.state.e2eStatus} isUser={true} />);
}
}
const avatarUrl = this.props.member.getMxcAvatarUrl();
@ -967,20 +974,25 @@ module.exports = withMatrixClient(React.createClass({
const httpUrl = this.props.matrixClient.mxcUrlToHttp(avatarUrl, 800, 800);
avatarElement = <div className="mx_MemberInfo_avatar">
<img src={httpUrl} />
</div>
</div>;
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const EmojiText = sdk.getComponent('elements.EmojiText');
let backButton;
if (this.props.member.roomId) {
backButton = (<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}
title={_t('Close')}
/>);
}
return (
<div className="mx_MemberInfo">
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src={require("../../../../res/img/minimise.svg")} width="10" height="16" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
{ this.state.e2eStatus ? <E2EIcon status={this.state.e2eStatus} isUser={true} /> : undefined }
<EmojiText element="h2">{ memberName }</EmojiText>
{ backButton }
{ e2eIconElement }
<h2>{ memberName }</h2>
</div>
{ avatarElement }
<div className="mx_MemberInfo_container">
@ -992,7 +1004,7 @@ module.exports = withMatrixClient(React.createClass({
{ roomMemberDetails }
</div>
</div>
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer">
<AutoHideScrollbar className="mx_MemberInfo_scrollContainer">
<div className="mx_MemberInfo_container">
{ this._renderUserOptions() }
@ -1004,7 +1016,7 @@ module.exports = withMatrixClient(React.createClass({
{ spinner }
</div>
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
</div>
);
},

View file

@ -20,6 +20,8 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {isValid3pidInvite} from "../../../RoomInvite";
const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
@ -339,12 +341,18 @@ module.exports = React.createClass({
return nameA.localeCompare(nameB);
},
onSearchQueryChanged: function(ev) {
const q = ev.target.value;
onSearchQueryChanged: function(searchQuery) {
this.setState({
searchQuery: q,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', q),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', q),
searchQuery,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery),
});
},
_onPending3pidInviteClick: function(inviteEvent) {
dis.dispatch({
action: 'view_3pid_invite',
event: inviteEvent,
});
},
@ -373,11 +381,7 @@ module.exports = React.createClass({
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function(e) {
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < requiredKeys.length; ++i) {
if (e.getContent()[requiredKeys[i]] === undefined) return false;
}
if (!isValid3pidInvite(e)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
@ -409,6 +413,7 @@ module.exports = React.createClass({
return <EntityTile key={e.getStateKey()}
name={e.getContent().display_name}
suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(e)}
/>;
}));
}
@ -438,17 +443,29 @@ module.exports = React.createClass({
return <div className="mx_MemberList"><Spinner /></div>;
}
const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
if (room && room.getMyMembership() === 'join') {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
// assume we can invite until proven false
let canInvite = true;
const plEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const me = room.getMember(cli.getUserId());
if (plEvent && me) {
const content = plEvent.getContent();
if (content && content.invite > me.powerLevel) {
canInvite = false;
}
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick}>
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!canInvite}>
<span>{ _t('Invite to this room') }</span>
</AccessibleButton>;
}
@ -467,7 +484,7 @@ module.exports = React.createClass({
return (
<div className="mx_MemberList">
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
<AutoHideScrollbar>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
@ -476,10 +493,11 @@ module.exports = React.createClass({
{ invitedHeader }
{ invitedSection }
</div>
</GeminiScrollbarWrapper>
<input className="mx_MemberList_query mx_textinput_icon mx_textinput_search" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
</AutoHideScrollbar>
<SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={ _t('Filter room members') }
onSearch={ this.onSearchQueryChanged } />
</div>
);
},

View file

@ -56,7 +56,7 @@ module.exports = React.createClass({
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
},
componentWillUmount() {
componentWillUnmount() {
const { user } = this.props.member;
if (!user) {
return;
@ -139,11 +139,21 @@ module.exports = React.createClass({
}
this.member_last_modified_time = member.getLastModifiedTime();
// We deliberately leave power levels that are not 100 or 50 undefined
const powerStatus = {
100: EntityTile.POWER_STATUS_ADMIN,
50: EntityTile.POWER_STATUS_MODERATOR,
}[this.props.member.powerLevel];
const powerStatusMap = new Map([
[100, EntityTile.POWER_STATUS_ADMIN],
[50, EntityTile.POWER_STATUS_MODERATOR],
]);
// Find the nearest power level with a badge
let powerLevel = this.props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (this.props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const powerStatus = powerStatusMap.get(powerLevel);
return (
<EntityTile {...this.props} presenceState={presenceState}

View file

@ -26,6 +26,7 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../matrix-to';
import ContentMessages from '../../../ContentMessages';
import classNames from 'classnames';
import E2EIcon from './E2EIcon';
@ -41,15 +42,159 @@ const formatButtonList = [
_td("numbered-list"),
];
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
return <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>;
}
ComposerAvatar.propTypes = {
me: PropTypes.object.isRequired,
}
function CallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: "voice",
room_id: props.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>
}
CallButton.propTypes = {
roomId: PropTypes.string.isRequired
}
function VideoCallButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: props.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
}
VideoCallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function HangupButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const onHangupClick = () => {
const call = CallHandler.getCallForRoom(props.roomId);
if (!call) {
return;
}
dis.dispatch({
action: 'hangup',
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId,
});
};
return <AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={_t('Hangup')}
/>;
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
}
function FormattingButton(props) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <AccessibleButton
element="img"
className="mx_MessageComposer_formatting"
alt={_t("Show Text Formatting Toolbar")}
title={_t("Show Text Formatting Toolbar")}
src={require("../../../../res/img/button-text-formatting.svg")}
style={{visibility: props.showFormatting ? 'hidden' : 'visible'}}
onClick={props.onClickHandler}
/>;
}
FormattingButton.propTypes = {
showFormatting: PropTypes.bool.isRequired,
onClickHandler: PropTypes.func.isRequired,
}
class UploadButton extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
}
constructor(props, context) {
super(props, context);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
}
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this.refs.uploadInput.click();
}
onUploadFileInputChange(ev) {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
// (Note it is a FileList: we can't use slice or sesnible iteration).
const tfiles = [];
for (let i = 0; i < ev.target.files.length; ++i) {
tfiles.push(ev.target.files[i]);
}
ContentMessages.sharedInstance().sendContentListToRoom(
tfiles, this.props.roomId, MatrixClientPeg.get(),
);
// This is the onChange handler for a file form control, but we're
// not keeping any state, so reset the value of the form control
// to empty.
// NB. we need to set 'value': the 'files' property is immutable.
ev.target.value = '';
}
render() {
const uploadInputStyle = {display: 'none'};
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_upload"
onClick={this.onUploadClick}
title={_t('Upload file')}
>
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileInputChange}
/>
</AccessibleButton>
);
}
}
export default class MessageComposer extends React.Component {
constructor(props, context) {
super(props, context);
this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
@ -58,6 +203,8 @@ export default class MessageComposer extends React.Component {
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
this.renderFormatBar = this.renderFormatBar.bind(this);
this.state = {
inputState: {
@ -68,6 +215,7 @@ export default class MessageComposer extends React.Component {
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
};
}
@ -120,6 +268,9 @@ export default class MessageComposer extends React.Component {
if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()});
}
if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()});
}
}
_getRoomTombstone() {
@ -132,131 +283,10 @@ export default class MessageComposer extends React.Component {
this.setState({ isQuoting });
}
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this.refs.uploadInput.click();
}
onUploadFileSelected(files) {
const tfiles = files.target.files;
this.uploadFiles(tfiles);
}
uploadFiles(files) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const fileList = [];
const acceptedFiles = [];
const failedFiles = [];
for (let i=0; i<files.length; i++) {
const fileAcceptedOrError = this.props.uploadAllowed(files[i]);
if (fileAcceptedOrError === true) {
acceptedFiles.push(<li key={i}>
<TintableSvg key={i} src={require("../../../../res/img/files.svg")} width="16" height="16" /> { files[i].name || _t('Attachment') }
</li>);
fileList.push(files[i]);
} else {
failedFiles.push(<li key={i}>
<TintableSvg key={i} src={require("../../../../res/img/files.svg")} width="16" height="16" /> { files[i].name || _t('Attachment') } <p>{ _t('Reason') + ": " + fileAcceptedOrError}</p>
</li>);
}
}
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
let replyToWarning = null;
if (isQuoting) {
replyToWarning = <p>{
_t('At this time it is not possible to reply with a file so this will be sent without being a reply.')
}</p>;
}
const acceptedFilesPart = acceptedFiles.length === 0 ? null : (
<div>
<p>{ _t('Are you sure you want to upload the following files?') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ acceptedFiles }
</ul>
</div>
);
const failedFilesPart = failedFiles.length === 0 ? null : (
<div>
<p>{ _t('The following files cannot be uploaded:') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ failedFiles }
</ul>
</div>
);
let buttonText;
if (acceptedFiles.length > 0 && failedFiles.length > 0) {
buttonText = "Upload selected"
} else if (failedFiles.length > 0) {
buttonText = "Close"
}
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
title: _t('Upload Files'),
description: (
<div>
{ acceptedFilesPart }
{ failedFilesPart }
{ replyToWarning }
</div>
),
hasCancelButton: acceptedFiles.length > 0,
button: buttonText,
onFinished: (shouldUpload) => {
if (shouldUpload) {
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
if (fileList) {
for (let i=0; i<fileList.length; i++) {
this.props.uploadFile(fileList[i]);
}
}
}
this.refs.uploadInput.value = null;
},
});
}
onHangupClick() {
const call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall();
if (!call) {
return;
}
dis.dispatch({
action: 'hangup',
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId,
});
}
onCallClick(ev) {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId,
});
}
onVoiceCallClick(ev) {
dis.dispatch({
action: 'place_call',
type: "voice",
room_id: this.props.room.roomId,
});
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
@ -303,162 +333,117 @@ export default class MessageComposer extends React.Component {
});
}
render() {
const uploadInputStyle = {display: 'none'};
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
const controls = [];
if (this.state.me) {
controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={this.state.me} width={24} height={24} />
</div>,
);
}
if (this.props.e2eStatus) {
controls.push(<E2EIcon
status={this.props.e2eStatus}
key="e2eIcon"
className="mx_MessageComposer_e2eIcon" />
);
}
let callButton;
let videoCallButton;
let hangupButton;
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
// Call buttons
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
<AccessibleButton key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src={require("../../../../res/img/hangup.svg")} alt={_t('Hangup')} title={_t('Hangup')} width="25" height="25" />
</AccessibleButton>;
} else {
callButton =
<AccessibleButton key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={_t('Voice call')}>
<TintableSvg src={require("../../../../res/img/feather-icons/phone.svg")} width="20" height="20" />
</AccessibleButton>;
videoCallButton =
<AccessibleButton key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title={_t('Video call')}>
<TintableSvg src={require("../../../../res/img/feather-icons/video.svg")} width="20" height="20" />
</AccessibleButton>;
}
const canSendMessages = !this.state.tombstone &&
this.props.room.maySendMessage();
// TODO: Remove temporary logging for riot-web#7838
// Note: we rip apart the power level event ourselves because we don't want to
// log too much data about it - just the bits we care about. Many of the variables
// logged here are to help figure out where in the stack the 'cannot post in room'
// warning is coming from. This means logging various numbers from the PL event to
// verify RoomState._maySendEventOfType is doing the right thing.
const room = this.props.room;
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
let plEventString = "<no power level event>";
if (plEvent) {
const content = plEvent.getContent();
if (!content) {
plEventString = "<no event content>";
renderPlaceholderText() {
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
if (this.state.isQuoting) {
if (roomIsEncrypted) {
return _t('Send an encrypted reply…');
} else {
const stringifyFalsey = (v) => v === null ? '<null>' : (v === undefined ? '<undefined>' : v);
const actualUserPl = stringifyFalsey(content.users ? content.users[room.myUserId] : "<no users in content>");
const usersPl = stringifyFalsey(content.users_default);
const actualEventPl = stringifyFalsey(content.events ? content.events['m.room.message'] : "<no events in content>");
const eventPl = stringifyFalsey(content.events_default);
plEventString = `actualUserPl=${actualUserPl} defaultUserPl=${usersPl} actualEventPl=${actualEventPl} defaultEventPl=${eventPl}`;
return _t('Send a reply (unencrypted)…');
}
} else {
if (roomIsEncrypted) {
return _t('Send an encrypted message…');
} else {
return _t('Send a message (unencrypted)…');
}
}
console.log(
`[riot-web#7838] renderComposer() hasTombstone=${!!this.state.tombstone} maySendMessage=${room.maySendMessage()}` +
` myMembership=${room.getMyMembership()} maySendEvent=${room.currentState.maySendEvent('m.room.message', room.myUserId)}` +
` myUserId=${room.myUserId} roomId=${room.roomId} hasPlEvent=${!!plEvent} powerLevels='${plEventString}'`
);
}
if (canSendMessages) {
renderFormatBar() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const {marks, blockType} = this.state.inputState;
const formatButtons = formatButtonList.map((name) => {
// special-case to match the md serializer and the special-case in MessageComposerInput.js
const markName = name === 'inline-code' ? 'code' : name;
const active = marks.some(mark => mark.type === markName) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return (
<img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
height="17"
/>
);
})
return (
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{ flex: 1 }}></div>
<AccessibleButton
className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
onClick={this.onToggleMarkdownClicked}
title={_t("Markdown is disabled")}
/>
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")}
/>
</div>
</div>
);
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ? <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : null,
];
if (!this.state.tombstone && this.state.canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
const uploadButton = (
<AccessibleButton key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={_t('Upload file')}>
<TintableSvg src={require("../../../../res/img/feather-icons/paperclip.svg")} width="20" height="20" />
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple
onChange={this.onUploadFileSelected} />
</AccessibleButton>
);
const formattingButton = this.state.inputState.isRichTextEnabled ? (
<AccessibleButton element="img" className="mx_MessageComposer_formatting"
alt={_t("Show Text Formatting Toolbar")}
title={_t("Show Text Formatting Toolbar")}
src={require("../../../../res/img/button-text-formatting.svg")}
onClick={this.onToggleFormattingClicked}
style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}}
key="controls_formatting" />
) : null;
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
let placeholderText;
if (this.state.isQuoting) {
if (roomIsEncrypted) {
placeholderText = _t('Send an encrypted reply…');
} else {
placeholderText = _t('Send a reply (unencrypted)…');
}
} else {
if (roomIsEncrypted) {
placeholderText = _t('Send an encrypted message…');
} else {
placeholderText = _t('Send a message (unencrypted)…');
}
}
const stickerpickerButton = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
const showFormattingButton = this.state.inputState.isRichTextEnabled;
const callInProgress = this.props.callState && this.props.callState !== 'ended';
controls.push(
<MessageComposerInput
ref={(c) => this.messageComposerInput = c}
key="controls_input"
onResize={this.props.onResize}
room={this.props.room}
placeholder={placeholderText}
onFilesPasted={this.uploadFiles}
onInputStateChanged={this.onInputStateChanged} />,
formattingButton,
stickerpickerButton,
uploadButton,
hangupButton,
callButton,
videoCallButton,
placeholder={this.renderPlaceholderText()}
onInputStateChanged={this.onInputStateChanged}
permalinkCreator={this.props.permalinkCreator} />,
showFormattingButton ? <FormattingButton key="controls_formatting"
showFormatting={this.state.showFormatting} onClickHandler={this.onToggleFormattingClicked} /> : null,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
) : '';
controls.push(<div className="mx_MessageComposer_replaced_wrapper">
<div className="mx_MessageComposer_replaced_valign">
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} />
<span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")}
</span><br />
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
{ continuesLink }
</div>
</div>);
} else {
// TODO: Remove temporary logging for riot-web#7838
console.log("[riot-web#7838] Falling back to showing cannot post in room error");
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
@ -466,41 +451,7 @@ export default class MessageComposer extends React.Component {
);
}
let formatBar;
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
const {marks, blockType} = this.state.inputState;
const formatButtons = formatButtonList.map((name) => {
// special-case to match the md serializer and the special-case in MessageComposerInput.js
const markName = name === 'inline-code' ? 'code' : name;
const active = marks.some(mark => mark.type === markName) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return <img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={require(`../../../../res/img/button-text-${name}${suffix}.svg`)}
height="17" />;
},
);
formatBar =
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichTextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={require(`../../../../res/img/button-md-${!this.state.inputState.isRichTextEnabled}.png`)} />
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")} />
</div>
</div>;
}
const showFormatBar = this.state.showFormatting && this.state.inputState.isRichTextEnabled;
const wrapperClasses = classNames({
mx_MessageComposer_wrapper: true,
@ -513,29 +464,19 @@ export default class MessageComposer extends React.Component {
{ controls }
</div>
</div>
{ formatBar }
{ showFormatBar ? this.renderFormatBar() : null }
</div>
);
}
}
MessageComposer.propTypes = {
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: PropTypes.func,
// js-sdk Room object
room: PropTypes.object.isRequired,
// string representing the current voip call state
callState: PropTypes.string,
// callback when a file to upload is chosen
uploadFile: PropTypes.func.isRequired,
// function to test whether a file should be allowed to be uploaded.
uploadAllowed: PropTypes.func.isRequired,
// string representing the current room app drawer state
showApps: PropTypes.bool
};

View file

@ -40,30 +40,28 @@ import Analytics from '../../../Analytics';
import dis from '../../../dispatcher';
import * as RichText from '../../../RichText';
import * as HtmlUtils from '../../../HtmlUtils';
import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter";
import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
import ContentMessages from '../../../ContentMessages';
import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
import {
asciiRegexp, unicodeRegexp, shortnameToUnicode,
asciiList, mapUnicodeToShort, toShort,
} from 'emojione';
import EMOJIBASE from 'emojibase-data/en/compact.json';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
import AccessibleButton from '../elements/AccessibleButton';
import {findEditableEvent} from '../../../utils/EventUtils';
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g');
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
const TYPING_USER_TIMEOUT = 10000; const TYPING_SERVER_TIMEOUT = 30000;
@ -134,21 +132,14 @@ function rangeEquals(a: Range, b: Range): boolean {
*/
export default class MessageComposerInput extends React.Component {
static propTypes = {
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: PropTypes.func,
// js-sdk Room object
room: PropTypes.object.isRequired,
onFilesPasted: PropTypes.func,
onInputStateChanged: PropTypes.func,
};
client: MatrixClient;
autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) {
super(props, context);
@ -277,9 +268,8 @@ export default class MessageComposerInput extends React.Component {
case 'emoji':
// XXX: apparently you can't return plain strings from serializer rules
// until https://github.com/ianstormtaylor/slate/pull/1854 is merged.
// So instead we temporarily wrap emoji from RTE in an arbitrary tag
// (<b/>). <span/> would be nicer, but in practice it causes CSS issues.
return <b>{ obj.data.get('emojiUnicode') }</b>;
// So instead we temporarily wrap emoji from RTE in a span.
return <span>{ obj.data.get('emojiUnicode') }</span>;
}
return this.renderNode({
node: obj,
@ -339,7 +329,6 @@ export default class MessageComposerInput extends React.Component {
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
}
componentWillUnmount() {
@ -379,7 +368,6 @@ export default class MessageComposerInput extends React.Component {
const html = HtmlUtils.bodyToHtml(payload.event.getContent(), null, {
forComposerQuote: true,
returnString: true,
emojiOne: false,
});
const fragment = this.html.deserialize(html);
// FIXME: do we want to put in a permalink to the original quote here?
@ -543,17 +531,15 @@ export default class MessageComposerInput extends React.Component {
// Automatic replacement of plaintext emoji to Unicode emoji
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
// The first matched group includes just the matched plaintext emoji
const emojiMatch = REGEX_EMOJI_WHITESPACE.exec(text.slice(0, currentStartOffset));
if (emojiMatch) {
// plaintext -> hex unicode
const emojiUc = asciiList[emojiMatch[1]];
// hex unicode -> shortname -> actual unicode
const unicodeEmoji = shortnameToUnicode(EMOJI_UNICODE_TO_SHORTNAME[emojiUc]);
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(text.slice(0, currentStartOffset));
if (emoticonMatch) {
const data = EMOJIBASE.find(e => e.emoticon === emoticonMatch[1]);
const unicodeEmoji = data ? data.unicode : '';
const range = Range.create({
anchor: {
key: editorState.startText.key,
offset: currentStartOffset - emojiMatch[1].length - 1,
offset: currentStartOffset - emoticonMatch[1].length - 1,
},
focus: {
key: editorState.startText.key,
@ -566,54 +552,6 @@ export default class MessageComposerInput extends React.Component {
}
}
// emojioneify any emoji
let foundEmoji;
do {
foundEmoji = false;
for (const node of editorState.document.getTexts()) {
if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) {
let match;
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(node.text)) !== null) {
const range = Range.create({
anchor: {
key: node.key,
offset: match.index,
},
focus: {
key: node.key,
offset: match.index + match[0].length,
},
});
const inline = Inline.create({
type: 'emoji',
data: { emojiUnicode: match[0] },
});
change = change.insertInlineAtRange(range, inline);
editorState = change.value;
// if we replaced an emoji, start again looking for more
// emoji in the new editor state since doing the replacement
// will change the node structure & offsets so we can't compute
// insertion ranges from node.key / match.index anymore.
foundEmoji = true;
break;
}
}
}
} while (foundEmoji);
// work around weird bug where inserting emoji via the macOS
// emoji picker can leave the selection stuck in the emoji's
// child text. This seems to happen due to selection getting
// moved in the normalisation phase after calculating these changes
if (editorState.selection.anchor.key &&
editorState.document.getParent(editorState.selection.anchor.key).type === 'emoji') {
change = change.moveToStartOfNextText();
editorState = change.value;
}
if (this.props.onInputStateChanged && editorState.blocks.size > 0) {
let blockType = editorState.blocks.first().type;
// console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks);
@ -628,7 +566,6 @@ export default class MessageComposerInput extends React.Component {
}
const inputState = {
marks: editorState.activeMarks,
isRichTextEnabled: this.state.isRichTextEnabled,
blockType,
};
this.props.onInputStateChanged(inputState);
@ -686,20 +623,22 @@ export default class MessageComposerInput extends React.Component {
enableRichtext(enabled: boolean) {
if (enabled === this.state.isRichTextEnabled) return;
let editorState = null;
if (enabled) {
editorState = this.mdToRichEditorState(this.state.editorState);
} else {
editorState = this.richToMdEditorState(this.state.editorState);
}
Analytics.setRichtextMode(enabled);
this.setState({
editorState: this.createEditorState(enabled, editorState),
editorState: this.createEditorState(
enabled,
this.state.editorState,
this.state.isRichTextEnabled,
),
isRichTextEnabled: enabled,
}, ()=>{
}, () => {
this._editor.focus();
if (this.props.onInputStateChanged) {
this.props.onInputStateChanged({
isRichTextEnabled: enabled,
});
}
});
SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled);
@ -1012,7 +951,13 @@ export default class MessageComposerInput extends React.Component {
switch (transfer.type) {
case 'files':
return this.props.onFilesPasted(transfer.files);
// This actually not so much for 'files' as such (at time of writing
// neither chrome nor firefox let you paste a plain file copied
// from Finder) but more images copied from a different website
// / word processor etc.
return ContentMessages.sharedInstance().sendContentListToRoom(
transfer.files, this.props.room.roomId, this.client,
);
case 'html': {
if (this.state.isRichTextEnabled) {
// FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means
@ -1039,10 +984,17 @@ export default class MessageComposerInput extends React.Component {
};
handleReturn = (ev, change) => {
if (ev.shiftKey) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (ev.shiftKey || (isMac && ev.altKey)) {
return change.insertText('\n');
}
if (this.autocomplete.countCompletions() > 0) {
this.autocomplete.hide();
ev.preventDefault();
return true;
}
const editorState = this.state.editorState;
const lastBlock = editorState.blocks.last();
@ -1084,7 +1036,6 @@ export default class MessageComposerInput extends React.Component {
if (cmd) {
if (!cmd.error) {
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
this.setState({
editorState: this.createEditorState(),
}, ()=>{
@ -1162,11 +1113,6 @@ export default class MessageComposerInput extends React.Component {
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(
editorState,
this.state.isRichTextEnabled ? 'rich' : 'markdown',
);
if (commandText && commandText.startsWith('/me')) {
if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1194,7 +1140,7 @@ export default class MessageComposerInput extends React.Component {
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv, this.props.permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -1241,14 +1187,16 @@ export default class MessageComposerInput extends React.Component {
// and we must be at the edge of the document (up=start, down=end)
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
} else {
if (!selection.anchor.isAtEndOfNode(document)) return;
}
const selected = this.selectHistory(up);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
dis.dispatch({
action: 'edit_event',
event: editEvent,
});
}
}
} else {
this.moveAutocompleteSelection(up);
@ -1256,54 +1204,6 @@ export default class MessageComposerInput extends React.Component {
}
};
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
});
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return;
}
let editorState;
const historyItem = this.historyManager.getItem(delta);
if (!historyItem) return;
if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
editorState = this.richToMdEditorState(historyItem.value);
} else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
editorState = this.mdToRichEditorState(historyItem.value);
} else {
editorState = historyItem.value;
}
// Move selection to the end of the selected history
const change = editorState.change().moveToEndOfNode(editorState.document);
// We don't call this.onChange(change) now, as fixups on stuff like emoji
// should already have been done and persisted in the history.
editorState = change.value;
this.suppressAutoComplete = true;
this.setState({ editorState }, ()=>{
this._editor.focus();
});
return true;
};
onTab = async (e) => {
this.setState({
someCompletions: null,
@ -1472,17 +1372,7 @@ export default class MessageComposerInput extends React.Component {
}
case 'emoji': {
const { data } = node;
const emojiUnicode = data.get('emojiUnicode');
const uri = RichText.unicodeToEmojiUri(emojiUnicode);
const shortname = toShort(emojiUnicode);
const className = classNames('mx_emojione', {
mx_emojione_selected: isSelected,
});
const style = {};
if (props.selected) style.border = '1px solid blue';
return <img className={ className } src={ uri }
title={ shortname } alt={ emojiUnicode } style={style}
/>;
return data.get('emojiUnicode');
}
}
};
@ -1583,10 +1473,15 @@ export default class MessageComposerInput extends React.Component {
placeholder = undefined;
}
const markdownClasses = classNames({
mx_MessageComposer_input_markdownIndicator: true,
mx_MessageComposer_markdownDisabled: this.state.isRichTextEnabled,
});
return (
<div className="mx_MessageComposer_input_wrapper" onClick={this.focusComposer}>
<div className="mx_MessageComposer_autocomplete_wrapper">
<ReplyPreview />
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}
@ -1597,10 +1492,10 @@ export default class MessageComposerInput extends React.Component {
/>
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
onMouseDown={this.onMarkdownToggleClicked}
title={this.state.isRichTextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")}
src={require(`../../../../res/img/button-md-${!this.state.isRichTextEnabled}.png`)} />
<AccessibleButton className={markdownClasses}
onClick={this.onMarkdownToggleClicked}
title={this.state.isRichTextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")}
/>
<Editor ref={this._collectEditor}
dir="auto"
className="mx_MessageComposer_editor"

View file

@ -92,7 +92,7 @@ module.exports = React.createClass({
</span>
<div className="mx_PinnedEventTile_message">
<MessageEvent mxEvent={this.props.mxEvent} className="mx_PinnedEventTile_body" maxImageHeight={150}
onWidgetLoad={() => {}} // we need to give this, apparently
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
</div>

View file

@ -20,6 +20,8 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import {RoomPermalinkCreator} from "../../../matrix-to";
function cancelQuoting() {
dis.dispatch({
@ -29,6 +31,10 @@ function cancelQuoting() {
}
export default class ReplyPreview extends React.Component {
static propTypes = {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
constructor(props, context) {
super(props, context);
@ -60,13 +66,12 @@ export default class ReplyPreview extends React.Component {
if (!this.state.event) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
const EmojiText = sdk.getComponent('views.elements.EmojiText');
return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section">
<EmojiText element="div" className="mx_ReplyPreview_header mx_ReplyPreview_title">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title">
{ '💬 ' + _t('Replying') }
</EmojiText>
</div>
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src={require("../../../../res/img/cancel.svg")} width="18" height="18"
onClick={cancelQuoting} />
@ -75,6 +80,7 @@ export default class ReplyPreview extends React.Component {
<EventTile last={true}
tileShape="reply_preview"
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</div>
</div>;

View file

@ -0,0 +1,347 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import dis from "../../../dispatcher";
import MatrixClientPeg from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomAvatar from '../avatars/RoomAvatar';
import classNames from 'classnames';
import sdk from "../../../index";
import Analytics from "../../../Analytics";
import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from "../../../utils/FormattingUtils";
import DMRoomMap from "../../../utils/DMRoomMap";
import {_t} from "../../../languageHandler";
const MAX_ROOMS = 20;
export default class RoomBreadcrumbs extends React.Component {
constructor(props) {
super(props);
this.state = {rooms: []};
this.onAction = this.onAction.bind(this);
this._dispatcherRef = null;
}
componentWillMount() {
this._dispatcherRef = dis.register(this.onAction);
let storedRooms = SettingsStore.getValue("breadcrumb_rooms");
if (!storedRooms || !storedRooms.length) {
// Fallback to the rooms stored in localstorage for those who would have had this.
// TODO: Remove this after a bit - the feature was only on develop, so a few weeks should be plenty time.
const roomStr = localStorage.getItem("mx_breadcrumb_rooms");
if (roomStr) {
try {
storedRooms = JSON.parse(roomStr);
} catch (e) {
console.error("Failed to parse breadcrumbs:", e);
}
}
}
this._loadRoomIds(storedRooms || []);
this._settingWatchRef = SettingsStore.watchSetting("breadcrumb_rooms", null, this.onBreadcrumbsChanged);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
SettingsStore.unwatchSetting(this._settingWatchRef);
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("Room.myMembership", this.onMyMembership);
client.removeListener("Room.receipt", this.onRoomReceipt);
client.removeListener("Room.timeline", this.onRoomTimeline);
client.removeListener("Event.decrypted", this.onEventDecrypted);
}
}
componentDidUpdate() {
const rooms = this.state.rooms.slice();
if (rooms.length) {
const roomModel = rooms[0];
if (!roomModel.animated) {
roomModel.animated = true;
setTimeout(() => this.setState({rooms}), 0);
}
}
}
onAction(payload) {
switch (payload.action) {
case 'view_room':
this._appendRoomId(payload.room_id);
break;
}
}
onMyMembership = (room, membership) => {
if (membership === "leave" || membership === "ban") {
const rooms = this.state.rooms.slice();
const roomState = rooms.find((r) => r.room.roomId === room.roomId);
if (roomState) {
roomState.left = true;
this.setState({rooms});
}
}
};
onRoomReceipt = (event, room) => {
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
this._calculateRoomBadges(room);
}
};
onRoomTimeline = (event, room) => {
if (!room) return; // Can be null for the notification timeline, etc.
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
this._calculateRoomBadges(room);
}
};
onEventDecrypted = (event) => {
if (this.state.rooms.map(r => r.room.roomId).includes(event.getRoomId())) {
this._calculateRoomBadges(MatrixClientPeg.get().getRoom(event.getRoomId()));
}
};
onBreadcrumbsChanged = (settingName, roomId, level, valueAtLevel, value) => {
if (!value) return;
const currentState = this.state.rooms.map((r) => r.room.roomId);
if (currentState.length === value.length) {
let changed = false;
for (let i = 0; i < currentState.length; i++) {
if (currentState[i] !== value[i]) {
changed = true;
break;
}
}
if (!changed) return;
}
this._loadRoomIds(value);
};
_loadRoomIds(roomIds) {
if (!roomIds || roomIds.length <= 0) return; // Skip updates with no rooms
// If we're here, the list changed.
const rooms = roomIds.map((r) => MatrixClientPeg.get().getRoom(r)).filter((r) => r).map((r) => {
const badges = this._calculateBadgesForRoom(r) || {};
return {
room: r,
animated: false,
...badges,
};
});
this.setState({
rooms: rooms,
});
}
_calculateBadgesForRoom(room) {
if (!room) return null;
// Reset the notification variables for simplicity
const roomModel = {
redBadge: false,
formattedCount: "0",
showCount: false,
};
const notifState = RoomNotifs.getRoomNotifsState(room.roomId);
if (RoomNotifs.MENTION_BADGE_STATES.includes(notifState)) {
const highlightNotifs = RoomNotifs.getUnreadNotificationCount(room, 'highlight');
const unreadNotifs = RoomNotifs.getUnreadNotificationCount(room);
const redBadge = highlightNotifs > 0;
const greyBadge = redBadge || (unreadNotifs > 0 && RoomNotifs.BADGE_STATES.includes(notifState));
if (redBadge || greyBadge) {
const notifCount = redBadge ? highlightNotifs : unreadNotifs;
const limitedCount = FormattingUtils.formatCount(notifCount);
roomModel.redBadge = redBadge;
roomModel.formattedCount = limitedCount;
roomModel.showCount = true;
}
}
return roomModel;
}
_calculateRoomBadges(room) {
if (!room) return;
const rooms = this.state.rooms.slice();
const roomModel = rooms.find((r) => r.room.roomId === room.roomId);
if (!roomModel) return; // No applicable room, so don't do math on it
const badges = this._calculateBadgesForRoom(room);
if (!badges) return; // No badges for some reason
Object.assign(roomModel, badges);
this.setState({rooms});
}
_appendRoomId(roomId) {
let room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return;
const rooms = this.state.rooms.slice();
// If the room is upgraded, use that room instead. We'll also splice out
// any children of the room.
const history = MatrixClientPeg.get().getRoomUpgradeHistory(roomId);
if (history.length > 1) {
room = history[history.length - 1]; // Last room is most recent
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex((r) => r.room.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1);
}
}
const existingIdx = rooms.findIndex((r) => r.room.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
rooms.splice(0, 0, {room, animated: false});
if (rooms.length > MAX_ROOMS) {
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
}
this.setState({rooms});
if (this.refs.scroller) {
this.refs.scroller.moveToOrigin();
}
// We don't track room aesthetics (badges, membership, etc) over the wire so we
// don't need to do this elsewhere in the file. Just where we alter the room IDs
// and their order.
const roomIds = rooms.map((r) => r.room.roomId);
if (roomIds.length > 0) {
SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
}
_viewRoom(room, index) {
Analytics.trackEvent("Breadcrumbs", "click_node", index);
dis.dispatch({action: "view_room", room_id: room.roomId});
}
_onMouseEnter(room) {
this._onHover(room);
}
_onMouseLeave(room) {
this._onHover(null); // clear hover states
}
_onHover(room) {
const rooms = this.state.rooms.slice();
for (const r of rooms) {
r.hover = room && r.room.roomId === room.roomId;
}
this.setState({rooms});
}
_isDmRoom(room) {
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
return Boolean(dmRooms);
}
render() {
const Tooltip = sdk.getComponent('elements.Tooltip');
const IndicatorScrollbar = sdk.getComponent('structures.IndicatorScrollbar');
// check for collapsed here and not at parent so we keep rooms in our state
// when collapsing and expanding
if (this.props.collapsed) {
return null;
}
const rooms = this.state.rooms;
const avatars = rooms.map((r, i) => {
const isFirst = i === 0;
const classes = classNames({
"mx_RoomBreadcrumbs_crumb": true,
"mx_RoomBreadcrumbs_preAnimate": isFirst && !r.animated,
"mx_RoomBreadcrumbs_animate": isFirst,
"mx_RoomBreadcrumbs_left": r.left,
});
let tooltip = null;
if (r.hover) {
tooltip = <Tooltip label={r.room.name} />;
}
let badge;
if (r.showCount) {
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': true,
'mx_RoomTile_badgeRed': r.redBadge,
'mx_RoomTile_badgeUnread': !r.redBadge,
});
badge = <div className={badgeClasses}>{r.formattedCount}</div>;
}
let dmIndicator;
if (this._isDmRoom(r.room)) {
dmIndicator = <img
src={require("../../../../res/img/icon_person.svg")}
className="mx_RoomBreadcrumbs_dmIndicator"
width="13"
height="15"
alt={_t("Direct Chat")}
/>;
}
return (
<AccessibleButton className={classes} key={r.room.roomId} onClick={() => this._viewRoom(r.room, i)}
onMouseEnter={() => this._onMouseEnter(r.room)} onMouseLeave={() => this._onMouseLeave(r.room)}>
<RoomAvatar room={r.room} width={32} height={32} />
{badge}
{dmIndicator}
{tooltip}
</AccessibleButton>
);
});
return (
<IndicatorScrollbar ref="scroller" className="mx_RoomBreadcrumbs"
trackHorizontalOverflow={true} verticalScrollsHorizontally={true}>
{ avatars }
</IndicatorScrollbar>
);
}
}

View file

@ -17,15 +17,11 @@ limitations under the License.
import sdk from '../../../index';
import React from 'react';
import { _t } from '../../../languageHandler';
import * as linkify from 'linkifyjs';
import linkifyElement from 'linkifyjs/element';
import linkifyMatrix from '../../../linkify-matrix';
import { linkifyElement } from '../../../HtmlUtils';
import { ContentRepo } from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg';
import PropTypes from 'prop-types';
linkifyMatrix(linkify);
export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
}
@ -53,7 +49,7 @@ export default React.createClass({
_linkifyTopic: function() {
if (this.refs.topic) {
linkifyElement(this.refs.topic, linkifyMatrix.options);
linkifyElement(this.refs.topic);
}
},

View file

@ -25,9 +25,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import RateLimitedFunc from '../../../ratelimitedfunc';
import * as linkify from 'linkifyjs';
import linkifyElement from 'linkifyjs/element';
import linkifyMatrix from '../../../linkify-matrix';
import { linkifyElement } from '../../../HtmlUtils';
import AccessibleButton from '../elements/AccessibleButton';
import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader';
@ -35,21 +33,16 @@ import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import E2EIcon from './E2EIcon';
linkifyMatrix(linkify);
module.exports = React.createClass({
displayName: 'RoomHeader',
propTypes: {
room: PropTypes.object,
oobData: PropTypes.object,
editing: PropTypes.bool,
saving: PropTypes.bool,
inRoom: PropTypes.bool,
collapsedRhs: PropTypes.bool,
onSettingsClick: PropTypes.func,
onPinnedClick: PropTypes.func,
onSaveClick: PropTypes.func,
onSearchClick: PropTypes.func,
onLeaveClick: PropTypes.func,
onCancelClick: PropTypes.func,
@ -60,7 +53,6 @@ module.exports = React.createClass({
return {
editing: false,
inRoom: false,
onSaveClick: function() {},
onCancelClick: null,
};
},
@ -80,7 +72,7 @@ module.exports = React.createClass({
componentDidUpdate: function() {
if (this.refs.topic) {
linkifyElement(this.refs.topic, linkifyMatrix.options);
linkifyElement(this.refs.topic);
}
},
@ -120,33 +112,6 @@ module.exports = React.createClass({
this.forceUpdate();
},
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
}
},
onAvatarSelected: function(ev) {
const changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
}
changeAvatar.onFileSelected(ev).catch(function(err) {
const errMsg = (typeof err === "string") ? err : (err.error || "");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set avatar: " + errMsg);
Modal.createTrackedDialog('Failed to set avatar', '', ErrorDialog, {
title: _t("Error"),
description: _t("Failed to set avatar."),
});
}).done();
},
onAvatarRemoveClick: function() {
MatrixClientPeg.get().sendStateEvent(this.props.room.roomId, 'm.room.avatar', {url: null}, '');
},
onShareRoomClick: function(ev) {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room dialog', '', ShareDialog, {
@ -180,65 +145,14 @@ module.exports = React.createClass({
return !(currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0);
},
/**
* After editing the settings, get the new name for the room
*
* @return {?string} newName or undefined if we didn't let the user edit the room name
*/
getEditedName: function() {
let newName;
if (this.refs.nameEditor) {
newName = this.refs.nameEditor.getRoomName();
}
return newName;
},
/**
* After editing the settings, get the new topic for the room
*
* @return {?string} newTopic or undefined if we didn't let the user edit the room topic
*/
getEditedTopic: function() {
let newTopic;
if (this.refs.topicEditor) {
newTopic = this.refs.topicEditor.getTopic();
}
return newTopic;
},
render: function() {
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
const ChangeAvatar = sdk.getComponent("settings.ChangeAvatar");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const EmojiText = sdk.getComponent('elements.EmojiText');
let name = null;
let searchStatus = null;
let topicElement = null;
let cancelButton = null;
let spinner = null;
let saveButton = null;
let settingsButton = null;
let pinnedEventsButton = null;
let canSetRoomName;
let canSetRoomAvatar;
let canSetRoomTopic;
if (this.props.editing) {
// calculate permissions. XXX: this should be done on mount or something
const userId = MatrixClientPeg.get().credentials.userId;
canSetRoomName = this.props.room.currentState.maySendStateEvent('m.room.name', userId);
canSetRoomAvatar = this.props.room.currentState.maySendStateEvent('m.room.avatar', userId);
canSetRoomTopic = this.props.room.currentState.maySendStateEvent('m.room.topic', userId);
saveButton = (
<AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>
{ _t("Save") }
</AccessibleButton>
);
}
const e2eIcon = this.props.e2eStatus ?
<E2EIcon status={this.props.e2eStatus} /> :
undefined;
@ -247,103 +161,68 @@ module.exports = React.createClass({
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
}
if (this.props.saving) {
const Spinner = sdk.getComponent("elements.Spinner");
spinner = <div className="mx_RoomHeader_spinner"><Spinner /></div>;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo &&
this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
</div>;
}
if (canSetRoomName) {
const RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor");
name = <RoomNameEditor ref="nameEditor" room={this.props.room} />;
} else {
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo &&
this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
</div>;
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
}
let roomName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
roomName = this.props.oobData.name;
} else if (this.props.room) {
roomName = this.props.room.name;
}
const emojiTextClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<EmojiText dir="auto" element="div" className={emojiTextClasses} title={roomName}>{ roomName }</EmojiText>
{ searchStatus }
</div>;
}
if (canSetRoomTopic) {
const RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor");
topicElement = <RoomTopicEditor ref="topicEditor" room={this.props.room} />;
} else {
let topic;
if (this.props.room) {
const ev = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (ev) {
topic = ev.getContent().topic;
}
}
topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
let roomName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
roomName = this.props.oobData.name;
} else if (this.props.room) {
roomName = this.props.room.name;
}
let roomAvatar = null;
const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
const name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>
{ searchStatus }
</div>;
let topic;
if (this.props.room) {
const ev = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (ev) {
topic = ev.getContent().topic;
}
}
const topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
const avatarSize = 28;
if (canSetRoomAvatar) {
roomAvatar = (
<div className="mx_RoomHeader_avatarPicker">
<div onClick={this.onAvatarPickerClick}>
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={avatarSize} height={avatarSize} />
</div>
<div className="mx_RoomHeader_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
<img src={require("../../../../res/img/camera.svg")}
alt={_t("Upload avatar")} title={_t("Upload avatar")}
width="17" height="15" />
</label>
<input id="avatarInput" type="file" onChange={this.onAvatarSelected} />
</div>
<div className="mx_RoomHeader_avatarPicker_remove" onClick={this.onAvatarRemoveClick}>
<img src={require("../../../../res/img/cancel.svg")}
className="mx_filterFlipColor"
width="10"
alt={_t("Remove avatar")}
title={_t("Remove avatar")} />
</div>
</div>
);
} else if (this.props.room || (this.props.oobData && this.props.oobData.name)) {
roomAvatar = (
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} oobData={this.props.oobData}
viewAvatarOnClick={true} />
);
let roomAvatar;
if (this.props.room) {
roomAvatar = (<RoomAvatar
room={this.props.room}
width={avatarSize}
height={avatarSize}
oobData={this.props.oobData}
viewAvatarOnClick={true} />);
}
if (this.props.onSettingsClick) {
settingsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}>
<TintableSvg src={require("../../../../res/img/feather-icons/settings.svg")} width="20" height="20" />
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_settingsButton"
onClick={this.props.onSettingsClick}
title={_t("Settings")}
>
</AccessibleButton>;
}
@ -359,7 +238,6 @@ module.exports = React.createClass({
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
onClick={this.props.onPinnedClick} title={_t("Pinned Messages")}>
{ pinsIndicator }
<TintableSvg src={require("../../../../res/img/icons-pin.svg")} width="16" height="16" />
</AccessibleButton>;
}
@ -374,28 +252,33 @@ module.exports = React.createClass({
let forgetButton;
if (this.props.onForgetClick) {
forgetButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title={_t("Forget room")}>
<TintableSvg src={require("../../../../res/img/leave.svg")} width="26" height="20" />
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
>
</AccessibleButton>;
}
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={_t("Search")}>
<TintableSvg src={require("../../../../res/img/feather-icons/search.svg")} width="20" height="20" />
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
>
</AccessibleButton>;
}
let shareRoomButton;
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShareRoomClick} title={_t('Share room')}>
<TintableSvg src={require("../../../../res/img/feather-icons/share.svg")} width="20" height="20" />
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_shareButton"
onClick={this.onShareRoomClick}
title={_t('Share room')}
>
</AccessibleButton>;
}
let rightRow;
let manageIntegsButton;
if (this.props.room && this.props.room.roomId && this.props.inRoom) {
manageIntegsButton = <ManageIntegsButton
@ -403,27 +286,23 @@ module.exports = React.createClass({
/>;
}
if (!this.props.editing) {
rightRow =
<div className="mx_RoomHeader_buttons">
{ settingsButton }
{ pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
</div>;
}
const rightRow =
<div className="mx_RoomHeader_buttons">
{ settingsButton }
{ pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
</div>;
return (
<div className={"mx_RoomHeader light-panel " + (this.props.editing ? "mx_RoomHeader_editing" : "")}>
<div className="mx_RoomHeader light-panel">
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ e2eIcon }
{ name }
{ topicElement }
{ spinner }
{ saveButton }
{ cancelButton }
{ rightRow }
<RoomHeaderButtons collapsedRhs={this.props.collapsedRhs} />

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict';
import SettingsStore from "../../../settings/SettingsStore";
import Timer from "../../../utils/Timer";
const React = require("react");
const ReactDOM = require("react-dom");
@ -32,14 +33,16 @@ import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
import ResizeHandle from '../elements/ResizeHandle';
import {Resizer} from '../../../resizer'
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
const HOVER_MOVE_TIMEOUT = 1000;
function labelForTagName(tagName) {
if (tagName.startsWith('u.')) return tagName.slice(2);
@ -72,6 +75,7 @@ module.exports = React.createClass({
getInitialState: function() {
this._hoverClearTimer = null;
this._subListRefs = {
// key => RoomSubList ref
};
@ -94,7 +98,7 @@ module.exports = React.createClass({
// update overflow indicators
this._checkSubListsOverflow();
// don't store height for collapsed sublists
if(!this.collapsedState[key]) {
if (!this.collapsedState[key]) {
this.subListSizes[key] = size;
window.localStorage.setItem("mx_roomlist_sizes",
JSON.stringify(this.subListSizes));
@ -121,6 +125,7 @@ module.exports = React.createClass({
incomingCall: null,
selectedTags: [],
hover: false,
customTags: CustomRoomTagStore.getTags(),
};
},
@ -170,6 +175,15 @@ module.exports = React.createClass({
this._delayedRefreshRoomList();
});
if (SettingsStore.isFeatureEnabled("feature_custom_tags")) {
this._customTagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({
customTags: CustomRoomTagStore.getTags(),
});
});
}
this.refreshRoomList();
// order of the sublists
@ -183,7 +197,7 @@ module.exports = React.createClass({
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
const cfg = {
layout: this._layout,
getLayout: () => this._layout,
};
this.resizer = new Resizer(this.resizeContainer, Distributor, cfg);
this.resizer.setClassNames({
@ -198,7 +212,9 @@ module.exports = React.createClass({
this._checkSubListsOverflow();
this.resizer.attach();
window.addEventListener("resize", this.onWindowResize);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("leftPanelResized", this.onResize);
}
this.mounted = true;
},
@ -246,7 +262,6 @@ module.exports = React.createClass({
componentWillUnmount: function() {
this.mounted = false;
window.removeEventListener("resize", this.onWindowResize);
dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
@ -259,6 +274,11 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize);
}
if (this._tagStoreToken) {
this._tagStoreToken.remove();
}
@ -266,6 +286,9 @@ module.exports = React.createClass({
if (this._roomListStoreToken) {
this._roomListStoreToken.remove();
}
if (this._customTagStoreToken) {
this._customTagStoreToken.remove();
}
// NB: GroupStore is not a Flux.Store
if (this._groupStoreToken) {
@ -276,13 +299,14 @@ module.exports = React.createClass({
this._delayedRefreshRoomList.cancelPendingCall();
},
onWindowResize: function() {
onResize: function() {
if (this.mounted && this._layout && this.resizeContainer &&
Array.isArray(this._layoutSections)
) {
this._layout.update(
this._layoutSections,
this.resizeContainer.offsetHeight
this.resizeContainer.offsetHeight,
);
}
},
@ -343,11 +367,32 @@ module.exports = React.createClass({
this.forceUpdate();
},
onMouseEnter: function(ev) {
this.setState({hover: true});
onMouseMove: async function(ev) {
if (!this._hoverClearTimer) {
this.setState({hover: true});
this._hoverClearTimer = new Timer(HOVER_MOVE_TIMEOUT);
this._hoverClearTimer.start();
let finished = true;
try {
await this._hoverClearTimer.finished();
} catch (err) {
finished = false;
}
this._hoverClearTimer = null;
if (finished) {
this.setState({hover: false});
this._delayedRefreshRoomList();
}
} else {
this._hoverClearTimer.restart();
}
},
onMouseLeave: function(ev) {
if (this._hoverClearTimer) {
this._hoverClearTimer.abort();
this._hoverClearTimer = null;
}
this.setState({hover: false});
// Refresh the room list just in case the user missed something.
@ -705,6 +750,7 @@ module.exports = React.createClass({
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})},
addRoomLabel: _t("Start chat"),
},
{
list: this.state.lists['im.vector.fake.recent'],
@ -717,7 +763,8 @@ module.exports = React.createClass({
];
const tagSubLists = Object.keys(this.state.lists)
.filter((tagName) => {
return !tagName.match(STANDARD_TAGS_REGEX);
return (!this.state.customTags || this.state.customTags[tagName]) &&
!tagName.match(STANDARD_TAGS_REGEX);
}).map((tagName) => {
return {
list: this.state.lists[tagName],
@ -759,7 +806,7 @@ module.exports = React.createClass({
return (
<div ref={this._collectResizeContainer} className="mx_RoomList"
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave}>
{ subListComponents }
</div>
);

View file

@ -17,13 +17,29 @@ limitations under the License.
'use strict';
const React = require('react');
import React from 'react';
import PropTypes from 'prop-types';
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
Loading: "Loading",
Rejecting: "Rejecting",
Kicked: "Kicked",
Banned: "Banned",
OtherThreePIDError: "OtherThreePIDError",
InvitedEmailMismatch: "InvitedEmailMismatch",
Invite: "Invite",
ViewingRoom: "ViewingRoom",
RoomNotFound: "RoomNotFound",
OtherError: "OtherError",
});
module.exports = React.createClass({
displayName: 'RoomPreviewBar',
@ -31,7 +47,6 @@ module.exports = React.createClass({
onJoinClick: PropTypes.func,
onRejectClick: 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
inviterName: PropTypes.string,
@ -50,7 +65,9 @@ module.exports = React.createClass({
// purpose of the spinner.
spinner: PropTypes.bool,
spinnerState: PropTypes.oneOf(["joining"]),
loading: PropTypes.bool,
joining: PropTypes.bool,
rejecting: PropTypes.bool,
// The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg.
// in error messages).
@ -60,7 +77,6 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
onJoinClick: function() {},
canPreview: true,
};
},
@ -72,7 +88,7 @@ module.exports = React.createClass({
componentWillMount: function() {
// If this is an invite and we've been told what email
// address was invited, fetch the user's list of 3pids
// address was invited, fetch the user's list of Threepids
// so we can check them against the one that was invited
if (this.props.inviterName && this.props.invitedEmail) {
this.setState({busy: true});
@ -88,157 +104,355 @@ module.exports = React.createClass({
}
},
_roomNameElement: function() {
return this.props.room ? this.props.room.name : (this.props.room_alias || "");
_getMessageCase() {
const isGuest = MatrixClientPeg.get().isGuest();
if (isGuest) {
return MessageCase.NotLoggedIn;
}
const myMember = this._getMyMember();
if (myMember) {
if (myMember.isKicked()) {
return MessageCase.Kicked;
} else if (myMember.membership === "ban") {
return MessageCase.Banned;
}
}
if (this.props.joining) {
return MessageCase.Joining;
} else if (this.props.rejecting) {
return MessageCase.Rejecting;
} else if (this.props.loading || this.state.busy) {
return MessageCase.Loading;
}
if (this.props.inviterName) {
if (this.props.invitedEmail) {
if (this.state.threePidFetchError) {
return MessageCase.OtherThreePIDError;
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().getUserId()) {
return MessageCase.InvitedEmailMismatch;
}
}
return MessageCase.Invite;
} else if (this.props.error) {
if (this.props.error.errcode == 'M_NOT_FOUND') {
return MessageCase.RoomNotFound;
} else {
return MessageCase.OtherError;
}
} else {
return MessageCase.ViewingRoom;
}
},
_getKickOrBanInfo() {
const myMember = this._getMyMember();
if (!myMember) {
return {};
}
const kickerMember = this.props.room.currentState.getMember(
myMember.events.member.getSender(),
);
const memberName = kickerMember ?
kickerMember.name : myMember.events.member.getSender();
const reason = myMember.events.member.getContent().reason;
return {memberName, reason};
},
_joinRule: function() {
const room = this.props.room;
if (room) {
const joinRules = room.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
return joinRules.getContent().join_rule;
}
}
},
_roomName: function(atStart = false) {
const name = this.props.room ? this.props.room.name : this.props.roomAlias;
if (name) {
return name;
} else if (atStart) {
return _t("This room");
} else {
return _t("this room");
}
},
_getMyMember() {
return (
this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId())
);
},
_getInviteMember: function() {
const {room} = this.props;
if (!room) {
return;
}
const myUserId = MatrixClientPeg.get().getUserId();
const inviteEvent = room.currentState.getMember(myUserId);
if (!inviteEvent) {
return;
}
const inviterUserId = inviteEvent.events.member.getSender();
return room.currentState.getMember(inviterUserId);
},
_isDMInvite() {
const myMember = this._getMyMember();
if (!myMember) {
return false;
}
const memberEvent = myMember.events.member;
const memberContent = memberEvent.getContent();
return memberContent.membership === "invite" && memberContent.is_direct;
},
onLoginClick: function() {
dis.dispatch({ action: 'start_login' });
},
onRegisterClick: function() {
dis.dispatch({ action: 'start_registration' });
},
render: function() {
let joinBlock; let previewBlock;
let showSpinner = false;
let darkStyle = false;
let title;
let subTitle;
let primaryActionHandler;
let primaryActionLabel;
let secondaryActionHandler;
let secondaryActionLabel;
if (this.props.spinner || this.state.busy) {
const Spinner = sdk.getComponent("elements.Spinner");
let spinnerIntro = "";
if (this.props.spinnerState === "joining") {
spinnerIntro = _t("Joining room...");
const messageCase = this._getMessageCase();
switch (messageCase) {
case MessageCase.Joining: {
title = _t("Joining room …");
showSpinner = true;
break;
}
case MessageCase.Loading: {
title = _t("Loading …");
showSpinner = true;
break;
}
case MessageCase.Rejecting: {
title = _t("Rejecting invite …");
showSpinner = true;
break;
}
case MessageCase.NotLoggedIn: {
darkStyle = true;
title = _t("Join the conversation with an account");
primaryActionLabel = _t("Sign Up");
primaryActionHandler = this.onRegisterClick;
secondaryActionLabel = _t("Sign In");
secondaryActionHandler = this.onLoginClick;
break;
}
case MessageCase.Kicked: {
const {memberName, reason} = this._getKickOrBanInfo();
title = _t("You were kicked from %(roomName)s by %(memberName)s",
{memberName, roomName: this._roomName()});
subTitle = _t("Reason: %(reason)s", {reason});
if (this._joinRule() === "invite") {
primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick;
} else {
primaryActionLabel = _t("Re-join");
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Forget this room");
secondaryActionHandler = this.props.onForgetClick;
}
break;
}
case MessageCase.Banned: {
const {memberName, reason} = this._getKickOrBanInfo();
title = _t("You were banned from %(roomName)s by %(memberName)s",
{memberName, roomName: this._roomName()});
subTitle = _t("Reason: %(reason)s", {reason});
primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick;
break;
}
case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to %(roomName)s",
{roomName: this._roomName()});
const joinRule = this._joinRule();
const errCodeMessage = _t("%(errcode)s was returned while trying to valide your invite. You could try to pass this information on to a room admin.",
{errcode: this.state.threePidFetchError.errcode},
);
switch (joinRule) {
case "invite":
subTitle = [
_t("You can only join it with a working invite."),
errCodeMessage,
];
break;
case "public":
subTitle = _t("You can still join it because this is a public room.");
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
break;
default:
subTitle = errCodeMessage;
primaryActionLabel = _t("Try to join anyway");
primaryActionHandler = this.props.onJoinClick;
break;
}
break;
}
case MessageCase.InvitedEmailMismatch: {
title = _t("This invite to %(roomName)s wasn't sent to your account",
{roomName: this._roomName()});
const joinRule = this._joinRule();
if (joinRule === "public") {
subTitle = _t("You can still join it because this is a public room.");
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
} else {
subTitle = _t(
"Sign in with a different account, ask for another invite, or " +
"add the e-mail address %(email)s to this account.",
{email: this.props.invitedEmail},
);
if (joinRule !== "invite") {
primaryActionLabel = _t("Try to join anyway");
primaryActionHandler = this.props.onJoinClick;
}
}
break;
}
case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const avatar = <RoomAvatar room={this.props.room} />;
const inviteMember = this._getInviteMember();
let inviterElement;
if (inviteMember) {
inviterElement = <span>
<span className="mx_RoomPreviewBar_inviter">
{inviteMember.rawDisplayName}
</span> ({inviteMember.userId})
</span>;
} else {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{this.props.inviterName}</span>);
}
const isDM = this._isDMInvite();
if (isDM) {
title = _t("Do you want to chat with %(user)s?",
{ user: inviteMember.name });
} else {
title = _t("Do you want to join %(roomName)s?",
{ roomName: this._roomName() });
}
subTitle = [
avatar,
_t("<userName/> invited you", {}, {userName: () => inviterElement}),
];
primaryActionLabel = _t("Accept");
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("Reject");
secondaryActionHandler = this.props.onRejectClick;
break;
}
case MessageCase.ViewingRoom: {
if (this.props.canPreview) {
title = _t("You're previewing %(roomName)s. Want to join it?",
{roomName: this._roomName()});
} else {
title = _t("%(roomName)s can't be previewed. Do you want to join it?",
{roomName: this._roomName(true)});
}
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.RoomNotFound: {
title = _t("%(roomName)s does not exist.", {roomName: this._roomName(true)});
subTitle = _t("This room doesn't exist. Are you sure you're at the right place?");
break;
}
case MessageCase.OtherError: {
title = _t("%(roomName)s is not accessible at this time.", {roomName: this._roomName(true)});
subTitle = [
_t("Try again later, or ask a room admin to check if you have access."),
_t(
"%(errcode)s was returned while trying to access the room. " +
"If you think you're seeing this message in error, please " +
"<issueLink>submit a bug report</issueLink>.",
{ errcode: this.props.error.errcode },
{ issueLink: label => <a href="https://github.com/vector-im/riot-web/issues/new/choose"
target="_blank" rel="noopener">{ label }</a> },
),
];
break;
}
return (<div className="mx_RoomPreviewBar">
<p className="mx_RoomPreviewBar_spinnerIntro">{ spinnerIntro }</p>
<Spinner />
</div>);
}
const myMember = this.props.room ?
this.props.room.getMember(MatrixClientPeg.get().getUserId()) :
null;
const kicked = myMember && myMember.isKicked();
const banned = myMember && myMember && myMember.membership == 'ban';
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner');
if (this.props.inviterName) {
let emailMatchBlock;
if (this.props.invitedEmail) {
if (this.state.threePidFetchError) {
emailMatchBlock = <div className="error">
{ _t("Unable to ascertain that the address this invite was sent to matches one associated with your account.") }
</div>;
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) {
emailMatchBlock =
<div className="mx_RoomPreviewBar_warning">
<div className="mx_RoomPreviewBar_warningIcon">
<img src={require("../../../../res/img/warning.svg")} width="24" height="23" title= "/!\\" alt="/!\\" />
</div>
<div className="mx_RoomPreviewBar_warningText">
{ _t("This invitation was sent to an email address which is not associated with this account:") }
<b><span className="email">{ this.props.invitedEmail }</span></b>
<br />
{ _t("You may wish to login with a different account, or add this email to this account.") }
</div>
</div>;
}
}
joinBlock = (
<div>
<div className="mx_RoomPreviewBar_invite_text">
{ _t('You have been invited to join this room by %(inviterName)s', {inviterName: this.props.inviterName}) }
</div>
<div className="mx_RoomPreviewBar_join_text">
{ _t(
'Would you like to <acceptText>accept</acceptText> or <declineText>decline</declineText> this invitation?',
{},
{
'acceptText': (sub) => <a onClick={this.props.onJoinClick}>{ sub }</a>,
'declineText': (sub) => <a onClick={this.props.onRejectClick}>{ sub }</a>,
},
) }
</div>
{ emailMatchBlock }
</div>
);
} else if (kicked || banned) {
const roomName = this._roomNameElement();
const kickerMember = this.props.room.currentState.getMember(
myMember.events.member.getSender(),
);
const kickerName = kickerMember ?
kickerMember.name : myMember.events.member.getSender();
let reason;
if (myMember.events.member.getContent().reason) {
reason = <div>{ _t("Reason: %(reasonText)s", {reasonText: myMember.events.member.getContent().reason}) }</div>;
}
let rejoinBlock;
if (!banned) {
rejoinBlock = <div><a onClick={this.props.onJoinClick}><b>{ _t("Rejoin") }</b></a></div>;
let subTitleElements;
if (subTitle) {
if (!Array.isArray(subTitle)) {
subTitle = [subTitle];
}
subTitleElements = subTitle.map((t, i) => <p key={`subTitle${i}`}>{t}</p>);
}
let actionText;
if (kicked) {
if (roomName) {
actionText = _t("You have been kicked from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
} else {
actionText = _t("You have been kicked from this room by %(userName)s.", {userName: kickerName});
}
} else if (banned) {
if (roomName) {
actionText = _t("You have been banned from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
} else {
actionText = _t("You have been banned from this room by %(userName)s.", {userName: kickerName});
}
} // no other options possible due to the kicked || banned check above.
joinBlock = (
<div>
<div className="mx_RoomPreviewBar_join_text">
{ actionText }
<br />
{ reason }
{ rejoinBlock }
<a onClick={this.props.onForgetClick}><b>{ _t("Forget room") }</b></a>
</div>
</div>
);
} else if (this.props.error) {
const name = this.props.roomAlias || _t("This room");
let error;
if (this.props.error.errcode == 'M_NOT_FOUND') {
error = _t("%(roomName)s does not exist.", {roomName: name});
} else {
error = _t("%(roomName)s is not accessible at this time.", {roomName: name});
}
joinBlock = (
<div>
<div className="mx_RoomPreviewBar_join_text">
{ error }
</div>
</div>
);
let titleElement;
if (showSpinner) {
titleElement = <h3 className="mx_RoomPreviewBar_spinnerTitle"><Spinner />{ title }</h3>;
} else {
const name = this._roomNameElement();
joinBlock = (
<div>
<div className="mx_RoomPreviewBar_join_text">
{ name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
<br />
{ _t("<a>Click here</a> to join the discussion!",
{},
{ 'a': (sub) => <a onClick={this.props.onJoinClick}><b>{ sub }</b></a> },
) }
</div>
</div>
titleElement = <h3>{ title }</h3>;
}
let primaryButton;
if (primaryActionHandler) {
primaryButton = (
<AccessibleButton kind="primary" onClick={primaryActionHandler}>
{ primaryActionLabel }
</AccessibleButton>
);
}
if (this.props.canPreview) {
previewBlock = (
<div className="mx_RoomPreviewBar_preview_text">
{ _t('This is a preview of this room. Room interactions have been disabled') }.
</div>
let secondaryButton;
if (secondaryActionHandler) {
secondaryButton = (
<AccessibleButton kind="secondary" onClick={secondaryActionHandler}>
{ secondaryActionLabel }
</AccessibleButton>
);
}
const classes = classNames("mx_RoomPreviewBar", "dark-panel", `mx_RoomPreviewBar_${messageCase}`, {
"mx_RoomPreviewBar_panel": this.props.canPreview,
"mx_RoomPreviewBar_dialog": !this.props.canPreview,
"mx_RoomPreviewBar_dark": darkStyle,
});
return (
<div className="mx_RoomPreviewBar">
<div className="mx_RoomPreviewBar_wrapper">
{ joinBlock }
{ previewBlock }
<div className={classes}>
<div className="mx_RoomPreviewBar_message">
{ titleElement }
{ subTitleElements }
</div>
<div className="mx_RoomPreviewBar_actions">
{ secondaryButton }
{ primaryButton }
</div>
</div>
);

View file

@ -38,7 +38,8 @@ export default class RoomRecoveryReminder extends React.PureComponent {
this.state = {
loading: true,
error: null,
unverifiedDevice: null,
backupInfo: null,
notNowClicked: false,
};
}
@ -47,10 +48,12 @@ export default class RoomRecoveryReminder extends React.PureComponent {
}
async _loadBackupStatus() {
let backupSigStatus;
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo);
this.setState({
loading: false,
backupInfo,
});
} catch (e) {
console.log("Unable to fetch key backup status", e);
this.setState({
@ -59,44 +62,24 @@ export default class RoomRecoveryReminder extends React.PureComponent {
});
return;
}
let unverifiedDevice;
for (const sig of backupSigStatus.sigs) {
if (sig.device && !sig.device.isVerified()) {
unverifiedDevice = sig.device;
break;
}
}
this.setState({
loading: false,
unverifiedDevice,
});
}
showSetupDialog = () => {
if (this.state.unverifiedDevice) {
if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not
// verified, so we'll show the device verify dialog.
// TODO: Should change to a restore key backup flow that checks the recovery
// passphrase while at the same time also cross-signing the device as well in
// a single flow (for cases where a key backup exists but the backup creating
// device is unverified). Since we don't have that yet, we'll look for an
// unverified device and verify it. Note that this means we won't restore
// keys yet; instead we'll only trust the backup for sending our own new keys
// to it.
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: this.state.unverifiedDevice,
});
return;
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
);
}
}
// The default case assumes that a key backup doesn't exist for this account, so
// we'll show the create key backup flow.
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
);
onOnNotNowClick = () => {
this.setState({notNowClicked: true});
}
onDontAskAgainClick = () => {
@ -126,60 +109,55 @@ export default class RoomRecoveryReminder extends React.PureComponent {
}
render() {
if (this.state.loading) {
// If there was an error loading just don't display the banner: we'll try again
// next time the user switchs to the room.
if (this.state.error || this.state.loading || this.state.notNowClicked) {
return null;
}
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
let body;
let primaryCaption = _t("Set up");
if (this.state.error) {
body = <div className="error">
{_t("Unable to load key backup status")}
</div>;
} else if (this.state.unverifiedDevice) {
// A key backup exists for this account, but the creating device is not
// verified.
body = <div>
<p>{_t(
"Secure Message Recovery has been set up on another device: <deviceName></deviceName>",
{},
{
deviceName: () => <i>{this.state.unverifiedDevice.unsigned.device_display_name}</i>,
},
)}</p>
<p>{_t(
"To view your secure message history and ensure you can view new " +
"messages on future devices, verify that device now.",
)}</p>
</div>;
primaryCaption = _t("Verify device");
let setupCaption;
if (this.state.backupInfo) {
setupCaption = _t("Connect this device to Key Backup");
} else {
// The default case assumes that a key backup doesn't exist for this account.
// (This component doesn't currently check that itself.)
body = _t(
"If you log out or use another device, you'll lose your " +
"secure message history. To prevent this, set up Secure " +
"Message Recovery.",
);
setupCaption = _t("Start using Key Backup");
}
return (
<div className="mx_RoomRecoveryReminder">
<div className="mx_RoomRecoveryReminder_header">{_t(
"Secure Message Recovery",
"Never lose encrypted messages",
)}</div>
<div className="mx_RoomRecoveryReminder_body">{body}</div>
<div className="mx_RoomRecoveryReminder_body">
<p>{_t(
"Messages in this room are secured with end-to-end " +
"encryption. Only you and the recipient(s) have the " +
"keys to read these messages.",
)}</p>
<p>{_t(
"Securely back up your keys to avoid losing them. " +
"<a>Learn more.</a>", {},
{
// TODO: We don't have this link yet: this will prevent the translators
// having to re-translate the string when we do.
a: sub => '',
},
)}</p>
</div>
<div className="mx_RoomRecoveryReminder_buttons">
<AccessibleButton className="mx_RoomRecoveryReminder_button mx_RoomRecoveryReminder_secondary"
onClick={this.onDontAskAgainClick}>
{ _t("Don't ask again") }
</AccessibleButton>
<AccessibleButton className="mx_RoomRecoveryReminder_button"
onClick={this.onSetupClick}>
{primaryCaption}
{setupCaption}
</AccessibleButton>
<p><AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
onClick={this.onOnNotNowClick}>
{ _t("Not now") }
</AccessibleButton></p>
<p><AccessibleButton className="mx_RoomRecoveryReminder_secondary mx_linkButton"
onClick={this.onDontAskAgainClick}>
{ _t("Don't ask me again") }
</AccessibleButton></p>
</div>
</div>
);

File diff suppressed because it is too large Load diff

View file

@ -68,12 +68,11 @@ module.exports = React.createClass({
},
_shouldShowNotifBadge: function() {
const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
return showBadgeInStates.indexOf(this.state.notifState) > -1;
return RoomNotifs.BADGE_STATES.includes(this.state.notifState);
},
_shouldShowMentionBadge: function() {
return this.state.notifState !== RoomNotifs.MUTE;
return RoomNotifs.MENTION_BADGE_STATES.includes(this.state.notifState);
},
_isDirectMessageRoom: function(roomId) {
@ -108,13 +107,6 @@ module.exports = React.createClass({
return statusUser._unstable_statusMessage;
},
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({
@ -159,7 +151,6 @@ 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);
@ -179,7 +170,6 @@ module.exports = React.createClass({
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);
@ -306,7 +296,7 @@ module.exports = React.createClass({
render: function() {
const isInvite = this.props.room.getMyMembership() === "invite";
const notificationCount = this.state.notificationCount;
const notificationCount = this.props.notificationCount;
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge();
@ -352,7 +342,6 @@ module.exports = React.createClass({
badge = <div className={badgeClasses}>{ badgeContent }</div>;
}
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
let subtextLabel;
let tooltip;
@ -364,17 +353,10 @@ module.exports = React.createClass({
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
if (this.state.selected) {
const nameSelected = <EmojiText>{ name }</EmojiText>;
label = <div title={name} className={nameClasses} dir="auto">{ nameSelected }</div>;
} else {
label = <EmojiText element="div" title={name} className={nameClasses} dir="auto">{ name }</EmojiText>;
}
label = <div title={name} className={nameClasses} dir="auto">{ name }</div>;
} else if (this.state.hover) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
tooltip = <RoomTooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
}
//var incomingCallBox;

View file

@ -1,115 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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 ReactDOM from 'react-dom';
import dis from '../../../dispatcher';
import classNames from 'classnames';
const MIN_TOOLTIP_HEIGHT = 25;
module.exports = React.createClass({
displayName: 'RoomTooltip',
propTypes: {
// Class applied to the element used to position the tooltip
className: React.PropTypes.string.isRequired,
// Class applied to the tooltip itself
tooltipClassName: React.PropTypes.string,
// The tooltip is derived from either the room name or a label
room: React.PropTypes.object,
label: React.PropTypes.node,
},
// Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() {
this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_RoomTileTooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this._renderTooltip, true);
this.parent = ReactDOM.findDOMNode(this).parentNode;
this._renderTooltip();
},
componentDidUpdate: function() {
this._renderTooltip();
},
// Remove the wrapper element, as the tooltip has finished using it
componentWillUnmount: function() {
dis.dispatch({
action: 'view_tooltip',
tooltip: null,
parent: null,
});
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this._renderTooltip, true);
},
_updatePosition(style) {
const parentBox = this.parent.getBoundingClientRect();
let offset = 0;
if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
offset = Math.floor((parentBox.height - MIN_TOOLTIP_HEIGHT) / 2);
}
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
style.left = 6 + parentBox.right + window.pageXOffset;
return style;
},
_renderTooltip: function() {
// Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom
// NOTE: The additional 6 pixels for the left position, is to take account of the
// tooltips chevron
const parent = ReactDOM.findDOMNode(this).parentNode;
let style = {};
style = this._updatePosition(style);
style.display = "block";
const tooltipClasses = classNames("mx_RoomTooltip", this.props.tooltipClassName);
const tooltip = (
<div className={tooltipClasses} style={style}>
<div className="mx_RoomTooltip_chevron" />
{ this.props.label }
</div>
);
// Render the tooltip manually, as we wish it not to be rendered within the parent
this.tooltip = ReactDOM.render(tooltip, this.tooltipContainer);
// Tell the roomlist about us so it can manipulate us if it wishes
dis.dispatch({
action: 'view_tooltip',
tooltip: this.tooltip,
parent: parent,
});
},
render: function() {
// Render a placeholder
return (
<div className={this.props.className} >
</div>
);
},
});

View file

@ -20,6 +20,7 @@ import sdk from '../../../index';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from "../../../MatrixClientPeg";
module.exports = React.createClass({
displayName: 'RoomUpgradeWarningBar',
@ -29,6 +30,24 @@ module.exports = React.createClass({
recommendation: PropTypes.object.isRequired,
},
componentWillMount: function() {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
},
_onStateEvents: function(event, state) {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
if (event.getType() !== "m.room.tombstone") return;
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
},
onUpgradeClick: function() {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: this.props.room});
@ -37,33 +56,40 @@ module.exports = React.createClass({
render: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let upgradeText = (
let doUpgradeWarnings = (
<div>
<div className="mx_RoomUpgradeWarningBar_body">
{_t("This room is using an unstable room version. If you aren't expecting " +
"this, please upgrade the room.")}
<p>
{_t(
"Upgrading this room will shut down the current instance of the room and create " +
"an upgraded room with the same name.",
)}
</p>
<p>
{_t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
"to the new version of the room.</i> We'll post a link to the new room in the old " +
"version of the room - room members will have to click this link to join the new room.",
{}, {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
},
)}
</p>
</div>
<p className="mx_RoomUpgradeWarningBar_upgradelink">
<AccessibleButton onClick={this.onUpgradeClick}>
{_t("Click here to upgrade to the latest room version.")}
{_t("Upgrade this room to the recommended room version")}
</AccessibleButton>
</p>
</div>
);
if (this.props.recommendation.urgent) {
upgradeText = (
<div>
<div className="mx_RoomUpgradeWarningBar_header">
{_t("There is a known vulnerability affecting this room.")}
</div>
<div className="mx_RoomUpgradeWarningBar_body">
{_t("This room version is vulnerable to malicious modification of room state.")}
</div>
<p className="mx_RoomUpgradeWarningBar_upgradelink">
<AccessibleButton onClick={this.onUpgradeClick}>
{_t("Click here to upgrade to the latest room version and ensure room integrity " +
"is protected.")}
</AccessibleButton>
if (this.state.upgraded) {
doUpgradeWarnings = (
<div className="mx_RoomUpgradeWarningBar_body">
<p>
{_t("This room has already been upgraded.")}
</p>
</div>
);
@ -71,7 +97,18 @@ module.exports = React.createClass({
return (
<div className="mx_RoomUpgradeWarningBar">
{upgradeText}
<div className="mx_RoomUpgradeWarningBar_header">
{_t(
"This room is running room version <roomVersion />, which this homeserver has " +
"marked as <i>unstable</i>.",
{},
{
"roomVersion": () => <code>{this.props.room.getVersion()}</code>,
"i": (sub) => <i>{sub}</i>,
},
)}
</div>
{doUpgradeWarnings}
<div className="mx_RoomUpgradeWarningBar_small">
{_t("Only room administrators will see this warning")}
</div>

View file

@ -33,7 +33,7 @@ module.exports = React.createClass({
// href for the highlights in this result
resultLink: PropTypes.string,
onWidgetLoad: PropTypes.func,
onHeightChanged: PropTypes.func,
},
render: function() {
@ -56,8 +56,9 @@ module.exports = React.createClass({
}
if (EventTile.haveTileForEvent(ev)) {
ret.push(<EventTile key={eventId+"+"+j} mxEvent={ev} contextual={contextual} highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}
onWidgetLoad={this.props.onWidgetLoad} />);
onHeightChanged={this.props.onHeightChanged} />);
}
}
return (

View file

@ -25,14 +25,20 @@ import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import PersistedElement from "../elements/PersistedElement";
const widgetType = 'm.stickerpicker';
// We sit in a context menu, so the persisted element container needs to float
// above it, so it needs a greater z-index than the ContextMenu
const STICKERPICKER_Z_INDEX = 5000;
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu.
const STICKERPICKER_Z_INDEX = 3500;
// Key to store the widget's AppTile under in PersistedElement
const PERSISTED_ELEMENT_KEY = "stickerPicker";
export default class Stickerpicker extends React.Component {
static currentWidget;
constructor(props) {
super(props);
this._onShowStickersClick = this._onShowStickersClick.bind(this);
@ -103,6 +109,9 @@ export default class Stickerpicker extends React.Component {
}
componentWillUnmount() {
const client = MatrixClientPeg.get();
if (client) client.removeListener('accountData', this._updateWidget);
window.removeEventListener('resize', this._onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
@ -123,6 +132,29 @@ export default class Stickerpicker extends React.Component {
_updateWidget() {
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
if (!stickerpickerWidget) {
Stickerpicker.currentWidget = null;
this.setState({stickerpickerWidget: null, widgetId: null});
return;
}
const currentWidget = Stickerpicker.currentWidget;
let currentUrl = null;
if (currentWidget && currentWidget.content && currentWidget.content.url) {
currentUrl = currentWidget.content.url;
}
let newUrl = null;
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
newUrl = stickerpickerWidget.content.url;
}
if (newUrl !== currentUrl) {
// Destroy the existing frame so a new one can be created
PersistedElement.destroyElement(PERSISTED_ELEMENT_KEY);
}
Stickerpicker.currentWidget = stickerpickerWidget;
this.setState({
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
@ -208,7 +240,7 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement persistKey="stickerPicker" style={{zIndex: STICKERPICKER_Z_INDEX}}>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
@ -226,6 +258,7 @@ export default class Stickerpicker extends React.Component {
showTitle={false}
showMinimise={true}
showDelete={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
@ -306,22 +339,25 @@ export default class Stickerpicker extends React.Component {
*/
_launchManageIntegrations() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
this.state.widgetId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
}, (err) => {
this.setState({imError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const ContextualMenu = sdk.getComponent('structures.ContextualMenu');
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
let stickersButton;
@ -339,6 +375,7 @@ export default class Stickerpicker extends React.Component {
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
zIndex={STICKERPICKER_Z_INDEX}
/>;
if (this.state.showStickers) {
@ -347,11 +384,10 @@ export default class Stickerpicker extends React.Component {
<AccessibleButton
id='stickersButton'
key="controls_hide_stickers"
className="mx_MessageComposer_stickers mx_Stickers_hideStickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers mx_Stickers_hideStickers"
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src={require("../../../../res/img/feather-icons/face.svg")} width="20" height="20" />
title={_t("Hide Stickers")}
>
</AccessibleButton>;
} else {
// Show show-stickers button
@ -359,10 +395,10 @@ export default class Stickerpicker extends React.Component {
<AccessibleButton
id='stickersButton'
key="controls_show_stickers"
className="mx_MessageComposer_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src={require("../../../../res/img/feather-icons/face.svg")} width="20" height="20" />
title={_t("Show Stickers")}
>
</AccessibleButton>;
}
return <div>

View file

@ -0,0 +1,143 @@
/*
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {MatrixEvent} from "matrix-js-sdk";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher";
import sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof(kickLevel) !== 'number') kickLevel = 50;
const sender = room.getMember(this.props.event.getSender());
this.state = {
stateKey: this.props.event.getStateKey(),
roomId: this.props.event.getRoomId(),
displayName: this.props.event.getContent().display_name,
invited: true,
canKick: me ? me.powerLevel > kickLevel : false,
senderName: sender ? sender.name : this.props.event.getSender(),
};
}
componentWillMount(): void {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
}
componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("RoomState.events", this.onRoomStateEvents);
}
}
onRoomStateEvents = (ev) => {
if (ev.getType() === "m.room.third_party_invite" && ev.getStateKey() === this.state.stateKey) {
const newDisplayName = ev.getContent().display_name;
const isInvited = isValid3pidInvite(ev);
const newState = {invited: isInvited};
if (newDisplayName) newState['displayName'] = newDisplayName;
this.setState(newState);
}
};
onCancel = () => {
dis.dispatch({
action: "view_3pid_invite",
event: null,
});
};
onKickClick = () => {
MatrixClientPeg.get().sendStateEvent(this.state.roomId, "m.room.third_party_invite", {}, this.state.stateKey)
.catch((err) => {
console.error(err);
// Revert echo because of error
this.setState({invited: true});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Revoke 3pid invite failed', '', ErrorDialog, {
title: _t("Failed to revoke invite"),
description: _t(
"Could not revoke the invite. The server may be experiencing a temporary problem or " +
"you do not have sufficient permissions to revoke the invite.",
),
});
});
// Local echo
this.setState({invited: false});
};
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
let adminTools = null;
if (this.state.canKick && this.state.invited) {
adminTools = (
<div className="mx_MemberInfo_container">
<h3>{_t("Admin Tools")}</h3>
<div className="mx_MemberInfo_buttons">
<AccessibleButton className="mx_MemberInfo_field" onClick={this.onKickClick}>
{_t("Revoke invite")}
</AccessibleButton>
</div>
</div>
);
}
// We shamelessly rip off the MemberInfo styles here.
return (
<div className="mx_MemberInfo">
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}
title={_t('Close')}
/>
<h2>{this.state.displayName}</h2>
</div>
<div className="mx_MemberInfo_container">
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{_t("Invited by %(sender)s", {sender: this.state.senderName})}
</div>
</div>
</div>
{adminTools}
</div>
);
}
}

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import WhoIsTyping from '../../../WhoIsTyping';
import Timer from '../../../utils/Timer';
import MatrixClientPeg from '../../../MatrixClientPeg';
@ -29,7 +28,8 @@ module.exports = React.createClass({
propTypes: {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
onVisible: PropTypes.func,
onShown: PropTypes.func,
onHidden: PropTypes.func,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
@ -59,11 +59,12 @@ module.exports = React.createClass({
},
componentDidUpdate: function(_, prevState) {
if (this.props.onVisible &&
!prevState.usersTyping.length &&
this.state.usersTyping.length
) {
this.props.onVisible();
const wasVisible = this._isVisible(prevState);
const isVisible = this._isVisible(this.state);
if (this.props.onShown && !wasVisible && isVisible) {
this.props.onShown();
} else if (this.props.onHidden && wasVisible && !isVisible) {
this.props.onHidden();
}
},
@ -77,8 +78,12 @@ module.exports = React.createClass({
Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort());
},
_isVisible: function(state) {
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
},
isVisible: function() {
return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0;
return this._isVisible(this.state);
},
onRoomTimeline: function(event, room) {
@ -170,6 +175,7 @@ module.exports = React.createClass({
width={24}
height={24}
resizeMethod="crop"
viewUserOnClick={true}
/>
);
});
@ -205,15 +211,13 @@ module.exports = React.createClass({
return (<div className="mx_WhoIsTypingTile_empty" />);
}
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<li className="mx_WhoIsTypingTile">
<div className="mx_WhoIsTypingTile_avatars">
{ this._renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) }
</div>
<div className="mx_WhoIsTypingTile_label">
<EmojiText>{ typingString }</EmojiText>
{ typingString }
</div>
</li>
);