Merge branch 'develop' into feature_confetti#14676

This commit is contained in:
Steffen Kolmer 2020-11-26 18:21:28 +01:00 committed by GitHub
commit 27312c3553
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
236 changed files with 13547 additions and 3882 deletions

View file

@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td(
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
You can even add images with Matrix URLs <img src="mxc://url" />
</p>
`);

View file

@ -15,20 +15,83 @@ limitations under the License.
*/
import * as React from "react";
import {useContext, useState} from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import { getHomePageUrl } from "../../utils/pages";
import { _t } from "../../languageHandler";
import {getHomePageUrl} from "../../utils/pages";
import {_t} from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
import * as sdk from "../../index";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import {Action} from "../../dispatcher/actions";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {OwnProfileStore} from "../../stores/OwnProfileStore";
import AccessibleButton from "../views/elements/AccessibleButton";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const onClickSendDm = () => {
Analytics.trackEvent('home_page', 'button', 'dm');
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
dis.dispatch({action: 'view_create_chat'});
};
const HomePage = () => {
const onClickExplore = () => {
Analytics.trackEvent('home_page', 'button', 'room_directory');
CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" });
dis.fire(Action.ViewRoomDirectory);
};
const onClickNewRoom = () => {
Analytics.trackEvent('home_page', 'button', 'create_room');
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
dis.dispatch({action: 'view_create_room'});
};
interface IProps {
justRegistered?: boolean;
}
const getOwnProfile = (userId: string) => ({
displayName: OwnProfileStore.instance.displayName || userId,
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
});
const UserWelcomeTop = () => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
setOwnProfile(getOwnProfile(userId));
});
return <div>
<MiniAvatarUploader
hasAvatar={!!ownProfile.avatarUrl}
hasAvatarLabel={_t("Great, that'll help people know it's you")}
noAvatarLabel={_t("Add a photo so people know it's you.")}
setAvatarUrl={url => cli.setAvatarUrl(url)}
>
<BaseAvatar
idName={userId}
name={ownProfile.displayName}
url={ownProfile.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
</MiniAvatarUploader>
<h1>{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
<h4>{ _t("Now, let's help you get started") }</h4>
</div>;
};
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
const config = SdkConfig.get();
const pageUrl = getHomePageUrl(config);
@ -37,18 +100,27 @@ const HomePage = () => {
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
}
const brandingConfig = config.branding;
let logoUrl = "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
let introSection;
if (justRegistered) {
introSection = <UserWelcomeTop />;
} else {
const brandingConfig = config.branding;
let logoUrl = "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
}
introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
</React.Fragment>;
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
<div className="mx_HomePage_default_wrapper">
<img src={logoUrl} alt={config.brand || "Element"} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
{ introSection }
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
{ _t("Send a Direct Message") }

View file

@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
interface IProps {
isMinimized: boolean;
@ -142,7 +143,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
@ -213,10 +214,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
}
} else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
if (header.style.bottom) {
header.style.removeProperty('bottom');
}
}
if (style.stickyTop || style.stickyBottom) {
@ -425,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList}
</div>
</div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
</aside>
</div>
);

View file

@ -0,0 +1,149 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import {Resizable} from "re-resizable";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton";
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
import {Key} from "../../Keyboard";
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings";
interface IProps {
onResize(): void;
}
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
const app = useMemo(() => {
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
if (!widgetConfig) return null;
return WidgetUtils.makeAppConfig(
widgetConfig.state_key,
widgetConfig.content,
widgetConfig.sender,
null,
widgetConfig.id);
}, [mWidgetsEvent, leftPanelWidgetId]);
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;
if (!app) return null;
let content;
if (expanded) {
content = <Resizable
size={{height} as any}
minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
onResize={onResize}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
}}
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
className="mx_LeftPanelWidget_resizeBox"
enable={{ top: true }}
>
<AppTile
app={app}
fullWidth
show
showMenubar={false}
userWidget
userId={cli.getUserId()}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
/>
</Resizable>;
}
return <div className="mx_LeftPanelWidget">
<div
onFocus={onFocus}
className="mx_LeftPanelWidget_headerContainer"
onKeyDown={(ev: React.KeyboardEvent) => {
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
setExpanded(false);
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
setExpanded(true);
break;
}
}
}}
>
<div className="mx_LeftPanelWidget_stickable">
<AccessibleButton
onFocus={onFocus}
inputRef={ref}
tabIndex={tabIndex}
className="mx_LeftPanelWidget_headerText"
role="treeitem"
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
}}
>
<span className={classNames({
"mx_LeftPanelWidget_collapseBtn": true,
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
})} />
<span>{ WidgetUtils.getWidgetName(app) }</span>
</AccessibleButton>
{/* Code for the maximise button for once we have full screen widgets */}
{/*<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={() => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
/>*/}
</div>
</div>
{ content }
</div>;
};
export default LeftPanelWidget;

View file

@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
// We need to fetch each pinned message individually (if we don't already have it)
@ -88,6 +89,7 @@ interface IProps {
currentUserId?: string;
currentGroupId?: string;
currentGroupIsNew?: boolean;
justRegistered?: boolean;
}
interface IUsageLimit {
@ -391,6 +393,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
switch (ev.key) {
case Key.PAGE_UP:
@ -435,6 +438,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
break;
case Key.H:
if (ev.altKey && modKey) {
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
@ -573,7 +586,7 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
case PageTypes.HomePage:
pageElement = <HomePage />;
pageElement = <HomePage justRegistered={this.props.justRegistered} />;
break;
case PageTypes.UserView:

View file

@ -29,6 +29,7 @@ import 'focus-visible';
import 'what-input';
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
@ -61,7 +62,7 @@ import DMRoomMap from '../../utils/DMRoomMap';
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from '../../settings/watchers/FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer, IDeferred } from "../../utils/promise";
import { defer, IDeferred, sleep } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
@ -86,37 +87,37 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
export enum Views {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.
LOADING = 0,
LOADING,
// we are showing the welcome view
WELCOME = 1,
WELCOME,
// we are showing the login view
LOGIN = 2,
LOGIN,
// we are showing the registration view
REGISTER = 3,
// completing the registration flow
POST_REGISTRATION = 4,
REGISTER,
// showing the 'forgot password' view
FORGOT_PASSWORD = 5,
FORGOT_PASSWORD,
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY = 6,
COMPLETE_SECURITY,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP = 7,
E2E_SETUP,
// we are logged in with an active matrix client.
LOGGED_IN = 8,
// we are logged in with an active matrix client. The logged_in state also
// includes guests users as they too are logged in at the client level.
LOGGED_IN,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT = 9,
SOFT_LOGOUT,
}
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
// Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
// re-factoring to be included in this list in future.
@ -199,6 +200,7 @@ interface IState {
roomOobData?: object;
viaServers?: string[];
pendingInitialSync?: boolean;
justRegistered?: boolean;
}
export default class MatrixChat extends React.PureComponent<IProps, IState> {
@ -349,6 +351,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
@ -363,6 +366,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.shouldTrackPageChange(prevState, this.state)) {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusComposer);
@ -415,6 +419,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
dis.dispatch({action: "view_welcome_page"});
}
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
});
// Note we don't catch errors from this: we catch everything within
@ -473,6 +479,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const newState = {
currentUserId: null,
justRegistered: false,
};
Object.assign(newState, state);
this.setState(newState);
@ -554,11 +561,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
ThemeController.isLogin = true;
this.themeWatcher.recheck();
break;
case 'start_post_registration':
this.setState({
view: Views.POST_REGISTRATION,
});
break;
case 'start_password_recovery':
this.setStateForNewView({
view: Views.FORGOT_PASSWORD,
@ -645,8 +647,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true);
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -663,13 +666,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewWelcome();
break;
case 'view_home_page':
this.viewHome();
this.viewHome(payload.justRegistered);
break;
case 'view_start_chat_or_reuse':
this.chatCreateOrReuse(payload.user_id);
break;
case 'view_create_chat':
showStartChatInviteDialog();
showStartChatInviteDialog(payload.initialText || "");
break;
case 'view_invite':
showRoomInviteDialog(payload.roomId);
@ -750,7 +753,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast();
Analytics.enable();
if (Analytics.canEnable()) {
Analytics.enable();
}
if (CountlyAnalytics.instance.canEnable()) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
break;
case 'reject_cookies':
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
@ -942,10 +950,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.themeWatcher.recheck();
}
private viewHome() {
private viewHome(justRegistered = false) {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
@ -1179,7 +1188,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (welcomeUserRoom === null) {
// We didn't redirect to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
dis.dispatch({action: 'view_home_page', justRegistered: true});
}
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
// The user has a 3pid invite pending - show them that
@ -1192,7 +1201,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
dis.dispatch({action: 'view_home_page', justRegistered: true});
}
} else {
this.showScreenAfterLogin();
@ -1200,7 +1209,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage();
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) {
// defer the following actions by 30 seconds to not throw them at the user immediately
await sleep(30);
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl);
}
}
@ -1330,8 +1343,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.firstSyncComplete = true;
this.firstSyncPromise.resolve();
if (Notifier.shouldShowPrompt()) {
showNotificationsToast();
if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) {
showNotificationsToast(false);
}
dis.fire(Action.FocusComposer);
@ -1340,18 +1353,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
});
if (SettingsStore.getValue(UIFeature.Voip)) {
cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
});
}
cli.on('Session.logged_out', function(errObj) {
if (Lifecycle.isLoggingOut()) return;
@ -1394,6 +1395,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
@ -1535,6 +1537,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
showScreen(screen: string, params?: {[key: string]: any}) {
const cli = MatrixClientPeg.get();
const isLoggedOutOrGuest = !cli || cli.isGuest();
if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) {
// user is logged in and landing on an auth page which will uproot their session, redirect them home instead
dis.dispatch({ action: "view_home_page" });
return;
}
if (screen === 'register') {
dis.dispatch({
action: 'start_registration',
@ -1551,7 +1561,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params,
});
} else if (screen === 'soft_logout') {
if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) {
if (cli.getUserId() && !Lifecycle.isSoftLogout()) {
// Logged in - visit a room
this.viewLastRoom();
} else {
@ -1581,6 +1591,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration',
});
} else if (screen === 'directory') {
if (this.state.view === Views.WELCOME) {
CountlyAnalytics.instance.track("onboarding_room_directory");
}
dis.fire(Action.ViewRoomDirectory);
} else if (screen === "start_sso" || screen === "start_cas") {
// TODO if logged in, skip SSO
@ -1599,14 +1612,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({
action: 'view_my_groups',
});
} else if (screen === 'complete_security') {
dis.dispatch({
action: 'start_complete_security',
});
} else if (screen === 'post_registration') {
dis.dispatch({
action: 'start_post_registration',
});
} else if (screen.indexOf('room/') === 0) {
// Rooms can have the following formats:
// #room_alias:domain or !opaque_id:domain
@ -1777,14 +1782,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return Lifecycle.setLoggedIn(credentials);
}
onFinishPostRegistration = () => {
// Don't confuse this with "PageType" which is the middle window to show
this.setState({
view: Views.LOGGED_IN,
});
this.showScreen("settings");
};
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
@ -1949,13 +1946,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
accountPassword={this.accountPassword}
/>
);
} else if (this.state.view === Views.POST_REGISTRATION) {
// needs to be before normal PageTypes as you are logged in technically
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
view = (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
);
} else if (this.state.view === Views.LOGGED_IN) {
// store errors stop the client syncing and require user intervention, so we'll
// be showing a dialog. Don't show anything else.

View file

@ -30,6 +30,8 @@ import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -952,15 +954,25 @@ class CreationGrouper {
}).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1];
let summaryText;
const roomId = ev.getRoomId();
const creator = ev.sender ? ev.sender.name : ev.getSender();
if (DMRoomMap.shared().getUserIdForRoomId(roomId)) {
summaryText = _t("%(creator)s created this DM.", { creator });
} else {
summaryText = _t("%(creator)s created and configured the room.", { creator });
}
ret.push(<NewRoomIntro key="newroomintro" />);
ret.push(
<EventListSummary
key="roomcreationsummary"
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={_t("%(creator)s created and configured the room.", {
creator: ev.sender ? ev.sender.name : ev.getSender(),
})}
summaryText={summaryText}
>
{ eventTiles }
</EventListSummary>,

View file

@ -33,6 +33,7 @@ import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
@ -43,12 +44,16 @@ function track(action) {
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
onFinished: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
@ -57,7 +62,7 @@ export default class RoomDirectory extends React.Component {
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: null,
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
@ -198,6 +203,11 @@ export default class RoomDirectory extends React.Component {
return;
}
if (this.state.filterString) {
const count = data.total_room_count_estimate || data.chunk.length;
CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString);
}
this.nextBatch = data.next_batch;
this.setState((s) => {
s.publicRooms.push(...(data.chunk || []));
@ -407,7 +417,7 @@ export default class RoomDirectory extends React.Component {
};
onCreateRoomClick = room => {
this.props.onFinished();
this.onFinished();
dis.dispatch({
action: 'view_create_room',
public: true,
@ -419,11 +429,12 @@ export default class RoomDirectory extends React.Component {
}
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
this.props.onFinished();
this.onFinished();
const payload = {
action: 'view_room',
auto_join: autoJoin,
should_peek: shouldPeek,
_type: "room_directory", // instrumentation
};
if (room) {
// Don't let the user view a room they won't be able to either
@ -575,6 +586,11 @@ export default class RoomDirectory extends React.Component {
}
};
onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished();
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
@ -671,6 +687,7 @@ export default class RoomDirectory extends React.Component {
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
initialText={this.props.initialText}
/>
{dropdown}
</div>;
@ -693,7 +710,7 @@ export default class RoomDirectory extends React.Component {
<BaseDialog
className={'mx_RoomDirectory_dialog'}
hasCancel={true}
onFinished={this.props.onFinished}
onFinished={this.onFinished}
title={title}
>
<div className="mx_RoomDirectory">

View file

@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Search")}
placeholder={_t("Filter")}
autoComplete="off"
/>
);
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (this.props.isMinimized) {
icon = (
<AccessibleButton
title={_t("Search rooms")}
title={_t("Filter rooms and people")}
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
onClick={this.openSearch}
/>

View file

@ -18,13 +18,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -41,16 +39,6 @@ export default class RoomStatusBar extends React.Component {
static propTypes = {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
// This is true when the user is alone in the room, but has also sent a message.
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool,
// The active call in the room, if any (means we show the call bar
// along with the status of the call)
callState: PropTypes.string,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
@ -68,10 +56,6 @@ export default class RoomStatusBar extends React.Component {
// 'you are alone' bar
onInviteClick: PropTypes.func,
// callback for when the user clicks on the 'stop warning me' button in the
// 'you are alone' bar
onStopWarningClick: PropTypes.func,
// callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent
// component.
@ -122,12 +106,6 @@ export default class RoomStatusBar extends React.Component {
});
};
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
@ -159,10 +137,7 @@ export default class RoomStatusBar extends React.Component {
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError() ||
this._showCallBar() ||
this.props.sentMessageAndIsAlone
) {
if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE;
@ -170,22 +145,6 @@ export default class RoomStatusBar extends React.Component {
return STATUS_BAR_HIDDEN;
}
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
);
}
if (this._shouldShowConnectionError()) {
return null;
}
return null;
}
_shouldShowConnectionError() {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
@ -276,25 +235,6 @@ export default class RoomStatusBar extends React.Component {
</div>;
}
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
if (this._shouldShowConnectionError()) {
@ -317,44 +257,14 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent();
}
if (this._showCallBar()) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ this._getCallStatusText() }</b>
</div>
);
}
// If you're alone in the room, and have sent a message, suggest to invite someone
if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) {
return (
<div className="mx_RoomStatusBar_isAlone">
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
"or <nowarnText>stop warning about the empty room</nowarnText>?",
{},
{
'inviteText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
'nowarnText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
},
) }
</div>
);
}
return null;
}
render() {
const content = this._getContent();
const indicator = this._getIndicator();
return (
<div className="mx_RoomStatusBar">
<div className="mx_RoomStatusBar_indicator">
{ indicator }
</div>
<div role="alert">
{ content }
</div>

View file

@ -41,7 +41,7 @@ import rateLimitedFunc from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
@ -56,7 +56,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
import {SettingLevel} from "../../settings/SettingLevel";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {IMatrixClientCreds} from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
@ -68,15 +67,16 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call";
import EffectsOverlay from "../views/elements/effects/EffectsOverlay";
import {containsEmoji} from '../views/elements/effects/effectUtilities';
import effects from '../views/elements/effects'
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -132,6 +132,7 @@ export interface IState {
initialEventPixelOffset?: number;
// Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
@ -150,7 +151,6 @@ export interface IState {
guestsCanJoin: boolean;
canPeek: boolean;
showApps: boolean;
isAlone: boolean;
isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
@ -223,7 +223,6 @@ export default class RoomView extends React.Component<IProps, IState> {
guestsCanJoin: false,
canPeek: false,
showApps: false,
isAlone: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
@ -320,6 +319,7 @@ export default class RoomView extends React.Component<IProps, IState> {
joining: RoomViewStore.isJoining(),
initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
@ -511,8 +511,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize();
document.addEventListener("keydown", this.onNativeKeyDown);
}
shouldComponentUpdate(nextProps, nextState) {
@ -597,8 +595,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onNativeKeyDown);
// Remove RoomStore listener
if (this.roomStoreToken) {
this.roomStoreToken.remove();
@ -647,33 +643,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMuteAudioClick();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onMuteVideoClick();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onReactKeyDown = ev => {
let handled = false;
@ -708,9 +677,8 @@ export default class RoomView extends React.Component<IProps, IState> {
private onAction = payload => {
switch (payload.action) {
case 'message_send_failed':
case 'message_sent':
this.checkIfAlone(this.state.room);
this.checkDesktopNotifications();
break;
case 'post_sticker_message':
this.injectSticker(
@ -1049,33 +1017,17 @@ export default class RoomView extends React.Component<IProps, IState> {
}
// rate limited because a power level change will emit an event for every member in the room.
private updateRoomMembers = rateLimitedFunc((dueToMember) => {
private updateRoomMembers = rateLimitedFunc(() => {
this.updateDMState();
let memberCountInfluence = 0;
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
// A member got invited, but the room hasn't detected that change yet. Influence the member
// count by 1 to counteract this.
memberCountInfluence = 1;
}
this.checkIfAlone(this.state.room, memberCountInfluence);
this.updateE2EStatus(this.state.room);
}, 500);
private checkIfAlone(room: Room, countInfluence?: number) {
let warnedAboutLonelyRoom = false;
if (localStorage) {
warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId));
private checkDesktopNotifications() {
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();
// if they are not alone prompt the user about notifications so they don't miss replies
if (memberCount > 1 && Notifier.shouldShowPrompt()) {
showNotificationsToast(true);
}
if (warnedAboutLonelyRoom) {
if (this.state.isAlone) this.setState({isAlone: false});
return;
}
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
}
private updateDMState() {
@ -1110,14 +1062,6 @@ export default class RoomView extends React.Component<IProps, IState> {
action: 'view_invite',
roomId: this.state.room.roomId,
});
this.setState({isAlone: false}); // there's a good chance they'll invite someone
};
private onStopAloneWarningClick = () => {
if (localStorage) {
localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true));
}
this.setState({isAlone: false});
};
private onJoinButtonClicked = () => {
@ -1139,6 +1083,7 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({
action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
_type: "unknown", // TODO: instrumentation
});
return Promise.resolve();
});
@ -1165,16 +1110,9 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.dataTransfer.dropEffect = 'none';
const items = [...ev.dataTransfer.items];
if (items.length >= 1) {
const isDraggingFiles = items.every(function(item) {
return item.kind == 'file';
});
if (isDraggingFiles) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
};
@ -1381,10 +1319,7 @@ export default class RoomView extends React.Component<IProps, IState> {
};
private onSettingsClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
dis.dispatch({ action: "open_room_settings" });
};
private onCancelClick = () => {
@ -1815,12 +1750,8 @@ export default class RoomView extends React.Component<IProps, IState> {
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar
room={this.state.room}
sentMessageAndIsAlone={this.state.isAlone}
callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
/>;
@ -1927,6 +1858,7 @@ export default class RoomView extends React.Component<IProps, IState> {
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
/>;
}
@ -1941,56 +1873,6 @@ export default class RoomView extends React.Component<IProps, IState> {
};
}
if (activeCall) {
let zoomButton; let videoMuteButton;
if (activeCall.type === CallType.Video) {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
src={require("../../../res/img/element-icons/call/fullscreen.svg")}
width="29"
height="22"
style={{ marginTop: 1, marginRight: 4 }}
/>
</div>
);
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width=""
height="27"
/>
</div>;
}
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
</div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar =
<div className="mx_RoomView_callStatusBar">
{ voiceMuteButton }
{ videoMuteButton }
{ zoomButton }
{ statusBar }
</div>;
}
// if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it.
let searchResultsPanel;

View file

@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component {
if (itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
if (sn.scrollTop !== sn.scrollHeight){
if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight;
}
debuglog("updateHeight to", newHeight);

View file

@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
let toast;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const {title, icon, key, component, props} = topToast;
const {title, icon, key, component, className, props} = topToast;
const toastClasses = classNames("mx_Toast_toast", {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
}, className);
let countIndicator;
if (isStacked || this.state.countSeen > 0) {

View file

@ -86,7 +86,9 @@ export default class UploadBar extends React.Component {
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)},
);
return (
<div className="mx_UploadBar">

View file

@ -23,7 +23,7 @@ import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
@ -186,15 +186,22 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSignOutClick = (ev: ButtonEvent) => {
private onSignOutClick = async (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
const cli = MatrixClientPeg.get();
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
// log out without user prompt if they have no local megolm sessions
dis.dispatch({action: 'logout'});
} else {
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
}
this.setState({contextMenuPosition: null}); // also close the menu
};
@ -203,6 +210,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onCommunitySettingsClick = (ev: ButtonEvent) => {
@ -452,7 +460,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
public render() {
const avatarSize = 32; // should match border-radius of the avatar
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
const userId = MatrixClientPeg.get().getUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
@ -507,7 +516,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={displayName}
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}

View file

@ -26,6 +26,7 @@ import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
// Phases
// Show controls to configure server details
@ -64,6 +65,12 @@ export default class ForgotPassword extends React.Component {
serverRequiresIdServer: null,
};
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
}
componentDidMount() {
this.reset = null;
this._checkServerLiveliness(this.props.serverConfig);
@ -299,15 +306,19 @@ export default class ForgotPassword extends React.Component {
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
autoFocus
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
name="reset_password"
type="password"
label={_t('Password')}
label={_t('New Password')}
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
/>
<Field
name="reset_password_confirm"
@ -315,6 +326,8 @@ export default class ForgotPassword extends React.Component {
label={_t('Confirm')}
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
/>
</div>
<span>{_t(

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2015, 2016, 2017, 2018, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, {ComponentProps, ReactNode} from 'react';
import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index';
import Login from '../../../Login';
@ -30,15 +28,13 @@ import SSOButton from "../../views/elements/SSOButton";
import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
import CountlyAnalytics from "../../../CountlyAnalytics";
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
import ServerConfig from "../../views/auth/ServerConfig";
import PasswordLogin from "../../views/auth/PasswordLogin";
import SignInToText from "../../views/auth/SignInToText";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
// Enable phases for login
const PHASES_ENABLED = true;
@ -54,64 +50,88 @@ _td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
interface IProps {
serverConfig: ValidatedServerConfig;
// If true, the component will consider itself busy.
busy?: boolean;
isSyncing?: boolean;
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(data: IMatrixClientCreds, password: string): void;
// login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
enum Phase {
// Show controls to configure server details
ServerDetails,
// Show the appropriate login flow(s) for the server
Login,
}
interface IState {
busy: boolean;
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
// used for preserving form values when changing homeserver
username: string;
phoneCountry?: string;
phoneNumber: string;
// Phase of the overall login dialog.
phase: Phase;
// The current login flow, such as password, SSO, etc.
// we need to load the flows from the server
currentFlow?: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
}
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.Component {
static propTypes = {
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
// If true, the component will consider itself busy.
busy: PropTypes.bool,
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration, password recovery,
// etc is done.
onRegisterClick: PropTypes.func.isRequired,
onForgotPasswordClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
isSyncing: PropTypes.bool,
};
export default class LoginComponent extends React.Component<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) {
super(props);
this._unmounted = false;
this.state = {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors?
// used for preserving form values when changing homeserver
canTryLogin: true,
username: "",
phoneCountry: null,
phoneNumber: "",
// Phase of the overall login dialog.
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: null, // we need to load the flows from the server
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
phase: Phase.Login,
currentFlow: null,
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
@ -119,23 +139,25 @@ export default class LoginComponent extends React.Component {
// map from login step type to a function which will render a control
// letting you do that login type
this._stepRendererMap = {
'm.login.password': this._renderPasswordStep,
this.stepRendererMap = {
'm.login.password': this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"),
'm.login.cas': () => this.renderSsoStep("cas"),
'm.login.sso': () => this.renderSsoStep("sso"),
};
CountlyAnalytics.instance.track("onboarding_login_begin");
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._initLoginLogic();
this.initLoginLogic(this.props.serverConfig);
}
componentWillUnmount() {
this._unmounted = true;
this.unmounted = true;
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -145,16 +167,9 @@ export default class LoginComponent extends React.Component {
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
this.initLoginLogic(newProps.serverConfig);
}
onPasswordLoginError = errorText => {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
};
isBusy = () => this.state.busy || this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
@ -191,13 +206,13 @@ export default class LoginComponent extends React.Component {
loginIncorrect: false,
});
this._loginLogic.loginViaPassword(
this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data, password);
}, (error) => {
if (this._unmounted) {
if (this.unmounted) {
return;
}
let errorText;
@ -209,21 +224,23 @@ export default class LoginComponent extends React.Component {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
error.data.admin_contact,
{
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
},
);
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
error.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
errorText = (
<div>
<div>{errorTop}</div>
@ -250,7 +267,7 @@ export default class LoginComponent extends React.Component {
}
} else {
// other errors, not specific to doing a password login
errorText = this._errorTextFromError(error);
errorText = this.errorTextFromError(error);
}
this.setState({
@ -288,7 +305,7 @@ export default class LoginComponent extends React.Component {
// the busy state. In the case of a full MXID that resolves to the same
// HS as Element's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages
// actually has changed, `initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
@ -301,7 +318,7 @@ export default class LoginComponent extends React.Component {
message = e.translatedMessage;
}
let errorText = message;
let errorText: ReactNode = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
@ -327,21 +344,6 @@ export default class LoginComponent extends React.Component {
});
};
onPhoneNumberBlur = phoneNumber => {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
canTryLogin: false,
});
} else {
this.setState({
errorText: null,
canTryLogin: true,
});
}
};
onRegisterClick = ev => {
ev.preventDefault();
ev.stopPropagation();
@ -349,14 +351,14 @@ export default class LoginComponent extends React.Component {
};
onTryRegisterClick = ev => {
const step = this._getCurrentFlowStep();
const step = this.getCurrentFlowStep();
if (step === 'm.login.sso' || step === 'm.login.cas') {
// If we're showing SSO it means that registration is also probably disabled,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind,
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
} else {
// Don't intercept - just go through to the register page
@ -364,24 +366,21 @@ export default class LoginComponent extends React.Component {
}
};
onServerDetailsNextPhaseClick = () => {
private onServerDetailsNextPhaseClick = () => {
this.setState({
phase: PHASE_LOGIN,
phase: Phase.Login,
});
};
onEditServerDetailsClick = ev => {
private onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
phase: Phase.ServerDetails,
});
};
async _initLoginLogic(hsUrl, isUrl) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
@ -394,7 +393,7 @@ export default class LoginComponent extends React.Component {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
this._loginLogic = loginLogic;
this.loginLogic = loginLogic;
this.setState({
busy: true,
@ -425,7 +424,7 @@ export default class LoginComponent extends React.Component {
if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead
this.setState({
phase: PHASE_SERVER_DETAILS,
phase: Phase.ServerDetails,
});
return;
}
@ -434,7 +433,7 @@ export default class LoginComponent extends React.Component {
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) {
if (!this.isSupportedFlow(flows[i])) {
continue;
}
@ -443,7 +442,7 @@ export default class LoginComponent extends React.Component {
// that for now).
loginLogic.chooseFlow(i);
this.setState({
currentFlow: this._getCurrentFlowStep(),
currentFlow: this.getCurrentFlowStep(),
});
return;
}
@ -457,7 +456,7 @@ export default class LoginComponent extends React.Component {
});
}, (err) => {
this.setState({
errorText: this._errorTextFromError(err),
errorText: this.errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
@ -468,28 +467,28 @@ export default class LoginComponent extends React.Component {
});
}
_isSupportedFlow(flow) {
private isSupportedFlow(flow) {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this._stepRendererMap[flow.type]) {
if (!this.stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
}
_getCurrentFlowStep() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
private getCurrentFlowStep() {
return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null;
}
_errorTextFromError(err) {
private errorTextFromError(err) {
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
}
let errorText = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : "");
let errorText: ReactNode = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
@ -499,29 +498,27 @@ export default class LoginComponent extends React.Component {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
) }
}) }
</span>;
} else {
errorText = <span>
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
},
) }
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
}) }
</span>;
}
}
@ -529,18 +526,16 @@ export default class LoginComponent extends React.Component {
return errorText;
}
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
private renderServerComponent() {
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) {
return null;
}
const serverDetailsProps = {};
const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
@ -555,8 +550,8 @@ export default class LoginComponent extends React.Component {
/>;
}
renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
private renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== Phase.Login) {
return null;
}
@ -566,7 +561,7 @@ export default class LoginComponent extends React.Component {
return null;
}
const stepRenderer = this._stepRendererMap[step];
const stepRenderer = this.stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
@ -575,9 +570,7 @@ export default class LoginComponent extends React.Component {
return null;
}
_renderPasswordStep = () => {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
private renderPasswordStep = () => {
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -586,29 +579,25 @@ export default class LoginComponent extends React.Component {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
onEditServerDetailsClick={onEditServerDetailsClick}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
onSubmit={this.onPasswordLogin}
onEditServerDetailsClick={onEditServerDetailsClick}
username={this.state.username}
phoneCountry={this.state.phoneCountry}
phoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/>
);
};
_renderSsoStep = loginType => {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
private renderSsoStep = loginType => {
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -629,7 +618,7 @@ export default class LoginComponent extends React.Component {
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
matrixClient={this.loginLogic.createTemporaryClient()}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
@ -638,12 +627,10 @@ export default class LoginComponent extends React.Component {
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null;
<div className="mx_Login_loader"><Spinner /></div> : null;
const errorText = this.state.errorText;

View file

@ -1,77 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import AuthPage from "../../views/auth/AuthPage";
export default class PostRegistration extends React.Component {
static propTypes = {
onComplete: PropTypes.func.isRequired,
};
state = {
avatarUrl: null,
errorString: null,
busy: false,
};
componentDidMount() {
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
// the URL to be passed to you (because it's also used for room avatars).
const cli = MatrixClientPeg.get();
this.setState({busy: true});
const self = this;
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
self.setState({
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
busy: false,
});
}, function(error) {
self.setState({
errorString: _t("Failed to fetch avatar URL"),
busy: false,
});
});
}
render() {
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<div className="mx_Login_profile">
{ _t('Set a display name:') }
<ChangeDisplayName />
{ _t('Upload an avatar:') }
<ChangeAvatar
initialAvatarUrl={this.state.avatarUrl} />
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
{ this.state.errorString }
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,8 +15,9 @@ limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import React from 'react';
import PropTypes from 'prop-types';
import React, {ComponentProps, ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
@ -34,36 +32,96 @@ import Login from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate registration flow(s) for the server
const PHASE_REGISTRATION = 1;
enum Phase {
// Show controls to configure server details
ServerDetails = 0,
// Show the appropriate registration flow(s) for the server
Registration = 1,
}
interface IProps {
serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName: string;
email?: string;
brand?: string;
clientSecret?: string;
sessionId?: string;
idSid?: string;
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(params: {
userId: string;
deviceId: string
homeserverUrl: string;
identityServerUrl?: string;
accessToken: string;
}, password: string): void;
makeRegistrationUrl(params: {
/* eslint-disable camelcase */
client_secret: string;
hs_url: string;
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
busy: boolean;
errorText?: ReactNode;
// true if we're waiting for the user to complete
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: Record<string, string>;
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: boolean;
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: boolean;
serverType: ServerType.FREE | ServerType.PREMIUM | ServerType.ADVANCED;
// Phase of the overall registration dialog.
phase: Phase;
flows: {
stages: string[];
}[];
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient?: MatrixClient;
// whether the HS requires an ID server to register with a threepid
serverRequiresIdServer?: boolean;
// The user ID we've just registered
registeredUsername?: string;
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId?: string;
}
// Enable phases for registration
const PHASES_ENABLED = true;
export default class Registration extends React.Component {
static propTypes = {
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onServerConfigChange: PropTypes.func.isRequired,
defaultDeviceDisplayName: PropTypes.string,
};
export default class Registration extends React.Component<IProps, IState> {
constructor(props) {
super(props);
@ -71,56 +129,22 @@ export default class Registration extends React.Component {
this.state = {
busy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
phase: Phase.Registration,
flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
// whether the HS requires an ID server to register with a threepid
serverRequiresIdServer: null,
// The user ID we've just registered
registeredUsername: null,
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId: null,
};
}
componentDidMount() {
this._unmounted = false;
this._replaceClient();
this.replaceClient(this.props.serverConfig);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -129,7 +153,7 @@ export default class Registration extends React.Component {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig);
this.replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point.
@ -138,25 +162,25 @@ export default class Registration extends React.Component {
// Reset the phase to default phase for the server type.
this.setState({
serverType,
phase: this.getDefaultPhaseForServerType(serverType),
phase: Registration.getDefaultPhaseForServerType(serverType),
});
}
}
getDefaultPhaseForServerType(type) {
private static getDefaultPhaseForServerType(type: IState["serverType"]) {
switch (type) {
case ServerType.FREE: {
// Move directly to the registration phase since the server
// details are fixed.
return PHASE_REGISTRATION;
return Phase.Registration;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS;
return Phase.ServerDetails;
}
}
onServerTypeChange = type => {
private onServerTypeChange = (type: IState["serverType"]) => {
this.setState({
serverType: type,
});
@ -181,11 +205,11 @@ export default class Registration extends React.Component {
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
phase: Registration.getDefaultPhaseForServerType(type),
});
};
async _replaceClient(serverConfig) {
private async replaceClient(serverConfig: ValidatedServerConfig) {
this.setState({
errorText: null,
serverDeadError: null,
@ -194,7 +218,6 @@ export default class Registration extends React.Component {
// the UI auth component while we don't have a matrix client)
busy: true,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs
try {
@ -246,7 +269,7 @@ export default class Registration extends React.Component {
// do SSO instead. If we've already started the UI Auth process though, we don't
// need to.
if (!this.state.doingUIAuth) {
await this._makeRegisterRequest(null);
await this.makeRegisterRequest(null);
// This should never succeed since we specified no auth object.
console.log("Expecting 401 from register request but got success!");
}
@ -287,7 +310,7 @@ export default class Registration extends React.Component {
}
}
onFormSubmit = formVals => {
private onFormSubmit = formVals => {
this.setState({
errorText: "",
busy: true,
@ -296,7 +319,7 @@ export default class Registration extends React.Component {
});
};
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
@ -310,28 +333,26 @@ export default class Registration extends React.Component {
);
}
_onUIAuthFinished = async (success, response, extra) => {
private onUIAuthFinished = async (success, response, extra) => {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
response.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
);
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
response.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
@ -339,7 +360,7 @@ export default class Registration extends React.Component {
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
}
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
@ -358,6 +379,10 @@ export default class Registration extends React.Component {
const newState = {
doingUIAuth: false,
registeredUsername: response.user_id,
differentLoggedInUserId: null,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
};
// The user came in through an email validation link. To avoid overwriting
@ -372,8 +397,6 @@ export default class Registration extends React.Component {
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
);
newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
}
if (response.access_token) {
@ -385,9 +408,7 @@ export default class Registration extends React.Component {
accessToken: response.access_token,
}, this.state.formVals.password);
this._setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
this.setupPushers();
} else {
newState.busy = false;
newState.completedNoSignin = true;
@ -396,7 +417,7 @@ export default class Registration extends React.Component {
this.setState(newState);
};
_setupPushers() {
private setupPushers() {
if (!this.props.brand) {
return Promise.resolve();
}
@ -419,38 +440,38 @@ export default class Registration extends React.Component {
});
}
onLoginClick = ev => {
private onLoginClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
onGoToFormClicked = ev => {
private onGoToFormClicked = ev => {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.replaceClient(this.props.serverConfig);
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
phase: Phase.Registration,
});
};
onServerDetailsNextPhaseClick = async () => {
private onServerDetailsNextPhaseClick = async () => {
this.setState({
phase: PHASE_REGISTRATION,
phase: Phase.Registration,
});
};
onEditServerDetailsClick = ev => {
private onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
phase: Phase.ServerDetails,
});
};
_makeRegisterRequest = auth => {
private makeRegisterRequest = auth => {
// We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after
@ -466,13 +487,15 @@ export default class Registration extends React.Component {
username: this.state.formVals.username,
password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
inhibit_login: undefined,
};
if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams);
};
_getUIAuthInputs() {
private getUIAuthInputs() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
@ -483,7 +506,7 @@ export default class Registration extends React.Component {
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
_onLoginClickWithCheck = async ev => {
private onLoginClickWithCheck = async ev => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
@ -493,7 +516,7 @@ export default class Registration extends React.Component {
}
};
renderServerComponent() {
private renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
@ -502,11 +525,16 @@ export default class Registration extends React.Component {
return null;
}
// Hide the server picker once the user is doing UI Auth unless encountered a fatal server error
if (this.state.phase !== Phase.ServerDetails && this.state.doingUIAuth && !this.state.serverErrorIsFatal) {
return null;
}
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
// (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails && !this.state.serverErrorIsFatal) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
@ -515,7 +543,7 @@ export default class Registration extends React.Component {
</div>;
}
const serverDetailsProps = {};
const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
@ -554,8 +582,8 @@ export default class Registration extends React.Component {
</div>;
}
renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
private renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== Phase.Registration) {
return null;
}
@ -566,10 +594,10 @@ export default class Registration extends React.Component {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
requestEmailToken={this._requestEmailToken}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
@ -582,17 +610,6 @@ export default class Registration extends React.Component {
<Spinner />
</div>;
} else if (this.state.flows.length) {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
if (
PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
@ -600,7 +617,6 @@ export default class Registration extends React.Component {
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
@ -640,7 +656,7 @@ export default class Registration extends React.Component {
// Only show the 'go back' button if you're not looking at the form
let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
if ((PHASES_ENABLED && this.state.phase !== Phase.Registration) || this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') }
</a>;
@ -658,7 +674,7 @@ export default class Registration extends React.Component {
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")}
</AccessibleButton></p>
</div>;
@ -667,7 +683,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
} else {
@ -677,7 +693,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
}
@ -686,11 +702,48 @@ export default class Registration extends React.Component {
{ regDoneText }
</div>;
} else {
let yourMatrixAccountText: ReactNode = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
// If custom URLs are allowed, user is not doing UIA flows and they haven't selected the Free server type,
// wire up the server details edit link.
let editLink = null;
if (PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE &&
!this.state.doingUIAuth
) {
editLink = (
<a className="mx_AuthBody_editServerDetails" href="#" onClick={this.onEditServerDetailsClick}>
{_t('Change')}
</a>
);
}
body = <div>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.state.phase !== Phase.ServerDetails && <h3>
{yourMatrixAccountText}
{editLink}
</h3> }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
const DIV_ID = 'mx_recaptcha';
@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component {
this._captchaWidgetId = null;
this._recaptchaContainer = createRef();
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
}
componentDidMount() {
@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
});
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
} catch (e) {
this.setState({
errorText: e.toString(),
});
CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() });
}
}

View file

@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ country.name } (+{ country.prefix })
{ _t(country.name) } (+{ country.prefix })
</div>;
});

View file

@ -26,6 +26,7 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -189,6 +190,7 @@ export class RecaptchaAuthEntry extends React.Component {
}
_onCaptchaResponse = response => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE,
response: response,
@ -297,6 +299,8 @@ export class TermsAuthEntry extends React.Component {
toggledPolicies: initToggles,
policies: pickedPolicies,
};
CountlyAnalytics.instance.track("onboarding_terms_begin");
}
@ -326,8 +330,12 @@ export class TermsAuthEntry extends React.Component {
allChecked = allChecked && checked;
}
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
if (allChecked) {
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
CountlyAnalytics.instance.track("onboarding_terms_complete");
} else {
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
}
};
render() {
@ -413,12 +421,12 @@ export class EmailIdentityAuthEntry extends React.Component {
return <Spinner />;
} else {
return (
<div>
<p>{ _t("An email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
) }
</p>
<p>{ _t("Please check your email to continue registration.") }</p>
<p>{ _t("Open the link in the email to continue registration.") }</p>
</div>
);
}

View file

@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field";
import Field, {IInputProps} from "../elements/Field";
interface IProps {
interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean;
id?: string;
className?: string;

View file

@ -1,354 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
/**
* A pure UI component which displays a username/password form.
*/
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onEditServerDetailsClick: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
busy: PropTypes.bool,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onPhoneNumberBlur: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
disableSubmit: false,
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
this.state = {
username: this.props.initialUsername,
password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
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);
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
ev.preventDefault();
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
let error;
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
username = this.state.username;
if (!username) {
error = _t('The email field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The username field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
phoneCountry = this.state.phoneCountry;
phoneNumber = this.state.phoneNumber;
if (!phoneNumber) {
error = _t('The phone number field must not be blank.');
}
break;
}
if (error) {
this.props.onError(error);
return;
}
if (!this.state.password) {
this.props.onError(_t('The password field must not be blank.'));
return;
}
this.props.onSubmit(
username,
phoneCountry,
phoneNumber,
this.state.password,
);
}
onUsernameChanged(ev) {
this.setState({username: ev.target.value});
this.props.onUsernameChanged(ev.target.value);
}
onUsernameBlur(ev) {
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
username: "", // Reset because email and username use the same state
});
}
onPhoneCountryChanged(country) {
this.setState({
phoneCountry: country.iso2,
phonePrefix: country.prefix,
});
this.props.onPhoneCountryChanged(country.iso2);
}
onPhoneNumberChanged(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
}
onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value);
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType, autoFocus) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
}
}
}
isLoginEmpty() {
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
case PasswordLogin.LOGIN_FIELD_MXID:
return !this.state.username;
case PasswordLogin.LOGIN_FIELD_PHONE:
return !this.state.phoneCountry || !this.state.phoneNumber;
}
}
render() {
const Field = sdk.getComponent('elements.Field');
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -0,0 +1,495 @@
/*
Copyright 2015, 2016, 2017, 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import SignInToText from "./SignInToText";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
interface IProps {
username: string; // also used for email address
phoneCountry: string;
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
disableSubmit?: boolean;
busy?: boolean;
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
onUsernameChanged?(username: string): void;
onUsernameBlur?(username: string): void;
onPhoneCountryChanged?(phoneCountry: string): void;
onPhoneNumberChanged?(phoneNumber: string): void;
onEditServerDetailsClick?(): void;
onForgotPasswordClick?(): void;
}
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
password: "",
}
enum LoginField {
Email = "login_field_email",
MatrixId = "login_field_mxid",
Phone = "login_field_phone",
Password = "login_field_phone",
}
/*
* A pure UI component which displays a username/password form.
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
loginIncorrect: false,
disableSubmit: false,
};
constructor(props) {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
loginType: LoginField.MatrixId,
password: "",
};
}
private onForgotPasswordClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
};
private onSubmitForm = async ev => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
username = this.props.username;
break;
case LoginField.Phone:
phoneCountry = this.props.phoneCountry;
phoneNumber = this.props.phoneNumber;
break;
}
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
this.props.onUsernameChanged(ev.target.value);
};
private onUsernameFocus = () => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
};
private onUsernameBlur = ev => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
};
private onLoginTypeChange = ev => {
const loginType = ev.target.value;
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
};
private onPhoneCountryChanged = country => {
this.props.onPhoneCountryChanged(country.iso2);
};
private onPhoneNumberChanged = ev => {
this.props.onPhoneNumberChanged(ev.target.value);
};
private onPhoneNumberFocus = () => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
};
private onPhoneNumberBlur = ev => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
};
private onPasswordChanged = ev => {
this.setState({password: ev.target.value});
};
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
this.state.loginType,
LoginField.Password,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
}
private findFirstInvalidField(fieldIDs: LoginField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
private validateUsernameRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter username"),
},
],
});
private onUsernameValidate = async (fieldState) => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(LoginField.MatrixId, result.valid);
return result;
};
private validateEmailRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(LoginField.Email, result.valid);
return result;
};
private validatePhoneNumberRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
key: "number",
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
invalid: () => _t("Doesn't look like a valid phone number"),
},
],
});
private onPhoneNumberValidate = async (fieldState) => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
};
private validatePasswordRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter password"),
},
],
});
private onPasswordValidate = async (fieldState) => {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
}
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
const classes = {
error: false,
};
switch (loginType) {
case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field}
/>;
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => this[LoginField.Password] = field}
/>;
}
}
}
private isLoginEmpty() {
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
return !this.props.username;
case LoginField.Phone:
return !this.props.phoneCountry || !this.props.phoneNumber;
}
}
render() {
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
@ -29,34 +27,59 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
Username = "field_username",
Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
interface IProps {
// Values pre-filled in the input boxes when the component loads
defaultEmail?: string;
defaultPhoneCountry?: string;
defaultPhoneNumber?: string;
defaultUsername?: string;
defaultPassword?: string;
flows: {
stages: string[];
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
serverRequiresIdServer?: boolean;
onRegisterClick(params: {
username: string;
password: string;
email?: string;
phoneCountry?: string;
phoneNumber?: string;
}): Promise<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
/*
* A pure UI component which displays a registration form.
*/
export default class RegistrationForm extends React.Component {
static propTypes = {
// Values pre-filled in the input boxes when the component loads
defaultEmail: PropTypes.string,
defaultPhoneCountry: PropTypes.string,
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
serverRequiresIdServer: PropTypes.bool,
};
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
static defaultProps = {
onValidationChange: console.error,
canSubmit: true,
@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
@ -77,19 +98,21 @@ export default class RegistrationForm extends React.Component {
passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null,
};
CountlyAnalytics.instance.track("onboarding_registration_begin");
}
onSubmit = async ev => {
private onSubmit = async ev => {
ev.preventDefault();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
const self = this;
if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl);
@ -99,35 +122,42 @@ export default class RegistrationForm extends React.Component {
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
);
} else if (this._showEmail()) {
} else if (this.showEmail()) {
desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?",
);
} else {
// user can't set an e-mail so don't prompt them to
self._doSubmit(ev);
this.doSubmit(ev);
return;
}
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description: desc,
button: _t("Continue"),
onFinished(confirmed) {
onFinished: (confirmed) => {
if (confirmed) {
self._doSubmit(ev);
this.doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
this.doSubmit(ev);
}
};
_doSubmit(ev) {
private doSubmit(ev) {
const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
email: !!email,
});
const promise = this.props.onRegisterClick({
username: this.state.username.trim(),
password: this.state.password.trim(),
@ -144,20 +174,20 @@ export default class RegistrationForm extends React.Component {
}
}
async verifyFieldsBeforeSubmit() {
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
RegistrationField.Username,
RegistrationField.Password,
RegistrationField.PasswordConfirm,
RegistrationField.Email,
RegistrationField.PhoneNumber,
];
// Run all fields with stricter validation that no longer allows empty
@ -198,7 +228,7 @@ export default class RegistrationForm extends React.Component {
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid() {
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
@ -208,7 +238,7 @@ export default class RegistrationForm extends React.Component {
return true;
}
findFirstInvalidField(fieldIDs) {
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
@ -217,7 +247,7 @@ export default class RegistrationForm extends React.Component {
return null;
}
markFieldValid(fieldID, valid) {
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
@ -225,25 +255,26 @@ export default class RegistrationForm extends React.Component {
});
}
onEmailChange = ev => {
private onEmailChange = ev => {
this.setState({
email: ev.target.value,
});
};
onEmailValidate = async fieldState => {
private onEmailValidate = async fieldState => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
this.markFieldValid(RegistrationField.Email, result.valid);
return result;
};
validateEmailRules = withValidation({
private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
@ -255,29 +286,29 @@ export default class RegistrationForm extends React.Component {
],
});
onPasswordChange = ev => {
private onPasswordChange = ev => {
this.setState({
password: ev.target.value,
});
};
onPasswordValidate = result => {
this.markFieldValid(FIELD_PASSWORD, result.valid);
private onPasswordValidate = result => {
this.markFieldValid(RegistrationField.Password, result.valid);
};
onPasswordConfirmChange = ev => {
private onPasswordConfirmChange = ev => {
this.setState({
passwordConfirm: ev.target.value,
});
};
onPasswordConfirmValidate = async fieldState => {
private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
};
validatePasswordConfirmRules = withValidation({
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
@ -286,40 +317,40 @@ export default class RegistrationForm extends React.Component {
},
{
key: "match",
test({ value }) {
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
],
});
onPhoneCountryChange = newVal => {
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
};
onPhoneNumberChange = ev => {
private onPhoneNumberChange = ev => {
this.setState({
phoneNumber: ev.target.value,
});
};
onPhoneNumberValidate = async fieldState => {
private onPhoneNumberValidate = async fieldState => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result;
};
validatePhoneNumberRules = withValidation({
private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
@ -331,20 +362,21 @@ export default class RegistrationForm extends React.Component {
],
});
onUsernameChange = ev => {
private onUsernameChange = ev => {
this.setState({
username: ev.target.value,
});
};
onUsernameValidate = async fieldState => {
private onUsernameValidate = async fieldState => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
this.markFieldValid(RegistrationField.Username, result.valid);
return result;
};
validateUsernameRules = withValidation({
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
@ -365,7 +397,7 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is required
*/
_authStepIsRequired(step) {
private authStepIsRequired(step: string) {
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
});
@ -377,62 +409,66 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is used
*/
_authStepIsUsed(step) {
private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
}
_showEmail() {
private showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity')
!this.authStepIsUsed('m.login.email.identity')
) {
return false;
}
return true;
}
_showPhoneNumber() {
private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
!threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn')
!this.authStepIsUsed('m.login.msisdn')
) {
return false;
}
return true;
}
renderEmail() {
if (!this._showEmail()) {
private renderEmail() {
if (!this.showEmail()) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
ref={field => this[FIELD_EMAIL] = field}
ref={field => this[RegistrationField.Email] = field}
type="text"
label={emailPlaceholder}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
/>;
}
renderPassword() {
private renderPassword() {
return <PassphraseField
id="mx_RegistrationForm_password"
fieldRef={field => this[FIELD_PASSWORD] = field}
fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
/>;
}
@ -440,23 +476,25 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
autoComplete="new-password"
label={_t("Confirm")}
label={_t("Confirm password")}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
/>;
}
renderPhoneNumber() {
if (!this._showPhoneNumber()) {
if (!this.showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
@ -466,7 +504,7 @@ export default class RegistrationForm extends React.Component {
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
ref={field => this[FIELD_PHONE_NUMBER] = field}
ref={field => this[RegistrationField.PhoneNumber] = field}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
@ -480,51 +518,26 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
ref={field => this[RegistrationField.Username] = field}
type="text"
autoFocus={true}
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
/>;
}
render() {
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
let emailHelperText = null;
if (this._showEmail()) {
if (this._showPhoneNumber()) {
if (this.showEmail()) {
if (this.showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
@ -553,10 +566,6 @@ export default class RegistrationForm extends React.Component {
return (
<div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
{this.renderUsername()}

View file

@ -26,6 +26,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/src/matrix';
import classNames from 'classnames';
import CountlyAnalytics from "../../../CountlyAnalytics";
/*
* A pure UI component which displays the HS and IS to use.
@ -70,6 +71,8 @@ export default class ServerConfig extends React.PureComponent {
isUrl: props.serverConfig.isUrl,
showIdentityServer: false,
};
CountlyAnalytics.instance.track("onboarding_custom_server");
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event

View file

@ -23,11 +23,18 @@ import AuthPage from "./AuthPage";
import {_td} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
// translatable strings for Welcome pages
_td("Sign in with SSO");
export default class Welcome extends React.PureComponent {
constructor(props) {
super(props);
CountlyAnalytics.instance.track("onboarding_welcome");
}
render() {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');

View file

@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => {
_urls = urls || [];
if (url) {
_urls.unshift(url); // put in urls[0]
// copy urls and put url first
_urls = [url, ..._urls];
}
}

View file

@ -35,6 +35,7 @@ interface IProps {
height?: number;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
onClick?(): void;
}
interface IState {
@ -130,7 +131,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
public render() {
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name;
@ -139,7 +140,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/>
);
}

View file

@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
// We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality

View file

@ -0,0 +1,138 @@
/*
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, {useState} from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import BugReportDialog from "./BugReportDialog";
import InfoDialog from "./InfoDialog";
import StyledRadioGroup from "../elements/StyledRadioGroup";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
export default (props) => {
const [rating, setRating] = useState("");
const [comment, setComment] = useState("");
const onDebugLogsLinkClick = () => {
props.onFinished();
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
const hasFeedback = CountlyAnalytics.instance.canEnable();
const onFinished = (sendFeedback) => {
if (hasFeedback && sendFeedback) {
CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment);
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
title: _t('Feedback sent'),
description: _t('Thank you!'),
});
}
props.onFinished();
};
const brand = SdkConfig.get().brand;
let countlyFeedbackSection;
if (hasFeedback) {
countlyFeedbackSection = <React.Fragment>
<hr />
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{_t("Rate %(brand)s", { brand })}</h3>
<p>{_t("Tell us below how you feel about %(brand)s so far.", { brand })}</p>
<p>{_t("Please go into as much detail as you like, so we can track down the problem.")}</p>
<StyledRadioGroup
name="feedbackRating"
value={rating}
onChange={setRating}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
/>
</div>
</React.Fragment>;
}
let subheading;
if (hasFeedback) {
subheading = (
<h2>{_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}</h2>
);
}
return (<QuestionDialog
className="mx_FeedbackDialog"
hasCancelButton={!!hasFeedback}
title={_t("Feedback")}
description={<React.Fragment>
{ subheading }
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
<h3>{_t("Report a bug")}</h3>
<p>{
_t("Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. " +
"No match? <newIssueLink>Start a new one</newIssueLink>.", {}, {
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>;
},
})
}</p>
<p>{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
),
})
}</p>
</div>
{ countlyFeedbackSection }
</React.Fragment>}
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
buttonDisabled={hasFeedback && rating === ""}
onFinished={onFinished}
/>);
};

View file

@ -31,7 +31,7 @@ import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
@ -40,6 +40,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -279,11 +281,17 @@ class DMRoomTile extends React.PureComponent {
</span>
);
const caption = this.props.member.isEmail
? _t("Invite by email")
: this._highlightName(this.props.member.userId);
return (
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}>
{stackedAvatar}
<span className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
<span className='mx_InviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
<span className="mx_InviteDialog_roomTile_nameStack">
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div>
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
</span>
{timestamp}
</div>
);
@ -301,10 +309,14 @@ export default class InviteDialog extends React.PureComponent {
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: PropTypes.string,
// Initial value to populate the filter with
initialText: PropTypes.string,
};
static defaultProps = {
kind: KIND_DM,
initialText: "",
};
_debounceTimer: number = null;
@ -325,11 +337,13 @@ export default class InviteDialog extends React.PureComponent {
room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId));
// add banned users, so we don't try to invite them
room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId));
CountlyAnalytics.instance.trackBeginInvite(props.roomId);
}
this.state = {
targets: [], // array of Member objects (see interface above)
filterText: "",
filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited),
@ -347,6 +361,12 @@ export default class InviteDialog extends React.PureComponent {
this._editorRef = createRef();
}
componentDidMount() {
if (this.props.initialText) {
this._updateSuggestions(this.props.initialText);
}
}
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
@ -566,7 +586,12 @@ export default class InviteDialog extends React.PureComponent {
const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
let existingRoom: Room;
if (targetIds.length === 1) {
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
} else {
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
}
if (existingRoom) {
dis.dispatch({
action: 'view_room',
@ -627,6 +652,7 @@ export default class InviteDialog extends React.PureComponent {
};
_inviteUsers = () => {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true});
this._convertFilter();
const targets = this._convertFilter();
@ -643,6 +669,7 @@ export default class InviteDialog extends React.PureComponent {
}
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => {
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished();
}
@ -658,12 +685,130 @@ export default class InviteDialog extends React.PureComponent {
};
_onKeyDown = (e) => {
// when the field is empty and the user hits backspace remove the right-most target
if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
!e.ctrlKey && !e.shiftKey && !e.metaKey
) {
if (this.state.busy) return;
const value = e.target.value.trim();
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
// when the field is empty and the user hits backspace remove the right-most target
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
} else if (value && e.key === Key.ENTER && !hasModifiers) {
// when the user hits enter with something in their field try to convert it
e.preventDefault();
this._convertFilter();
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
// when the user hits space and their input looks like an e-mail/MXID then try to convert it
e.preventDefault();
this._convertFilter();
}
};
_updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
// Add a result anyways, just without a profile. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: term,
avatar_url: null,
});
}
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
user: new DirectoryMember(u),
})),
});
}).catch(e => {
console.error("Error searching user directory:");
console.error(e);
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
});
// Whenever we search the directory, also try to search the identity server. It's
// all debounced the same anyways.
if (!this.state.canUseIdentityServer) {
// The user doesn't have an identity server set - warn them of that.
this.setState({tryingIdentityServer: true});
return;
}
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
// per above: the userId is a lie here - it's just a regular identifier
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
});
try {
const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid(
'email',
term,
undefined, // callback
token,
);
if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) {
// We weren't able to find anyone - we're already suggesting the plain email
// as an alternative, so do nothing.
return;
}
// We append the user suggestion to give the user an option to click
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [...this.state.threepidResultsMixin, {
user: new DirectoryMember({
user_id: lookup.mxid,
display_name: profile.displayname,
avatar_url: profile.avatar_url,
}),
userId: lookup.mxid,
}],
});
} catch (e) {
console.error("Error searching identity server:");
console.error(e);
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
}
}
};
@ -677,113 +822,8 @@ export default class InviteDialog extends React.PureComponent {
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(async () => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
// Add a result anyways, just without a profile. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: term,
avatar_url: null,
});
}
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
user: new DirectoryMember(u),
})),
});
}).catch(e => {
console.error("Error searching user directory:");
console.error(e);
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
});
// Whenever we search the directory, also try to search the identity server. It's
// all debounced the same anyways.
if (!this.state.canUseIdentityServer) {
// The user doesn't have an identity server set - warn them of that.
this.setState({tryingIdentityServer: true});
return;
}
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
// per above: the userId is a lie here - it's just a regular identifier
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
});
try {
const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid(
'email',
term,
undefined, // callback
token,
);
if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) {
// We weren't able to find anyone - we're already suggesting the plain email
// as an alternative, so do nothing.
return;
}
// We append the user suggestion to give the user an option to click
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [...this.state.threepidResultsMixin, {
user: new DirectoryMember({
user_id: lookup.mxid,
display_name: profile.displayname,
avatar_url: profile.avatar_url,
}),
userId: lookup.mxid,
}],
});
} catch (e) {
console.error("Error searching identity server:");
console.error(e);
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
}
}
this._debounceTimer = setTimeout(() => {
this._updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some)
};
@ -806,6 +846,10 @@ export default class InviteDialog extends React.PureComponent {
filterText = ""; // clear the filter when the user accepts a suggestion
}
this.setState({targets, filterText});
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
};
_removeMember = (member: Member) => {
@ -815,6 +859,10 @@ export default class InviteDialog extends React.PureComponent {
targets.splice(idx, 1);
this.setState({targets});
}
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
};
_onPaste = async (e) => {
@ -824,7 +872,7 @@ export default class InviteDialog extends React.PureComponent {
return;
}
// Prevent the text being pasted into the textarea
// Prevent the text being pasted into the input
e.preventDefault();
// Process it as a list of addresses to add instead
@ -1019,8 +1067,8 @@ export default class InviteDialog extends React.PureComponent {
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
));
const input = (
<textarea
rows={1}
<input
type="text"
onKeyDown={this._onKeyDown}
onChange={this._updateFilter}
value={this.state.filterText}
@ -1028,6 +1076,7 @@ export default class InviteDialog extends React.PureComponent {
onPaste={this._onPaste}
autoFocus={true}
disabled={this.state.busy}
autoComplete="off"
/>
);
return (
@ -1098,7 +1147,7 @@ export default class InviteDialog extends React.PureComponent {
if (identityServersEnabled) {
helpText = _t(
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
"Start a conversation with someone using their name, email address or username (like <userId/>).",
{},
{userId: () => {
return (
@ -1153,7 +1202,7 @@ export default class InviteDialog extends React.PureComponent {
if (identityServersEnabled) {
helpText = _t(
"Invite someone using their name, username (like <userId/>), email address or " +
"Invite someone using their name, email address, username (like <userId/>) or " +
"<a>share this room</a>.",
{},
{

View file

@ -0,0 +1,203 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import {
ClientWidgetApi,
IModalWidgetCloseRequest,
IModalWidgetOpenRequestData,
IModalWidgetReturnData,
ISetModalButtonEnabledActionRequest,
IWidgetApiAcknowledgeResponseData,
IWidgetApiErrorResponseData,
BuiltInModalButtonID,
ModalButtonID,
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore";
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
sourceWidgetId: string;
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
}
interface IState {
messaging?: ClientWidgetApi;
disabledButtonIds: ModalButtonID[];
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private readonly widget: Widget;
private readonly possibleButtons: ModalButtonID[];
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {
disabledButtonIds: [],
};
constructor(props) {
super(props);
this.widget = new ElementWidget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
this.possibleButtons = (this.props.widgetDefinition.buttons || []).map(b => b.id);
}
public componentDidMount() {
const driver = new StopGapWidgetDriver( [], this.widget, WidgetKind.Modal);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({messaging});
}
public componentWillUnmount() {
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.stop();
}
private onReady = () => {
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition);
};
private onLoad = () => {
this.state.messaging.once("ready", this.onReady);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
this.props.onFinished(true, ev.detail.data);
}
private onButtonEnableToggle = (ev: CustomEvent<ISetModalButtonEnabledActionRequest>) => {
ev.preventDefault();
const isClose = ev.detail.data.button === BuiltInModalButtonID.Close;
if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) {
return this.state.messaging.transport.reply(ev.detail, {
error: {message: "Invalid button"},
} as IWidgetApiErrorResponseData);
}
let buttonIds: ModalButtonID[];
if (ev.detail.data.enabled) {
buttonIds = arrayFastClone(this.state.disabledButtonIds).filter(i => i !== ev.detail.data.button);
} else {
// use a set to swap the operation to avoid memory leaky arrays.
const tempSet = new Set(this.state.disabledButtonIds);
tempSet.add(ev.detail.data.button);
buttonIds = Array.from(tempSet);
}
this.setState({disabledButtonIds: buttonIds});
this.state.messaging.transport.reply(ev.detail, {} as IWidgetApiAcknowledgeResponseData);
};
public render() {
const templated = this.widget.getCompleteUrl({
currentRoomId: RoomViewStore.getRoomId(),
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
});
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set('widgetId', this.widget.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
const widgetUrl = parsed.toString().replace(/%24/g, '$');
let buttons;
if (this.props.widgetDefinition.buttons) {
// show first button rightmost for a more natural specification
buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => {
let kind = "secondary";
switch (def.kind) {
case ModalButtonKind.Primary:
kind = "primary";
break;
case ModalButtonKind.Secondary:
kind = "primary_outline";
break
case ModalButtonKind.Danger:
kind = "danger";
break;
}
const onClick = () => {
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
};
const isDisabled = this.state.disabledButtonIds.includes(def.id);
return <AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
{ def.label }
</AccessibleButton>;
});
}
return <BaseDialog
title={this.props.widgetDefinition.name || _t("Modal Widget")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="16"
width="16"
alt=""
/>
{_t("Data on this screen is shared with %(widgetDomain)s", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
ref={this.appFrame}
sandbox="allow-forms allow-scripts allow-same-origin"
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons">
{ buttons }
</div>
</BaseDialog>;
}
}

View file

@ -17,6 +17,8 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from "classnames";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -26,12 +28,14 @@ export default class QuestionDialog extends React.Component {
description: PropTypes.node,
extraButtons: PropTypes.node,
button: PropTypes.string,
buttonDisabled: PropTypes.bool,
danger: PropTypes.bool,
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string,
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth: PropTypes.bool,
className: PropTypes.string,
};
static defaultProps = {
@ -61,7 +65,7 @@ export default class QuestionDialog extends React.Component {
}
return (
<BaseDialog
className="mx_QuestionDialog"
className={classNames("mx_QuestionDialog", this.props.className)}
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
@ -74,6 +78,7 @@ export default class QuestionDialog extends React.Component {
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
primaryButtonClass={primaryButtonClass}
primaryDisabled={this.props.buttonDisabled}
cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk}

View file

@ -1,49 +0,0 @@
/*
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/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const description1 =
_t("If you run into any bugs or have feedback you'd like to share, " +
"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

@ -0,0 +1,147 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
Widget,
WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
widget: Widget;
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
}
interface IBooleanStates {
// @ts-ignore - TS wants a string key, but we know better
[capability: Capability]: boolean;
}
interface IState {
booleanStates: IBooleanStates;
rememberSelection: boolean;
}
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
constructor(props: IProps) {
super(props);
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e));
const states: IBooleanStates = {};
this.props.requestedCapabilities.forEach(c => states[c] = true);
this.state = {
booleanStates: states,
rememberSelection: true,
};
}
private onToggle = (capability: Capability) => {
const newStates = objectShallowClone(this.state.booleanStates);
newStates[capability] = !newStates[capability];
this.setState({booleanStates: newStates});
};
private onRememberSelectionChange = (newVal: boolean) => {
this.setState({rememberSelection: newVal});
};
private onSubmit = async (ev) => {
this.closeAndTryRemember(Object.entries(this.state.booleanStates)
.filter(([_, isSelected]) => isSelected)
.map(([cap]) => cap));
};
private onReject = async (ev) => {
this.closeAndTryRemember([]); // nothing was approved
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({approved});
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
: null;
return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
<StyledCheckbox
checked={isChecked}
onChange={() => this.onToggle(cap)}
>{text.primary}</StyledCheckbox>
{byline}
</div>
);
});
return (
<BaseDialog
className="mx_WidgetCapabilitiesPromptDialog"
onFinished={this.props.onFinished}
title={_t("Approve widget permissions")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="text-muted">{_t("This widget would like to:")}</div>
{checkboxRows}
<DialogButtons
primaryButton={_t("Approve")}
cancelButton={_t("Decline All")}
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />}
/>
</div>
</form>
</BaseDialog>
);
}
}

View file

@ -17,18 +17,17 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import WidgetUtils from "../../../utils/WidgetUtils";
import {SettingLevel} from "../../../settings/SettingLevel";
import {Widget} from "matrix-widget-api";
import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
widgetUrl: PropTypes.string.isRequired,
widgetId: PropTypes.string.isRequired,
isUserWidget: PropTypes.bool.isRequired,
widget: PropTypes.objectOf(Widget).isRequired,
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
inRoomId: PropTypes.string,
};
constructor() {
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
if (!currentValues.allow) currentValues.allow = [];
if (!currentValues.deny) currentValues.deny = [];
const securityKey = WidgetUtils.getWidgetSecurityKey(
this.props.widgetId,
this.props.widgetUrl,
this.props.isUserWidget);
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
WidgetPermissionStore.instance.setOIDCState(
this.props.widget, this.props.widgetKind, this.props.inRoomId,
allowed ? OIDCState.Allowed : OIDCState.Denied,
);
}
this.props.onFinished(allowed);
@ -84,7 +77,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
"A widget located at %(widgetUrl)s would like to verify your identity. " +
"By allowing this, the widget will be able to verify your user ID, but not " +
"perform actions as you.", {
widgetUrl: this.props.widgetUrl,
widgetUrl: this.props.widget.templateUrl.split("?")[0],
},
)}
</p>

View file

@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number;
disabled?: boolean;
className?: string;
onClick?(e?: ButtonEvent): void;
onClick(e?: ButtonEvent): void;
}
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {

View file

@ -46,7 +46,7 @@ export default class AddressSelector extends React.Component {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(props) {
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
// Make sure the selected item isn't outside the list bounds
const selected = this.state.selected;
const maxSelected = this._maxSelected(props.addressList);

View file

@ -23,7 +23,6 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import Spinner from './Spinner';
@ -61,6 +60,7 @@ export default class AppTile extends React.Component {
// This is a function to make the impact of calling SettingsStore slightly less
hasPermissionToLoad = (props) => {
if (this._usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
@ -335,6 +335,7 @@ export default class AppTile extends React.Component {
</div>
);
if (!this.state.hasPermissionToLoad) {
// only possible for room widgets, can assert this.props.room here
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
<div className={appTileBodyClass}>
@ -373,19 +374,18 @@ export default class AppTile extends React.Component {
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be
// re-mounted later when we do.
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
{appTileBody}
</PersistedElement>
</div>;
}
// all widgets can theoretically be allowed to remain on screen, so we wrap
// them all in a PersistedElement from the get-go. If we wait, the iframe will
// be re-mounted later, which means the widget has to start over, which is bad.
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
{appTileBody}
</PersistedElement>
</div>;
}
}
@ -446,7 +446,9 @@ AppTile.displayName = 'AppTile';
AppTile.propTypes = {
app: PropTypes.object.isRequired,
room: PropTypes.object.isRequired,
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: PropTypes.object,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
@ -470,10 +472,6 @@ AppTile.propTypes = {
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Widget capabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
};
@ -484,7 +482,6 @@ AppTile.defaultProps = {
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,
miniMode: false,
};

View file

@ -54,6 +54,9 @@ export default class DialogButtons extends React.Component {
// disables only the primary button
primaryDisabled: PropTypes.bool,
// something to stick next to the buttons, optionally
additive: PropTypes.element,
};
static defaultProps = {
@ -85,8 +88,14 @@ export default class DialogButtons extends React.Component {
</button>;
}
let additive = null;
if (this.props.additive) {
additive = <div className="mx_Dialog_buttons_additive">{this.props.additive}</div>;
}
return (
<div className="mx_Dialog_buttons">
{ additive }
{ cancelButton }
{ this.props.children }
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}

View file

@ -16,13 +16,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default class DirectorySearchBox extends React.Component {
constructor() {
super();
constructor(props) {
super(props);
this._collectInput = this._collectInput.bind(this);
this._onClearClick = this._onClearClick.bind(this);
this._onChange = this._onChange.bind(this);
@ -32,7 +31,7 @@ export default class DirectorySearchBox extends React.Component {
this.input = null;
this.state = {
value: '',
value: this.props.initialText || '',
};
}
@ -78,28 +77,33 @@ export default class DirectorySearchBox extends React.Component {
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const searchbox_classes = {
const searchboxClasses = {
mx_DirectorySearchBox: true,
};
searchbox_classes[this.props.className] = true;
searchboxClasses[this.props.className] = true;
let join_button;
let joinButton;
if (this.props.showJoinButton) {
join_button = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
onClick={this._onJoinButtonClick}
>{_t("Join")}</AccessibleButton>;
}
return <div className={`mx_DirectorySearchBox ${this.props.className} mx_textinput`}>
<input type="text" name="dirsearch" value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange} onKeyUp={this._onKeyUp}
placeholder={this.props.placeholder} autoFocus
/>
{ join_button }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick}></AccessibleButton>
</div>;
<input
type="text"
name="dirsearch"
value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange}
onKeyUp={this._onKeyUp}
placeholder={this.props.placeholder}
autoFocus
/>
{ joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
</div>;
}
}
@ -110,4 +114,5 @@ DirectorySearchBox.propTypes = {
onJoinClick: PropTypes.func,
placeholder: PropTypes.string,
showJoinButton: PropTypes.bool,
initialText: PropTypes.string,
};

View file

@ -64,7 +64,7 @@ interface IProps {
// All other props pass through to the <input>.
}
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The element to create. Defaults to "input".
element?: "input";
// The input's value. This is a controlled component, so the value is required.

View file

@ -1,34 +0,0 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import AccessibleButton from "./AccessibleButton";
export default function IconButton(props) {
const {icon, className, ...restProps} = props;
let newClassName = (className || "") + " mx_IconButton";
newClassName = newClassName + " mx_IconButton_icon_" + icon;
const allProps = Object.assign({}, restProps, {className: newClassName});
return React.createElement(AccessibleButton, allProps);
}
IconButton.propTypes = Object.assign({
icon: PropTypes.string,
}, AccessibleButton.propTypes);

View file

@ -0,0 +1,94 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useRef, useState} from 'react';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import Tooltip from './Tooltip';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useTimeout} from "../../../hooks/useTimeout";
import Analytics from "../../../Analytics";
import CountlyAnalytics from '../../../CountlyAnalytics';
export const AVATAR_SIZE = 52;
interface IProps {
hasAvatar: boolean;
noAvatarLabel?: string;
hasAvatarLabel?: string;
setAvatarUrl(url: string): Promise<void>;
}
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
const cli = useContext(MatrixClientContext);
const [busy, setBusy] = useState(false);
const [hover, setHover] = useState(false);
const [show, setShow] = useState(false);
useTimeout(() => {
setShow(true);
}, 3000); // show after 3 seconds
useTimeout(() => {
setShow(false);
}, 13000); // hide after being shown for 10 seconds
const uploadRef = useRef<HTMLInputElement>();
const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel;
return <React.Fragment>
<input
type="file"
ref={uploadRef}
className="mx_MiniAvatarUploader_input"
onChange={async (ev) => {
if (!ev.target.files?.length) return;
setBusy(true);
Analytics.trackEvent("mini_avatar", "upload");
CountlyAnalytics.instance.track("mini_avatar_upload");
const file = ev.target.files[0];
const uri = await cli.uploadContent(file);
await setAvatarUrl(uri);
setBusy(false);
}}
accept="image/*"
/>
<AccessibleButton
className={classNames("mx_MiniAvatarUploader", {
mx_MiniAvatarUploader_busy: busy,
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
})}
disabled={busy}
onClick={() => {
uploadRef.current.click();
}}
onMouseOver={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{ children }
<Tooltip
label={label}
visible={!!label && (hover || show)}
forceOnRight
/>
</AccessibleButton>
</React.Fragment>;
};
export default MiniAvatarUploader;

View file

@ -21,6 +21,8 @@ import {throttle} from "lodash";
import ResizeObserver from 'resize-observer-polyfill';
import dis from '../../../dispatcher/dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -144,9 +146,11 @@ export default class PersistedElement extends React.Component {
}
renderApp() {
const content = <div ref={this.collectChild} style={this.props.style}>
{this.props.children}
</div>;
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
<div ref={this.collectChild} style={this.props.style}>
{this.props.children}
</div>
</MatrixClientContext.Provider>;
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
}

View file

@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component {
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(),
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
@ -82,7 +81,6 @@ export default class PersistentApp extends React.Component {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
miniMode={true}
showMenubar={false}
/>;

View file

@ -32,7 +32,8 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> {
rules: IRule<T, D>[];
description(this: T, derivedData: D): React.ReactChild;
description?(this: T, derivedData: D): React.ReactChild;
hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>;
}
@ -54,6 +55,8 @@ export interface IValidationResult {
* @param {Function} description
* Function that returns a string summary of the kind of value that will
* meet the validation rules. Shown at the top of the validation feedback.
* @param {Boolean} hideDescriptionIfValid
* If true, don't show the description if the validation passes validation.
* @param {Function} deriveData
* Optional function that returns a Promise to an object of generic type D.
* The result of this Promise is passed to rule methods `skip`, `test`, `valid`, and `invalid`.
@ -71,7 +74,9 @@ export interface IValidationResult {
* A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail.
*/
export default function withValidation<T = undefined, D = void>({ description, deriveData, rules }: IArgs<T, D>) {
export default function withValidation<T = undefined, D = void>({
description, hideDescriptionIfValid, deriveData, rules,
}: IArgs<T, D>) {
return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
if (!value && allowEmpty) {
return {
@ -156,7 +161,7 @@ export default function withValidation<T = undefined, D = void>({ description, d
}
let summary;
if (description) {
if (description && (details || !hideDescriptionIfValid)) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this, derivedData);

View file

@ -1,63 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
export default class EncryptionEvent extends React.Component {
render() {
const {mxEvent} = this.props;
let body;
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
<div className="mx_cryptoEvent_subtitle">
{_t(
"Messages in this room are end-to-end encrypted. " +
"Learn more & verify this user in their user profile.",
)}
</div>
</div>;
} else if (isRoomEncrypted) {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
<div className="mx_cryptoEvent_subtitle">
{_t("Ignored attempt to disable encryption")}
</div>
</div>;
} else {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
<div className="mx_cryptoEvent_subtitle">{_t("The encryption used by this room isn't supported.")}</div>
</div>;
classes += " mx_cryptoEvent_icon_warning";
}
return (<div className={classes}>
{body}
</div>);
}
}
EncryptionEvent.propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};

View file

@ -0,0 +1,68 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {forwardRef, useContext} from 'react';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
interface IProps {
mxEvent: MatrixEvent;
}
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({mxEvent}, ref) => {
const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId();
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (dmPartner) {
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
subtitle = _t("Messages here are end-to-end encrypted. " +
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
} else {
subtitle = _t("Messages in this room are end-to-end encrypted. " +
"When people join, you can verify them in their profile, just tap on their avatar.");
}
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("Encryption enabled")}
subtitle={subtitle}
/>;
} else if (isRoomEncrypted) {
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("Encryption enabled")}
subtitle={_t("Ignored attempt to disable encryption")}
/>;
}
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon mx_cryptoEvent_icon_warning"
title={_t("Encryption not enabled")}
subtitle={_t("The encryption used by this room isn't supported.")}
ref={ref}
/>;
});
export default EncryptionEvent;

View file

@ -14,15 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {forwardRef, ReactNode} from "react";
import classNames from "classnames";
interface IProps {
className: string;
title: string;
subtitle?: ReactNode;
}
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
const EventTileBubble = forwardRef<HTMLDivElement, IProps>(({ className, title, subtitle, children }, ref) => {
return <div className={classNames("mx_EventTileBubble", className)} ref={ref}>
<div className="mx_EventTileBubble_title">{ title }</div>
{ subtitle && <div className="mx_EventTileBubble_subtitle">{ subtitle }</div> }
{ children }
</div>;
};
});
export default PulsedAvatar;
export default EventTileBubble;

View file

@ -144,7 +144,7 @@ export default class MFileBody extends React.Component {
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @params {Object} content The "content" key of the matrix event.
* @param {Object} content The "content" key of the matrix event.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile(content) {

View file

@ -85,6 +85,7 @@ export default class MImageBody extends React.Component {
showImage() {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({showImage: true});
this._downloadImage();
}
onClick(ev) {
@ -253,10 +254,7 @@ export default class MImageBody extends React.Component {
}
}
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
_downloadImage() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
@ -289,9 +287,18 @@ export default class MImageBody extends React.Component {
});
});
}
}
// Remember that the user wanted to show this particular image
if (!this.state.showImage && localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true") {
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
const showImage = this.state.showImage ||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// Don't download anything becaue we don't want to display anything.
this._downloadImage();
this.setState({showImage: true});
}

View file

@ -18,6 +18,7 @@ import React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import EventTileBubble from "./EventTileBubble";
interface IProps {
mxEvent: MatrixEvent;
@ -40,37 +41,24 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
if (!url) {
// removed
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference ended by %(senderName)s', {senderName})}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference ended by %(senderName)s', {senderName})}
/>;
} else if (prevUrl) {
// modified
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference updated by %(senderName)s', {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference updated by %(senderName)s', {senderName})}
subtitle={joinCopy}
/>;
} else {
// assume added
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t("Video conference started by %(senderName)s", {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t("Video conference started by %(senderName)s", {senderName})}
subtitle={joinCopy}
/>;
}
}
}

View file

@ -21,6 +21,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import {getNameForEventRoom, userLabelForEventRoom}
from '../../../utils/KeyVerificationStateObserver';
import EventTileBubble from "./EventTileBubble";
export default class MKeyVerificationConclusion extends React.Component {
constructor(props) {
@ -115,14 +116,14 @@ export default class MKeyVerificationConclusion extends React.Component {
}
if (title) {
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId());
const classes = classNames("mx_EventTile_bubble", "mx_cryptoEvent", "mx_cryptoEvent_icon", {
const classes = classNames("mx_cryptoEvent mx_cryptoEvent_icon", {
mx_cryptoEvent_icon_verified: request.done,
});
return (<div className={classes}>
<div className="mx_cryptoEvent_title">{title}</div>
<div className="mx_cryptoEvent_subtitle">{subtitle}</div>
</div>);
return <EventTileBubble
className={classes}
title={title}
subtitle={userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId())}
/>;
}
return null;

View file

@ -24,6 +24,7 @@ import {getNameForEventRoom, userLabelForEventRoom}
import dis from "../../../dispatcher/dispatcher";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions";
import EventTileBubble from "./EventTileBubble";
export default class MKeyVerificationRequest extends React.Component {
constructor(props) {
@ -146,10 +147,8 @@ export default class MKeyVerificationRequest extends React.Component {
if (!request.initiatedByMe) {
const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
title = (<div className="mx_cryptoEvent_title">{
_t("%(name)s wants to verify", {name})}</div>);
subtitle = (<div className="mx_cryptoEvent_subtitle">{
userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
title = _t("%(name)s wants to verify", {name});
subtitle = userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId());
if (request.canAccept) {
stateNode = (<div className="mx_cryptoEvent_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
@ -157,18 +156,18 @@ export default class MKeyVerificationRequest extends React.Component {
</div>);
}
} else { // request sent by us
title = (<div className="mx_cryptoEvent_title">{
_t("You sent a verification request")}</div>);
subtitle = (<div className="mx_cryptoEvent_subtitle">{
userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
title = _t("You sent a verification request");
subtitle = userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId());
}
if (title) {
return (<div className="mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon">
{title}
{subtitle}
{stateNode}
</div>);
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={title}
subtitle={subtitle}
>
{ stateNode }
</EventTileBubble>;
}
return null;
}

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
@ -24,23 +23,36 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
export default class MVideoBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
interface IProps {
/* the MatrixEvent to show */
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
/* called when the video has loaded */
onHeightChanged: PropTypes.func.isRequired,
};
interface IState {
decryptedUrl: string|null,
decryptedThumbnailUrl: string|null,
decryptedBlob: Blob|null,
error: any|null,
fetchingData: boolean,
}
state = {
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
};
export default class MVideoBody extends React.PureComponent<IProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
thumbScale(fullWidth, fullHeight, thumbWidth, thumbHeight) {
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
}
}
thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
@ -61,7 +73,7 @@ export default class MVideoBody extends React.Component {
}
}
_getContentUrl() {
private getContentUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
@ -70,7 +82,12 @@ export default class MVideoBody extends React.Component {
}
}
_getThumbUrl() {
private hasContentUrl(): boolean {
const url = this.getContentUrl();
return url && !url.startsWith("data:");
}
private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl;
@ -81,7 +98,8 @@ export default class MVideoBody extends React.Component {
}
}
componentDidMount() {
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
@ -92,26 +110,36 @@ export default class MVideoBody extends React.Component {
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return URL.createObjectURL(blob);
}).then((contentUrl) => {
try {
const thumbnailUrl = await thumbnailPromise;
if (autoplay) {
console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onHeightChanged();
});
}).catch((err) => {
} else {
console.log("NOT preloading video");
this.setState({
// For Chrome and Electron, we need to set some non-empty `src` to
// enable the play button. Firefox does not seem to care either
// way, so it's fine to do for all browsers.
decryptedUrl: `data:${content?.info?.mimetype},`,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: null,
});
}
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.setState({
error: err,
});
});
}
}
}
@ -124,8 +152,38 @@ export default class MVideoBody extends React.Component {
}
}
private videoOnPlay = async () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error.
return;
}
this.setState({
// To stop subsequent download attempts
fetchingData: true,
});
const content = this.props.mxEvent.getContent();
if (!content.file) {
this.setState({
error: "No file given in content",
});
return;
}
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedBlob: decryptedBlob,
fetchingData: false,
}, () => {
if (!this.videoRef.current) return;
this.videoRef.current.play();
});
this.props.onHeightChanged();
}
render() {
const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
if (this.state.error !== null) {
return (
@ -136,7 +194,8 @@ export default class MVideoBody extends React.Component {
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
@ -149,9 +208,8 @@ export default class MVideoBody extends React.Component {
);
}
const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
const contentUrl = this.getContentUrl();
const thumbUrl = this.getThumbUrl();
let height = null;
let width = null;
let poster = null;
@ -170,9 +228,20 @@ export default class MVideoBody extends React.Component {
}
return (
<span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={contentUrl} alt={content.body}
controls preload={preload} muted={autoplay} autoPlay={autoplay}
height={height} width={width} poster={poster}>
<video
className="mx_MVideoBody"
ref={this.videoRef}
src={contentUrl}
title={content.body}
controls
preload={preload}
muted={autoplay}
autoPlay={autoplay}
height={height}
width={width}
poster={poster}
onPlay={this.videoOnPlay}
>
</video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>

View file

@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
export default class RoomCreate extends React.Component {
static propTypes = {
@ -51,17 +52,16 @@ export default class RoomCreate extends React.Component {
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
return <div className="mx_CreateEvent">
<div className="mx_CreateEvent_image" />
<div className="mx_CreateEvent_header">
{_t("This room is a continuation of another conversation.")}
</div>
<a className="mx_CreateEvent_link"
href={predecessorPermalink}
onClick={this._onLinkClicked}
>
const link = (
<a href={predecessorPermalink} onClick={this._onLinkClicked}>
{_t("Click here to see older messages.")}
</a>
</div>;
);
return <EventTileBubble
className="mx_CreateEvent"
title={_t("This room is a continuation of another conversation.")}
subtitle={link}
/>;
}
}

View file

@ -416,7 +416,9 @@ export default class TextualBody extends React.Component {
if (this.props.highlightLink) {
body = <a href={this.props.highlightLink}>{ body }</a>;
} else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") {
body = <a href="#" onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}>{ body }</a>;
body = <a href="#"
onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}
>{ body }</a>;
}
let widgets;

View file

@ -31,7 +31,6 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
import Modal from "../../../Modal";
import ShareDialog from '../dialogs/ShareDialog';
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
@ -77,7 +76,6 @@ export const useWidgets = (room: Room) => {
}, [room]);
useEffect(updateApps, [room]);
useEventEmitter(WidgetEchoStore, "update", updateApps);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
return apps;

View file

@ -28,7 +28,7 @@ import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal';
import {_t} from '../../../languageHandler';
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig';
@ -51,7 +51,6 @@ import BaseCard from "./BaseCard";
import {E2EStatus} from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
import IconButton from "../elements/IconButton";
import PowerSelector from "../elements/PowerSelector";
import MemberAvatar from "../avatars/MemberAvatar";
import PresenceLabel from "../rooms/PresenceLabel";
@ -60,6 +59,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import InfoDialog from "../dialogs/InfoDialog";
import { EventType } from "matrix-js-sdk/src/@types/event";
interface IDevice {
deviceId: string;
@ -105,17 +105,7 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice
};
async function openDMForUser(matrixClient: MatrixClient, userId: string) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
const room = matrixClient.getRoom(roomId);
if (!room || room.getMyMembership() === "leave") {
return lastActiveRoom;
}
if (!lastActiveRoom || lastActiveRoom.getLastActiveTimestamp() < room.getLastActiveTimestamp()) {
return room;
}
return lastActiveRoom;
}, null);
const lastActiveRoom = findDMForUser(matrixClient, userId);
if (lastActiveRoom) {
dis.dispatch({
@ -586,7 +576,10 @@ const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
while (timeline) {
eventsToRedact = timeline.getEvents().reduce((events, event) => {
if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction() &&
event.getType() !== "m.room.create"
event.getType() !== EventType.RoomCreate &&
// Don't redact ACLs because that'll obliterate the room
// See https://github.com/matrix-org/synapse/issues/4042 for details.
event.getType() !== EventType.RoomServerAcl
) {
return events.concat(event);
} else {
@ -1024,24 +1017,15 @@ const PowerLevelSection: React.FC<{
roomPermissions: IRoomPermissions;
powerLevels: IPowerLevelsContent;
}> = ({user, room, roomPermissions, powerLevels}) => {
const [isEditing, setEditing] = useState(false);
if (isEditing) {
return (<PowerLevelEditor
user={user} room={room} roomPermissions={roomPermissions}
onFinished={() => setEditing(false)} />);
if (roomPermissions.canEdit) {
return (<PowerLevelEditor user={user} room={room} roomPermissions={roomPermissions} />);
} else {
const powerLevelUsersDefault = powerLevels.users_default || 0;
const powerLevel = parseInt(user.powerLevel, 10);
const modifyButton = roomPermissions.canEdit ?
(<IconButton icon="edit" onClick={() => setEditing(true)} />) : null;
const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
const label = _t("<strong>%(role)s</strong> in %(roomName)s",
{role, roomName: room.name},
{strong: label => <strong>{label}</strong>},
);
return (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_roleDescription">{label}{modifyButton}</div>
<div className="mx_UserInfo_roleDescription">{role}</div>
</div>
);
}
@ -1051,20 +1035,15 @@ const PowerLevelEditor: React.FC<{
user: User;
room: Room;
roomPermissions: IRoomPermissions;
onFinished(): void;
}> = ({user, room, roomPermissions, onFinished}) => {
}> = ({user, room, roomPermissions}) => {
const cli = useContext(MatrixClientContext);
const [isUpdating, setIsUpdating] = useState(false);
const [selectedPowerLevel, setSelectedPowerLevel] = useState(parseInt(user.powerLevel, 10));
const [isDirty, setIsDirty] = useState(false);
const onPowerChange = useCallback((powerLevel) => {
setIsDirty(true);
setSelectedPowerLevel(parseInt(powerLevel, 10));
}, [setSelectedPowerLevel, setIsDirty]);
const onPowerChange = useCallback(async (powerLevelStr: string) => {
const powerLevel = parseInt(powerLevelStr, 10);
setSelectedPowerLevel(powerLevel);
const changePowerLevel = useCallback(async () => {
const _applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {
const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {
return cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
@ -1080,64 +1059,42 @@ const PowerLevelEditor: React.FC<{
);
};
try {
if (!isDirty) {
return;
}
const roomId = user.roomId;
const target = user.userId;
setIsUpdating(true);
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return;
const powerLevel = selectedPowerLevel;
const myUserId = cli.getUserId();
const myPower = powerLevelEvent.getContent().users[myUserId];
if (myPower && parseInt(myPower) === powerLevel) {
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("You will not be able to undo this change as you are promoting the user " +
"to have the same power level as yourself.") }<br />
{ _t("Are you sure?") }
</div>,
button: _t("Continue"),
});
const roomId = user.roomId;
const target = user.userId;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return;
if (!powerLevelEvent.getContent().users) {
_applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
return;
}
const myUserId = cli.getUserId();
const [confirmed] = await finished;
if (!confirmed) return;
} else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
if (myUserId === target) {
try {
if (!(await warnSelfDemote())) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
return;
try {
if (!(await warnSelfDemote())) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
const myPower = powerLevelEvent.getContent().users[myUserId];
if (parseInt(myPower) === powerLevel) {
const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("You will not be able to undo this change as you are promoting the user " +
"to have the same power level as yourself.") }<br />
{ _t("Are you sure?") }
</div>,
button: _t("Continue"),
});
const [confirmed] = await finished;
if (!confirmed) return;
}
await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
} finally {
onFinished();
}
}, [user.roomId, user.userId, cli, selectedPowerLevel, isDirty, setIsUpdating, onFinished, room]);
await applyPowerChange(roomId, target, powerLevel, powerLevelEvent);
}, [user.roomId, user.userId, cli, room]);
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
<IconButton icon="check" onClick={changePowerLevel} />;
return (
<div className="mx_UserInfo_profileField">
@ -1147,9 +1104,7 @@ const PowerLevelEditor: React.FC<{
maxValue={roomPermissions.modifyLevelMax}
usersDefault={powerLevelUsersDefault}
onChange={onPowerChange}
disabled={isUpdating}
/>
{buttonOrSpinner}
</div>
);
};
@ -1339,13 +1294,17 @@ const BasicUserInfo: React.FC<{
}
let memberDetails;
if (room && member.roomId) {
memberDetails = <PowerLevelSection
powerLevels={powerLevels}
user={member}
room={room}
roomPermissions={roomPermissions}
/>;
// hide the Roles section for DMs as it doesn't make sense there
if (room && member.roomId && !DMRoomMap.shared().getUserIdForRoomId(member.roomId)) {
memberDetails = <div className="mx_UserInfo_container">
<h3>{ _t("Role") }</h3>
<PowerLevelSection
powerLevels={powerLevels}
user={member}
room={room}
roomPermissions={roomPermissions}
/>
</div>;
}
// only display the devices list if our client supports E2E
@ -1415,12 +1374,7 @@ const BasicUserInfo: React.FC<{
);
return <React.Fragment>
{ memberDetails &&
<div className="mx_UserInfo_container mx_UserInfo_separator mx_UserInfo_memberDetailsContainer">
<div className="mx_UserInfo_memberDetails">
{ memberDetails }
</div>
</div> }
{ memberDetails }
{ securitySection }
<UserOptionsSection

View file

@ -103,7 +103,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)}
/>
</BaseCard>;
};

View file

@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
return (<AppTile
key={app.id}
app={app}
@ -221,7 +219,6 @@ export default class AppsDrawer extends React.Component {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
/>);
});

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2015, 2016, 2017, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,8 +15,8 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { Room } from 'matrix-js-sdk/src/models/room'
import * as sdk from '../../../index';
import dis from "../../../dispatcher/dispatcher";
import * as ObjectUtils from '../../../ObjectUtils';
@ -29,28 +28,42 @@ import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import CallView from "../voip/CallView";
import {UIFeature} from "../../../settings/UIFeature";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
interface IProps {
// js-sdk room object
room: Room,
userId: string,
showApps: boolean, // Render apps
export default class AuxPanel extends React.Component {
static propTypes = {
// js-sdk room object
room: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
showApps: PropTypes.bool, // Render apps
// set to true to show the file drop target
draggingFile: boolean,
// set to true to show the file drop target
draggingFile: PropTypes.bool,
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number,
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: PropTypes.number,
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: () => void,
fullHeight: boolean,
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: PropTypes.func,
fullHeight: PropTypes.bool,
};
resizeNotifier: ResizeNotifier,
}
interface Counter {
title: string,
value: number,
link: string,
severity: string,
stateKey: string,
}
interface IState {
counters: Counter[],
}
export default class AuxPanel extends React.Component<IProps, IState> {
static defaultProps = {
showApps: true,
};
@ -104,7 +117,7 @@ export default class AuxPanel extends React.Component {
}, 500);
_computeCounters() {
let counters = [];
const counters = [];
if (this.props.room && SettingsStore.getValue("feature_state_counters")) {
const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
@ -112,7 +125,7 @@ export default class AuxPanel extends React.Component {
return a.getStateKey() < b.getStateKey();
});
stateEvs.forEach((ev, idx) => {
for (const ev of stateEvs) {
const title = ev.getContent().title;
const value = ev.getContent().value;
const link = ev.getContent().link;
@ -123,14 +136,14 @@ export default class AuxPanel extends React.Component {
// zero)
if (title && value !== undefined) {
counters.push({
"title": title,
"value": value,
"link": link,
"severity": severity,
"stateKey": stateKey
title,
value,
link,
severity,
stateKey,
})
}
});
}
}
return counters;
@ -143,8 +156,7 @@ export default class AuxPanel extends React.Component {
if (this.props.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel"
title={_t("Drop File Here")}>
<div className="mx_RoomView_fileDropTargetLabel" title={_t("Drop File Here")}>
<TintableSvg src={require("../../../../res/img/upload-big.svg")} width="45" height="59" />
<br />
{ _t("Drop file here to upload") }
@ -208,7 +220,7 @@ export default class AuxPanel extends React.Component {
<span
className="m_RoomView_auxPanel_stateViews_delim"
key={"delim" + idx}
> </span>
> </span>,
);
});
@ -226,7 +238,7 @@ export default class AuxPanel extends React.Component {
"mx_RoomView_auxPanel": true,
"mx_RoomView_auxPanel_fullHeight": this.props.fullHeight,
});
const style = {};
const style: React.CSSProperties = {};
if (!this.props.fullHeight) {
style.maxHeight = this.props.maxHeight;
}

View file

@ -29,9 +29,11 @@ import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames';
import {EventStatus} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard";
import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -135,7 +137,10 @@ export default class EditMessageComposer extends React.Component {
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
if (event.key === Key.ENTER) {
const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event)
: event.key === Key.ENTER;
if (send) {
this._sendEdit();
event.preventDefault();
} else if (event.key === Key.ESCAPE) {
@ -182,6 +187,7 @@ export default class EditMessageComposer extends React.Component {
}
_sendEdit = () => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"];
@ -190,8 +196,9 @@ export default class EditMessageComposer extends React.Component {
if (this._isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
this._cancelPreviousPendingEdit();
this.context.sendMessage(roomId, editContent);
const prom = this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
}
// close the event editing and focus composer

View file

@ -21,6 +21,7 @@ import ReplyThread from "../elements/ReplyThread";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import classNames from "classnames";
import {EventType} from "matrix-js-sdk/src/@types/event";
import { _t, _td } from '../../../languageHandler';
import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
@ -646,12 +647,13 @@ export default class EventTile extends React.Component {
// Info messages are basically information about commands processed on a room
const isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === "m.room.encryption") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== 'm.room.message' &&
eventType !== 'm.sticker' && eventType !== 'm.room.create'
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// If we're showing hidden events in the timeline, we should use the

View file

@ -114,7 +114,10 @@ export default class LinkPreviewWidget extends React.Component {
let thumbHeight = imageMaxHeight;
if (p["og:image:width"] && p["og:image:height"]) {
thumbHeight = ImageUtils.thumbHeight(p["og:image:width"], p["og:image:height"], imageMaxWidth, imageMaxHeight);
thumbHeight = ImageUtils.thumbHeight(
p["og:image:width"], p["og:image:height"],
imageMaxWidth, imageMaxHeight,
);
}
let img;

View file

@ -23,7 +23,6 @@ import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import RoomViewStore from '../../../stores/RoomViewStore';
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
@ -254,7 +253,6 @@ export default class MessageComposer extends React.Component {
super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
@ -262,7 +260,6 @@ export default class MessageComposer extends React.Component {
this._dispatcherRef = null;
this.state = {
replyToEvent: RoomViewStore.getQuotingEvent(),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
@ -294,7 +291,6 @@ export default class MessageComposer extends React.Component {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
}
@ -318,9 +314,6 @@ export default class MessageComposer extends React.Component {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
}
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
dis.unregister(this.dispatcherRef);
@ -341,12 +334,6 @@ export default class MessageComposer extends React.Component {
return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
}
_onRoomViewStoreUpdate() {
const replyToEvent = RoomViewStore.getQuotingEvent();
if (this.state.replyToEvent === replyToEvent) return;
this.setState({ replyToEvent });
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
@ -371,6 +358,7 @@ export default class MessageComposer extends React.Component {
event_id: createEventId,
room_id: replacementRoomId,
auto_join: true,
_type: "tombstone", // instrumentation
// Try to join via the server that sent the event. This converts @something:example.org
// into a server domain by splitting on colons and ignoring the first entry ("@something").
@ -383,7 +371,7 @@ export default class MessageComposer extends React.Component {
}
renderPlaceholderText() {
if (this.state.replyToEvent) {
if (this.props.replyToEvent) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
} else {
@ -429,7 +417,7 @@ export default class MessageComposer extends React.Component {
placeholder={this.renderPlaceholderText()}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.state.replyToEvent}
replyToEvent={this.props.replyToEvent}
/>,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,

View file

@ -0,0 +1,135 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext} from "react";
import {EventType} from "matrix-js-sdk/src/@types/event";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import MiniAvatarUploader, {AVATAR_SIZE} from "../elements/MiniAvatarUploader";
import RoomAvatar from "../avatars/RoomAvatar";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {ViewUserPayload} from "../../../dispatcher/payloads/ViewUserPayload";
import {Action} from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
const NewRoomIntro = () => {
const cli = useContext(MatrixClientContext);
const {room, roomId} = useContext(RoomContext);
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
let body;
if (dmPartner) {
let caption;
if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) {
caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join.");
}
const member = room?.getMember(dmPartner);
const displayName = member?.rawDisplayName || dmPartner;
body = <React.Fragment>
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} onClick={() => {
defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || {userId: dmPartner},
});
}} />
<h2>{ room.name }</h2>
<p>{_t("This is the beginning of your direct message history with <displayName/>.", {}, {
displayName: () => <b>{ displayName }</b>,
})}</p>
{ caption && <p>{ caption }</p> }
</React.Fragment>;
} else {
const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
const canAddTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getUserId());
const onTopicClick = () => {
dis.dispatch({
action: "open_room_settings",
room_id: roomId,
}, true);
// focus the topic field to help the user find it as it'll gain an outline
setImmediate(() => {
window.document.getElementById("profileTopic").focus();
});
};
let topicText;
if (canAddTopic && topic) {
topicText = _t("Topic: %(topic)s (<a>edit</a>)", { topic }, {
a: sub => <AccessibleButton kind="link" onClick={onTopicClick}>{ sub }</AccessibleButton>,
});
} else if (topic) {
topicText = _t("Topic: %(topic)s ", { topic });
} else if (canAddTopic) {
topicText = _t("<a>Add a topic</a> to help people know what it is about.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onTopicClick}>{ sub }</AccessibleButton>,
});
}
const creator = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
const creatorName = room?.getMember(creator)?.rawDisplayName || creator;
let createdText;
if (creator === cli.getUserId()) {
createdText = _t("You created this room.");
} else {
createdText = _t("%(displayName)s created this room.", {
displayName: creatorName,
});
}
const onInviteClick = () => {
dis.dispatch({ action: "view_invite", roomId });
};
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
body = <React.Fragment>
<MiniAvatarUploader
hasAvatar={!!avatarUrl}
noAvatarLabel={_t("Add a photo, so people can easily spot your room.")}
setAvatarUrl={url => cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')}
>
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} />
</MiniAvatarUploader>
<h2>{ room.name }</h2>
<p>{createdText} {_t("This is the start of <roomName/>.", {}, {
roomName: () => <b>{ room.name }</b>,
})}</p>
<p>{topicText}</p>
<div className="mx_NewRoomIntro_buttons">
<AccessibleButton className="mx_NewRoomIntro_inviteButton" kind="primary" onClick={onInviteClick}>
{_t("Invite to this room")}
</AccessibleButton>
</div>
</React.Fragment>;
}
return <div className="mx_NewRoomIntro">
{ body }
</div>;
};
export default NewRoomIntro;

View file

@ -58,6 +58,7 @@ interface IProps {
interface IState {
sublists: ITagMap;
isNameFiltering: boolean;
}
const TAG_ORDER: TagID[] = [
@ -183,6 +184,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
this.state = {
sublists: {},
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
@ -253,7 +255,8 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return CustomRoomTagStore.getTags()[t];
});
let doUpdate = arrayHasDiff(previousListIds, newListIds);
const isNameFiltering = !!RoomListStore.instance.getFirstNameFilterCondition();
let doUpdate = this.state.isNameFiltering !== isNameFiltering || arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
@ -275,14 +278,20 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({sublists}, () => {
this.setState({sublists, isNameFiltering}, () => {
this.props.onResize();
});
}
};
private onStartChat = () => {
const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search;
dis.dispatch({ action: "view_create_chat", initialText });
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search;
dis.dispatch({ action: Action.ViewRoomDirectory, initialText });
};
private renderCommunityInvites(): TemporaryTile[] {
@ -332,6 +341,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return p;
}, [] as TagID[]);
// show a skeleton UI if the user is in no rooms and they are not filtering
const showSkeleton = !this.state.isNameFiltering &&
Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
@ -356,6 +369,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraBadTilesThatShouldntExist={extraTiles}
/>);
}
@ -365,13 +379,50 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
public render() {
let explorePrompt: JSX.Element;
if (!this.props.isMinimized && RoomListStore.instance.getFirstNameFilterCondition()) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;
if (!this.props.isMinimized) {
if (this.state.isNameFiltering) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
kind="link"
onClick={this.onExplore}
>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists
const unfilteredRooms = unfilteredLists[DefaultTagID.Untagged] || [];
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Use the + to make a new room or explore existing ones below")}</div>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
kind="link"
onClick={this.onExplore}
>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;
}
}
}
const sublists = this.renderSublists();

View file

@ -284,7 +284,7 @@ export default class RoomPreviewBar extends React.Component {
room_name: this.props.oobData ? this.props.oobData.room_name : null,
room_avatar_url: this.props.oobData ? this.props.oobData.avatarUrl : null,
inviter_name: this.props.oobData ? this.props.oobData.inviterName : null,
}
},
};
}
@ -337,7 +337,7 @@ export default class RoomPreviewBar extends React.Component {
if (this.props.previewLoading) {
footer = (
<div>
<Spinner w={20} h={20}/>
<Spinner w={20} h={20} />
{_t("Loading room preview")}
</div>
);

View file

@ -71,6 +71,7 @@ interface IProps {
isMinimized: boolean;
tagId: TagID;
onResize: () => void;
showSkeleton?: boolean;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this.
@ -421,7 +422,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = this.state.rooms.find((r: Room) => {
room = RoomListStore.instance.unfilteredLists[this.props.tagId].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.notificationState.color;
});
@ -877,6 +878,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
</Resizable>
</React.Fragment>
);
} else if (this.props.showSkeleton && this.state.isExpanded) {
content = <div className="mx_RoomSublist_skeletonUI" />;
}
return (

View file

@ -38,12 +38,14 @@ import * as sdk from '../../../index';
import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard";
import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
import {containsEmoji} from "../elements/effects/effectUtilities";
import effects from '../elements/effects';
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -123,7 +125,11 @@ export default class SendMessageComposer extends React.Component {
return;
}
const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
if (event.key === Key.ENTER && !hasModifier) {
const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
const send = ctrlEnterToSend
? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event)
: event.key === Key.ENTER && !hasModifier;
if (send) {
this._sendMessage();
event.preventDefault();
} else if (event.key === Key.ARROW_UP) {
@ -306,9 +312,13 @@ export default class SendMessageComposer extends React.Component {
const replyToEvent = this.props.replyToEvent;
if (shouldSend) {
const startTime = CountlyAnalytics.getTimestamp();
const {roomId} = this.props.room;
const content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent);
this.context.sendMessage(roomId, content);
// don't bother sending an empty message
if (!content.body.trim()) return;
const prom = this.context.sendMessage(roomId, content);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
@ -323,6 +333,7 @@ export default class SendMessageComposer extends React.Component {
dis.dispatch({action: `effects.${effect.command}`});
}
});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}
this.sendHistoryManager.save(this.model, replyToEvent);

View file

@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component {
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true}
/>
</PersistedElement>

View file

@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Spinner from '../elements/Spinner';
export default class ChangeAvatar extends React.Component {
static propTypes = {
@ -58,7 +59,7 @@ export default class ChangeAvatar extends React.Component {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) {
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (this.avatarSet) {
// don't clobber what the user has just set
return;
@ -143,7 +144,9 @@ export default class ChangeAvatar extends React.Component {
// time to propagate through to the RoomAvatar.
if (this.props.room && !this.avatarSet) {
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
avatarImg = <RoomAvatar room={this.props.room} width={this.props.width} height={this.props.height} resizeMethod='crop' />;
avatarImg = <RoomAvatar
room={this.props.room} width={this.props.width} height={this.props.height} resizeMethod='crop'
/>;
} else {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
@ -174,9 +177,8 @@ export default class ChangeAvatar extends React.Component {
</div>
);
case ChangeAvatar.Phases.Uploading:
var Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
<Spinner />
);
}
}

View file

@ -74,7 +74,7 @@ export default class DevicesPanel extends React.Component {
}
/**
/*
* compare two devices, sorting from most-recently-seen to least-recently-seen
* (and then, for stability, by device id)
*/

View file

@ -129,11 +129,16 @@ export default class EventIndexPanel extends React.Component {
eventIndexingSettings = (
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t( "Securely cache encrypted messages locally for them " +
"to appear in search results, using ")
} {formatBytes(this.state.eventIndexSize, 0)}
{_t( " to store messages from ")}
{formatCountLong(this.state.roomCount)} {_t("rooms.")}
{_t("Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
{
size: formatBytes(this.state.eventIndexSize, 0),
// This drives the singular / plural string
// selection for "room" / "rooms" only.
count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount),
},
)}
</div>
<div>
<AccessibleButton kind="primary" onClick={this._onManage}>

View file

@ -31,6 +31,7 @@ import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import {SettingLevel} from "../../../settings/SettingLevel";
import {UIFeature} from "../../../settings/UIFeature";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
@ -94,7 +95,9 @@ export default class Notifications extends React.Component {
phase: Notifications.phases.LOADING,
});
MatrixClientPeg.get().setPushRuleEnabled('global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked).then(function() {
MatrixClientPeg.get().setPushRuleEnabled(
'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
).then(function() {
self._refreshFromServer();
});
};
@ -216,8 +219,8 @@ export default class Notifications extends React.Component {
description: _t('Enter keywords separated by a comma:'),
button: _t('OK'),
value: keywords,
onFinished: (should_leave, newValue) => {
if (should_leave && newValue !== keywords) {
onFinished: (shouldLeave, newValue) => {
if (shouldLeave && newValue !== keywords) {
let newKeywords = newValue.split(',');
for (const i in newKeywords) {
newKeywords[i] = newKeywords[i].trim();
@ -403,7 +406,9 @@ export default class Notifications extends React.Component {
// when creating the new rule.
// Thus, this new rule will join the 'vectorContentRules' set.
if (self.state.vectorContentRules.rules.length) {
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(self.state.vectorContentRules.rules[0]);
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(
self.state.vectorContentRules.rules[0],
);
} else {
// ON is default
pushRuleVectorStateKind = PushRuleVectorState.ON;
@ -415,10 +420,9 @@ export default class Notifications extends React.Component {
if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
deferreds.push(cli.addPushRule
('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
deferreds.push(cli.addPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
}));
} else {
deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
@ -482,12 +486,14 @@ export default class Notifications extends React.Component {
_refreshFromServer = () => {
const self = this;
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(self._portRulesToNewAPI).then(function(rulesets) {
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(
self._portRulesToNewAPI,
).then(function(rulesets) {
/// XXX seriously? wtf is this?
MatrixClientPeg.get().pushRules = rulesets;
// Get homeserver default rules and triage them by categories
const rule_categories = {
const ruleCategories = {
// The master rule (all notifications disabling)
'.m.rule.master': 'master',
@ -514,7 +520,7 @@ export default class Notifications extends React.Component {
for (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
const cat = rule_categories[r.rule_id];
const cat = ruleCategories[r.rule_id];
r.kind = kind;
if (r.rule_id[0] === '.') {
@ -750,7 +756,7 @@ export default class Notifications extends React.Component {
if (this.state.masterPushRule) {
masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
onChange={this.onEnableNotificationsChange}
label={_t('Enable notifications for this account')}/>;
label={_t('Enable notifications for this account')} />;
}
let clearNotificationsButton;
@ -778,14 +784,14 @@ export default class Notifications extends React.Component {
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRows;
if (emailThreepids.length === 0) {
emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') }
</div>;
} else {
if (emailThreepids.length > 0) {
emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
));
} else if (SettingsStore.getValue(UIFeature.ThirdPartyID)) {
emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') }
</div>;
}
// Build external push rules
@ -803,7 +809,10 @@ export default class Notifications extends React.Component {
}
if (externalKeywords.length) {
externalKeywords = externalKeywords.join(", ");
externalRules.push(<li>{ _t('Notifications on the following keywords follow rules which cant be displayed here:') } { externalKeywords }</li>);
externalRules.push(<li>
{_t('Notifications on the following keywords follow rules which cant be displayed here:') }
{ externalKeywords }
</li>);
}
let devicesSection;

View file

@ -84,6 +84,9 @@ export default class ProfileSettings extends React.Component {
}
if (this.state.avatarFile) {
console.log(
`Uploading new avatar, ${this.state.avatarFile.name} of type ${this.state.avatarFile.type},` +
` (${this.state.avatarFile.size}) bytes`);
const uri = await client.uploadContent(this.state.avatarFile);
await client.setAvatarUrl(uri);
newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false);
@ -93,6 +96,7 @@ export default class ProfileSettings extends React.Component {
await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
}
} catch (err) {
console.log("Failed to save profile", err);
Modal.createTrackedDialog('Failed to save profile', '', ErrorDialog, {
title: _t("Failed to save your profile"),
description: ((err && err.message) ? err.message : _t("The operation could not be completed")),

View file

@ -25,6 +25,7 @@ import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup from '../../../elements/StyledRadioGroup';
import {SettingLevel} from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature";
export default class SecurityRoomSettingsTab extends React.Component {
static propTypes = {
@ -350,6 +351,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
/>;
}
let historySection = (<>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()}
</div>
</>);
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
historySection = null;
}
return (
<div className="mx_SettingsTab mx_SecurityRoomSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div>
@ -371,10 +382,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
{this._renderRoomAccess()}
</div>
<span className='mx_SettingsTab_subheading'>{_t("Who can read history?")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
{this._renderHistory()}
</div>
{historySection}
</div>
);
}

View file

@ -33,6 +33,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
];
static TIMELINE_SETTINGS = [

View file

@ -33,6 +33,7 @@ import SecureBackupPanel from "../../SecureBackupPanel";
import SettingsStore from "../../../../../settings/SettingsStore";
import {UIFeature} from "../../../../../settings/UIFeature";
import {isE2eAdvancedPanelPossible} from "../../E2eAdvancedPanel";
import CountlyAnalytics from "../../../../../CountlyAnalytics";
export class IgnoredUser extends React.Component {
static propTypes = {
@ -102,6 +103,7 @@ export default class SecurityUserSettingsTab extends React.Component {
_updateAnalytics = (checked) => {
checked ? Analytics.enable() : Analytics.disable();
CountlyAnalytics.instance.enable(/* anonymous = */ !checked);
};
_onExportE2eKeysClicked = () => {
@ -339,7 +341,7 @@ export default class SecurityUserSettingsTab extends React.Component {
}
let privacySection;
if (Analytics.canEnable()) {
if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) {
privacySection = <React.Fragment>
<div className="mx_SettingsTab_heading">{_t("Privacy")}</div>
<div className="mx_SettingsTab_section">

View file

@ -24,7 +24,16 @@ import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { CallState, MatrixCall } from 'matrix-js-sdk/lib/webrtc/call';
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
CallState.InviteSent,
CallState.Connecting,
CallState.CreateAnswer,
CallState.CreateOffer,
CallState.WaitLocalMedia,
];
interface IProps {
}
@ -94,14 +103,13 @@ export default class CallPreview extends React.Component<IProps, IState> {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = (
this.state.activeCall &&
this.state.activeCall.state === CallState.Connected &&
SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
!callForRoom
);
if (showCall) {
return (
<CallView
className="mx_CallPreview"
onClick={this.onCallViewClick}
showHangup={true}
/>

View file

@ -15,17 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React, { createRef } from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import VideoView from "./VideoView";
import VideoFeed, { VideoFeedType } from "./VideoFeed";
import RoomAvatar from "../avatars/RoomAvatar";
import PulsedAvatar from '../avatars/PulsedAvatar';
import { CallState, MatrixCall } from 'matrix-js-sdk/lib/webrtc/call';
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
interface IProps {
// js-sdk room object. If set, we will only show calls for the given
@ -42,61 +44,131 @@ interface IProps {
// in a way that is likely to cause a resize.
onResize?: any;
// classname applied to view,
className?: string;
// Whether to show the hang up icon:W
showHangup?: boolean;
}
interface IState {
call: any;
call: MatrixCall;
isLocalOnHold: boolean,
micMuted: boolean,
vidMuted: boolean,
callState: CallState,
controlsVisible: boolean,
}
export default class CallView extends React.Component<IProps, IState> {
private videoref: React.RefObject<any>;
private dispatcherRef: string;
public call: any;
function getFullScreenElement() {
return (
document.fullscreenElement ||
// moz omitted because firefox supports this unprefixed now (webkit here for safari)
document.webkitFullscreenElement ||
document.msFullscreenElement
);
}
function requestFullscreen(element: Element) {
const method = (
element.requestFullscreen ||
// moz omitted since firefox supports unprefixed now
element.webkitRequestFullScreen ||
element.msRequestFullscreen
);
if (method) method.call(element);
}
function exitFullscreen() {
const exitMethod = (
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.msExitFullscreen
);
if (exitMethod) exitMethod.call(document);
}
const CONTROLS_HIDE_DELAY = 1000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const HEADER_HEIGHT = 44;
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) {
super(props);
const call = this.getCall();
this.state = {
// the call this view is displaying (if any)
call: null,
};
call,
isLocalOnHold: call ? call.isLocalOnHold() : null,
micMuted: call ? call.isMicrophoneMuted() : null,
vidMuted: call ? call.isLocalVideoMuted() : null,
callState: call ? call.state : null,
controlsVisible: true,
}
this.videoref = createRef();
this.updateCallListeners(null, call);
}
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.showCall();
document.addEventListener('keydown', this.onNativeKeyDown);
}
public componentWillUnmount() {
document.removeEventListener("keydown", this.onNativeKeyDown);
this.updateCallListeners(this.state.call, null);
dis.unregister(this.dispatcherRef);
}
private onAction = (payload) => {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
if (payload.action !== 'call_state') {
return;
switch (payload.action) {
case 'video_fullscreen': {
if (!this.contentRef.current) {
return;
}
if (payload.fullscreen) {
requestFullscreen(this.contentRef.current);
} else if (getFullScreenElement()) {
exitFullscreen();
}
break;
}
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.updateCallListeners(this.state.call, newCall);
let newControlsVisible = this.state.controlsVisible;
if (newCall && !this.state.call) {
newControlsVisible = true;
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
this.setState({
call: newCall,
isLocalOnHold: newCall ? newCall.isLocalOnHold() : null,
micMuted: newCall ? newCall.isMicrophoneMuted() : null,
vidMuted: newCall ? newCall.isLocalVideoMuted() : null,
callState: newCall ? newCall.state : null,
controlsVisible: newControlsVisible,
});
}
if (!newCall && getFullScreenElement()) {
exitFullscreen();
}
break;
}
}
this.showCall();
};
private showCall() {
private getCall(): MatrixCall {
let call: MatrixCall;
if (this.props.room) {
const roomId = this.props.room.roomId;
call = CallHandler.sharedInstance().getCallForRoom(roomId);
if (this.call) {
this.setState({ call: call });
}
} else {
call = CallHandler.sharedInstance().getAnyActiveCall();
// Ignore calls if we can't get the room associated with them.
@ -106,84 +178,259 @@ export default class CallView extends React.Component<IProps, IState> {
if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
call = null;
}
this.setState({ call: call });
}
if (call) {
if (this.getVideoView()) {
call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
// always use a separate element for audio stream playback.
// this is to let us move CallView around the DOM without interrupting remote audio
// during playback, by having the audio rendered by a top-level <audio/> element.
// rather than being rendered by the main remoteVideo <video/> element.
call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
}
}
if (call && call.type === "video" && call.state !== CallState.Ended && call.state !== CallState.Ringing) {
this.getVideoView().getLocalVideoElement().style.display = "block";
this.getVideoView().getRemoteVideoElement().style.display = "block";
} else {
this.getVideoView().getLocalVideoElement().style.display = "none";
this.getVideoView().getRemoteVideoElement().style.display = "none";
dis.dispatch({action: 'video_fullscreen', fullscreen: false});
}
if (this.props.onResize) {
this.props.onResize();
}
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
private getVideoView() {
return this.videoref.current;
private updateCallListeners(oldCall: MatrixCall, newCall: MatrixCall) {
if (oldCall === newCall) return;
if (oldCall) oldCall.removeListener(CallEvent.HoldUnhold, this.onCallHoldUnhold);
if (newCall) newCall.on(CallEvent.HoldUnhold, this.onCallHoldUnhold);
}
private onCallHoldUnhold = () => {
this.setState({
isLocalOnHold: this.state.call ? this.state.call.isLocalOnHold() : null,
});
};
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
private onExpandClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
};
private onControlsHideTimer = () => {
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
});
}
private onMouseMove = () => {
this.showControls();
}
private showControls() {
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onMicMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.micMuted;
this.state.call.setMicrophoneMuted(newVal);
this.setState({micMuted: newVal});
}
private onVidMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.vidMuted;
this.state.call.setLocalVideoMuted(newVal);
this.setState({vidMuted: newVal});
}
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time
// CallHandler would probably be a better place for this
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onRoomAvatarClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
}
public render() {
let view: React.ReactNode;
if (this.state.call && this.state.call.type === "voice") {
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
if (!this.state.call) return null;
view = <AccessibleButton className="mx_CallView_voice" onClick={this.props.onClick}>
<PulsedAvatar>
<RoomAvatar
room={callRoom}
height={35}
width={35}
/>
</PulsedAvatar>
<div>
<h1>{callRoom.name}</h1>
<p>{ _t("Active call") }</p>
</div>
</AccessibleButton>;
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
let callControls;
if (this.props.room) {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.state.call.type === CallType.Video ? <div
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
callControls = <div className={callControlsClasses}>
<div
className={micClasses}
onClick={this.onMicMuteClick}
/>
<div
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
</div>;
}
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
if (this.state.call.type === CallType.Video) {
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT;
contentView = <div className="mx_CallView_video" ref={this.contentRef} onMouseMove={this.onMouseMove}>
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
{callControls}
</div>;
} else {
view = <VideoView
ref={this.videoref}
onClick={this.props.onClick}
onResize={this.props.onResize}
maxHeight={this.props.maxVideoHeight}
const avatarSize = this.props.room ? 200 : 75;
contentView = <div className="mx_CallView_voice" onMouseMove={this.onMouseMove}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
{callControls}
</div>;
}
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.state.call.type === CallType.Video && this.props.room) {
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
/>;
}
let hangup: React.ReactNode;
if (this.props.showHangup) {
hangup = <div
className="mx_CallView_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
let expandButton;
if (!this.props.room) {
expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} title={_t("Return to call")}
/>;
}
return <div className={this.props.className}>
{view}
{hangup}
const headerControls = <div className="mx_CallView_header_controls">
{fullScreenButton}
{expandButton}
</div>;
let header: React.ReactNode;
if (this.props.room) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon"></div>
<span className="mx_CallView_header_callType">{callTypeText}</span>
{headerControls}
</div>;
myClassName = 'mx_CallView_large';
} else {
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div>
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
</div>
{headerControls}
</div>;
myClassName = 'mx_CallView_pip';
}
return <div className={"mx_CallView " + myClassName}>
{header}
{contentView}
</div>;
}
}

View file

@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler from '../../../CallHandler';
import PulsedAvatar from '../avatars/PulsedAvatar';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
return <div className="mx_IncomingCallBox">
<div className="mx_IncomingCallBox_CallerInfo">
<PulsedAvatar>
<RoomAvatar
room={room}
height={32}
width={32}
/>
</PulsedAvatar>
<RoomAvatar
room={room}
height={32}
width={32}
/>
<div>
<h1>{caller}</h1>
<p>{incomingCallText}</p>

View file

@ -1,58 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
export default class VideoFeed extends React.Component {
static propTypes = {
// maxHeight style attribute for the video element
maxHeight: PropTypes.number,
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize: PropTypes.func,
};
constructor(props) {
super(props);
this._vid = createRef();
}
componentDidMount() {
this._vid.current.addEventListener('resize', this.onResize);
}
componentWillUnmount() {
this._vid.current.removeEventListener('resize', this.onResize);
}
onResize = (e) => {
if (this.props.onResize) {
this.props.onResize(e);
}
};
render() {
return (
<video ref={this._vid} style={{maxHeight: this.props.maxHeight}}>
</video>
);
}
}

View file

@ -0,0 +1,78 @@
/*
Copyright 2015, 2016, 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, {createRef} from 'react';
import SettingsStore from "../../../settings/SettingsStore";
export enum VideoFeedType {
Local,
Remote,
}
interface IProps {
call: MatrixCall,
type: VideoFeedType,
// maxHeight style attribute for the video element
maxHeight?: number,
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize?: (e: Event) => void,
}
export default class VideoFeed extends React.Component<IProps> {
private vid = createRef<HTMLVideoElement>();
componentDidMount() {
this.vid.current.addEventListener('resize', this.onResize);
if (this.props.type === VideoFeedType.Local) {
this.props.call.setLocalVideoElement(this.vid.current);
} else {
this.props.call.setRemoteVideoElement(this.vid.current);
}
}
componentWillUnmount() {
this.vid.current.removeEventListener('resize', this.onResize);
}
onResize = (e) => {
if (this.props.onResize) {
this.props.onResize(e);
}
};
render() {
const videoClasses = {
mx_VideoFeed: true,
mx_VideoFeed_local: this.props.type === VideoFeedType.Local,
mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote,
mx_VideoFeed_mirror: (
this.props.type === VideoFeedType.Local &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
};
let videoStyle = {};
if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight };
return <video className={classnames(videoClasses)} ref={this.vid} style={videoStyle} />;
}
}

View file

@ -1,142 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
function getFullScreenElement() {
return (
document.fullscreenElement ||
document.mozFullScreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
}
export default class VideoView extends React.Component {
static propTypes = {
// maxHeight style attribute for the video element
maxHeight: PropTypes.number,
// a callback which is called when the user clicks on the video div
onClick: PropTypes.func,
// a callback which is called when the video element is resized due to
// a change in video metadata
onResize: PropTypes.func,
};
constructor(props) {
super(props);
this._local = createRef();
this._remote = createRef();
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
getRemoteVideoElement = () => {
return ReactDOM.findDOMNode(this._remote.current);
};
getRemoteAudioElement = () => {
// this needs to be somewhere at the top of the DOM which
// always exists to avoid audio interruptions.
// Might as well just use DOM.
const remoteAudioElement = document.getElementById("remoteAudio");
if (!remoteAudioElement) {
console.error("Failed to find remoteAudio element - cannot play audio!"
+ "You need to add an <audio/> to the DOM.");
}
return remoteAudioElement;
};
getLocalVideoElement = () => {
return ReactDOM.findDOMNode(this._local.current);
};
setContainer = (c) => {
this.container = c;
};
onAction = (payload) => {
switch (payload.action) {
case 'video_fullscreen': {
if (!this.container) {
return;
}
const element = this.container;
if (payload.fullscreen) {
const requestMethod = (
element.requestFullScreen ||
element.webkitRequestFullScreen ||
element.mozRequestFullScreen ||
element.msRequestFullscreen
);
requestMethod.call(element);
} else if (getFullScreenElement()) {
const exitMethod = (
document.exitFullscreen ||
document.mozCancelFullScreen ||
document.webkitExitFullscreen ||
document.msExitFullscreen
);
if (exitMethod) {
exitMethod.call(document);
}
}
break;
}
}
};
render() {
const VideoFeed = sdk.getComponent('voip.VideoFeed');
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxHeight;
const localVideoFeedClasses = classNames("mx_VideoView_localVideoFeed",
{ "mx_VideoView_localVideoFeed_flipped":
SettingsStore.getValue('VideoView.flipVideoHorizontally'),
},
);
return (
<div className="mx_VideoView" ref={this.setContainer} onClick={this.props.onClick}>
<div className="mx_VideoView_remoteVideoFeed">
<VideoFeed ref={this._remote} onResize={this.props.onResize}
maxHeight={maxVideoHeight} />
</div>
<div className={localVideoFeedClasses}>
<VideoFeed ref={this._local} />
</div>
</div>
);
}
}