Merge branch 'experimental' into bwindels/roomgridview-experimental

This commit is contained in:
Bruno Windels 2019-01-07 14:17:57 +01:00
commit 290dc9d8fb
146 changed files with 5292 additions and 1051 deletions

View file

@ -69,6 +69,7 @@ export default class AutoHideScrollbar extends React.Component {
this.onOverflow = this.onOverflow.bind(this);
this.onUnderflow = this.onUnderflow.bind(this);
this._collectContainerRef = this._collectContainerRef.bind(this);
this._needsOverflowListener = null;
}
onOverflow() {
@ -81,21 +82,35 @@ export default class AutoHideScrollbar extends React.Component {
this.containerRef.classList.add("mx_AutoHideScrollbar_underflow");
}
checkOverflow() {
if (!this._needsOverflowListener) {
return;
}
if (this.containerRef.scrollHeight > this.containerRef.clientHeight) {
this.onOverflow();
} else {
this.onUnderflow();
}
}
componentDidUpdate() {
this.checkOverflow();
}
componentDidMount() {
installBodyClassesIfNeeded();
this._needsOverflowListener =
document.body.classList.contains("mx_scrollbar_nooverlay");
if (this._needsOverflowListener) {
this.containerRef.addEventListener("overflow", this.onOverflow);
this.containerRef.addEventListener("underflow", this.onUnderflow);
}
this.checkOverflow();
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
const needsOverflowListener =
document.body.classList.contains("mx_scrollbar_nooverlay");
if (needsOverflowListener) {
this.containerRef.addEventListener("overflow", this.onOverflow);
this.containerRef.addEventListener("underflow", this.onUnderflow);
}
if (ref.scrollHeight > ref.clientHeight) {
this.onOverflow();
} else {
this.onUnderflow();
}
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
@ -103,14 +118,13 @@ export default class AutoHideScrollbar extends React.Component {
}
componentWillUnmount() {
if (this.containerRef) {
if (this._needsOverflowListener && this.containerRef) {
this.containerRef.removeEventListener("overflow", this.onOverflow);
this.containerRef.removeEventListener("underflow", this.onUnderflow);
}
}
render() {
installBodyClassesIfNeeded();
return (<div
ref={this._collectContainerRef}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}

View file

@ -473,7 +473,7 @@ export default React.createClass({
GroupStore.registerListener(groupId, this.onGroupStoreUpdated.bind(this, firstInit));
let willDoOnboarding = false;
// XXX: This should be more fluxy - let's get the error from GroupStore .getError or something
GroupStore.on('error', (err, errorGroupId) => {
GroupStore.on('error', (err, errorGroupId, stateKey) => {
if (this._unmounted || groupId !== errorGroupId) return;
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN' && !willDoOnboarding) {
dis.dispatch({
@ -486,11 +486,13 @@ export default React.createClass({
dis.dispatch({action: 'require_registration'});
willDoOnboarding = true;
}
this.setState({
summary: null,
error: err,
editing: false,
});
if (stateKey === GroupStore.STATE_KEY.Summary) {
this.setState({
summary: null,
error: err,
editing: false,
});
}
});
},
@ -514,7 +516,6 @@ export default React.createClass({
isUserMember: GroupStore.getGroupMembers(this.props.groupId).some(
(m) => m.userId === this._matrixClient.credentials.userId,
),
error: null,
});
// XXX: This might not work but this.props.groupIsNew unused anyway
if (this.props.groupIsNew && firstInit) {
@ -1079,6 +1080,7 @@ export default React.createClass({
},
_getJoinableNode: function() {
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
return this.state.editing ? <div>
<h3>
{ _t('Who can join this community?') }
@ -1160,7 +1162,7 @@ export default React.createClass({
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
return <Spinner />;
} else if (this.state.summary) {
} else if (this.state.summary && !this.state.error) {
const summary = this.state.summary;
let avatarNode;
@ -1272,15 +1274,6 @@ export default React.createClass({
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>,
);
if (this.props.collapsedRhs) {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
onClick={this._onShowRhsClick} title={_t('Show panel')} key="_maximiseButton"
>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
</AccessibleButton>,
);
}
}
const rightPanel = !this.props.collapsedRhs ? <RightPanel groupId={this.props.groupId} /> : undefined;
@ -1311,7 +1304,7 @@ export default React.createClass({
<div className="mx_GroupView_header_rightCol">
{ rightButtons }
</div>
<GroupHeaderButtons />
<GroupHeaderButtons collapsedRhs={this.props.collapsedRhs} />
</div>
<MainSplit collapsedRhs={this.props.collapsedRhs} panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body">

View file

@ -91,11 +91,15 @@ class HomePage extends React.Component {
this._unmounted = true;
}
onLoginClick() {
onLoginClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_login' });
}
onRegisterClick() {
onRegisterClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_registration' });
}

View file

@ -21,41 +21,52 @@ export default class IndicatorScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
}
_collectScroller(scroller) {
if (scroller && !this._scroller) {
this._scroller = scroller;
this._scroller.addEventListener("scroll", this.checkOverflow);
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow);
this.checkOverflow();
}
}
_collectScrollerComponent(autoHideScrollbar) {
this._autoHideScrollbar = autoHideScrollbar;
}
checkOverflow() {
const hasTopOverflow = this._scroller.scrollTop > 0;
const hasBottomOverflow = this._scroller.scrollHeight >
(this._scroller.scrollTop + this._scroller.clientHeight);
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
if (hasTopOverflow) {
this._scroller.classList.add("mx_IndicatorScrollbar_topOverflow");
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
this._scroller.classList.remove("mx_IndicatorScrollbar_topOverflow");
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
}
if (hasBottomOverflow) {
this._scroller.classList.add("mx_IndicatorScrollbar_bottomOverflow");
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else {
this._scroller.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
}
componentWillUnmount() {
if (this._scroller) {
this._scroller.removeEventListener("scroll", this.checkOverflow);
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
}
}
render() {
return (<AutoHideScrollbar wrappedRef={this._collectScroller} {... this.props}>
return (<AutoHideScrollbar ref={this._collectScrollerComponent} wrappedRef={this._collectScroller} {... this.props}>
{ this.props.children }
</AutoHideScrollbar>);
}

View file

@ -151,7 +151,7 @@ const LeftPanel = React.createClass({
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search")));
classes.contains("mx_textinput_search")));
if (element) {
element.focus();

View file

@ -63,7 +63,7 @@ const LoggedInView = React.createClass({
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
collapsedRhs: PropTypes.bool,
teamToken: PropTypes.string,
// Used by the RoomView to handle joining rooms
@ -447,7 +447,7 @@ const LoggedInView = React.createClass({
eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
/>;
break;
@ -499,7 +499,7 @@ const LoggedInView = React.createClass({
page_element = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
/>;
break;
}

View file

@ -41,10 +41,13 @@ export default class MainSplit extends React.Component {
{onResized: this._onResized},
);
resizer.setClassNames(classNames);
const rhsSize = window.localStorage.getItem("mx_rhs_size");
let rhsSize = window.localStorage.getItem("mx_rhs_size");
if (rhsSize !== null) {
resizer.forHandleAt(0).resize(parseInt(rhsSize, 10));
rhsSize = parseInt(rhsSize, 10);
} else {
rhsSize = 350;
}
resizer.forHandleAt(0).resize(rhsSize);
resizer.attach();
this.resizer = resizer;

View file

@ -48,6 +48,8 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
const AutoDiscovery = Matrix.AutoDiscovery;
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
Promise.config({warnings: false});
@ -161,7 +163,7 @@ export default React.createClass({
viewUserId: null,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true",
leftDisabled: false,
middleDisabled: false,
rightDisabled: false,
@ -181,6 +183,12 @@ export default React.createClass({
register_is_url: null,
register_id_sid: null,
// Parameters used for setting up the login/registration views
defaultServerName: this.props.config.default_server_name,
defaultHsUrl: this.props.config.default_hs_url,
defaultIsUrl: this.props.config.default_is_url,
defaultServerDiscoveryError: null,
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
@ -199,20 +207,24 @@ export default React.createClass({
};
},
getDefaultServerName: function() {
return this.state.defaultServerName;
},
getCurrentHsUrl: function() {
if (this.state.register_hs_url) {
return this.state.register_hs_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getHomeserverUrl();
} else if (window.localStorage && window.localStorage.getItem("mx_hs_url")) {
return window.localStorage.getItem("mx_hs_url");
} else {
return this.getDefaultHsUrl();
}
},
getDefaultHsUrl() {
return this.props.config.default_hs_url || "https://matrix.org";
getDefaultHsUrl(defaultToMatrixDotOrg) {
defaultToMatrixDotOrg = typeof(defaultToMatrixDotOrg) !== 'boolean' ? true : defaultToMatrixDotOrg;
if (!this.state.defaultHsUrl && defaultToMatrixDotOrg) return "https://matrix.org";
return this.state.defaultHsUrl;
},
getFallbackHsUrl: function() {
@ -224,15 +236,13 @@ export default React.createClass({
return this.state.register_is_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getIdentityServerUrl();
} else if (window.localStorage && window.localStorage.getItem("mx_is_url")) {
return window.localStorage.getItem("mx_is_url");
} else {
return this.getDefaultIsUrl();
}
},
getDefaultIsUrl() {
return this.props.config.default_is_url || "https://vector.im";
return this.state.defaultIsUrl || "https://vector.im";
},
componentWillMount: function() {
@ -282,6 +292,20 @@ export default React.createClass({
console.info(`Team token set to ${this._teamToken}`);
}
// Set up the default URLs (async)
if (this.getDefaultServerName() && !this.getDefaultHsUrl(false)) {
this.setState({loadingDefaultHomeserver: true});
this._tryDiscoverDefaultHomeserver(this.getDefaultServerName());
} else if (this.getDefaultServerName() && this.getDefaultHsUrl(false)) {
// Ideally we would somehow only communicate this to the server admins, but
// given this is at login time we can't really do much besides hope that people
// will check their settings.
this.setState({
defaultServerName: null, // To un-hide any secrets people might be keeping
defaultServerDiscoveryError: _t("Invalid configuration: Cannot supply a default homeserver URL and a default server name"),
});
}
// Set a default HS with query param `hs_url`
const paramHs = this.props.startingFragmentQueryParams.hs_url;
if (paramHs) {
@ -555,7 +579,7 @@ export default React.createClass({
break;
case 'view_user':
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
if (this.state.collapseRhs) {
if (this.state.collapsedRhs) {
setTimeout(()=>{
dis.dispatch({
action: 'show_right_panel',
@ -659,13 +683,15 @@ export default React.createClass({
});
break;
case 'hide_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", true);
this.setState({
collapseRhs: true,
collapsedRhs: true,
});
break;
case 'show_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", false);
this.setState({
collapseRhs: false,
collapsedRhs: false,
});
break;
case 'panel_disable': {
@ -676,9 +702,11 @@ export default React.createClass({
});
break;
}
case 'set_theme':
this._onSetTheme(payload.value);
break;
// case 'set_theme':
// disable changing the theme for now
// as other themes are not compatible with dharma
// this._onSetTheme(payload.value);
// break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// NB. This does not touch 'ready' since if our dispatches
@ -911,6 +939,10 @@ export default React.createClass({
},
_viewHome: function() {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
view: VIEWS.LOGGED_IN,
});
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
},
@ -1167,10 +1199,7 @@ export default React.createClass({
* @param {string} teamToken
*/
_onLoggedIn: async function(teamToken) {
this.setState({
view: VIEWS.LOGGED_IN,
});
this.setStateForNewView({view: VIEWS.LOGGED_IN});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
@ -1227,7 +1256,7 @@ export default React.createClass({
view: VIEWS.LOGIN,
ready: false,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: false,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
@ -1418,6 +1447,11 @@ export default React.createClass({
break;
}
});
cli.on("crypto.keyBackupFailed", () => {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
);
});
// Fire the tinter right on startup to ensure the default theme is applied
// A later sync can/will correct the tint to be the right value for the user
@ -1744,6 +1778,36 @@ export default React.createClass({
this.setState(newState);
},
_tryDiscoverDefaultHomeserver: async function(serverName) {
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS) {
console.error("Failed to discover homeserver on startup:", discovery);
this.setState({
defaultServerDiscoveryError: discovery["m.homeserver"].error,
loadingDefaultHomeserver: false,
});
} else {
const hsUrl = discovery["m.homeserver"].base_url;
const isUrl = discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "https://vector.im";
this.setState({
defaultHsUrl: hsUrl,
defaultIsUrl: isUrl,
loadingDefaultHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
defaultServerDiscoveryError: _t("Unknown error discovering homeserver"),
loadingDefaultHomeserver: false,
});
}
},
_makeRegistrationUrl: function(params) {
if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer;
@ -1758,7 +1822,7 @@ export default React.createClass({
render: function() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
if (this.state.view === VIEWS.LOADING || this.state.view === VIEWS.LOGGING_IN) {
if (this.state.view === VIEWS.LOADING || this.state.view === VIEWS.LOGGING_IN || this.state.loadingDefaultHomeserver) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
@ -1832,6 +1896,8 @@ export default React.createClass({
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand}
@ -1854,6 +1920,8 @@ export default React.createClass({
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
return (
<ForgotPassword
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
@ -1870,6 +1938,8 @@ export default React.createClass({
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}

View file

@ -110,8 +110,9 @@ const RoomSubList = React.createClass({
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden});
this.props.onHeaderClick(isHidden);
this.setState({hidden: isHidden}, () => {
this.props.onHeaderClick(isHidden);
});
} else {
// The header is stuck, so the click is to be interpreted as a scroll to the header
this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
@ -268,17 +269,10 @@ const RoomSubList = React.createClass({
let incomingCall;
if (this.props.incomingCall) {
const self = this;
// Check if the incoming call is for this section
const incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId;
});
if (incomingCallRoom.length === 1) {
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
// We can assume that if we have an incoming call then it is for this list
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
let addRoomButton;
@ -313,6 +307,12 @@ const RoomSubList = React.createClass({
);
},
checkOverflow: function() {
if (this.refs.scroller) {
this.refs.scroller.checkOverflow();
}
},
render: function() {
const len = this.props.list.length + this.props.extraTiles.length;
if (len) {
@ -330,7 +330,7 @@ const RoomSubList = React.createClass({
tiles.push(...this.props.extraTiles);
return <div className={subListClasses}>
{this._getHeaderJsx()}
<IndicatorScrollbar className="mx_RoomSubList_scroll">
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
{ tiles }
</IndicatorScrollbar>
</div>;

View file

@ -27,6 +27,7 @@ const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import filesize from 'filesize';
const classNames = require("classnames");
import { _t } from '../../languageHandler';
@ -103,6 +104,10 @@ module.exports = React.createClass({
roomLoading: true,
peekLoading: false,
shouldPeek: true,
// Media limits for uploading.
mediaConfig: undefined,
// used to trigger a rerender in TimelinePanel once the members are loaded,
// so RR are rendered again (now with the members available), ...
membersLoaded: !llMembers,
@ -158,7 +163,8 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
this._fetchMediaConfig();
// Start listening for RoomViewStore updates
this._roomStoreToken = this.props.roomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true);
@ -166,6 +172,27 @@ module.exports = React.createClass({
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
},
_fetchMediaConfig: function(invalidateCache: boolean = false) {
/// NOTE: Using global here so we don't make repeated requests for the
/// config every time we swap room.
if(global.mediaConfig !== undefined && !invalidateCache) {
this.setState({mediaConfig: global.mediaConfig});
return;
}
console.log("[Media Config] Fetching");
MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
global.mediaConfig = config;
this.setState({mediaConfig: config});
});
},
_onRoomViewStoreUpdate: function(initial) {
if (this.unmounted) {
return;
@ -424,6 +451,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus);
}
window.removeEventListener('beforeunload', this.onPageUnload);
@ -500,6 +528,10 @@ module.exports = React.createClass({
break;
case 'notifier_enabled':
case 'upload_failed':
// 413: File was too big or upset the server in some way.
if(payload.error.http_status === 413) {
this._fetchMediaConfig(true);
}
case 'upload_started':
case 'upload_finished':
this.forceUpdate();
@ -578,6 +610,25 @@ module.exports = React.createClass({
}
},
async onRoomRecoveryReminderFinished(backupCreated) {
// If the user cancelled the key backup dialog, it suggests they don't
// want to be reminded anymore.
if (!backupCreated) {
await SettingsStore.setValue(
"showRoomRecoveryReminder",
null,
SettingLevel.ACCOUNT,
false,
);
}
},
onKeyBackupStatus() {
// Key backup status changes affect whether the in-room recovery
// reminder is displayed.
this.forceUpdate();
},
canResetTimeline: function() {
if (!this.refs.messagePanel) {
return true;
@ -932,6 +983,15 @@ module.exports = React.createClass({
this.setState({ draggingFile: false });
},
isFileUploadAllowed(file) {
if (this.state.mediaConfig !== undefined &&
this.state.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.state.mediaConfig["m.upload.size"]) {
return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])});
}
return true;
},
uploadFile: async function(file) {
this.props.roomViewStore.getDispatcher().dispatch({action: 'focus_composer'});
@ -1483,6 +1543,7 @@ module.exports = React.createClass({
const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) {
@ -1622,6 +1683,13 @@ module.exports = React.createClass({
this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId)
);
const showRoomRecoveryReminder = (
SettingsStore.isFeatureEnabled("feature_keybackup") &&
SettingsStore.getValue("showRoomRecoveryReminder") &&
MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) &&
!MatrixClientPeg.get().getKeyBackupEnabled()
);
let aux = null;
let hideCancel = false;
if (this.state.editingRoomSettings) {
@ -1636,6 +1704,9 @@ module.exports = React.createClass({
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} />;
hideCancel = true;
} else if (showRoomRecoveryReminder) {
aux = <RoomRecoveryReminder onFinished={this.onRoomRecoveryReminderFinished} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
@ -1696,6 +1767,7 @@ module.exports = React.createClass({
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
uploadAllowed={this.isFileUploadAllowed}
/>;
}

View file

@ -56,7 +56,6 @@ module.exports = React.createClass({
case 'focus_room_filter':
if (this.refs.search) {
this.refs.search.focus();
this.refs.search.select();
}
break;
}
@ -83,6 +82,10 @@ module.exports = React.createClass({
}
},
_onFocus: function(ev) {
ev.target.select();
},
_clearSearch: function(source) {
this.refs.search.value = "";
this.onChange();
@ -108,6 +111,7 @@ module.exports = React.createClass({
ref="search"
className="mx_textinput_icon mx_textinput_search"
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }

View file

@ -23,6 +23,7 @@ import GroupActions from '../../actions/GroupActions';
import sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
@ -47,6 +48,8 @@ const TagPanel = React.createClass({
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this._dispatcherRef = dis.register(this._onAction);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
@ -67,6 +70,9 @@ const TagPanel = React.createClass({
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
}
},
_onGroupMyMembership() {
@ -100,13 +106,21 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'deselect_tags'});
},
_onAction(payload) {
if (payload.action === "show_redesign_feedback_dialog") {
const RedesignFeedbackDialog =
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
Modal.createDialog(RedesignFeedbackDialog);
}
},
render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const ActionButton = sdk.getComponent("elements.ActionButton");
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -162,7 +176,10 @@ const TagPanel = React.createClass({
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton tooltip={true} />
<GroupsButton />
<ActionButton
className="mx_TagPanel_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>
</div>;
},

View file

@ -64,6 +64,7 @@ const SIMPLE_SETTINGS = [
{ id: "urlPreviewsEnabled" },
{ id: "autoplayGifsAndVideos" },
{ id: "alwaysShowEncryptionIcons" },
{ id: "showRoomRecoveryReminder" },
{ id: "hideReadReceipts" },
{ id: "dontSendTypingNotifications" },
{ id: "alwaysShowTimestamps" },
@ -188,9 +189,11 @@ module.exports = React.createClass({
phase: "UserSettings.LOADING", // LOADING, DISPLAY
email_add_pending: false,
vectorVersion: undefined,
canSelfUpdate: null,
rejectingInvites: false,
mediaDevices: null,
ignoredUsers: [],
autoLaunchEnabled: null,
};
},
@ -209,6 +212,13 @@ module.exports = React.createClass({
}, (e) => {
console.log("Failed to fetch app version", e);
});
PlatformPeg.get().canSelfUpdate().then((canUpdate) => {
if (this._unmounted) return;
this.setState({
canSelfUpdate: canUpdate,
});
});
}
this._refreshMediaDevices();
@ -227,11 +237,12 @@ module.exports = React.createClass({
});
this._refreshFromServer();
if (PlatformPeg.get().isElectron()) {
const {ipcRenderer} = require('electron');
ipcRenderer.on('settings', this._electronSettings);
ipcRenderer.send('settings_get');
if (PlatformPeg.get().supportsAutoLaunch()) {
PlatformPeg.get().getAutoLaunchEnabled().then(enabled => {
this.setState({
autoLaunchEnabled: enabled,
});
});
}
this.setState({
@ -262,11 +273,6 @@ module.exports = React.createClass({
if (cli) {
cli.removeListener("RoomMember.membership", this._onInviteStateChange);
}
if (PlatformPeg.get().isElectron()) {
const {ipcRenderer} = require('electron');
ipcRenderer.removeListener('settings', this._electronSettings);
}
},
// `UserSettings` assumes that the client peg will not be null, so give it some
@ -285,10 +291,6 @@ module.exports = React.createClass({
});
},
_electronSettings: function(ev, settings) {
this.setState({ electron_settings: settings });
},
_refreshMediaDevices: function(stream) {
if (stream) {
// kill stream so that we don't leave it lingering around with webcam enabled etc
@ -943,7 +945,7 @@ module.exports = React.createClass({
_renderCheckUpdate: function() {
const platform = PlatformPeg.get();
if ('canSelfUpdate' in platform && platform.canSelfUpdate() && 'startUpdateCheck' in platform) {
if (this.state.canSelfUpdate) {
return <div>
<h3>{ _t('Updates') }</h3>
<div className="mx_UserSettings_section">
@ -988,8 +990,7 @@ module.exports = React.createClass({
},
_renderElectronSettings: function() {
const settings = this.state.electron_settings;
if (!settings) return;
if (!PlatformPeg.get().supportsAutoLaunch()) return;
// TODO: This should probably be a granular setting, but it only applies to electron
// and ends up being get/set outside of matrix anyways (local system setting).
@ -999,7 +1000,7 @@ module.exports = React.createClass({
<div className="mx_UserSettings_toggle">
<input type="checkbox"
name="auto-launch"
defaultChecked={settings['auto-launch']}
defaultChecked={this.state.autoLaunchEnabled}
onChange={this._onAutoLaunchChanged}
/>
<label htmlFor="auto-launch">{ _t('Start automatically after system login') }</label>
@ -1009,8 +1010,11 @@ module.exports = React.createClass({
},
_onAutoLaunchChanged: function(e) {
const {ipcRenderer} = require('electron');
ipcRenderer.send('settings_set', 'auto-launch', e.target.checked);
PlatformPeg.get().setAutoLaunchEnabled(e.target.checked).then(() => {
this.setState({
autoLaunchEnabled: e.target.checked,
});
});
},
_mapWebRtcDevicesToSpans: function(devices) {
@ -1369,7 +1373,7 @@ module.exports = React.createClass({
{ this._renderBulkOptions() }
{ this._renderBugReport() }
{ PlatformPeg.get().isElectron() && this._renderElectronSettings() }
{ this._renderElectronSettings() }
{ this._renderAnalyticsControl() }

View file

@ -36,6 +36,14 @@ module.exports = React.createClass({
onLoginClick: PropTypes.func,
onRegisterClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
},
getInitialState: function() {
@ -45,6 +53,7 @@ module.exports = React.createClass({
progress: null,
password: null,
password2: null,
errorText: null,
};
},
@ -81,6 +90,13 @@ module.exports = React.createClass({
onSubmitForm: function(ev) {
ev.preventDefault();
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
@ -146,6 +162,18 @@ module.exports = React.createClass({
this.setState(newState);
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
showErrorDialog: function(body, title) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
@ -200,6 +228,12 @@ module.exports = React.createClass({
);
}
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
resetPasswordJsx = (
@ -230,10 +264,11 @@ module.exports = React.createClass({
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form>
{ serverConfigSection }
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
{ errorText }
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ _t('Return to login screen') }
</a>
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
<LanguageSelector />

View file

@ -26,10 +26,17 @@ import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { AutoDiscovery } from "matrix-js-sdk";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
_td("Invalid homeserver discovery response");
_td("Invalid identity server discovery response");
_td("General failure");
/**
* A wire component which glues together login UI components and Login logic
*/
@ -50,6 +57,14 @@ module.exports = React.createClass({
// different home server without confusing users.
fallbackHsUrl: PropTypes.string,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration is done.
@ -74,6 +89,12 @@ module.exports = React.createClass({
phoneCountry: null,
phoneNumber: "",
currentFlow: "m.login.password",
// .well-known discovery
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: "",
findingHomeserver: false,
};
},
@ -105,6 +126,10 @@ module.exports = React.createClass({
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
// Prevent people from submitting their password when homeserver
// discovery went wrong
if (this.state.discoveryError || this.props.defaultServerDiscoveryError) return;
this.setState({
busy: true,
errorText: null,
@ -189,7 +214,10 @@ module.exports = React.createClass({
}).done();
},
_onLoginAsGuestClick: function() {
_onLoginAsGuestClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
const self = this;
self.setState({
busy: true,
@ -221,6 +249,22 @@ module.exports = React.createClass({
this.setState({ username: username });
},
onUsernameBlur: function(username) {
this.setState({ username: username });
if (username[0] === "@") {
const serverName = username.split(':').slice(1).join(':');
try {
// we have to append 'https://' to make the URL constructor happy
// otherwise we get things like 'protocol: matrix.org, pathname: 8448'
const url = new URL("https://" + serverName);
this._tryWellKnownDiscovery(url.hostname);
} catch (e) {
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
this.setState({discoveryError: _t("Failed to perform homeserver discovery")});
}
}
},
onPhoneCountryChanged: function(phoneCountry) {
this.setState({ phoneCountry: phoneCountry });
},
@ -256,6 +300,65 @@ module.exports = React.createClass({
});
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
this.setState({discoveryError: "", discoveredHsUrl: "", discoveredIsUrl: "", findingHomeserver: false});
return;
}
this.setState({findingHomeserver: true});
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) {
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: discovery["m.homeserver"].error,
findingHomeserver: false,
});
} else if (state === AutoDiscovery.PROMPT) {
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: "",
findingHomeserver: false,
});
} else if (state === AutoDiscovery.SUCCESS) {
this.setState({
discoveredHsUrl: discovery["m.homeserver"].base_url,
discoveredIsUrl:
discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "",
discoveryError: "",
findingHomeserver: false,
});
} else {
console.warn("Unknown state for m.homeserver in discovery response: ", discovery);
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: _t("Unknown failure discovering homeserver"),
findingHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
findingHomeserver: false,
discoveryError: _t("Unknown error discovering homeserver"),
});
}
},
_initLoginLogic: function(hsUrl, isUrl) {
const self = this;
hsUrl = hsUrl || this.state.enteredHomeserverUrl;
@ -393,11 +496,14 @@ module.exports = React.createClass({
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsUrl={this.state.enteredHomeserverUrl}
hsName={this.props.defaultServerName}
disableSubmit={this.state.findingHomeserver}
/>
);
},
@ -416,6 +522,8 @@ module.exports = React.createClass({
const ServerConfig = sdk.getComponent("login.ServerConfig");
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
let loginAsGuestJsx;
if (this.props.enableGuest) {
loginAsGuestJsx =
@ -430,8 +538,8 @@ module.exports = React.createClass({
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfig = <ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
customIsUrl={this.state.discoveredIsUrl || this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
@ -443,16 +551,16 @@ module.exports = React.createClass({
if (theme !== "status") {
header = <h2>{ _t('Sign in') } { loader }</h2>;
} else {
if (!this.state.errorText) {
if (!errorText) {
header = <h2>{ _t('Sign in to get started') } { loader }</h2>;
}
}
let errorTextSection;
if (this.state.errorText) {
if (errorText) {
errorTextSection = (
<div className="mx_Login_error">
{ this.state.errorText }
{ errorText }
</div>
);
}
@ -468,7 +576,7 @@ module.exports = React.createClass({
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }

View file

@ -57,6 +57,14 @@ module.exports = React.createClass({
}),
teamSelected: PropTypes.object,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// registration shouldn't know or care how login is done.
@ -170,6 +178,12 @@ module.exports = React.createClass({
},
onFormSubmit: function(formVals) {
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
this.setState({
errorText: "",
busy: true,
@ -328,7 +342,7 @@ module.exports = React.createClass({
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t('User names may only contain letters, numbers, dots, hyphens and underscores.');
errMsg = _t("Only use lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a user name.');
@ -349,6 +363,12 @@ module.exports = React.createClass({
}
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
_makeRegisterRequest: function(auth) {
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
@ -441,19 +461,20 @@ module.exports = React.createClass({
let header;
let errorText;
// FIXME: remove hardcoded Status team tweaks at some point
if (theme === 'status' && this.state.errorText) {
header = <div className="mx_Login_error">{ this.state.errorText }</div>;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (theme === 'status' && err) {
header = <div className="mx_Login_error">{ err }</div>;
} else {
header = <h2>{ _t('Create an account') }</h2>;
if (this.state.errorText) {
errorText = <div className="mx_Login_error">{ this.state.errorText }</div>;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
}
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);

View file

@ -0,0 +1,120 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import AccessibleButton from '../elements/AccessibleButton';
import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames';
import * as ContextualMenu from "../../structures/ContextualMenu";
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
export default class MemberStatusMessageAvatar extends React.Component {
static propTypes = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
width: 40,
height: 40,
resizeMethod: 'crop',
};
constructor(props, context) {
super(props, context);
}
componentWillMount() {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
}
componentDidMount() {
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
if (this.props.member.user) {
this.setState({message: this.props.member.user._unstable_statusMessage});
} else {
this.setState({message: ""});
}
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
}
}
_onRoomStateEvents = (ev, state) => {
if (ev.getStateKey() !== MatrixClientPeg.get().getUserId()) return;
if (ev.getType() !== "im.vector.user_status") return;
// TODO: We should be relying on `this.props.member.user._unstable_statusMessage`
// We don't currently because the js-sdk doesn't emit a specific event for this
// change, and we don't want to race it. This should be improved when we rip out
// the im.vector.user_status stuff and replace it with a complete solution.
this.setState({message: ev.getContent()["status"]});
};
_onClick = (e) => {
e.stopPropagation();
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
ContextualMenu.createMenu(StatusMessageContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 190,
user: this.props.member.user,
});
};
render() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return <MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />;
}
const hasStatus = this.props.member.user ? !!this.props.member.user._unstable_statusMessage : false;
const classes = classNames({
"mx_MemberStatusMessageAvatar": true,
"mx_MemberStatusMessageAvatar_hasStatus": hasStatus,
});
return <AccessibleButton onClick={this._onClick} className={classes} element="div">
<MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />
</AccessibleButton>;
}
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props, context) {
super(props, context);
this.state = {
message: props.user ? props.user._unstable_statusMessage : "",
};
}
_onClearClick = async(e) => {
await MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({message: ""});
};
_onSubmit = (e) => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
};
_onStatusChange = (e) => {
this.setState({message: e.target.value});
};
render() {
const formSubmitClasses = classNames({
"mx_StatusMessageContextMenu_submit": true,
"mx_StatusMessageContextMenu_submitFaded": !this.state.message, // no message == faded
});
const form = <form className="mx_StatusMessageContextMenu_form" onSubmit={this._onSubmit} autoComplete="off">
<input type="text" key="message" placeholder={_t("Set a new status...")} autoFocus={true}
className="mx_StatusMessageContextMenu_message"
value={this.state.message} onChange={this._onStatusChange} maxLength="60" />
<AccessibleButton onClick={this._onSubmit} element="div" className={formSubmitClasses}>
<img src="img/icons-checkmark.svg" width="22" height="22" />
</AccessibleButton>
</form>;
const clearIcon = this.state.message ? "img/cancel-red.svg" : "img/cancel.svg";
const clearButton = <AccessibleButton onClick={this._onClearClick} disabled={!this.state.message}
className="mx_StatusMessageContextMenu_clear">
<img src={clearIcon} alt={_t('Clear status')} width="12" height="12"
className="mx_filterFlipColor mx_StatusMessageContextMenu_clearIcon" />
<span>{_t("Clear status")}</span>
</AccessibleButton>;
const menuClasses = classNames({
"mx_StatusMessageContextMenu": true,
"mx_StatusMessageContextMenu_hasStatus": this.state.message,
});
return <div className={menuClasses}>
{ form }
<hr />
{ clearButton }
</div>;
}
}

View file

@ -23,6 +23,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStore from '../../../stores/GroupStore';
import * as Email from "../../../email";
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -419,6 +420,10 @@ module.exports = React.createClass({
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
if (this.props.validAddressTypes.includes(addrType)) {
if (addrType === 'email' && !Email.looksValid(query)) {
this.setState({searchError: _t("That doesn't look like a valid email address")});
return;
}
suggestedList.unshift({
addressType: addrType,
address: query,

View file

@ -57,8 +57,7 @@ export default React.createClass({
className: PropTypes.string,
// Title for the dialog.
// (could probably actually be something more complicated than a string if desired)
title: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
// children should be the content of the dialog
children: PropTypes.node,

View file

@ -36,8 +36,12 @@ export default class ChangelogDialog extends React.Component {
for (let i=0; i<REPOS.length; i++) {
const oldVersion = version2[2*i];
const newVersion = version[2*i];
request(`https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`, (a, b, body) => {
if (body == null) return;
const url = `https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`;
request(url, (err, response, body) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
this.setState({ [REPOS[i]]: response.statusText });
return;
}
this.setState({[REPOS[i]]: JSON.parse(body).commits});
});
}
@ -58,13 +62,20 @@ export default class ChangelogDialog extends React.Component {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
const logs = REPOS.map(repo => {
if (this.state[repo] == null) return <Spinner key={repo} />;
let content;
if (this.state[repo] == null) {
content = <Spinner key={repo} />;
} else if (typeof this.state[repo] === "string") {
content = _t("Unable to load commit detail: %(msg)s", {
msg: this.state[repo],
});
} else {
content = this.state[repo].map(this._elementsForCommit);
}
return (
<div key={repo}>
<h2>{repo}</h2>
<ul>
{this.state[repo].map(this._elementsForCommit)}
</ul>
<ul>{content}</ul>
</div>
);
});

View file

@ -35,19 +35,10 @@ export default class DeactivateAccountDialog extends React.Component {
this._onPasswordFieldChange = this._onPasswordFieldChange.bind(this);
this._onEraseFieldChange = this._onEraseFieldChange.bind(this);
const deactivationPreferences =
MatrixClientPeg.get().getAccountData('im.riot.account_deactivation_preferences');
const shouldErase = (
deactivationPreferences &&
deactivationPreferences.getContent() &&
deactivationPreferences.getContent().shouldErase
) || false;
this.state = {
confirmButtonEnabled: false,
busy: false,
shouldErase,
shouldErase: false,
errStr: null,
};
}
@ -67,36 +58,6 @@ export default class DeactivateAccountDialog extends React.Component {
async _onOk() {
this.setState({busy: true});
// Before we deactivate the account insert an event into
// the user's account data indicating that they wish to be
// erased from the homeserver.
//
// We do this because the API for erasing after deactivation
// might not be supported by the connected homeserver. Leaving
// an indication in account data is only best-effort, and
// in the worse case, the HS maintainer would have to run a
// script to erase deactivated accounts that have shouldErase
// set to true in im.riot.account_deactivation_preferences.
//
// Note: The preferences are scoped to Riot, hence the
// "im.riot..." event type.
//
// Note: This may have already been set on previous attempts
// where, for example, the user entered the wrong password.
// This is fine because the UI always indicates the preference
// prior to us calling `deactivateAccount`.
try {
await MatrixClientPeg.get().setAccountData('im.riot.account_deactivation_preferences', {
shouldErase: this.state.shouldErase,
});
} catch (err) {
this.setState({
busy: false,
errStr: _t('Failed to indicate account erasure'),
});
return;
}
try {
// This assumes that the HS requires password UI auth
// for this endpoint. In reality it could be any UI auth.

View file

@ -0,0 +1,51 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
"?q=is%3Aopen+is%3Aissue+label%3Aredesign+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new" +
"?assignees=&labels=redesign&template=redesign_issue.md&title=";
const description1 =
_t("Thanks for testing the Riot Redesign. " +
"If you run into any bugs or visual issues, " +
"please let us know on GitHub.");
const description2 = _t("To help avoid duplicate issues, " +
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
"if you can't find it.", {},
{
existingIssuesLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
},
newIssueLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
},
});
return (<QuestionDialog
hasCancelButton={false}
title={_t("Report bugs & give feedback")}
description={<div><p>{description1}</p><p>{description2}</p></div>}
button={_t("Go back")}
onFinished={props.onFinished}
/>);
};

View file

@ -23,6 +23,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import classnames from 'classnames';
import { KeyCode } from '../../../Keyboard';
import { _t } from '../../../languageHandler';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
// The amount of time to wait for further changes to the input username before
// sending a request to the server
@ -110,12 +111,11 @@ export default React.createClass({
},
_doUsernameCheck: function() {
// XXX: SPEC-1
// Check if username is valid
// Naive impl copied from https://github.com/matrix-org/matrix-react-sdk/blob/66c3a6d9ca695780eb6b662e242e88323053ff33/src/components/views/login/RegistrationForm.js#L190
if (encodeURIComponent(this.state.username) !== this.state.username) {
// We do a quick check ahead of the username availability API to ensure the
// user ID roughly looks okay from a Matrix perspective.
if (!SAFE_LOCALPART_REGEX.test(this.state.username)) {
this.setState({
usernameError: _t('User names may only contain letters, numbers, dots, hyphens and underscores.'),
usernameError: _t("Only use lower case letters, numbers and '=_-./'"),
});
return Promise.reject();
}
@ -210,7 +210,6 @@ export default React.createClass({
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent('elements.Spinner');
let auth;
if (this.state.doingUIAuth) {
@ -230,9 +229,8 @@ export default React.createClass({
});
let usernameIndicator = null;
let usernameBusyIndicator = null;
if (this.state.usernameBusy) {
usernameBusyIndicator = <Spinner w="24" h="24" />;
usernameIndicator = <div>{_t("Checking...")}</div>;
} else {
const usernameAvailable = this.state.username &&
this.state.usernameCheckSupport && !this.state.usernameError;
@ -270,7 +268,6 @@ export default React.createClass({
size="30"
className={inputClasses}
/>
{ usernameBusyIndicator }
</div>
{ usernameIndicator }
<p>

View file

@ -31,6 +31,7 @@ export default React.createClass({
mouseOverAction: PropTypes.string,
label: PropTypes.string.isRequired,
iconPath: PropTypes.string,
className: PropTypes.string,
},
getDefaultProps: function() {
@ -76,8 +77,13 @@ export default React.createClass({
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
const classNames = ["mx_RoleButton"];
if (this.props.className) {
classNames.push(this.props.className);
}
return (
<AccessibleButton className="mx_RoleButton"
<AccessibleButton className={classNames.join(" ")}
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}

View file

@ -22,7 +22,6 @@ import qs from 'querystring';
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg';
import ScalarAuthClient from '../../../ScalarAuthClient';
import WidgetMessaging from '../../../WidgetMessaging';
import TintableSvgButton from './TintableSvgButton';
@ -49,7 +48,6 @@ export default class AppTile extends React.Component {
this.state = this._getNewState(props);
this._onAction = this._onAction.bind(this);
this._onMessage = this._onMessage.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
@ -143,10 +141,6 @@ export default class AppTile extends React.Component {
}
componentDidMount() {
// Legacy Jitsi widget messaging -- TODO replace this with standard widget
// postMessaging API
window.addEventListener('message', this._onMessage, false);
// Widget action listeners
this.dispatcherRef = dis.register(this._onAction);
}
@ -155,9 +149,6 @@ export default class AppTile extends React.Component {
// Widget action listeners
dis.unregister(this.dispatcherRef);
// Jitsi listener
window.removeEventListener('message', this._onMessage);
// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
ActiveWidgetStore.destroyPersistentWidget();
@ -233,32 +224,6 @@ export default class AppTile extends React.Component {
}
}
// Legacy Jitsi widget messaging
// TODO -- This should be replaced with the new widget postMessaging API
_onMessage(event) {
if (this.props.type !== 'jitsi') {
return;
}
if (!event.origin) {
event.origin = event.originalEvent.origin;
}
const widgetUrlObj = url.parse(this.state.widgetUrl);
const eventOrigin = url.parse(event.origin);
if (
eventOrigin.protocol !== widgetUrlObj.protocol ||
eventOrigin.host !== widgetUrlObj.host
) {
return;
}
if (event.data.widgetAction === 'jitsi_iframe_loaded') {
const iframe = this.refs.appFrame.contentWindow
.document.querySelector('iframe[id^="jitsiConferenceFrame"]');
PlatformPeg.get().setupScreenSharingForIframe(iframe);
}
}
_canUserModify() {
// User widgets should always be modifiable by their creator
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
@ -544,7 +509,7 @@ export default class AppTile extends React.Component {
// Additional iframe feature pemissions
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media;";
const iframeFeatures = "microphone; camera; encrypted-media; autoplay;";
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');

View file

@ -22,17 +22,16 @@ import { _t } from '../../../languageHandler';
const GroupsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_my_groups"
<ActionButton className="mx_GroupsButton" action="view_my_groups"
label={_t("Communities")}
size={props.size}
tooltip={props.tooltip}
tooltip={true}
/>
);
};
GroupsButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default GroupsButton;

View file

@ -91,7 +91,7 @@ export default class ManageIntegsButton extends React.Component {
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses} onClick={this.onManageIntegrations} title={_t('Manage Integrations')}>
<TintableSvg src="img/icons-apps.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/grid.svg" width="20" height="20" />
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>

View file

@ -14,13 +14,14 @@ const ResizeHandle = (props) => {
classNames.push('mx_ResizeHandle_reverse');
}
return (
<div className={classNames.join(' ')} data-id={props.id} />
<div className={classNames.join(' ')} data-id={props.id}><div /></div>
);
};
ResizeHandle.propTypes = {
vertical: PropTypes.bool,
reverse: PropTypes.bool,
id: PropTypes.string,
};
export default ResizeHandle;

View file

@ -37,7 +37,9 @@ export default React.createClass({
getInitialState: function() {
return {
members: null,
membersError: null,
invitedMembers: null,
invitedMembersError: null,
truncateAt: INITIAL_LOAD_NUM_MEMBERS,
};
},
@ -55,6 +57,19 @@ export default React.createClass({
GroupStore.registerListener(groupId, () => {
this._fetchMembers();
});
GroupStore.on('error', (err, errorGroupId, stateKey) => {
if (this._unmounted || groupId !== errorGroupId) return;
if (stateKey === GroupStore.STATE_KEY.GroupMembers) {
this.setState({
membersError: err,
});
}
if (stateKey === GroupStore.STATE_KEY.GroupInvitedMembers) {
this.setState({
invitedMembersError: err,
});
}
});
},
_fetchMembers: function() {
@ -88,7 +103,11 @@ export default React.createClass({
this.setState({ searchQuery: ev.target.value });
},
makeGroupMemberTiles: function(query, memberList) {
makeGroupMemberTiles: function(query, memberList, memberListError) {
if (memberListError) {
return <div className="warning">{ _t("Failed to load group members") }</div>;
}
const GroupMemberTile = sdk.getComponent("groups.GroupMemberTile");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
query = (query || "").toLowerCase();
@ -166,13 +185,25 @@ export default React.createClass({
);
const joined = this.state.members ? <div className="mx_MemberList_joined">
{ this.makeGroupMemberTiles(this.state.searchQuery, this.state.members) }
{
this.makeGroupMemberTiles(
this.state.searchQuery,
this.state.members,
this.state.membersError,
)
}
</div> : <div />;
const invited = (this.state.invitedMembers && this.state.invitedMembers.length > 0) ?
<div className="mx_MemberList_invited">
<h2>{ _t("Invited") }</h2>
{ this.makeGroupMemberTiles(this.state.searchQuery, this.state.invitedMembers) }
<h2>{_t("Invited")}</h2>
{
this.makeGroupMemberTiles(
this.state.searchQuery,
this.state.invitedMembers,
this.state.invitedMembersError,
)
}
</div> : <div />;
let inviteButton;

View file

@ -30,6 +30,7 @@ class PasswordLogin extends React.Component {
static defaultProps = {
onError: function() {},
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
@ -39,6 +40,8 @@ class PasswordLogin extends React.Component {
initialPassword: "",
loginIncorrect: false,
hsDomain: "",
hsName: null,
disableSubmit: false,
}
constructor(props) {
@ -53,6 +56,7 @@ class PasswordLogin extends React.Component {
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
@ -124,6 +128,10 @@ class PasswordLogin extends React.Component {
this.props.onUsernameChanged(ev.target.value);
}
onUsernameBlur(ev) {
this.props.onUsernameBlur(this.state.username);
}
onLoginTypeChange(loginType) {
this.props.onError(null); // send a null error to clear any error messages
this.setState({
@ -167,6 +175,7 @@ class PasswordLogin extends React.Component {
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
placeholder="joe@example.com"
value={this.state.username}
autoFocus
@ -182,6 +191,7 @@ class PasswordLogin extends React.Component {
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
placeholder={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
@ -242,13 +252,15 @@ class PasswordLogin extends React.Component {
);
}
let matrixIdText = '';
if (this.props.hsUrl) {
let matrixIdText = _t('Matrix ID');
if (this.props.hsName) {
matrixIdText = _t('%(serverName)s Matrix ID', {serverName: this.props.hsName});
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
matrixIdText = _t('%(serverName)s Matrix ID', {serverName: parsedHsUrl.hostname});
} catch (e) {
// pass
// ignore
}
}
@ -280,6 +292,8 @@ class PasswordLogin extends React.Component {
);
}
const disableSubmit = this.props.disableSubmit || matrixIdText === '';
return (
<div>
<form onSubmit={this.onSubmitForm}>
@ -293,7 +307,7 @@ class PasswordLogin extends React.Component {
/>
<br />
{ forgotPasswordJsx }
<input className="mx_Login_submit" type="submit" value={_t('Sign in')} disabled={matrixIdText === ''} />
<input className="mx_Login_submit" type="submit" value={_t('Sign in')} disabled={disableSubmit} />
</form>
</div>
);
@ -317,6 +331,8 @@ PasswordLogin.propTypes = {
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
hsName: PropTypes.string,
disableSubmit: PropTypes.bool,
};
module.exports = PasswordLogin;

View file

@ -25,7 +25,7 @@ import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_COUNTRY = 'field_phone_country';
@ -194,9 +194,8 @@ module.exports = React.createClass({
} else this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break;
case FIELD_USERNAME:
// XXX: SPEC-1
var username = this.refs.username.value.trim();
if (encodeURIComponent(username) != username) {
const username = this.refs.username.value.trim();
if (!SAFE_LOCALPART_REGEX.test(username)) {
this.markFieldValid(
field_id,
false,

View file

@ -70,6 +70,23 @@ module.exports = React.createClass({
};
},
componentWillReceiveProps: function(newProps) {
if (newProps.customHsUrl === this.state.hs_url &&
newProps.customIsUrl === this.state.is_url) return;
this.setState({
hs_url: newProps.customHsUrl,
is_url: newProps.customIsUrl,
configVisible: !newProps.withToggleButton ||
(newProps.customHsUrl !== newProps.defaultHsUrl) ||
(newProps.customIsUrl !== newProps.defaultIsUrl),
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
});
},
onHomeserverChanged: function(ev) {
this.setState({hs_url: ev.target.value}, function() {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() {

View file

@ -85,8 +85,8 @@ export default React.createClass({
_getDisplayedGroups(userGroups, relatedGroups) {
let displayedGroups = userGroups || [];
if (relatedGroups && relatedGroups.length > 0) {
displayedGroups = displayedGroups.filter((groupId) => {
return relatedGroups.includes(groupId);
displayedGroups = relatedGroups.filter((groupId) => {
return displayedGroups.includes(groupId);
});
} else {
displayedGroups = [];

View file

@ -55,23 +55,23 @@ export default class GroupHeaderButtons extends HeaderButtons {
}
renderButtons() {
const isPhaseGroup = [
const groupPhases = [
RightPanel.Phase.GroupMemberInfo,
RightPanel.Phase.GroupMemberList,
].includes(this.state.phase);
const isPhaseRoom = [
];
const roomPhases = [
RightPanel.Phase.GroupRoomList,
RightPanel.Phase.GroupRoomInfo,
].includes(this.state.phase);
];
return [
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={isPhaseGroup}
isHighlighted={this.isPhase(groupPhases)}
clickPhase={RightPanel.Phase.GroupMemberList}
analytics={['Right Panel', 'Group Member List Button', 'click']}
/>,
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room-nobg.svg"
isHighlighted={isPhaseRoom}
isHighlighted={this.isPhase(roomPhases)}
clickPhase={RightPanel.Phase.GroupRoomList}
analytics={['Right Panel', 'Group Room List Button', 'click']}
/>,

View file

@ -36,6 +36,7 @@ export default class HeaderButton extends React.Component {
dis.dispatch({
action: 'view_right_panel_phase',
phase: this.props.clickPhase,
fromHeader: true,
});
}

View file

@ -18,6 +18,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
export default class HeaderButtons extends React.Component {
@ -25,7 +26,7 @@ export default class HeaderButtons extends React.Component {
super(props);
this.state = {
phase: initialPhase,
phase: props.collapsedRhs ? null : initialPhase,
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
@ -47,11 +48,42 @@ export default class HeaderButtons extends React.Component {
}, extras));
}
isPhase(phases) {
if (this.props.collapsedRhs) {
return false;
}
if (Array.isArray(phases)) {
return phases.includes(this.state.phase);
} else {
return phases === this.state.phase;
}
}
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
this.setState({
phase: payload.phase,
});
// only actions coming from header buttons should collapse the right panel
if (this.state.phase === payload.phase && payload.fromHeader) {
dis.dispatch({
action: 'hide_right_panel',
});
this.setState({
phase: null,
});
} else {
if (this.props.collapsedRhs && payload.fromHeader) {
dis.dispatch({
action: 'show_right_panel',
});
// emit payload again as the RightPanel didn't exist up
// till show_right_panel, just without the fromHeader flag
// as that would hide the right panel again
dis.dispatch(Object.assign({}, payload, {fromHeader: false}));
}
this.setState({
phase: payload.phase,
});
}
}
}
@ -62,3 +94,7 @@ export default class HeaderButtons extends React.Component {
</div>;
}
}
HeaderButtons.propTypes = {
collapsedRhs: PropTypes.bool,
};

View file

@ -46,24 +46,24 @@ export default class RoomHeaderButtons extends HeaderButtons {
}
renderButtons() {
const isMembersPhase = [
const membersPhases = [
RightPanel.Phase.RoomMemberList,
RightPanel.Phase.RoomMemberInfo,
].includes(this.state.phase);
];
return [
<HeaderButton key="_membersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={isMembersPhase}
<HeaderButton key="_membersButton" title={_t('Members')} iconSrc="img/feather-icons/user.svg"
isHighlighted={this.isPhase(membersPhases)}
clickPhase={RightPanel.Phase.RoomMemberList}
analytics={['Right Panel', 'Member List Button', 'click']}
/>,
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/icons-files.svg"
isHighlighted={this.state.phase === RightPanel.Phase.FilePanel}
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/feather-icons/files.svg"
isHighlighted={this.isPhase(RightPanel.Phase.FilePanel)}
clickPhase={RightPanel.Phase.FilePanel}
analytics={['Right Panel', 'File List Button', 'click']}
/>,
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/icons-notifications.svg"
isHighlighted={this.state.phase === RightPanel.Phase.NotificationPanel}
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/feather-icons/notifications.svg"
isHighlighted={this.isPhase(RightPanel.Phase.NotificationPanel)}
clickPhase={RightPanel.Phase.NotificationPanel}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,

View file

@ -130,7 +130,7 @@ module.exports = React.createClass({
},
isAliasValid: function(alias) {
// XXX: FIXME SPEC-1
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias);
},

View file

@ -70,6 +70,7 @@ const EntityTile = React.createClass({
onClick: PropTypes.func,
suppressOnHover: PropTypes.bool,
showPresence: PropTypes.bool,
subtextLabel: PropTypes.string,
},
getDefaultProps: function() {
@ -129,6 +130,9 @@ const EntityTile = React.createClass({
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">
@ -137,6 +141,15 @@ const EntityTile = React.createClass({
{presenceLabel}
</div>
);
} else if (this.props.subtextLabel) {
nameEl = (
<div className="mx_EntityTile_details">
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">
{name}
</EmojiText>
<span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>
</div>
);
} else {
nameEl = (
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">{ name }</EmojiText>

View file

@ -40,6 +40,8 @@ import { findReadReceiptFromUserId } from '../../../utils/Receipt';
import withMatrixClient from '../../../wrappers/withMatrixClient';
import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig';
import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore";
module.exports = withMatrixClient(React.createClass({
displayName: 'MemberInfo',
@ -714,12 +716,18 @@ module.exports = withMatrixClient(React.createClass({
const roomId = member && member.roomId ? member.roomId : this.props.roomId;
const onInviteUserButton = async() => {
try {
await cli.invite(roomId, member.userId);
// We use a MultiInviter to re-use the invite logic, even though
// 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));
});
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t('Failed to invite'),
description: ((err && err.message) ? err.message : "Operation failed"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
}
};
@ -882,11 +890,16 @@ module.exports = withMatrixClient(React.createClass({
let presenceState;
let presenceLastActiveAgo;
let presenceCurrentlyActive;
let statusMessage;
if (this.props.member.user) {
presenceState = this.props.member.user.presence;
presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
presenceCurrentlyActive = this.props.member.user.currentlyActive;
if (SettingsStore.isFeatureEnabled("feature_custom_status")) {
statusMessage = this.props.member.user._unstable_statusMessage;
}
}
const room = this.props.matrixClient.getRoom(this.props.member.roomId);
@ -908,6 +921,11 @@ module.exports = withMatrixClient(React.createClass({
presenceState={presenceState} />;
}
let statusLabel = null;
if (statusMessage) {
statusLabel = <span className="mx_MemberInfo_statusMessage">{ statusMessage }</span>;
}
let roomMemberDetails = null;
if (this.props.member.roomId) { // is in room
const PowerSelector = sdk.getComponent('elements.PowerSelector');
@ -924,6 +942,7 @@ module.exports = withMatrixClient(React.createClass({
</div>
<div className="mx_MemberInfo_profileField">
{presenceLabel}
{statusLabel}
</div>
</div>;
}

View file

@ -68,7 +68,9 @@ module.exports = React.createClass({
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.on("User.lastPresenceTs", this.onUserPresenceChange);
cli.on("User.presence", this.onUserPresenceChange);
cli.on("User.currentlyActive", this.onUserPresenceChange);
// cli.on("Room.timeline", this.onRoomTimeline);
},
@ -81,7 +83,9 @@ module.exports = React.createClass({
cli.removeListener("Room.myMembership", this.onMyMembership);
cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom);
cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.removeListener("User.lastPresenceTs", this.onUserPresenceChange);
cli.removeListener("User.presence", this.onUserPresenceChange);
cli.removeListener("User.currentlyActive", this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
@ -132,12 +136,12 @@ module.exports = React.createClass({
};
},
onUserLastPresenceTs(event, user) {
onUserPresenceChange(event, user) {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
// console.log("explicit presence from " + user.userId);
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this._updateList(); // reorder the membership list
}
@ -181,6 +185,10 @@ module.exports = React.createClass({
},
_updateList: new rate_limited_func(function() {
this._updateListNow();
}, 500),
_updateListNow: function() {
// console.log("Updating memberlist");
const newState = {
loading: false,
@ -189,7 +197,7 @@ module.exports = React.createClass({
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery);
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery);
this.setState(newState);
}, 500),
},
getMembersWithUser: function() {
if (!this.props.roomId) return [];
@ -267,7 +275,8 @@ module.exports = React.createClass({
if (!member) {
return "(null)";
} else {
return "(" + member.name + ", " + member.powerLevel + ", " + member.user.lastActiveAgo + ", " + member.user.currentlyActive + ")";
const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")";
}
},
@ -275,48 +284,59 @@ module.exports = React.createClass({
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
memberSort: function(memberA, memberB) {
// order by last active, with "active now" first.
// ...and then by power
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
const userA = memberA.user;
const userB = memberB.user;
// console.log(`Comparing userA=${this.memberString(memberA)} userB=${this.memberString(memberB)}`);
// if (!userA || !userB) {
// console.log("comparing " + memberA.name + " user=" + memberA.user + " with " + memberB.name + " user=" + memberB.user);
// }
const userA = memberA.user;
const userB = memberB.user;
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
// if (!userA) console.log("!! MISSING USER FOR A-SIDE: " + memberA.name + " !!");
// if (!userB) console.log("!! MISSING USER FOR B-SIDE: " + memberB.name + " !!");
// console.log("comparing " + this.memberString(memberA) + " and " + this.memberString(memberB));
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
if ((userA.currentlyActive && userB.currentlyActive) || !this._showPresence) {
// console.log(memberA.name + " and " + memberB.name + " are both active");
if (memberA.powerLevel === memberB.powerLevel) {
// console.log(memberA + " and " + memberB + " have same power level");
if (memberA.name && memberB.name) {
// console.log("comparing names: " + memberA.name + " and " + memberB.name);
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
return nameA.localeCompare(nameB);
} else {
return 0;
}
} else {
// console.log("comparing power: " + memberA.powerLevel + " and " + memberB.powerLevel);
return memberB.powerLevel - memberA.powerLevel;
}
// First by presence
if (this._showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? 'active' : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? 'active' : userB.presence);
// console.log(`userA_presenceGroup=${idxA} userB_presenceGroup=${idxB}`);
if (idxA !== idxB) {
// console.log("Comparing on presence group - returning");
return idxA - idxB;
}
}
if (userA.currentlyActive && !userB.currentlyActive) return -1;
if (!userA.currentlyActive && userB.currentlyActive) return 1;
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
// console.log("Comparing on power level - returning");
return memberB.powerLevel - memberA.powerLevel;
}
// For now, let's just order things by timestamp. It's really annoying
// that a user disappears from sight just because they temporarily go offline
// Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB);
},
onSearchQueryChanged: function(ev) {

View file

@ -16,6 +16,8 @@ limitations under the License.
'use strict';
import SettingsStore from "../../../settings/SettingsStore";
const React = require('react');
import PropTypes from 'prop-types';
@ -85,6 +87,11 @@ module.exports = React.createClass({
const active = -1;
const presenceState = member.user ? member.user.presence : null;
let statusMessage = null;
if (member.user && SettingsStore.isFeatureEnabled("feature_custom_status")) {
statusMessage = member.user._unstable_statusMessage;
}
const av = (
<MemberAvatar member={member} width={36} height={36} />
);
@ -106,7 +113,9 @@ module.exports = React.createClass({
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} />
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence}
subtextLabel={statusMessage}
/>
);
},
});

View file

@ -138,7 +138,8 @@ export default class MessageComposer extends React.Component {
}
onUploadFileSelected(files) {
this.uploadFiles(files.target.files);
const tfiles = files.target.files;
this.uploadFiles(tfiles);
}
uploadFiles(files) {
@ -146,10 +147,21 @@ export default class MessageComposer extends React.Component {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const fileList = [];
const acceptedFiles = [];
const failedFiles = [];
for (let i=0; i<files.length; i++) {
fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> { files[i].name || _t('Attachment') }
</li>);
const fileAcceptedOrError = this.props.uploadAllowed(files[i]);
if (fileAcceptedOrError === true) {
acceptedFiles.push(<li key={i}>
<TintableSvg key={i} src="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="img/files.svg" width="16" height="16" /> { files[i].name || _t('Attachment') } <p>{ _t('Reason') + ": " + fileAcceptedOrError}</p>
</li>);
}
}
const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent());
@ -160,23 +172,47 @@ export default class MessageComposer extends React.Component {
}</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>
<p>{ _t('Are you sure you want to upload the following files?') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ fileList }
</ul>
{ 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 (files) {
for (let i=0; i<files.length; i++) {
this.props.uploadFile(files[i]);
if (fileList) {
for (let i=0; i<fileList.length; i++) {
this.props.uploadFile(fileList[i]);
}
}
}
@ -254,7 +290,7 @@ export default class MessageComposer extends React.Component {
render() {
const uploadInputStyle = {display: 'none'};
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
@ -263,7 +299,7 @@ export default class MessageComposer extends React.Component {
if (this.state.me) {
controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberAvatar member={this.state.me} width={24} height={24} />
<MemberStatusMessageAvatar member={this.state.me} width={24} height={24} />
</div>,
);
}
@ -301,17 +337,45 @@ export default class MessageComposer extends React.Component {
} else {
callButton =
<AccessibleButton key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={_t('Voice call')}>
<TintableSvg src="img/icon-call.svg" width="25" height="25" />
<TintableSvg src="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="img/icons-video.svg" width="25" height="25" />
<TintableSvg src="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>";
} 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}`;
}
}
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) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
@ -319,7 +383,7 @@ export default class MessageComposer extends React.Component {
const uploadButton = (
<AccessibleButton key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={_t('Upload file')}>
<TintableSvg src="img/icons-upload.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/paperclip.svg" width="20" height="20" />
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple
@ -390,6 +454,8 @@ export default class MessageComposer extends React.Component {
</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') }
@ -460,6 +526,9 @@ MessageComposer.propTypes = {
// 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,
roomViewStore: PropTypes.object.isRequired,

View file

@ -23,7 +23,6 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import dis from "../../../dispatcher";
import RateLimitedFunc from '../../../ratelimitedfunc';
import * as linkify from 'linkifyjs';
@ -146,10 +145,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendStateEvent(this.props.room.roomId, 'm.room.avatar', {url: null}, '');
},
onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' });
},
onShareRoomClick: function(ev) {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room dialog', '', ShareDialog, {
@ -342,7 +337,7 @@ module.exports = React.createClass({
if (this.props.onSettingsClick) {
settingsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16" />
<TintableSvg src="img/feather-icons/settings.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -382,7 +377,7 @@ module.exports = React.createClass({
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={_t("Search")}>
<TintableSvg src="img/icons-search.svg" width="16" height="16" />
<TintableSvg src="img/feather-icons/search.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -390,15 +385,7 @@ module.exports = React.createClass({
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShareRoomClick} title={_t('Share room')}>
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>;
}
let rightPanelButtons;
if (this.props.collapsedRhs) {
rightPanelButtons =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_showPanel" onClick={this.onShowRhsClick} title={_t('Show panel')}>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
<TintableSvg src="img/feather-icons/share.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -419,7 +406,6 @@ module.exports = React.createClass({
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
{ rightPanelButtons }
</div>;
}
@ -433,7 +419,7 @@ module.exports = React.createClass({
{ saveButton }
{ cancelButton }
{ rightRow }
{ !this.props.isGrid ? <RoomHeaderButtons /> : undefined }
{ !this.props.isGrid ? <RoomHeaderButtons collapsedRhs={this.props.collapsedRhs} /> : undefined }
</div>
</div>
);

View file

@ -71,6 +71,10 @@ module.exports = React.createClass({
getInitialState: function() {
this._subListRefs = {
// key => RoomSubList ref
};
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
@ -79,8 +83,10 @@ module.exports = React.createClass({
isLoadingLeftRooms: false,
totalRoomCount: null,
lists: {},
incomingCallTag: null,
incomingCall: null,
selectedTags: [],
hover: false,
};
},
@ -148,6 +154,8 @@ module.exports = React.createClass({
}
this.subListSizes[id] = newSize;
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.subListSizes));
// update overflow indicators
this._checkSubListsOverflow();
},
componentDidMount: function() {
@ -163,19 +171,24 @@ module.exports = React.createClass({
});
// load stored sizes
Object.entries(this.subListSizes).forEach(([id, size]) => {
const handle = this.resizer.forHandleWithId(id);
if (handle) {
handle.resize(size);
}
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
this.resizer.attach();
this.mounted = true;
},
componentDidUpdate: function() {
componentDidUpdate: function(prevProps) {
this._repositionIncomingCallBox(undefined, false);
if (this.props.searchFilter !== prevProps.searchFilter) {
// restore sizes
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
}
},
onAction: function(payload) {
@ -188,11 +201,13 @@ module.exports = React.createClass({
if (call && call.call_state === 'ringing') {
this.setState({
incomingCall: call,
incomingCallTag: this.getTagNameForRoomId(payload.room_id),
});
this._repositionIncomingCallBox(undefined, true);
} else {
this.setState({
incomingCall: null,
incomingCallTag: null,
});
}
break;
@ -280,6 +295,17 @@ module.exports = React.createClass({
this.forceUpdate();
},
onMouseEnter: function(ev) {
this.setState({hover: true});
},
onMouseLeave: function(ev) {
this.setState({hover: false});
// Refresh the room list just in case the user missed something.
this._delayedRefreshRoomList();
},
_delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList();
}, 500),
@ -332,6 +358,11 @@ module.exports = React.createClass({
},
refreshRoomList: function() {
if (this.state.hover) {
// Don't re-sort the list if we're hovering over the list
return;
}
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
// as needed.
@ -347,11 +378,36 @@ module.exports = React.createClass({
// Do this here so as to not render every time the selected tags
// themselves change.
selectedTags: TagOrderStore.getSelectedTags(),
}, () => {
// we don't need to restore any size here, do we?
// i guess we could have triggered a new group to appear
// that already an explicit size the last time it appeared ...
this._checkSubListsOverflow();
});
// this._lastRefreshRoomListTs = Date.now();
},
getTagNameForRoomId: function(roomId) {
const lists = RoomListStore.getRoomLists();
for (const tagName of Object.keys(lists)) {
for (const room of lists[tagName]) {
// Should be impossible, but guard anyways.
if (!room) {
continue;
}
const myUserId = MatrixClientPeg.get().getUserId();
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, myUserId, this.props.ConferenceHandler)) {
continue;
}
if (room.roomId === roomId) return tagName;
}
}
return null;
},
getRoomLists: function() {
const lists = RoomListStore.getRoomLists();
@ -478,9 +534,38 @@ module.exports = React.createClass({
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
_persistCollapsedState: function(key, collapsed) {
_handleCollapsedState: function(key, collapsed) {
// persist collapsed state
this.collapsedState[key] = collapsed;
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
// load the persisted size configuration of the expanded sub list
if (!collapsed) {
this._restoreSubListSize(key);
}
// check overflow, as sub lists sizes have changed
// important this happens after calling resize above
this._checkSubListsOverflow();
},
_restoreSubListSize(key) {
const size = this.subListSizes[key];
const handle = this.resizer.forHandleWithId(key);
if (handle) {
handle.resize(size);
}
},
// check overflow for scroll indicator gradient
_checkSubListsOverflow() {
Object.values(this._subListRefs).forEach(l => l.checkOverflow());
},
_subListRef: function(key, ref) {
if (!ref) {
delete this._subListRefs[key];
} else {
this._subListRefs[key] = ref;
}
},
_mapSubListProps: function(subListsProps) {
@ -505,7 +590,7 @@ module.exports = React.createClass({
const {key, label, onHeaderClick, ... otherProps} = props;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._persistCollapsedState(chosenKey, collapsed);
this._handleCollapsedState(chosenKey, collapsed);
if (onHeaderClick) {
onHeaderClick(collapsed);
}
@ -513,6 +598,7 @@ module.exports = React.createClass({
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
let subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
onHeaderClick={onSubListHeaderClick}
key={chosenKey}
@ -535,6 +621,12 @@ module.exports = React.createClass({
},
render: function() {
const incomingCallIfTaggedAs = (tagName) => {
if (!this.state.incomingCall) return null;
if (this.state.incomingCallTag !== tagName) return null;
return this.state.incomingCall;
};
let subLists = [
{
list: [],
@ -547,6 +639,7 @@ module.exports = React.createClass({
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
{
@ -554,6 +647,7 @@ module.exports = React.createClass({
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
@ -561,6 +655,7 @@ module.exports = React.createClass({
tagName: "im.vector.fake.direct",
headerItems: this._getHeaderItems('im.vector.fake.direct'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})},
},
{
@ -568,6 +663,7 @@ module.exports = React.createClass({
label: _t('Rooms'),
headerItems: this._getHeaderItems('im.vector.fake.recent'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'})},
},
];
@ -581,6 +677,7 @@ module.exports = React.createClass({
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
subLists = subLists.concat(tagSubLists);
@ -590,11 +687,13 @@ module.exports = React.createClass({
label: _t('Low priority'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
onHeaderClick: this.onArchivedHeaderClick,
@ -604,15 +703,17 @@ module.exports = React.createClass({
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
return (
<div ref={this._collectResizeContainer} className="mx_RoomList">
<div ref={this._collectResizeContainer} className="mx_RoomList"
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{ subListComponents }
</div>
);
},
});
});

View file

@ -0,0 +1,170 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
loading: true,
error: null,
unverifiedDevice: null,
};
}
componentWillMount() {
this._loadBackupStatus();
}
async _loadBackupStatus() {
let backupSigStatus;
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo);
} catch (e) {
console.log("Unable to fetch key backup status", e);
this.setState({
loading: false,
error: e,
});
return;
}
let unverifiedDevice;
for (const sig of backupSigStatus.sigs) {
if (!sig.device.isVerified()) {
unverifiedDevice = sig.device;
break;
}
}
this.setState({
loading: false,
unverifiedDevice,
});
}
showSetupDialog = () => {
if (this.state.unverifiedDevice) {
// 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,
onFinished: this.props.onFinished,
});
return;
}
// 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"),
{
onFinished: this.props.onFinished,
},
);
}
onDontAskAgainClick = () => {
// When you choose "Don't ask again" from the room reminder, we show a
// dialog to confirm the choice.
Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder",
import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"),
{
onDontAskAgain: () => {
// Report false to the caller, who should prevent the
// reminder from appearing in the future.
this.props.onFinished(false);
},
onSetup: () => {
this.showSetupDialog();
},
},
);
}
onSetupClick = () => {
this.showSetupDialog();
}
render() {
if (this.state.loading) {
return null;
}
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
let body;
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 = _t(
"To view your secure message history and ensure you can view new " +
"messages on future devices, set up Secure Message Recovery.",
);
} 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.",
);
}
return (
<div className="mx_RoomRecoveryReminder">
<div className="mx_RoomRecoveryReminder_header">{_t(
"Secure Message Recovery",
)}</div>
<div className="mx_RoomRecoveryReminder_body">{body}</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}>
{ _t("Set up") }
</AccessibleButton>
</div>
</div>
);
}
}

View file

@ -29,6 +29,7 @@ import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import AccessibleButton from '../elements/AccessibleButton';
import ActiveRoomObserver from '../../../ActiveRoomObserver';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
displayName: 'RoomTile',
@ -250,6 +251,17 @@ module.exports = React.createClass({
const mentionBadges = this.props.highlight && this._shouldShowMentionBadge();
const badges = notifBadges || mentionBadges;
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
let subtext = null;
if (!isInvite && isJoined && looksLikeDm && SettingsStore.isFeatureEnabled("feature_custom_status")) {
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (otherMember && otherMember.user && otherMember.user._unstable_statusMessage) {
subtext = otherMember.user._unstable_statusMessage;
}
}
const classes = classNames({
'mx_RoomTile': true,
'mx_RoomTile_selected': this.state.selected,
@ -260,6 +272,7 @@ module.exports = React.createClass({
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
});
const avatarClasses = classNames({
@ -285,6 +298,7 @@ module.exports = React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
let subtextLabel;
let tooltip;
if (!this.props.collapsed) {
const nameClasses = classNames({
@ -293,6 +307,8 @@ module.exports = React.createClass({
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed,
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
if (this.state.selected) {
const nameSelected = <EmojiText>{ name }</EmojiText>;
@ -332,13 +348,18 @@ module.exports = React.createClass({
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={32} height={32} />
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
{ label }
{ contextMenuButton }
{ badge }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>;

View file

@ -351,7 +351,7 @@ export default class Stickerpicker extends React.Component {
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src="img/icons-stickers.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
} else {
// Show show-stickers button
@ -362,7 +362,7 @@ export default class Stickerpicker extends React.Component {
className="mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src="img/icons-stickers.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
}
return <div>

View file

@ -154,6 +154,7 @@ export default class KeyBackupPanel extends React.Component {
}
let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => {
const deviceName = sig.device.getDisplayName() || sig.device.deviceId;
const sigStatusSubstitutions = {
validity: sub =>
<span className={sig.valid ? 'mx_KeyBackupPanel_sigValid' : 'mx_KeyBackupPanel_sigInvalid'}>
@ -163,7 +164,7 @@ export default class KeyBackupPanel extends React.Component {
<span className={sig.device.isVerified() ? 'mx_KeyBackupPanel_deviceVerified' : 'mx_KeyBackupPanel_deviceNotVerified'}>
{sub}
</span>,
device: sub => <span className="mx_KeyBackupPanel_deviceName">{sig.device.getDisplayName()}</span>,
device: sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>,
};
let sigStatus;
if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) {
@ -174,7 +175,7 @@ export default class KeyBackupPanel extends React.Component {
} else if (sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>verified</verify> device <device>x</device>",
"<verify>verified</verify> device <device></device>",
{}, sigStatusSubstitutions,
);
} else if (sig.valid && !sig.device.isVerified()) {

View file

@ -483,8 +483,11 @@ module.exports = React.createClass({
// The default push rules displayed by Vector UI
'.m.rule.contains_display_name': 'vector',
'.m.rule.contains_user_name': 'vector',
'.m.rule.roomnotif': 'vector',
'.m.rule.room_one_to_one': 'vector',
'.m.rule.encrypted_room_one_to_one': 'vector',
'.m.rule.message': 'vector',
'.m.rule.encrypted': 'vector',
'.m.rule.invite_for_me': 'vector',
//'.m.rule.member_event': 'vector',
'.m.rule.call': 'vector',
@ -534,9 +537,12 @@ module.exports = React.createClass({
const vectorRuleIds = [
'.m.rule.contains_display_name',
'.m.rule.contains_user_name',
'.m.rule.roomnotif',
'_keywords',
'.m.rule.room_one_to_one',
'.m.rule.encrypted_room_one_to_one',
'.m.rule.message',
'.m.rule.encrypted',
'.m.rule.invite_for_me',
//'im.vector.rule.member_event',
'.m.rule.call',