Merge branch 'develop' into feature-multi-language-spell-check

This commit is contained in:
Šimon Brandner 2021-02-18 18:16:16 +01:00
commit bd0e5446c5
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
296 changed files with 21708 additions and 6831 deletions

View file

@ -397,8 +397,9 @@ export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
return {left, top, chevronOffset};
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
@ -408,14 +409,49 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom;
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// and always above elementRect
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
}
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
// and always above elementRect
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.pageXOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
return menuOptions;
};
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);

View file

@ -45,7 +45,7 @@ class FilePanel extends React.Component {
};
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
if (room.roomId !== this.props.roomId) return;
if (room?.roomId !== this.props?.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
if (ev.isBeingDecrypted()) {

View file

@ -0,0 +1,56 @@
/*
Copyright 2021 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 {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
interface IProps {}
interface IState {}
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => {
await HostSignupStore.instance.setHostSignupActive(true);
}
public render(): React.ReactNode {
const hostSignupConfig = SdkConfig.get().hostSignup;
if (!hostSignupConfig?.brand) {
return null;
}
return (
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHosting"
label={_t(
"Upgrade to %(hostSignupBrand)s",
{
hostSignupBrand: hostSignupConfig.brand,
},
)}
onClick={this.openDialog}
/>
</IconizedContextMenuOptionList>
);
}
}

View file

@ -177,7 +177,14 @@ export default class InteractiveAuthComponent extends React.Component {
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage != stageType) this._setFocus();
if (oldStage !== stageType) {
this._setFocus();
} else if (
!stageState.error && this._stageComponent.current &&
this._stageComponent.current.attemptFailed
) {
this._stageComponent.current.attemptFailed();
}
});
};

View file

@ -56,7 +56,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded]);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;

View file

@ -54,6 +54,7 @@ import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPa
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
@ -140,7 +141,7 @@ class LoggedInView extends React.Component<IProps, IState> {
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _compactLayoutWatcherRef: string;
protected compactLayoutWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
@ -157,18 +158,6 @@ class LoggedInView extends React.Component<IProps, IState> {
CallMediaHandler.loadDevices();
document.addEventListener('keydown', this._onNativeKeyDown, false);
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
fixupColorFonts();
this._roomView = React.createRef();
@ -176,6 +165,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
// Call `onSync` with the current state as well
this.onSync(
this._matrixClient.getSyncState(),
null,
this._matrixClient.getSyncStateData(),
);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
@ -186,7 +193,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
this.resizer.detach();
}
@ -209,10 +216,12 @@ class LoggedInView extends React.Component<IProps, IState> {
_createResizer() {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
if (collapsed) {
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
dis.dispatch({action: "hide_left_panel"}, true);
window.localStorage.setItem("mx_lhs_size", '0');
} else {
@ -227,7 +236,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this.props.resizeNotifier.startResizing();
},
onResizeStop: () => {
window.localStorage.setItem("mx_lhs_size", '' + size);
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.stopResizing();
},
};
@ -419,6 +428,14 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
}
break;
case Key.F:
if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
dis.dispatch({
action: 'focus_search',
});
handled = true;
}
break;
case Key.BACKTICK:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
@ -632,6 +649,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div>
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
</MatrixClientContext.Provider>
);
}

View file

@ -34,7 +34,6 @@ import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
@ -48,7 +47,6 @@ import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
@ -82,6 +80,8 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
/** constants for MatrixChat.state.view */
export enum Views {
@ -219,6 +219,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private screenAfterLogin?: IScreen;
private windowWidth: number;
private pageChanging: boolean;
private tokenLogin?: boolean;
private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean;
@ -324,13 +325,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Lifecycle.attemptTokenLogin(
this.props.realQueryParams,
this.props.defaultDeviceDisplayName,
).then((loggedIn) => {
if (loggedIn) {
this.getFragmentAfterLogin(),
).then(async (loggedIn) => {
if (this.props.realQueryParams?.loginToken) {
// remove the loginToken from the URL regardless
this.props.onTokenLoginCompleted();
}
// don't do anything else until the page reloads - just stay in
// the 'loading' state.
return;
if (loggedIn) {
this.tokenLogin = true;
// Create and start the client
await Lifecycle.restoreFromLocalStorage({
ignoreGuest: true,
});
return this.postLoginSetup();
}
// if the user has followed a login or register link, don't reanimate
@ -354,6 +363,42 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
private async postLoginSetup() {
const cli = MatrixClientPeg.get();
const cryptoEnabled = cli.isCryptoEnabled();
if (!cryptoEnabled) {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
promisesList.push(cli.downloadKeys([cli.getUserId()]));
}
// Now update the state to say we're waiting for the first sync to complete rather
// than for the login to finish.
this.setState({ pendingInitialSync: true });
await Promise.all(promisesList);
if (!cryptoEnabled) {
this.setState({ pendingInitialSync: false });
return;
}
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: false });
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
// eslint-disable-next-line camelcase
UNSAFE_componentWillUpdate(props, state) {
@ -591,7 +636,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
MatrixClientPeg.get().leave(payload.room_id).then(() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
dis.dispatch({action: 'view_home_page'});
}
}, (err) => {
modal.close();
@ -620,9 +665,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case 'view_next_room':
this.viewNextRoom(1);
break;
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
@ -708,8 +750,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case Action.OpenDialPad:
Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
break;
case 'on_logged_in':
if (
// Skip this handling for token login as that always calls onLoggedIn itself
!this.tokenLogin &&
!Lifecycle.isSoftLogout() &&
this.state.view !== Views.LOGIN &&
this.state.view !== Views.REGISTER &&
@ -802,35 +849,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.notifyNewScreen('register');
}
// TODO: Move to RoomViewStore
private viewNextRoom(roomIndexDelta: number) {
const allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms(),
);
// If there are 0 rooms or 1 room, view the home page because otherwise
// if there are 0, we end up trying to index into an empty array, and
// if there is 1, we end up viewing the same room.
if (allRooms.length < 2) {
dis.dispatch({
action: 'view_home_page',
});
return;
}
let roomIndex = -1;
for (let i = 0; i < allRooms.length; ++i) {
if (allRooms[i].roomId === this.state.currentRoomId) {
roomIndex = i;
break;
}
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
dis.dispatch({
action: 'view_room',
room_id: allRooms[roomIndex].roomId,
});
}
// switch view to the given room
//
// @param {Object} roomInfo Object containing data about the room to be joined
@ -1097,9 +1115,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private forgetRoom(roomId: string) {
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to another room view if we're currently viewing the historical room
// Switch to home page if we're currently viewing the forgotten room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_next_room" });
dis.dispatch({ action: "view_home_page" });
}
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
@ -1216,6 +1234,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl);
}
if (SdkConfig.get().mobileGuideToast) {
// The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc.
showMobileGuideToast();
}
}
private showScreenAfterLogin() {
@ -1233,12 +1256,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
} else if (getHomePageUrl(this.props.config)) {
dis.dispatch({action: 'view_home_page'});
} else {
this.firstSyncPromise.promise.then(() => {
dis.dispatch({action: 'view_next_room'});
});
dis.dispatch({action: 'view_home_page'});
}
}
}
@ -1356,6 +1375,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
cli.on('Session.logged_out', function(errObj) {
if (Lifecycle.isLoggingOut()) return;
// A modal might have been open when we were logged out by the server
Modal.closeCurrentModal('Session.logged_out');
if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) {
console.warn("Soft logout issued by server - avoiding data deletion");
Lifecycle.softLogout();
@ -1366,6 +1388,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
title: _t('Signed Out'),
description: _t('For security, this session has been signed out. Please sign in again.'),
});
dis.dispatch({
action: 'logout',
});
@ -1635,10 +1658,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
let threepidInvite: IThreepidInvite;
// if we landed here from a 3PID invite, persist it
if (params.signurl && params.email) {
threepidInvite = ThreepidInviteStore.instance
.storeInvite(roomString, params as IThreepidInviteWireFormat);
}
// otherwise check that this room doesn't already have a known invite
if (!threepidInvite) {
const invites = ThreepidInviteStore.instance.getInvites();
threepidInvite = invites.find(invite => invite.roomId === roomString);
}
// on our URLs there might be a ?via=matrix.org or similar to help
// joins to the room succeed. We'll pass these through as an array
@ -1867,40 +1896,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
const cli = MatrixClientPeg.get();
const cryptoEnabled = cli.isCryptoEnabled();
if (!cryptoEnabled) {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
promisesList.push(cli.downloadKeys([cli.getUserId()]));
}
// Now update the state to say we're waiting for the first sync to complete rather
// than for the login to finish.
this.setState({ pendingInitialSync: true });
await Promise.all(promisesList);
if (!cryptoEnabled) {
this.setState({ pendingInitialSync: false });
return;
}
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
if (crossSigningIsSetUp) {
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
} else {
this.onLoggedIn();
}
this.setState({ pendingInitialSync: false });
await this.postLoginSetup();
};
// complete security / e2e setup has finished
@ -1944,6 +1940,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this.accountPassword}
tokenLogin={!!this.tokenLogin}
/>
);
} else if (this.state.view === Views.LOGGED_IN) {

View file

@ -23,9 +23,11 @@ import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index';
import dis from "../../dispatcher/dispatcher";
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import {Layout, LayoutPropType} from "../../settings/Layout";
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
@ -135,14 +137,13 @@ export default class MessagePanel extends React.Component {
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
// whether or not to show flair at all
enableFlair: PropTypes.bool,
};
// Force props to be loaded for useIRCLayout
constructor(props) {
super(props);
@ -207,11 +208,13 @@ export default class MessagePanel extends React.Component {
componentDidMount() {
this._isMounted = true;
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
this._isMounted = false;
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
dis.unregister(this.dispatcherRef);
}
componentDidUpdate(prevProps, prevState) {
@ -224,6 +227,14 @@ export default class MessagePanel extends React.Component {
}
}
onAction = (payload) => {
switch (payload.action) {
case "scroll_to_bottom":
this.scrollToBottom();
break;
}
}
onShowTypingNotificationsChange = () => {
this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -612,7 +623,7 @@ export default class MessagePanel extends React.Component {
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
/>
</TileErrorBoundary>
@ -810,7 +821,7 @@ export default class MessagePanel extends React.Component {
}
let ircResizer = null;
if (this.props.useIRCLayout) {
if (this.props.layout == Layout.IRC) {
ircResizer = <IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}

View file

@ -39,7 +39,7 @@ class NotificationPanel extends React.Component {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications in this room.')}</p>
<p>{_t('You have no visible notifications.')}</p>
</div>);
let content;

View file

@ -30,7 +30,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import defaultDispatcher from "../../dispatcher/dispatcher";
export default class RightPanel extends React.Component {
static get propTypes() {
@ -186,7 +185,7 @@ export default class RightPanel extends React.Component {
}
}
onCloseUserInfo = () => {
onClose = () => {
// XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly.
@ -198,31 +197,21 @@ export default class RightPanel extends React.Component {
dis.dispatch({
action: "view_home_page",
});
} else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
} else if (
this.state.phase === RightPanelPhases.EncryptionPanel &&
this.state.verificationRequest && this.state.verificationRequest.pending
) {
// When the user clicks close on the encryption panel cancel the pending request first if any
this.state.verificationRequest.cancel();
} else {
// Otherwise we have got our user from RoomViewStore which means we're being shown
// within a room/group, so go back to the member panel if we were in the encryption panel,
// or the member list if we were in the member panel... phew.
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
dis.dispatch({
action: Action.ViewUser,
member: isEncryptionPhase ? this.state.member : null,
action: Action.ToggleRightPanel,
type: this.props.groupId ? "group" : "room",
});
}
};
onClose = () => {
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
defaultDispatcher.dispatch({
action: Action.ToggleRightPanel,
type: this.props.groupId ? "group" : "room",
});
};
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
@ -260,7 +249,7 @@ export default class RightPanel extends React.Component {
user={this.state.member}
room={this.props.room}
key={roomId || this.state.member.userId}
onClose={this.onCloseUserInfo}
onClose={this.onClose}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
verificationRequestPromise={this.state.verificationRequestPromise}
@ -276,7 +265,7 @@ export default class RightPanel extends React.Component {
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={this.onCloseUserInfo} />;
onClose={this.onClose} />;
break;
case RightPanelPhases.GroupRoomInfo:

View file

@ -477,7 +477,7 @@ export default class RoomDirectory extends React.Component {
dis.dispatch(payload);
}
getRow(room) {
createRoomCells(room) {
const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
@ -487,7 +487,11 @@ export default class RoomDirectory extends React.Component {
let previewButton;
let joinOrViewButton;
if (room.world_readable && !hasJoinedRoom) {
// Element Web currently does not allow guests to join rooms, so we
// instead show them preview buttons for all rooms. If the room is not
// world readable, a modal will appear asking you to register first. If
// it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
);
@ -496,7 +500,7 @@ export default class RoomDirectory extends React.Component {
joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
);
} else if (!isGuest || room.guest_can_join) {
} else if (!isGuest) {
joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
);
@ -519,31 +523,56 @@ export default class RoomDirectory extends React.Component {
MatrixClientPeg.get().getHomeserverUrl(),
room.avatar_url, 32, 32, "crop",
);
return (
<tr key={ room.room_id }
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar"
>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl } />
</td>
<td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
</td>
<td className="mx_RoomDirectory_roomMemberCount">
{ room.num_joined_members }
</td>
<td className="mx_RoomDirectory_preview">{previewButton}</td>
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
</tr>
);
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl }
/>
</div>,
<div key={ `${room.room_id}_description` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomDescription"
>
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
<div key={ `${room.room_id}_preview` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview"
>
{previewButton}
</div>,
<div key={ `${room.room_id}_join` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_join"
>
{joinOrViewButton}
</div>,
];
}
collectScrollPanel = (element) => {
@ -602,7 +631,8 @@ export default class RoomDirectory extends React.Component {
} else if (this.state.protocolsLoading) {
content = <Loader />;
} else {
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
@ -613,14 +643,12 @@ export default class RoomDirectory extends React.Component {
}
let scrollpanel_content;
if (rows.length === 0 && !this.state.loading) {
if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
} else {
scrollpanel_content = <table className="mx_RoomDirectory_table">
<tbody>
{ rows }
</tbody>
</table>;
scrollpanel_content = <div className="mx_RoomDirectory_table">
{ cells }
</div>;
}
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = <ScrollPanel ref={this.collectScrollPanel}

View file

@ -21,15 +21,15 @@ limitations under the License.
// - Search results component
// - Drag and drop
import React, {createRef} from 'react';
import React, { createRef } from 'react';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {EventSubscription} from "fbemitter";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventSubscription } from "fbemitter";
import shouldHideEvent from '../../shouldHideEvent';
import {_t} from '../../languageHandler';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import { _t } from '../../languageHandler';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
@ -40,23 +40,24 @@ import Tinter from '../../Tinter';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard';
import eventSearch, { searchPagination } from '../../Searching';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile";
import { haveTileForEvent } from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
import {SettingLevel} from "../../settings/SettingLevel";
import {IMatrixClientCreds} from "../../MatrixClientPeg";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
@ -67,13 +68,18 @@ 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 {XOR} from "../../@types/common";
import { XOR } from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -176,7 +182,7 @@ export interface IState {
};
canReact: boolean;
canReply: boolean;
useIRCLayout: boolean;
layout: Layout;
matrixClientIsReady: boolean;
showUrlPreview?: boolean;
e2eStatus?: E2EStatus;
@ -231,7 +237,7 @@ export default class RoomView extends React.Component<IProps, IState> {
statusBarVisible: false,
canReact: false,
canReply: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
};
@ -248,6 +254,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.on("Event.decrypted", this.onEventDecrypted);
this.context.on("event", this.onEvent);
// Start listening for RoomViewStore updates
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
@ -257,13 +265,7 @@ export default class RoomView extends React.Component<IProps, IState> {
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this.onRoomViewStoreUpdate(true);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
}
private onWidgetStoreUpdate = () => {
@ -274,8 +276,9 @@ export default class RoomView extends React.Component<IProps, IState> {
private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0,
})
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
showApps: this.shouldShowApps(room),
});
};
private onReadReceiptsChange = () => {
@ -412,11 +415,17 @@ export default class RoomView extends React.Component<IProps, IState> {
}
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0,
showApps: this.shouldShowApps(this.state.room),
});
};
private onWidgetLayoutChange = () => {
this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters
};
private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
@ -482,7 +491,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}
private shouldShowApps(room: Room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false;
if (!BROWSER_SUPPORTS_SANDBOX || !room) return false;
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
@ -491,10 +500,15 @@ export default class RoomView extends React.Component<IProps, IState> {
// This is confusing, but it means to say that we default to the tray being
// hidden unless the user clicked to open it.
return hideWidgetDrawer === "false";
const isManuallyShown = hideWidgetDrawer === "false";
const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
return widgets.length > 0 || isManuallyShown;
}
componentDidMount() {
this.onRoomViewStoreUpdate(true);
const call = this.getCallForRoom();
const callState = call ? call.state : null;
this.setState({
@ -581,6 +595,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
this.context.removeListener("event", this.onEvent);
}
window.removeEventListener('beforeunload', this.onPageUnload);
@ -600,6 +616,13 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
if (this.state.room) {
WidgetLayoutStore.instance.off(
WidgetLayoutStore.emissionForRoom(this.state.room),
this.onWidgetLayoutChange,
);
}
if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
}
@ -616,7 +639,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private onLayoutChange = () => {
this.setState({
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
});
};
@ -740,6 +763,9 @@ export default class RoomView extends React.Component<IProps, IState> {
});
}
break;
case 'focus_search':
this.onSearchClick();
break;
}
};
@ -781,6 +807,30 @@ export default class RoomView extends React.Component<IProps, IState> {
}
};
private onEventDecrypted = (ev) => {
if (ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private handleEffects = (ev) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
if (!notifState.isUnread) return;
CHAT_EFFECTS.forEach(effect => {
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
dis.dispatch({action: `effects.${effect.command}`});
}
});
};
private onRoomName = (room: Room) => {
if (this.state.room && room.roomId == this.state.room.roomId) {
this.forceUpdate();
@ -803,6 +853,10 @@ export default class RoomView extends React.Component<IProps, IState> {
// called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room) => {
// Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update
this.calculatePeekRules(room);
this.updatePreviewUrlVisibility(room);
this.loadMembersIfJoined(room);
@ -865,6 +919,15 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!room || room.roomId !== this.state.roomId) {
return;
}
// Detach the listener if the room is changing for some reason
if (this.state.room) {
WidgetLayoutStore.instance.off(
WidgetLayoutStore.emissionForRoom(this.state.room),
this.onWidgetLayoutChange,
);
}
this.setState({
room: room,
}, () => {
@ -1332,7 +1395,7 @@ export default class RoomView extends React.Component<IProps, IState> {
rejecting: true,
});
this.context.leave(this.state.roomId).then(() => {
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1366,7 +1429,7 @@ export default class RoomView extends React.Component<IProps, IState> {
await this.context.setIgnoredUsers(ignoredUsers);
await this.context.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1883,8 +1946,8 @@ export default class RoomView extends React.Component<IProps, IState> {
const messagePanelClassNames = classNames(
"mx_RoomView_messagePanel",
{
"mx_IRCLayout": this.state.useIRCLayout,
"mx_GroupLayout": !this.state.useIRCLayout,
"mx_IRCLayout": this.state.layout == Layout.IRC,
"mx_GroupLayout": this.state.layout == Layout.Group,
});
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
@ -1907,7 +1970,7 @@ export default class RoomView extends React.Component<IProps, IState> {
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
useIRCLayout={this.state.useIRCLayout}
layout={this.state.layout}
/>);
let topUnreadMessagesBar = null;
@ -1946,9 +2009,14 @@ export default class RoomView extends React.Component<IProps, IState> {
mx_RoomView_inCall: Boolean(activeCall),
});
const showChatEffects = SettingsStore.getValue('showChatEffects');
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
{showChatEffects && this.roomView.current &&
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
}
<ErrorBoundary>
<RoomHeader
room={this.state.room}

View file

@ -20,7 +20,6 @@ import * as React from "react";
import {_t} from '../../languageHandler';
import * as sdk from "../../index";
import AutoHideScrollbar from './AutoHideScrollbar';
import { ReactNode } from "react";
/**
* Represents a tab for the TabbedView.

View file

@ -18,6 +18,7 @@ limitations under the License.
*/
import SettingsStore from "../../settings/SettingsStore";
import {LayoutPropType} from "../../settings/Layout";
import React, {createRef} from 'react';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
@ -111,8 +112,8 @@ class TimelinePanel extends React.Component {
// whether to show reactions for an event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
}
// a map from room id to read marker event timestamp
@ -715,26 +716,22 @@ class TimelinePanel extends React.Component {
}
this.lastRMSentEventId = this.state.readMarkerEventId;
const roomId = this.props.timelineSet.room.roomId;
const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId);
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{hidden: hiddenRR},
{},
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
return MatrixClientPeg.get().sendReadReceipt(
lastReadEvent,
{hidden: hiddenRR},
{},
).catch((e) => {
console.error(e);
this.lastRRSentEventId = undefined;
@ -1446,7 +1443,7 @@ class TimelinePanel extends React.Component {
getRelationsForEvent={this.getRelationsForEvent}
editState={this.state.editState}
showReactions={this.props.showReactions}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 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.
@ -28,7 +28,6 @@ import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
@ -51,6 +50,8 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
import {UIFeature} from "../../settings/UIFeature";
import HostSignupAction from "./HostSignupAction";
import {IHostSignupConfig} from "../views/dialogs/HostSignupDialogTypes";
interface IProps {
isMinimized: boolean;
@ -272,7 +273,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let topSection;
const signupLink = getHostingLink("user-context-menu");
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
@ -292,24 +293,19 @@ export default class UserMenu extends React.Component<IProps, IState> {
})}
</div>
)
} else if (signupLink) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
} else if (hostSignupConfig) {
if (hostSignupConfig && hostSignupConfig.url) {
// If hostSignup.domains is set to a non-empty array, only show
// dialog if the user is on the domain or a subdomain.
const hostSignupDomains = hostSignupConfig.domains || [];
const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.domains || validDomains.length > 0) {
topSection = <div onClick={this.onCloseMenu}>
<HostSignupAction />
</div>;
}
}
}
let homeButton = null;

View file

@ -24,6 +24,7 @@ export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
accountPassword: PropTypes.string,
tokenLogin: PropTypes.bool,
};
render() {
@ -33,6 +34,7 @@ export default class E2eSetup extends React.Component {
<CreateCrossSigningDialog
onFinished={this.props.onFinished}
accountPassword={this.props.accountPassword}
tokenLogin={this.props.tokenLogin}
/>
</CompleteSecurityBody>
</AuthPage>

View file

@ -340,8 +340,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
};
onTryRegisterClick = ev => {
const hasPasswordFlow = this.state.flows.find(flow => flow.type === "m.login.password");
const ssoFlow = this.state.flows.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password");
const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
// TODO: instead hide the Register button if registration is disabled by checking with the server,
// has no specific errCode currently and uses M_FORBIDDEN.

View file

@ -325,8 +325,7 @@ export default class Registration extends React.Component<IProps, IState> {
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
console.log(
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
@ -463,8 +462,7 @@ export default class Registration extends React.Component<IProps, IState> {
let ssoSection;
if (this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"]
|| this.state.ssoFlow["identity_providers"] || [];
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context

View file

@ -120,9 +120,9 @@ export default class SetupEncryptionBody extends React.Component {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
recoveryKeyPrompt = _t("Use Security Key or Phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Use Recovery Key");
recoveryKeyPrompt = _t("Use Security Key");
}
let useRecoveryKeyButton;

View file

@ -72,9 +72,12 @@ export default class SoftLogout extends React.Component {
this._initLogin();
MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => {
this.setState({keyBackupNeeded: remaining > 0});
});
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
cli.countSessionsNeedingBackup().then(remaining => {
this.setState({ keyBackupNeeded: remaining > 0 });
});
}
}
onClearAll = () => {

View file

@ -609,8 +609,12 @@ export class SSOAuthEntry extends React.Component {
this.props.authSessionId,
);
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH,
attemptFailed: false,
};
}
@ -618,12 +622,35 @@ export class SSOAuthEntry extends React.Component {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
}
}
attemptFailed = () => {
this.setState({
attemptFailed: true,
});
};
_onReceiveMessage = event => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
}
}
};
onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid losing application
// context.
window.open(this._ssoUrl, '_blank');
this._popupWindow = window.open(this._ssoUrl, "_blank");
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
@ -656,10 +683,28 @@ export class SSOAuthEntry extends React.Component {
);
}
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
{cancelButton}
{continueButton}
</div>;
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
} else if (this.state.attemptFailed) {
errorSection = (
<div className="error" role="alert">
{ _t("Something went wrong in confirming your identity. Cancel and try again.") }
</div>
);
}
return <React.Fragment>
{ errorSection }
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
{cancelButton}
{continueButton}
</div>
</React.Fragment>;
}
}
@ -710,8 +755,7 @@ export class FallbackAuthEntry extends React.Component {
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = window.open(url);
this._popupWindow.opener = null;
this._popupWindow = window.open(url, "_blank");
};
_onReceiveMessage = event => {

View file

@ -196,7 +196,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
// 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));
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;

View file

@ -194,7 +194,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
// 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));
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;

View file

@ -0,0 +1,77 @@
/*
Copyright 2020 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
import Modal from '../../../Modal';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
export default class CallContextMenu extends React.Component<IProps> {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props);
}
onHoldClick = () => {
this.props.call.setRemoteOnHold(true);
this.props.onFinished();
}
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
}
onTransferClick = () => {
Modal.createTrackedDialog(
'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
this.props.onFinished();
}
render() {
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
let transferItem;
if (this.props.call.opponentCanBeTransferred()) {
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
{_t("Transfer")}
</MenuItem>;
}
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
{holdUnholdCaption}
</MenuItem>
{transferItem}
</ContextMenu>;
}
}

View file

@ -0,0 +1,59 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Dialpad from '../voip/DialPad';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
interface IState {
value: string;
}
export default class DialpadContextMenu extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
value: '',
}
}
onDigitPress = (digit) => {
this.props.call.sendDtmfDigit(digit);
this.setState({value: this.state.value + digit});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
</div>
</ContextMenu>;
}
}

View file

@ -149,7 +149,7 @@ export default class MessageContextMenu extends React.Component {
onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed) => {
onFinished: async (proceed, reason) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
@ -157,6 +157,8 @@ export default class MessageContextMenu extends React.Component {
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
);
} catch (e) {
const code = e.errcode || e.statusCode;

View file

@ -20,17 +20,17 @@ import {MatrixCapabilities} from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import {IApp} from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
@ -57,7 +57,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetStore.instance.unpinWidget(room.roomId, app.id);
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
onFinished();
};
@ -127,7 +127,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
console.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[app.eventId] = false;
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => {
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
@ -137,13 +138,13 @@ const WidgetContextMenu: React.FC<IProps> = ({
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId);
const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, -1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
onFinished();
};
@ -153,7 +154,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, 1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
onFinished();
};

View file

@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler';
*/
export default class ConfirmRedactDialog extends React.Component {
render() {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
return (
<QuestionDialog onFinished={this.props.onFinished}
<TextInputDialog onFinished={this.props.onFinished}
title={_t("Confirm Removal")}
description={
_t("Are you sure you wish to remove (delete) this event? " +
"Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}>
</QuestionDialog>
</TextInputDialog>
);
}
}

View file

@ -32,6 +32,8 @@ import {
PHASE_STARTED,
PHASE_CANCELLED,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
class GenericEditor extends React.PureComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};
@ -701,6 +703,97 @@ class VerificationExplorer extends React.Component {
}
}
class WidgetExplorer extends React.Component {
static getLabel() {
return _t("Active Widgets");
}
constructor(props) {
super(props);
this.state = {
query: '',
editWidget: null, // set to an IApp when editing
};
}
onWidgetStoreUpdate = () => {
this.forceUpdate();
};
onQueryChange = (query) => {
this.setState({query});
};
onEditWidget = (widget) => {
this.setState({editWidget: widget});
};
onBack = () => {
const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
this.setState({editWidget: null});
} else {
this.props.onBack();
}
};
componentDidMount() {
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
}
componentWillUnmount() {
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate);
}
render() {
const room = this.props.room;
const editWidget = this.state.editWidget;
const widgets = WidgetStore.instance.getApps(room.roomId);
if (editWidget && widgets.includes(editWidget)) {
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values()))
.reduce((p, c) => {p.push(...c); return p;}, []);
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
if (!stateEv) { // "should never happen"
return <div>
{_t("There was an error finding this widget.")}
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>;
}
return <SendCustomEvent
onBack={this.onBack}
room={room}
forceStateEvent={true}
inputs={{
eventType: stateEv.getType(),
evContent: JSON.stringify(stateEv.getContent(), null, '\t'),
stateKey: stateEv.getStateKey(),
}}
/>;
}
return (<div>
<div className="mx_Dialog_content">
<FilteredList query={this.state.query} onChange={this.onQueryChange}>
{widgets.map(w => {
return <button
className='mx_DevTools_RoomStateExplorer_button'
key={w.url + w.eventId}
onClick={() => this.onEditWidget(w)}
>{w.url}</button>;
})}
</FilteredList>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>);
}
}
const Entries = [
SendCustomEvent,
RoomStateExplorer,
@ -708,6 +801,7 @@ const Entries = [
AccountDataExplorer,
ServersInRoomList,
VerificationExplorer,
WidgetExplorer,
];
export default class DevtoolsDialog extends React.PureComponent {

View file

@ -50,6 +50,10 @@ export default class ErrorDialog extends React.Component {
button: null,
};
onClick = () => {
this.props.onFinished(true);
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
@ -64,7 +68,7 @@ export default class ErrorDialog extends React.Component {
{ this.props.description || _t('An error has occurred.') }
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
<button className="mx_Dialog_primary" onClick={this.onClick} autoFocus={this.props.focus}>
{ this.props.button || _t('OK') }
</button>
</div>

View file

@ -0,0 +1,291 @@
/*
Copyright 2021 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 AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import PersistedElement from "../elements/PersistedElement";
import QuestionDialog from './QuestionDialog';
import SdkConfig from "../../../SdkConfig";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { HostSignupStore } from "../../../stores/HostSignupStore";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import {
IHostSignupConfig,
IPostmessage,
IPostmessageResponseData,
PostmessageAction,
} from "./HostSignupDialogTypes";
const HOST_SIGNUP_KEY = "host_signup";
interface IProps {}
interface IState {
completed: boolean;
error: string;
minimized: boolean;
}
export default class HostSignupDialog extends React.PureComponent<IProps, IState> {
private iframeRef: React.RefObject<HTMLIFrameElement> = React.createRef();
private readonly config: IHostSignupConfig;
constructor(props: IProps) {
super(props);
this.state = {
completed: false,
error: null,
minimized: false,
};
this.config = SdkConfig.get().hostSignup;
}
private messageHandler = async (message: IPostmessage) => {
if (!this.config.url.startsWith(message.origin)) {
return;
}
switch (message.data.action) {
case PostmessageAction.HostSignupAccountDetailsRequest:
this.onAccountDetailsRequest();
break;
case PostmessageAction.Maximize:
this.setState({
minimized: false,
});
break;
case PostmessageAction.Minimize:
this.setState({
minimized: true,
});
break;
case PostmessageAction.SetupComplete:
this.setState({
completed: true,
});
break;
case PostmessageAction.CloseDialog:
return this.closeDialog();
}
}
private maximizeDialog = () => {
this.setState({
minimized: false,
});
// Send this action to the iframe so it can act accordingly
this.sendMessage({
action: PostmessageAction.Maximize,
});
}
private minimizeDialog = () => {
this.setState({
minimized: true,
});
// Send this action to the iframe so it can act accordingly
this.sendMessage({
action: PostmessageAction.Minimize,
});
}
private closeDialog = async () => {
window.removeEventListener("message", this.messageHandler);
// Ensure we destroy the host signup persisted element
PersistedElement.destroyElement("host_signup");
// Finally clear the flag in
return HostSignupStore.instance.setHostSignupActive(false);
}
private onCloseClick = async () => {
if (this.state.completed) {
// We're done, close
return this.closeDialog();
} else {
Modal.createDialog(
QuestionDialog,
{
title: _t("Confirm abort of host creation"),
description: _t(
"Are you sure you wish to abort creation of the host? The process cannot be continued.",
),
button: _t("Abort"),
onFinished: result => {
if (result) {
return this.closeDialog();
}
},
},
);
}
}
private sendMessage = (message: IPostmessageResponseData) => {
this.iframeRef.current.contentWindow.postMessage(message, this.config.url);
}
private async sendAccountDetails() {
const openIdToken = await MatrixClientPeg.get().getOpenIdToken();
if (!openIdToken || !openIdToken.access_token) {
console.warn("Failed to connect to homeserver for OpenID token.")
this.setState({
completed: true,
error: _t("Failed to connect to your homeserver. Please close this dialog and try again."),
});
return;
}
this.sendMessage({
action: PostmessageAction.HostSignupAccountDetails,
account: {
accessToken: await MatrixClientPeg.get().getAccessToken(),
name: OwnProfileStore.instance.displayName,
openIdToken: openIdToken.access_token,
serverName: await MatrixClientPeg.get().getDomain(),
userLocalpart: await MatrixClientPeg.get().getUserIdLocalpart(),
termsAccepted: true,
},
});
}
private onAccountDetailsDialogFinished = async (result) => {
if (result) {
return this.sendAccountDetails();
}
return this.closeDialog();
}
private onAccountDetailsRequest = () => {
const textComponent = (
<>
<p>
{_t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " +
"account to fetch verified email addresses. This data is not stored.", {
hostSignupBrand: this.config.brand,
})}
</p>
<p>
{_t("Learn more in our <privacyPolicyLink />, <termsOfServiceLink /> and <cookiePolicyLink />.",
{},
{
cookiePolicyLink: () => (
<a href={this.config.cookiePolicyUrl} target="_blank" rel="noreferrer noopener">
{_t("Cookie Policy")}
</a>
),
privacyPolicyLink: () => (
<a href={this.config.privacyPolicyUrl} target="_blank" rel="noreferrer noopener">
{_t("Privacy Policy")}
</a>
),
termsOfServiceLink: () => (
<a href={this.config.termsOfServiceUrl} target="_blank" rel="noreferrer noopener">
{_t("Terms of Service")}
</a>
),
},
)}
</p>
</>
);
Modal.createDialog(
QuestionDialog,
{
title: _t("You should know"),
description: textComponent,
button: _t("Continue"),
onFinished: this.onAccountDetailsDialogFinished,
},
);
}
public componentDidMount() {
window.addEventListener("message", this.messageHandler);
}
public componentWillUnmount() {
if (HostSignupStore.instance.isHostSignupActive) {
// Run the close dialog actions if we're still active, otherwise good to go
return this.closeDialog();
}
}
public render(): React.ReactNode {
return (
<div className="mx_HostSignup_persisted">
<PersistedElement key={HOST_SIGNUP_KEY} persistKey={HOST_SIGNUP_KEY}>
<div className={classNames({ "mx_Dialog_wrapper": !this.state.minimized })}>
<div
className={classNames("mx_Dialog",
{
"mx_HostSignupDialog_minimized": this.state.minimized,
"mx_HostSignupDialog": !this.state.minimized,
},
)}
>
{this.state.minimized &&
<div className="mx_Dialog_header mx_Dialog_headerWithButton">
<div className="mx_Dialog_title">
{_t("%(hostSignupBrand)s Setup", {
hostSignupBrand: this.config.brand,
})}
</div>
<AccessibleButton
className="mx_HostSignup_maximize_button"
onClick={this.maximizeDialog}
aria-label={_t("Maximize dialog")}
title={_t("Maximize dialog")}
/>
</div>
}
{!this.state.minimized &&
<div className="mx_Dialog_header mx_Dialog_headerWithCancel">
<AccessibleButton
onClick={this.minimizeDialog}
className="mx_HostSignup_minimize_button"
aria-label={_t("Minimize dialog")}
title={_t("Minimize dialog")}
/>
<AccessibleButton
onClick={this.onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("Close dialog")}
title={_t("Close dialog")}
/>
</div>
}
{this.state.error &&
<div>
{this.state.error}
</div>
}
{!this.state.error &&
<iframe
src={this.config.url}
ref={this.iframeRef}
sandbox="allow-forms allow-scripts allow-same-origin allow-popups"
/>
}
</div>
</div>
</PersistedElement>
</div>
);
}
}

View file

@ -0,0 +1,56 @@
/*
Copyright 2021 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.
*/
export enum PostmessageAction {
CloseDialog = "close_dialog",
HostSignupAccountDetails = "host_signup_account_details",
HostSignupAccountDetailsRequest = "host_signup_account_details_request",
Minimize = "host_signup_minimize",
Maximize = "host_signup_maximize",
SetupComplete = "setup_complete",
}
interface IAccountData {
accessToken: string;
name: string;
openIdToken: string;
serverName: string;
userLocalpart: string;
termsAccepted: boolean;
}
export interface IPostmessageRequestData {
action: PostmessageAction;
}
export interface IPostmessageResponseData {
action: PostmessageAction;
account?: IAccountData;
}
export interface IPostmessage {
data: IPostmessageRequestData;
origin: string;
}
export interface IHostSignupConfig {
brand: string;
cookiePolicyUrl: string;
domains: Array<string>;
privacyPolicyUrl: string;
termsOfServiceUrl: string;
url: string;
}

View file

@ -15,13 +15,12 @@ limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/matrix";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import SdkConfig from "../../../SdkConfig";
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import * as Email from "../../../email";
@ -42,12 +41,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
@ -132,12 +133,12 @@ class ThreepidMember extends Member {
}
}
class DMUserTile extends React.PureComponent {
static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
onRemove: PropTypes.func, // takes 1 argument, the member being removed
};
interface IDMUserTileProps {
member: RoomMember;
onRemove: (RoomMember) => any;
}
class DMUserTile extends React.PureComponent<IDMUserTileProps> {
_onRemove = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
@ -173,7 +174,9 @@ class DMUserTile extends React.PureComponent {
className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
<img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8}
/>
</AccessibleButton>
);
}
@ -190,15 +193,15 @@ class DMUserTile extends React.PureComponent {
}
}
class DMRoomTile extends React.PureComponent {
static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
lastActiveTs: PropTypes.number,
onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled
highlightWord: PropTypes.string,
isSelected: PropTypes.bool,
};
interface IDMRoomTileProps {
member: RoomMember;
lastActiveTs: number;
onToggle: (RoomMember) => any;
highlightWord: string;
isSelected: boolean;
}
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
_onClick = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
@ -298,28 +301,48 @@ class DMRoomTile extends React.PureComponent {
}
}
export default class InviteDialog extends React.PureComponent {
static propTypes = {
// Takes an array of user IDs/emails to invite.
onFinished: PropTypes.func.isRequired,
interface IInviteDialogProps {
// Takes an array of user IDs/emails to invite.
onFinished: (toInvite?: string[]) => any;
// The kind of invite being performed. Assumed to be KIND_DM if
// not provided.
kind: PropTypes.string,
// The kind of invite being performed. Assumed to be KIND_DM if
// not provided.
kind: string,
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: PropTypes.string,
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: string,
// Initial value to populate the filter with
initialText: PropTypes.string,
};
// The call to transfer. Only required for KIND_CALL_TRANSFER.
call: MatrixCall,
// Initial value to populate the filter with
initialText: string,
}
interface IInviteDialogState {
targets: RoomMember[]; // array of Member objects (see interface above)
filterText: string;
recents: { user: Member, userId: string }[];
numRecentsShown: number;
suggestions: { user: Member, userId: string }[];
numSuggestionsShown: number;
serverResultsMixin: { user: Member, userId: string }[];
threepidResultsMixin: { user: Member, userId: string}[];
canUseIdentityServer: boolean;
tryingIdentityServer: boolean;
// These two flags are used for the 'Go' button to communicate what is going on.
busy: boolean,
errorText: string,
}
export default class InviteDialog extends React.PureComponent<IInviteDialogProps, IInviteDialogState> {
static defaultProps = {
kind: KIND_DM,
initialText: "",
};
_debounceTimer: number = null;
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
_editorRef: any = null;
constructor(props) {
@ -327,6 +350,8 @@ export default class InviteDialog extends React.PureComponent {
if (props.kind === KIND_INVITE && !props.roomId) {
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
} else if (props.kind === KIND_CALL_TRANSFER && !props.call) {
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
}
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]);
@ -348,8 +373,8 @@ export default class InviteDialog extends React.PureComponent {
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions
serverResultsMixin: [],
threepidResultsMixin: [],
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false,
@ -367,7 +392,7 @@ export default class InviteDialog extends React.PureComponent {
}
}
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
@ -430,7 +455,7 @@ export default class InviteDialog extends React.PureComponent {
return recents;
}
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember} {
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
const maxConsideredMembers = 200;
const joinedRooms = MatrixClientPeg.get().getRooms()
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
@ -470,7 +495,7 @@ export default class InviteDialog extends React.PureComponent {
}, {});
// Generates { userId: {member, numRooms, score} }
const memberScores = Object.values(memberRooms).reduce((scores, entry) => {
const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
const maxRange = maxConsideredMembers * entry.rooms.length;
scores[entry.member.userId] = {
@ -603,7 +628,7 @@ export default class InviteDialog extends React.PureComponent {
return;
}
const createRoomOptions = {inlineErrors: true};
const createRoomOptions = {inlineErrors: true} as any; // XXX: Type out `createRoomOptions`
if (privateShouldBeEncrypted()) {
// Check whether all users have uploaded device keys before.
@ -620,7 +645,7 @@ export default class InviteDialog extends React.PureComponent {
// Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
let createRoomPromise = Promise.resolve();
let createRoomPromise = Promise.resolve(null) as Promise<string | null | boolean>;
const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId();
if (targetIds.length === 1 && !isSelf) {
createRoomOptions.dmUserId = targetIds[0];
@ -684,6 +709,29 @@ export default class InviteDialog extends React.PureComponent {
});
};
_transferCall = async () => {
this._convertFilter();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId);
if (targetIds.length > 1) {
this.setState({
errorText: _t("A call can only be transferred to a single user."),
});
}
this.setState({busy: true});
try {
await this.props.call.transfer(targetIds[0]);
this.setState({busy: false});
this.props.onFinished();
} catch (e) {
this.setState({
busy: false,
errorText: _t("Failed to transfer call"),
});
}
};
_onKeyDown = (e) => {
if (this.state.busy) return;
const value = e.target.value.trim();
@ -990,7 +1038,8 @@ export default class InviteDialog extends React.PureComponent {
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
if (this.state.filterText && hasMixins && kind === 'suggestions') {
// We don't want to duplicate members though, so just exclude anyone we've already seen.
const notAlreadyExists = (u: Member): boolean => {
// The type of u is a pain to define but members of both mixins have the 'userId' property
const notAlreadyExists = (u: any): boolean => {
return !sourceMembers.some(m => m.userId === u.userId)
&& !priorityAdditionalMembers.some(m => m.userId === u.userId)
&& !otherAdditionalMembers.some(m => m.userId === u.userId);
@ -1169,7 +1218,8 @@ export default class InviteDialog extends React.PureComponent {
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
const inviteText = _t("This won't invite them to %(communityName)s. " +
const inviteText = _t(
"This won't invite them to %(communityName)s. " +
"To invite someone to %(communityName)s, click <a>here</a>",
{communityName}, {
userId: () => {
@ -1197,7 +1247,7 @@ export default class InviteDialog extends React.PureComponent {
}
buttonText = _t("Go");
goButtonFn = this._startDm;
} else { // KIND_INVITE
} else if (this.props.kind === KIND_INVITE) {
title = _t("Invite to this room");
if (identityServersEnabled) {
@ -1209,7 +1259,9 @@ export default class InviteDialog extends React.PureComponent {
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
{sub}
</a>,
},
);
} else {
@ -1220,13 +1272,21 @@ export default class InviteDialog extends React.PureComponent {
userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">
{sub}
</a>,
},
);
}
buttonText = _t("Invite");
goButtonFn = this._inviteUsers;
} else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer");
buttonText = _t("Transfer");
goButtonFn = this._transferCall;
} else {
console.error("Unknown kind of InviteDialog: " + this.props.kind);
}
const hasSelection = this.state.targets.length > 0

View file

@ -35,13 +35,13 @@ import {
} 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;
widgetRoomId?: string;
sourceWidgetId: string;
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
}
@ -123,7 +123,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
public render() {
const templated = this.widget.getCompleteUrl({
currentRoomId: RoomViewStore.getRoomId(),
widgetRoomId: this.props.widgetRoomId,
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),

View file

@ -47,7 +47,7 @@ export default class NewSessionReviewDialog extends React.PureComponent {
<li>{_t("The internet connection either session is using")}</li>
</ul>
<div>
{_t("We recommend you change your password and recovery key in Settings immediately")}
{_t("We recommend you change your password and Security Key in Settings immediately")}
</div>
</div>,
onFinished: () => this.props.onFinished(false),

View file

@ -44,7 +44,8 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({onFinished}) => {
const [email, setEmail] = useState("");
const fieldRef = useRef<Field>();
const onSubmit = async () => {
const onSubmit = async (e) => {
e.preventDefault();
if (email) {
const valid = await fieldRef.current.validate({ allowEmpty: false });
@ -73,6 +74,7 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({onFinished}) => {
<form onSubmit={onSubmit}>
<Field
ref={fieldRef}
autoFocus={true}
type="text"
label={_t("Email (optional)")}
value={email}

View file

@ -53,9 +53,9 @@ export default class RoomSettingsDialog extends React.Component {
}
_onAction = (payload) => {
// When room changes below us, close the room settings
// When view changes below us, close the room settings
// whilst the modal is open this can only be triggered when someone hits Leave Room
if (payload.action === 'view_next_room') {
if (payload.action === 'view_home_page') {
this.props.onFinished();
}
};

View file

@ -14,7 +14,8 @@ 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 {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import BaseDialog from './BaseDialog';
@ -47,9 +48,20 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
const config = SdkConfig.get();
this.defaultServer = config["validated_server_config"] as ValidatedServerConfig;
const { serverConfig } = this.props;
let otherHomeserver = "";
if (!serverConfig.isDefault) {
if (serverConfig.isNameResolvable && serverConfig.hsName) {
otherHomeserver = serverConfig.hsName;
} else {
otherHomeserver = serverConfig.hsUrl;
}
}
this.state = {
defaultChosen: this.props.serverConfig.isDefault,
otherHomeserver: this.props.serverConfig.isDefault ? "" : this.props.serverConfig.hsUrl,
defaultChosen: serverConfig.isDefault,
otherHomeserver,
};
}
@ -69,10 +81,25 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
private validate = withValidation<this, { error?: string }>({
deriveData: async ({ value: hsUrl }) => {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl) return {};
deriveData: async ({ value }) => {
let hsUrl = value.trim(); // trim to account for random whitespace
// if the URL has no protocol, try validate it as a serverName via well-known
if (!hsUrl.includes("://")) {
try {
const discoveryResult = await AutoDiscovery.findClientConfig(hsUrl);
this.validatedConf = AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(hsUrl, discoveryResult);
return {}; // we have a validated config, we don't need to try the other paths
} catch (e) {
console.error(`Attempted ${hsUrl} as a server_name but it failed`, e);
}
}
// if we got to this stage then either the well-known failed or the URL had a protocol specified,
// so validate statically only. If the URL has no protocol, default to https.
if (!hsUrl.includes("://")) {
hsUrl = "https://" + hsUrl;
}
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl);
@ -81,17 +108,22 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
// carry on anyway
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
return {};
} else {
let error = _t("Unable to validate homeserver/identity server");
if (stateForError.isFatalError) {
let error = _t("Unable to validate homeserver");
if (e.translatedMessage) {
error = e.translatedMessage;
}
return { error };
}
// try to carry on anyway
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
return {};
} catch (e) {
console.error(e);
return { error: _t("Invalid URL") };
}
}
},
rules: [
@ -119,13 +151,13 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
const valid = await this.fieldRef.current.validate({ allowEmpty: false });
if (!valid) {
if (!valid && !this.state.defaultChosen) {
this.fieldRef.current.focus();
this.fieldRef.current.validate({ allowEmpty: false, focused: true });
return;
}
this.props.onFinished(this.validatedConf);
this.props.onFinished(this.state.defaultChosen ? this.defaultServer : this.validatedConf);
};
public render() {
@ -153,7 +185,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
>
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
<p>
{_t("We call the places you where you can host your account homeservers.")} {text}
{_t("We call the places where you can host your account homeservers.")} {text}
</p>
<StyledRadioButton

View file

@ -146,7 +146,7 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
const events = this.props.target.getLiveTimeline().getEvents();
matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId());
} else {
matrixToUrl = this.state.permalinkCreator.forRoom();
matrixToUrl = this.state.permalinkCreator.forShareableRoom();
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
matrixToUrl = makeUserPermalink(this.props.target.userId);

View file

@ -18,6 +18,7 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import Field from "../elements/Field";
import { _t, _td } from '../../../languageHandler';
export default class TextInputDialog extends React.Component {
static propTypes = {
@ -29,6 +30,7 @@ export default class TextInputDialog extends React.Component {
value: PropTypes.string,
placeholder: PropTypes.string,
button: PropTypes.string,
busyMessage: PropTypes.string, // pass _td string
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
hasCancel: PropTypes.bool,
@ -40,6 +42,7 @@ export default class TextInputDialog extends React.Component {
title: "",
value: "",
description: "",
busyMessage: _td("Loading..."),
focus: true,
hasCancel: true,
};
@ -51,6 +54,7 @@ export default class TextInputDialog extends React.Component {
this.state = {
value: this.props.value,
busy: false,
valid: false,
};
}
@ -66,11 +70,13 @@ export default class TextInputDialog extends React.Component {
onOk = async ev => {
ev.preventDefault();
if (this.props.validator) {
this.setState({ busy: true });
await this._field.current.validate({ allowEmpty: false });
if (!this._field.current.state.valid) {
this._field.current.focus();
this._field.current.validate({ allowEmpty: false, focused: true });
this.setState({ busy: false });
return;
}
}
@ -125,7 +131,8 @@ export default class TextInputDialog extends React.Component {
</div>
</form>
<DialogButtons
primaryButton={this.props.button}
primaryButton={this.state.busy ? _t(this.props.busyMessage) : this.props.button}
disabled={this.state.busy}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel}
hasCancel={this.props.hasCancel}

View file

@ -70,26 +70,26 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
return (
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("A widget would like to verify your identity")}>
title={_t("Allow this widget to verify your identity")}>
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
<p>
{_t(
"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.widget.templateUrl.split("?")[0],
},
)}
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")}
</p>
<p className="text-muted">
{/* cheap trim to just get the path */}
{this.props.widget.templateUrl.split("?")[0].split("#")[0]}
</p>
<LabelledToggleSwitch value={this.state.rememberSelection} toggleInFront={true}
onChange={this._onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />
</div>
<DialogButtons
primaryButton={_t("Allow")}
primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onAllow}
cancelButton={_t("Deny")}
onCancel={this._onDeny}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this._onRememberSelectionChange}
label={_t("Remember this")} />}
/>
</BaseDialog>
);

View file

@ -199,11 +199,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
} else if (this.state.recoveryKeyCorrect) {
return _t("Looks good!");
} else if (this.state.recoveryKeyValid) {
return _t("Wrong Recovery Key");
return _t("Wrong Security Key");
} else if (this.state.recoveryKeyValid === null) {
return '';
} else {
return _t("Invalid Recovery Key");
return _t("Invalid Security Key");
}
}
@ -231,7 +231,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}{_t(
"Unable to access secret storage. " +
"Please verify that you entered the correct recovery passphrase.",
"Please verify that you entered the correct Security Phrase.",
)}
</div>;
} else {
@ -298,7 +298,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
<Field
type="text"
type="password"
label={_t('Security Key')}
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}

View file

@ -34,6 +34,7 @@ import InteractiveAuthDialog from '../InteractiveAuthDialog';
export default class CreateCrossSigningDialog extends React.PureComponent {
static propTypes = {
accountPassword: PropTypes.string,
tokenLogin: PropTypes.bool,
};
constructor(props) {
@ -96,6 +97,9 @@ export default class CreateCrossSigningDialog extends React.PureComponent {
user: MatrixClientPeg.get().getUserId(),
password: this.state.accountPassword,
});
} else if (this.props.tokenLogin) {
// We are hoping the grace period is active
await makeRequest({});
} else {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
@ -144,6 +148,12 @@ export default class CreateCrossSigningDialog extends React.PureComponent {
});
this.props.onFinished(true);
} catch (e) {
if (this.props.tokenLogin) {
// ignore any failures, we are relying on grace period here
this.props.onFinished();
return;
}
this.setState({ error: e });
console.error("Error bootstrapping cross-signing", e);
}

View file

@ -297,19 +297,19 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
} else if (this.state.restoreError) {
if (this.state.restoreError.errcode === MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY) {
if (this.state.restoreType === RESTORE_TYPE_RECOVERYKEY) {
title = _t("Recovery key mismatch");
title = _t("Security Key mismatch");
content = <div>
<p>{_t(
"Backup could not be decrypted with this recovery key: " +
"please verify that you entered the correct recovery key.",
"Backup could not be decrypted with this Security Key: " +
"please verify that you entered the correct Security Key.",
)}</p>
</div>;
} else {
title = _t("Incorrect recovery passphrase");
title = _t("Incorrect Security Phrase");
content = <div>
<p>{_t(
"Backup could not be decrypted with this recovery passphrase: " +
"please verify that you entered the correct recovery passphrase.",
"Backup could not be decrypted with this Security Phrase: " +
"please verify that you entered the correct Security Phrase.",
)}</p>
</div>;
}
@ -342,7 +342,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
title = _t("Enter recovery passphrase");
title = _t("Enter Security Phrase");
content = <div>
<p>{_t(
"<b>Warning</b>: you should only set up key backup " +
@ -351,7 +351,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
)}</p>
<p>{_t(
"Access your secure message history and set up secure " +
"messaging by entering your recovery passphrase.",
"messaging by entering your Security Phrase.",
)}</p>
<form className="mx_RestoreKeyBackupDialog_primaryContainer">
@ -371,8 +371,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
/>
</form>
{_t(
"If you've forgotten your recovery passphrase you can "+
"<button1>use your recovery key</button1> or " +
"If you've forgotten your Security Phrase you can "+
"<button1>use your Security Key</button1> or " +
"<button2>set up new recovery options</button2>"
, {}, {
button1: s => <AccessibleButton className="mx_linkButton"
@ -390,7 +390,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
})}
</div>;
} else {
title = _t("Enter recovery key");
title = _t("Enter Security Key");
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
@ -399,11 +399,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus"></div>;
} else if (this.state.recoveryKeyValid) {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
{"\uD83D\uDC4D "}{_t("This looks like a valid Security Key!")}
</div>;
} else {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
{"\uD83D\uDC4E "}{_t("Not a valid Security Key")}
</div>;
}
@ -415,7 +415,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
)}</p>
<p>{_t(
"Access your secure message history and set up secure " +
"messaging by entering your recovery key.",
"messaging by entering your Security Key.",
)}</p>
<div className="mx_RestoreKeyBackupDialog_primaryContainer">
@ -434,7 +434,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
/>
</div>
{_t(
"If you've forgotten your recovery key you can "+
"If you've forgotten your Security Key you can "+
"<button>set up new recovery options</button>"
, {}, {
button: s => <AccessibleButton className="mx_linkButton"

View file

@ -33,7 +33,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement, {getPersistKey} from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType";
import {SettingLevel} from "../../../settings/SettingLevel";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
import {MatrixCapabilities} from "matrix-widget-api";
@ -240,7 +239,8 @@ export default class AppTile extends React.Component {
console.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[this.props.app.eventId] = true;
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to

View file

@ -0,0 +1,173 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import BaseDialog from "..//dialogs/BaseDialog"
import AccessibleButton from './AccessibleButton';
import {getDesktopCapturerSources} from "matrix-js-sdk/src/webrtc/call";
export interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL;
}
export enum Tabs {
Screens = "screens",
Windows = "windows",
}
export interface DesktopCapturerSourceIProps {
source: DesktopCapturerSource;
onSelect(source: DesktopCapturerSource): void;
}
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
constructor(props) {
super(props);
}
onClick = (ev) => {
this.props.onSelect(this.props.source);
}
render() {
return (
<AccessibleButton
className="mx_desktopCapturerSourcePicker_stream_button"
title={this.props.source.name}
onClick={this.onClick} >
<img
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
src={this.props.source.thumbnailURL}
/>
<span className="mx_desktopCapturerSourcePicker_stream_name">{this.props.source.name}</span>
</AccessibleButton>
);
}
}
export interface DesktopCapturerSourcePickerIState {
selectedTab: Tabs;
sources: Array<DesktopCapturerSource>;
}
export interface DesktopCapturerSourcePickerIProps {
onFinished(source: DesktopCapturerSource): void;
}
export default class DesktopCapturerSourcePicker extends React.Component<
DesktopCapturerSourcePickerIProps,
DesktopCapturerSourcePickerIState
> {
interval;
constructor(props) {
super(props);
this.state = {
selectedTab: Tabs.Screens,
sources: [],
};
}
async componentDidMount() {
// setInterval() first waits and then executes, therefore
// we call getDesktopCapturerSources() here without any delay.
// Otherwise the dialog would be left empty for some time.
this.setState({
sources: await getDesktopCapturerSources(),
});
// We update the sources every 500ms to get newer thumbnails
this.interval = setInterval(async () => {
this.setState({
sources: await getDesktopCapturerSources(),
});
}, 500);
}
componentWillUnmount() {
clearInterval(this.interval);
}
onSelect = (source) => {
this.props.onFinished(source);
}
onScreensClick = (ev) => {
this.setState({selectedTab: Tabs.Screens});
}
onWindowsClick = (ev) => {
this.setState({selectedTab: Tabs.Windows});
}
onCloseClick = (ev) => {
this.props.onFinished(null);
}
render() {
let sources;
if (this.state.selectedTab === Tabs.Screens) {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("screen");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
});
} else {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("window");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
});
}
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
return (
<BaseDialog
className="mx_desktopCapturerSourcePicker"
onFinished={this.onCloseClick}
title={_t("Share your screen")}
>
<div className="mx_desktopCapturerSourcePicker_tabLabels">
<AccessibleButton
className={screensButtonStyle}
onClick={this.onScreensClick}
>
{_t("Screens")}
</AccessibleButton>
<AccessibleButton
className={windowsButtonStyle}
onClick={this.onWindowsClick}
>
{_t("Windows")}
</AccessibleButton>
</div>
<div className="mx_desktopCapturerSourcePicker_panel">
{ sources }
</div>
</BaseDialog>
);
}
}

View file

@ -124,7 +124,7 @@ export default class EditableItemList extends React.Component {
<Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit">
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")}
</AccessibleButton>
</form>

View file

@ -0,0 +1,94 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
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, { FunctionComponent, useEffect, useRef } from 'react';
import dis from '../../../dispatcher/dispatcher';
import ICanvasEffect from '../../../effects/ICanvasEffect';
import {CHAT_EFFECTS} from '../../../effects'
interface IProps {
roomWidth: number;
}
const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const effectsRef = useRef<Map<string, ICanvasEffect>>(new Map<string, ICanvasEffect>());
const lazyLoadEffectModule = async (name: string): Promise<ICanvasEffect> => {
if (!name) return null;
let effect: ICanvasEffect | null = effectsRef.current[name] || null;
if (effect === null) {
const options = CHAT_EFFECTS.find((e) => e.command === name)?.options
try {
const { default: Effect } = await import(`../../../effects/${name}`);
effect = new Effect(options);
effectsRef.current[name] = effect;
} catch (err) {
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
}
}
return effect;
};
useEffect(() => {
const resize = () => {
if (canvasRef.current) {
canvasRef.current.height = window.innerHeight;
}
};
const onAction = (payload: { action: string }) => {
const actionPrefix = 'effects.';
if (payload.action.indexOf(actionPrefix) === 0) {
const effect = payload.action.substr(actionPrefix.length);
lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current));
}
}
const dispatcherRef = dis.register(onAction);
const canvas = canvasRef.current;
canvas.height = window.innerHeight;
window.addEventListener('resize', resize, true);
return () => {
dis.unregister(dispatcherRef);
window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
for (const effect in currentEffects) {
const effectModule: ICanvasEffect = currentEffects[effect];
if (effectModule && effectModule.isRunning) {
effectModule.stop();
}
}
};
}, []);
return (
<canvas
ref={canvasRef}
width={roomWidth}
style={{
display: 'block',
zIndex: 999999,
pointerEvents: 'none',
position: 'fixed',
top: 0,
right: 0,
}}
/>
)
}
export default EffectsOverlay;

View file

@ -22,6 +22,7 @@ import * as Avatar from '../../../Avatar';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTile from '../rooms/EventTile';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout} from "../../../settings/Layout";
import {UIFeature} from "../../../settings/UIFeature";
interface IProps {
@ -33,7 +34,7 @@ interface IProps {
/**
* Whether to use the irc layout or not
*/
useIRCLayout: boolean;
layout: Layout;
/**
* classnames to apply to the wrapper of the preview
@ -121,14 +122,14 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
const event = this.fakeEvent(this.state);
const className = classnames(this.props.className, {
"mx_IRCLayout": this.props.useIRCLayout,
"mx_GroupLayout": !this.props.useIRCLayout,
"mx_IRCLayout": this.props.layout == Layout.IRC,
"mx_GroupLayout": this.props.layout == Layout.Group,
});
return <div className={className}>
<EventTile
mxEvent={event}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
/>
</div>;

View file

@ -216,7 +216,8 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
public render() {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
...inputProps} = this.props;
// Set some defaults for the <input> element
const ref = input => this.input = input;

View file

@ -15,14 +15,15 @@ limitations under the License.
*/
import React, {useContext, useRef, useState} from 'react';
import {EventType} from 'matrix-js-sdk/src/@types/event';
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';
import RoomContext from "../../../contexts/RoomContext";
export const AVATAR_SIZE = 52;
@ -50,6 +51,11 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel;
const {room} = useContext(RoomContext);
const canSetAvatar = room?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId());
if (!canSetAvatar) return <React.Fragment>{ children }</React.Fragment>;
const visible = !!label && (hover || show);
return <React.Fragment>
<input
type="file"
@ -82,11 +88,13 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
>
{ children }
<Tooltip
label={label}
visible={!!label && (hover || show)}
forceOnRight
/>
<div className={classNames("mx_Tooltip", {
"mx_Tooltip_visible": visible,
"mx_Tooltip_invisible": !visible,
})}>
<div className="mx_Tooltip_chevron" />
{ label }
</div>
</AccessibleButton>
</React.Fragment>;
};

View file

@ -23,6 +23,7 @@ import ResizeObserver from 'resize-observer-polyfill';
import dis from '../../../dispatcher/dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {isNullOrUndefined} from "matrix-js-sdk/src/utils";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -61,6 +62,9 @@ export default class PersistedElement extends React.Component {
// Any PersistedElements with the same persistKey will use
// the same DOM container.
persistKey: PropTypes.string.isRequired,
// z-index for the element. Defaults to 9.
zIndex: PropTypes.number,
};
constructor() {
@ -165,6 +169,7 @@ export default class PersistedElement extends React.Component {
const parentRect = parent.getBoundingClientRect();
Object.assign(child.style, {
zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex,
position: 'absolute',
top: parentRect.top + 'px',
left: parentRect.left + 'px',

View file

@ -1,7 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 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.
@ -23,23 +23,11 @@ import { Room, RoomMember } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import FlairStore from "../../../stores/FlairStore";
import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks";
import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+]).*?)(?=\/|\?|$)/;
class Pill extends React.Component {
static isPillUrl(url) {
return !!getPrimaryPermalinkEntity(url);
}
static isMessagePillUrl(url) {
return !!REGEX_LOCAL_PERMALINK.exec(url);
}
static roomNotifPos(text) {
return text.indexOf("@room");
}
@ -56,7 +44,7 @@ class Pill extends React.Component {
static propTypes = {
// The Type of this Pill. If url is given, this is auto-detected.
type: PropTypes.string,
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
// The URL to pillify (no validation is done)
url: PropTypes.string,
// Whether the pill is in a message
inMessage: PropTypes.bool,
@ -90,12 +78,9 @@ class Pill extends React.Component {
if (nextProps.url) {
if (nextProps.inMessage) {
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = REGEX_LOCAL_PERMALINK.exec(nextProps.url) || [];
resourceId = matrixToMatch[1]; // The room/user ID
prefix = matrixToMatch[2]; // The first character of prefix
const parts = parseAppLocalLink(nextProps.url);
resourceId = parts.primaryEntityId; // The room/user ID
prefix = parts.sigil; // The first character of prefix
} else {
resourceId = getPrimaryPermalinkEntity(nextProps.url);
prefix = resourceId ? resourceId[0] : undefined;

View file

@ -24,6 +24,7 @@ import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import {LayoutPropType} from "../../../settings/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
@ -42,7 +43,7 @@ export default class ReplyThread extends React.Component {
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
useIRCLayout: PropTypes.bool,
layout: LayoutPropType,
};
static contextType = MatrixClientContext;
@ -209,7 +210,7 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, useIRCLayout) {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />;
}
@ -218,7 +219,7 @@ export default class ReplyThread extends React.Component {
onHeightChanged={onHeightChanged}
ref={ref}
permalinkCreator={permalinkCreator}
useIRCLayout={useIRCLayout}
layout={layout}
/>;
}
@ -386,7 +387,7 @@ export default class ReplyThread extends React.Component {
permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
useIRCLayout={this.props.useIRCLayout}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
/>

View file

@ -15,19 +15,40 @@ limitations under the License.
*/
import React from "react";
import { chunk } from "lodash";
import classNames from "classnames";
import {MatrixClient} from "matrix-js-sdk/src/client";
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
import {IIdentityProvider, ISSOFlow} from "../../../Login";
import classNames from "classnames";
import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
interface ISSOButtonProps extends Omit<IProps, "flow"> {
idp: IIdentityProvider;
mini?: boolean;
}
const getIcon = (brand: IdentityProviderBrand | string) => {
switch (brand) {
case IdentityProviderBrand.Apple:
return require(`../../../../res/img/element-icons/brands/apple.svg`);
case IdentityProviderBrand.Facebook:
return require(`../../../../res/img/element-icons/brands/facebook.svg`);
case IdentityProviderBrand.Github:
return require(`../../../../res/img/element-icons/brands/github.svg`);
case IdentityProviderBrand.Gitlab:
return require(`../../../../res/img/element-icons/brands/gitlab.svg`);
case IdentityProviderBrand.Google:
return require(`../../../../res/img/element-icons/brands/google.svg`);
case IdentityProviderBrand.Twitter:
return require(`../../../../res/img/element-icons/brands/twitter.svg`);
default:
return null;
}
}
const SSOButton: React.FC<ISSOButtonProps> = ({
matrixClient,
loginType,
@ -37,33 +58,42 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
mini,
...props
}) => {
const kind = primary ? "primary" : "primary_outline";
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp.id);
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id);
};
let icon;
if (idp && idp.icon && idp.icon.startsWith("https://")) {
icon = <img src={idp.icon} height="24" width="24" alt={label} />;
let brandClass;
const brandIcon = idp ? getIcon(idp.brand) : null;
if (brandIcon) {
const brandName = idp.brand.split(".").pop();
brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
const src = matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true);
icon = <img src={src} height="24" width="24" alt={idp.name} />;
}
const classes = classNames("mx_SSOButton", {
[brandClass]: brandClass,
mx_SSOButton_mini: mini,
mx_SSOButton_default: !idp,
mx_SSOButton_primary: primary,
});
if (mini) {
// TODO fallback icon
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
<AccessibleTooltipButton {...props} title={label} className={classes} onClick={onClick}>
{ icon }
</AccessibleButton>
</AccessibleTooltipButton>
);
}
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
<AccessibleButton {...props} className={classes} onClick={onClick}>
{ icon }
{ label }
</AccessibleButton>
@ -78,8 +108,10 @@ interface IProps {
primary?: boolean;
}
const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || [];
const providers = flow["org.matrix.msc2858.identity_providers"] || [];
if (providers.length < 2) {
return <div className="mx_SSOButtons">
<SSOButton
@ -92,17 +124,24 @@ const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAf
</div>;
}
const rows = Math.ceil(providers.length / MAX_PER_ROW);
const size = Math.ceil(providers.length / rows);
return <div className="mx_SSOButtons">
{ providers.map(idp => (
<SSOButton
key={idp.id}
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={idp}
mini={true}
primary={primary}
/>
{ chunk(providers, size).map(chunk => (
<div key={chunk[0].id} className="mx_SSOButtons_row">
{ chunk.map(idp => (
<SSOButton
key={idp.id}
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={idp}
mini={true}
primary={primary}
/>
)) }
</div>
)) }
</div>;
};

View file

@ -67,7 +67,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
</AccessibleButton>;
}
let serverName = serverConfig.hsName;
let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl;
if (serverConfig.hsNameIsDifferent) {
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
{serverConfig.hsName}

View file

@ -0,0 +1,36 @@
/*
Copyright 2021 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, { useState } from 'react';
import HostSignupDialog from "../dialogs/HostSignupDialog";
import { HostSignupStore } from "../../../stores/HostSignupStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
const HostSignupContainer = () => {
const [isActive, setIsActive] = useState(HostSignupStore.instance.isHostSignupActive);
useEventEmitter(HostSignupStore.instance, UPDATE_EVENT, () => {
setIsActive(HostSignupStore.instance.isHostSignupActive);
});
return <div className="mx_HostSignupContainer">
{isActive &&
<HostSignupDialog />
}
</div>;
};
export default HostSignupContainer

View file

@ -288,7 +288,7 @@ export default class MFileBody extends React.Component {
<a ref={this._dummyLink} />
</div>
<iframe
src={`${url}?origin=${encodeURIComponent(window.location.origin)}`}
src={url}
onLoad={onIframeLoad}
ref={this._iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />

View file

@ -362,7 +362,7 @@ export default class MImageBody extends React.Component {
}
// The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight);
const maxHeight = Math.min(this.props.maxImageHeight || 240, infoHeight);
// The maximum width of the thumbnail, as dictated by its natural
// maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight;

View file

@ -19,6 +19,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import EventTileBubble from "./EventTileBubble";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
@ -33,10 +35,15 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find(w => w.id === widgetId);
let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) {
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
joinCopy = _t('Join the conference from the room information card on the right');
} else if (!widget) {
joinCopy = null;
}
if (!url) {

View file

@ -81,6 +81,7 @@ export default class TextualBody extends React.Component {
}
_applyFormatting() {
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
this.activateSpoilers([this._content.current]);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
@ -91,29 +92,140 @@ export default class TextualBody extends React.Component {
this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
const blocks = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (blocks.length > 0) {
// Handle expansion and add buttons
const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre");
if (pres.length > 0) {
for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this.
// This happens after the codeblock was edited.
if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue;
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = this._wrapInDiv(pres[i]);
this._handleCodeBlockExpansion(pres[i]);
this._addCodeExpansionButton(div, pres[i]);
this._addCodeCopyButton(div);
if (showLineNumbers) {
this._addLineNumbers(pres[i]);
}
}
}
// Highlight code
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (codes.length > 0) {
// Do this asynchronously: parsing code takes time and we don't
// need to block the DOM update on it.
setTimeout(() => {
if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(blocks[i]);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = blocks[i].className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
for (let i = 0; i < codes.length; i++) {
// If the code already has the hljs class we want to skip this.
// This happens after the codeblock was edited.
if (codes[i].className.includes("hljs")) continue;
this._highlightCode(codes[i]);
}
}, 10);
}
this._addCodeCopyButton();
}
}
_addCodeExpansionButton(div, pre) {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100;
if (percentageOfViewport < 30) return;
const button = document.createElement("span");
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
button.className += "mx_EventTile_expandButton";
} else {
button.className += "mx_EventTile_collapseButton";
}
button.onclick = async () => {
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
pre.className = "";
button.className += "mx_EventTile_collapseButton";
} else {
pre.className = "mx_EventTile_collapsedCodeBlock";
button.className += "mx_EventTile_expandButton";
}
// By expanding/collapsing we changed
// the height, therefore we call this
this.props.onHeightChanged();
};
div.appendChild(button);
}
_addCodeCopyButton(div) {
const button = document.createElement("span");
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
// Check if expansion button exists. If so
// we put the copy button to the bottom
const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("code")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
div.appendChild(button);
}
_wrapInDiv(pre) {
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
pre.parentNode.replaceChild(div, pre);
// Append <pre> block and copy button to container
div.appendChild(pre);
return div;
}
_handleCodeBlockExpansion(pre) {
if (!SettingsStore.getValue("expandCodeByDefault")) {
pre.className = "mx_EventTile_collapsedCodeBlock";
}
}
_addLineNumbers(pre) {
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0];
// Calculate number of lines in pre
const number = pre.innerHTML.split(/\n/).length;
// Iterate through lines starting with 1 (number of the first line is 1)
for (let i = 1; i < number; i++) {
lineNumbers.innerHTML += '<span class="mx_EventTile_lineNumber">' + i + '</span>';
}
}
_highlightCode(code) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(code);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = code.className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(code);
}
}
}
@ -254,38 +366,6 @@ export default class TextualBody extends React.Component {
}
}
_addCodeCopyButton() {
// Add 'copy' buttons to pre blocks
Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
const button = document.createElement("span");
button.className = "mx_EventTile_copyButton";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("pre")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
p.parentNode.replaceChild(div, p);
// Append <pre> block and copy button to container
div.appendChild(p);
div.appendChild(button);
});
}
onCancelClick = event => {
this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage
@ -410,7 +490,10 @@ export default class TextualBody extends React.Component {
ref: this._content,
});
if (this.props.replacingEventId) {
body = [body, this._renderEditedMarker()];
body = <>
{body}
{this._renderEditedMarker()}
</>;
}
if (this.props.highlightLink) {

View file

@ -33,6 +33,7 @@ interface IProps {
previousPhase?: RightPanelPhases;
closeLabel?: string;
onClose?(): void;
refireParams?;
}
interface IGroupProps {
@ -56,6 +57,7 @@ const BaseCard: React.FC<IProps> = ({
withoutScrollContainer,
previousPhase,
children,
refireParams,
}) => {
let backButton;
if (previousPhase) {
@ -63,6 +65,7 @@ const BaseCard: React.FC<IProps> = ({
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: previousPhase,
refireParams: refireParams,
});
};
backButton = <AccessibleButton className="mx_BaseCard_back" onClick={onBackClick} title={_t("Back")} />;

View file

@ -37,12 +37,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip";
import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature";
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
room: Room;
@ -75,8 +77,9 @@ export const useWidgets = (room: Room) => {
setApps([...WidgetStore.instance.getApps(room.roomId)]);
}, [room]);
useEffect(updateApps, [room]);
useEffect(updateApps, [room, updateApps]);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps);
return apps;
};
@ -101,10 +104,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
};
const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const togglePin = isPinned
? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); }
: () => { WidgetStore.instance.pinWidget(room.roomId, app.id); };
? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); }
: () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); };
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
@ -119,7 +122,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
/>;
}
const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id);
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
let pinTitle: string;
if (cannotPin) {
@ -183,9 +186,18 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
}
};
let copyLayoutBtn = null;
if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) {
copyLayoutBtn = (
<AccessibleButton kind="link" onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
{ _t("Set my room layout for everyone") }
</AccessibleButton>
);
}
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => <AppRow key={app.id} app={app} room={room} />) }
{ copyLayoutBtn }
<AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }
</AccessibleButton>
@ -210,14 +222,6 @@ const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
const useMemberCount = (room: Room) => {
const [count, setCount] = useState(room.getJoinedMembers().length);
useEventEmitter(room.currentState, "RoomState.members", () => {
setCount(room.getJoinedMembers().length);
});
return count;
};
const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
const cli = useContext(MatrixClientContext);
@ -251,7 +255,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
</div>
</React.Fragment>;
const memberCount = useMemberCount(room);
const memberCount = useRoomMemberCount(room);
return <BaseCard header={header} className="mx_RoomSummaryCard" onClose={onClose}>
<Group title={_t("About")} className="mx_RoomSummaryCard_aboutGroup">

View file

@ -60,6 +60,7 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import InfoDialog from "../dialogs/InfoDialog";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
interface IDevice {
deviceId: string;
@ -1534,6 +1535,24 @@ const UserInfo: React.FC<Props> = ({
const classes = ["mx_UserInfo"];
let refireParams;
let previousPhase: RightPanelPhases;
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room && phase === RightPanelPhases.EncryptionPanel) {
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member};
} else if (room) {
previousPhase = RightPanelPhases.RoomMemberList;
}
const onEncryptionPanelClose = () => {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: previousPhase,
refireParams: refireParams,
});
}
let content;
switch (phase) {
case RightPanelPhases.RoomMemberInfo:
@ -1553,19 +1572,13 @@ const UserInfo: React.FC<Props> = ({
<EncryptionPanel
{...props as React.ComponentProps<typeof EncryptionPanel>}
member={member}
onClose={onClose}
onClose={onEncryptionPanelClose}
isRoomEncrypted={isRoomEncrypted}
/>
);
break;
}
let previousPhase: RightPanelPhases;
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room) {
previousPhase = RightPanelPhases.RoomMemberList;
}
let closeLabel = undefined;
if (phase === RightPanelPhases.EncryptionPanel) {
const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
@ -1581,6 +1594,7 @@ const UserInfo: React.FC<Props> = ({
onClose={onClose}
closeLabel={closeLabel}
previousPhase={previousPhase}
refireParams={refireParams}
>
{ content }
</BaseCard>;

View file

@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import React, { useContext, useEffect } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { _t } from "../../../languageHandler";
import { useWidgets } from "./RoomSummaryCard";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
room: Room;
@ -42,7 +42,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
const apps = useWidgets(room);
const app = apps.find(a => a.id === widgetId);
const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();

View file

@ -69,19 +69,24 @@ export default class RoomProfileSettings extends React.Component {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.setState({
avatarUrl: undefined,
avatarFile: undefined,
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
});
};
_clearProfile = async (e) => {
_cancelProfileChanges = async (e) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this._removeAvatar();
this.setState({enableProfileSave: false, displayName: this.state.originalDisplayName});
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
});
};
_saveProfile = async (e) => {
@ -108,7 +113,7 @@ export default class RoomProfileSettings extends React.Component {
newState.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: undefined}, '');
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, '');
}
if (this.state.originalTopic !== this.state.topic) {
@ -120,17 +125,21 @@ export default class RoomProfileSettings extends React.Component {
};
_onDisplayNameChanged = (e) => {
this.setState({
displayName: e.target.value,
enableProfileSave: true,
});
this.setState({displayName: e.target.value});
if (this.state.originalDisplayName === e.target.value) {
this.setState({enableProfileSave: false});
} else {
this.setState({enableProfileSave: true});
}
};
_onTopicChanged = (e) => {
this.setState({
topic: e.target.value,
enableProfileSave: true,
});
this.setState({topic: e.target.value});
if (this.state.originalTopic === e.target.value) {
this.setState({enableProfileSave: false});
} else {
this.setState({enableProfileSave: true});
}
};
_onAvatarChanged = (e) => {
@ -158,6 +167,33 @@ export default class RoomProfileSettings extends React.Component {
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
let profileSettingsButtons;
if (
this.state.canSetName ||
this.state.canSetTopic ||
this.state.canSetAvatar
) {
profileSettingsButtons = (
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton
onClick={this._saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
>
{_t("Save")}
</AccessibleButton>
</div>
);
}
return (
<form
onSubmit={this._saveProfile}
@ -172,7 +208,7 @@ export default class RoomProfileSettings extends React.Component {
<Field label={_t("Room Name")}
type="text" value={this.state.displayName} autoComplete="off"
onChange={this._onDisplayNameChanged} disabled={!this.state.canSetName} />
<Field id="profileTopic" label={_t("Room Topic")} disabled={!this.state.canSetTopic}
<Field className="mx_ProfileSettings_controls_topic" id="profileTopic" label={_t("Room Topic")} disabled={!this.state.canSetTopic}
type="text" value={this.state.topic} autoComplete="off"
onChange={this._onTopicChanged} element="textarea" />
</div>
@ -183,22 +219,7 @@ export default class RoomProfileSettings extends React.Component {
uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} />
</div>
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._clearProfile}
kind="link"
disabled={!this.state.enableProfileSave}
>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton
onClick={this._saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
>
{_t("Save")}
</AccessibleButton>
</div>
{ profileSettingsButtons }
</form>
);
}

View file

@ -28,12 +28,13 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {useLocalStorageState} from "../../../hooks/useLocalStorageState";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import WidgetStore from "../../../stores/WidgetStore";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore";
import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers";
import {useStateCallback} from "../../../hooks/useStateCallback";
export default class AppsDrawer extends React.Component {
static propTypes = {
@ -62,13 +63,13 @@ export default class AppsDrawer extends React.Component {
componentDidMount() {
ScalarMessaging.startListening();
WidgetStore.instance.on(this.props.room.roomId, this._updateApps);
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
ScalarMessaging.stopListening();
WidgetStore.instance.off(this.props.room.roomId, this._updateApps);
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this._resizeContainer) {
this.resizer.detach();
@ -102,11 +103,10 @@ export default class AppsDrawer extends React.Component {
},
onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
// persist to localStorage
localStorage.setItem(this._getStorageKey(), JSON.stringify([
this.state.apps.map(app => app.id),
...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
]));
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room, Container.Top,
this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
);
},
};
// pass a truthy container for now, we won't call attach until we update it
@ -128,8 +128,6 @@ export default class AppsDrawer extends React.Component {
this._loadResizerPreferences();
};
_getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`;
_getAppsHash = (apps) => apps.map(app => app.id).join("~");
componentDidUpdate(prevProps, prevState) {
@ -147,24 +145,16 @@ export default class AppsDrawer extends React.Component {
};
_loadResizerPreferences = () => {
try {
const [[...lastIds], ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey()));
// Every app was included in the last split, reuse the last sizes
if (this.state.apps.length <= lastIds.length && this.state.apps.every((app, i) => lastIds[i] === app.id)) {
sizes.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
if (distributor) {
distributor.size = size;
distributor.finish();
}
});
return;
}
} catch (e) {
// this is expected
}
if (this.state.apps) {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
if (this.state.apps && (this.state.apps.length - 1) === distributions.length) {
distributions.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
if (distributor) {
distributor.size = size;
distributor.finish();
}
});
} else if (this.state.apps) {
const distributors = this.resizer.getDistributors();
distributors.forEach(d => d.item.clearSize());
distributors.forEach(d => d.start());
@ -190,7 +180,7 @@ export default class AppsDrawer extends React.Component {
}
};
_getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
_getApps = () => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
_updateApps = () => {
this.setState({
@ -248,10 +238,11 @@ export default class AppsDrawer extends React.Component {
return (
<div className={classes}>
<PersistentVResizer
id={"apps-drawer_" + this.props.room.roomId}
room={this.props.room}
minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
handleWrapperClass="mx_AppsContainer_resizerHandleContainer"
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
@ -272,7 +263,7 @@ export default class AppsDrawer extends React.Component {
}
const PersistentVResizer = ({
id,
room,
minHeight,
maxHeight,
className,
@ -281,7 +272,24 @@ const PersistentVResizer = ({
resizeNotifier,
children,
}) => {
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
if (!maxHeight) maxHeight = (window.innerHeight / 4) * 3;
// Convert from percentage to height. Note that the default height is 280px.
if (defaultHeight) {
defaultHeight = clamp(defaultHeight, 0, 100);
defaultHeight = percentageWithin(defaultHeight / 100, minHeight, maxHeight);
} else {
defaultHeight = 280;
}
const [height, setHeight] = useStateCallback(defaultHeight, newHeight => {
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
});
return <Resizable
size={{height: Math.min(height, maxHeight)}}

View file

@ -26,9 +26,9 @@ import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
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";
import CallViewForRoom from '../voip/CallViewForRoom';
interface IProps {
// js-sdk room object
@ -166,8 +166,8 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
const callView = (
<CallView
room={this.props.room}
<CallViewForRoom
roomId={this.props.room.roomId}
onResize={this.props.onResize}
maxVideoHeight={this.props.maxHeight}
/>

View file

@ -47,6 +47,7 @@ import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import {ICompletion} from "../../../autocomplete/Autocompleter";
// matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -518,13 +519,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private async tabCompleteName(event: React.KeyboardEvent) {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
await new Promise<void>(resolve => this.setState({showVisualBell: false}, resolve));
const {model} = this.props;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && (
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"

View file

@ -27,6 +27,7 @@ import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout, LayoutPropType} from "../../../settings/Layout";
import {EventStatus} from 'matrix-js-sdk';
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
@ -37,6 +38,7 @@ import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStore";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@ -65,6 +67,7 @@ const stateEventTileTypes = {
'm.room.server_acl': 'messages.TextualEvent',
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': 'messages.TextualEvent',
[WIDGET_LAYOUT_EVENT_TYPE]: 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
'm.room.guest_access': 'messages.TextualEvent',
@ -225,8 +228,8 @@ export default class EventTile extends React.Component {
// whether to show reactions for this event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
// whether or not to show flair at all
enableFlair: PropTypes.bool,
@ -732,7 +735,7 @@ export default class EventTile extends React.Component {
// joins/parts/etc
avatarSize = 14;
needsSenderProfile = false;
} else if (this.props.useIRCLayout) {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
@ -843,10 +846,11 @@ export default class EventTile extends React.Component {
{ timestamp }
</a>;
const groupTimestamp = !this.props.useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = this.props.useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const useIRCLayout = this.props.layout == Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
switch (this.props.tileShape) {
case 'notif': {
@ -941,7 +945,7 @@ export default class EventTile extends React.Component {
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.props.useIRCLayout,
this.props.layout,
);
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers

View file

@ -107,7 +107,7 @@ export default class LinkPreviewWidget extends React.Component {
if (!SettingsStore.getValue("showImages")) {
image = null; // Don't render a button to show the image, just hide it outright
}
const imageMaxWidth = 100; const imageMaxHeight = 100;
const imageMaxWidth = 320; const imageMaxHeight = 240;
if (image && image.startsWith("mxc://")) {
image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight);
}
@ -134,6 +134,10 @@ export default class LinkPreviewWidget extends React.Component {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget">
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div>
@ -142,10 +146,6 @@ export default class LinkPreviewWidget extends React.Component {
{ description }
</div>
</div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
</div>
);
}

View file

@ -109,9 +109,12 @@ function HangupButton(props) {
dis.dispatch({
action,
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId,
// hangup the call for this room. NB. We use the room in props as the room ID
// as call.roomId may be the 'virtual room', and the dispatch actions always
// use the user-facing room (there was a time when we deliberately used
// call.roomId and *not* props.roomId, but that was for the old
// style Freeswitch conference calls and those times are gone.)
room_id: props.roomId,
});
};
@ -423,7 +426,8 @@ export default class MessageComposer extends React.Component {
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
);
if (SettingsStore.getValue(UIFeature.Widgets)) {
if (SettingsStore.getValue(UIFeature.Widgets) &&
SettingsStore.getValue("MessageComposerInput.showStickersButton")) {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
}

View file

@ -60,8 +60,9 @@ const NewRoomIntro = () => {
{ caption && <p>{ caption }</p> }
</React.Fragment>;
} else {
const inRoom = room && room.getMyMembership() === "join";
const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
const canAddTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getUserId());
const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getUserId());
const onTopicClick = () => {
dis.dispatch({
@ -99,9 +100,25 @@ const NewRoomIntro = () => {
});
}
const onInviteClick = () => {
dis.dispatch({ action: "view_invite", roomId });
};
let canInvite = inRoom;
const powerLevels = room.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
const me = room.getMember(cli.getUserId());
if (powerLevels && me && powerLevels.invite > me.powerLevel) {
canInvite = false;
}
let buttons;
if (canInvite) {
const onInviteClick = () => {
dis.dispatch({ action: "view_invite", roomId });
};
buttons = <div className="mx_NewRoomIntro_buttons">
<AccessibleButton className="mx_NewRoomIntro_inviteButton" kind="primary" onClick={onInviteClick}>
{_t("Invite to this room")}
</AccessibleButton>
</div>
}
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
body = <React.Fragment>
@ -119,11 +136,7 @@ const NewRoomIntro = () => {
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>
{ buttons }
</React.Fragment>;
}

View file

@ -111,7 +111,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
appear={true} in={this.state.doAnimation} timeout={640}
classNames='mx_RoomBreadcrumbs'
>
<Toolbar className='mx_RoomBreadcrumbs'>
<Toolbar className='mx_RoomBreadcrumbs' aria-label={_t("Recently visited rooms")}>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>

View file

@ -46,6 +46,7 @@ import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -89,10 +90,44 @@ interface ITagAesthetics {
defaultHidden: boolean;
}
const TAG_AESTHETICS: {
interface ITagAestheticsMap {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: ITagAesthetics;
} = {
}
// If we have no dialer support, we just show the create chat dialog
const dmOnAddRoom = (dispatcher?: Dispatcher<ActionPayload>) => {
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
};
// If we have dialer support, show a context menu so the user can pick between
// the dialer and the create chat dialog
const dmAddRoomContextMenu = (onFinished: () => void) => {
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Start a Conversation")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.dispatch({action: "view_create_chat"});
}}
/>
<IconizedContextMenuOption
label={_t("Open dial pad")}
iconClassName="mx_RoomList_iconDialpad"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.OpenDialPad);
}}
/>
</IconizedContextMenuOptionList>;
};
const TAG_AESTHETICS: ITagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("Invites"),
isInvite: true,
@ -108,9 +143,8 @@ const TAG_AESTHETICS: {
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
onAddRoom: (dispatcher?: Dispatcher<ActionPayload>) => {
(dispatcher || defaultDispatcher).dispatch({action: 'view_create_chat'});
},
// Either onAddRoom or addRoomContextMenu are set depending on whether we
// have dialer support.
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
@ -178,6 +212,7 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef;
private customTagStoreRef;
private tagAesthetics: ITagAestheticsMap;
constructor(props: IProps) {
super(props);
@ -187,6 +222,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
};
// shallow-copy from the template as we need to make modifications to it
this.tagAesthetics = objectShallowClone(TAG_AESTHETICS);
this.updateDmAddRoomAction();
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
@ -202,6 +241,17 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
if (this.customTagStoreRef) this.customTagStoreRef.remove();
}
private updateDmAddRoomAction() {
const dmTagAesthetics = objectShallowClone(TAG_AESTHETICS[DefaultTagID.DM]);
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
dmTagAesthetics.addRoomContextMenu = dmAddRoomContextMenu;
} else {
dmTagAesthetics.onAddRoom = dmOnAddRoom;
}
this.tagAesthetics[DefaultTagID.DM] = dmTagAesthetics;
}
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
@ -214,6 +264,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
} else if (payload.action === Action.PstnSupportUpdated) {
this.updateDmAddRoomAction();
this.updateLists();
}
};
@ -355,7 +408,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: TAG_AESTHETICS[orderedTagId];
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push(<RoomSublist
@ -402,8 +455,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredLists = RoomListStore.instance.unfilteredLists
const unfilteredRooms = unfilteredLists[DefaultTagID.Untagged] || [];
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1) {
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Use the + to make a new room or explore existing ones below")}</div>
<AccessibleButton

View file

@ -29,7 +29,7 @@ import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore, ROOM_PREVIEW_CHANGED } from "../../../stores/room-list/MessagePreviewStore";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -51,7 +51,6 @@ import IconizedContextMenu, {
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps {
room: Room;
@ -99,12 +98,23 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(ROOM_PREVIEW_CHANGED, this.onRoomPreviewChanged);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CommunityPrototypeStore.instance.on(UPDATE_EVENT, this.onCommunityUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.on("Room.name", this.onRoomNameUpdate);
}
private onRoomNameUpdate = (room) => {
this.forceUpdate();
}
private onNotificationUpdate = () => {
@ -128,6 +138,26 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
if (prevProps.showMessagePreview !== this.props.showMessagePreview && this.showMessagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
if (prevProps.room?.roomId !== this.props.room?.roomId) {
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(prevProps.room),
this.onRoomPreviewChanged,
);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
CommunityPrototypeStore.instance.off(
CommunityPrototypeStore.getUpdateEventName(prevProps.room?.roomId),
this.onCommunityUpdate,
);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room?.roomId),
this.onCommunityUpdate,
);
prevProps.room?.off("Room.name", this.onRoomNameUpdate);
this.props.room?.on("Room.name", this.onRoomNameUpdate);
}
}
public componentDidMount() {
@ -140,11 +170,18 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
CommunityPrototypeStore.instance.off(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
defaultDispatcher.unregister(this.dispatcherRef);
MessagePreviewStore.instance.off(ROOM_PREVIEW_CHANGED, this.onRoomPreviewChanged);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
CommunityPrototypeStore.instance.off(UPDATE_EVENT, this.onCommunityUpdate);
}
private onAction = (payload: ActionPayload) => {

View file

@ -42,8 +42,12 @@ import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
import {containsEmoji} from "../../../effects/utils";
import {CHAT_EFFECTS} from '../../../effects';
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -89,6 +93,24 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) {
return content;
}
// exported for tests
export function isQuickReaction(model) {
const parts = model.parts;
if (parts.length == 0) return false;
const text = textSerialize(model);
// shortcut takes the form "+:emoji:" or "+ :emoji:""
// can be in 1 or 2 parts
if (parts.length <= 2) {
const hasShortcut = text.startsWith("+") || text.startsWith("+ ");
const emojiMatch = text.match(EMOJI_REGEX);
if (hasShortcut && emojiMatch && emojiMatch.length == 1) {
return emojiMatch[0] === text.substring(1) ||
emojiMatch[0] === text.substring(2);
}
}
return false;
}
export default class SendMessageComposer extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
@ -134,13 +156,14 @@ export default class SendMessageComposer extends React.Component {
this.onVerticalArrow(event, true);
} else if (event.key === Key.ARROW_DOWN) {
this.onVerticalArrow(event, false);
} else if (this._prepareToEncrypt) {
this._prepareToEncrypt();
} else if (event.key === Key.ESCAPE) {
dis.dispatch({
action: 'reply_to_event',
event: null,
});
} else if (this._prepareToEncrypt) {
// This needs to be last!
this._prepareToEncrypt();
}
};
@ -221,6 +244,41 @@ export default class SendMessageComposer extends React.Component {
return false;
}
_sendQuickReaction() {
const timeline = this.props.room.getLiveTimeline();
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === "m.room.message") {
let shouldReact = true;
const lastMessage = events[i];
const userId = MatrixClientPeg.get().getUserId();
const messageReactions = this.props.room.getUnfilteredTimelineSet()
.getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction");
// if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) {
const myReactionEvents = messageReactions.getAnnotationsBySender()[userId] || [];
const myReactionKeys = [...myReactionEvents]
.filter(event => !event.isRedacted())
.map(event => event.getRelation().key);
shouldReact = !myReactionKeys.includes(reaction);
}
if (shouldReact) {
MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": lastMessage.getId(),
"key": reaction,
},
});
dis.dispatch({action: "message_sent"});
}
break;
}
}
}
_getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
@ -308,6 +366,11 @@ export default class SendMessageComposer extends React.Component {
}
}
if (isQuickReaction(this.model)) {
shouldSend = false;
this._sendQuickReaction();
}
const replyToEvent = this.props.replyToEvent;
if (shouldSend) {
const startTime = CountlyAnalytics.getTimestamp();
@ -326,6 +389,11 @@ export default class SendMessageComposer extends React.Component {
});
}
dis.dispatch({action: "message_sent"});
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content, effect.emojis)) {
dis.dispatch({action: `effects.${effect.command}`});
}
});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}
@ -335,6 +403,7 @@ export default class SendMessageComposer extends React.Component {
this._editorRef.clearUndoHistory();
this._editorRef.focus();
this._clearStoredEditorState();
dis.dispatch({action: "scroll_to_bottom"});
}
componentWillUnmount() {

View file

@ -264,7 +264,7 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}

View file

@ -81,7 +81,7 @@ export default class WhoIsTypingTile extends React.Component {
};
onRoomTimeline = (event, room) => {
if (room && room.roomId === this.props.room.roomId) {
if (room?.roomId === this.props.room?.roomId) {
const userId = event.getSender();
// remove user from usersTyping
const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId);

View file

@ -65,7 +65,7 @@ const AvatarSetting = ({avatarUrl, avatarAltText, avatarName, uploadAvatar, remo
const avatarClasses = classNames({
"mx_AvatarSetting_avatar": true,
"mx_AvatarSetting_avatar_hovering": isHovering,
"mx_AvatarSetting_avatar_hovering": isHovering && uploadAvatar,
});
return <div className={avatarClasses}>
{avatarElement}

View file

@ -1,115 +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 {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import Pill from "../elements/Pill";
import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import BaseAvatar from "../avatars/BaseAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
@replaceableComponent("views.settings.BridgeTile")
export default class BridgeTile extends React.PureComponent {
static propTypes = {
ev: PropTypes.object.isRequired,
room: PropTypes.object.isRequired,
}
state = {
visible: false,
}
_toggleVisible() {
this.setState({
visible: !this.state.visible,
});
}
render() {
const content = this.props.ev.getContent();
const { channel, network, protocol } = content;
const protocolName = protocol.displayname || protocol.id;
const channelName = channel.displayname || channel.id;
const networkName = network ? network.displayname || network.id : protocolName;
let creator = null;
if (content.creator) {
creator = _t("This bridge was provisioned by <user />.", {}, {
user: <Pill
type={Pill.TYPE_USER_MENTION}
room={this.props.room}
url={makeUserPermalink(content.creator)}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>,
});
}
const bot = _t("This bridge is managed by <user />.", {}, {
user: <Pill
type={Pill.TYPE_USER_MENTION}
room={this.props.room}
url={makeUserPermalink(this.props.ev.getSender())}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>,
});
let networkIcon;
if (protocol.avatar) {
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
protocol.avatar, 64, 64, "crop",
);
networkIcon = <BaseAvatar className="protocol-icon"
width={48}
height={48}
resizeMethod='crop'
name={ protocolName }
idName={ protocolName }
url={ avatarUrl }
/>;
} else {
networkIcon = <div class="noProtocolIcon"></div>;
}
const id = this.props.ev.getId();
const metadataClassname = "metadata" + (this.state.visible ? " visible" : "");
return (<li key={id}>
<div className="column-icon">
{networkIcon}
</div>
<div className="column-data">
<h3>{protocolName}</h3>
<p className="workspace-channel-details">
<span>{_t("Workspace: %(networkName)s", {networkName})}</span>
<span className="channel">{_t("Channel: %(channelName)s", {channelName})}</span>
</p>
<p className={metadataClassname}>
{creator} {bot}
</p>
<AccessibleButton className="mx_showMore" kind="secondary" onClick={this._toggleVisible.bind(this)}>
{ this.state.visible ? _t("Show less") : _t("Show more") }
</AccessibleButton>
</div>
</li>);
}
}

View file

@ -0,0 +1,167 @@
/*
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 {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import Pill from "../elements/Pill";
import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import BaseAvatar from "../avatars/BaseAvatar";
import SettingsStore from "../../../settings/SettingsStore";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { isUrlPermitted } from '../../../HtmlUtils';
interface IProps {
ev: MatrixEvent;
room: Room;
}
/**
* This should match https://github.com/matrix-org/matrix-doc/blob/hs/msc-bridge-inf/proposals/2346-bridge-info-state-event.md#mbridge
*/
interface IBridgeStateEvent {
bridgebot: string;
creator?: string;
protocol: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
network?: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
channel: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
}
export default class BridgeTile extends React.PureComponent<IProps> {
static propTypes = {
ev: PropTypes.object.isRequired,
room: PropTypes.object.isRequired,
}
render() {
const content: IBridgeStateEvent = this.props.ev.getContent();
// Validate
if (!content.channel?.id || !content.protocol?.id) {
console.warn(`Bridge info event ${this.props.ev.getId()} has missing content. Tile will not render`);
return null;
}
if (!content.bridgebot) {
// Bridgebot was not required previously, so in order to not break rooms we are allowing
// the sender to be used in place. When the proposal is merged, this should be removed.
console.warn(`Bridge info event ${this.props.ev.getId()} does not provide a 'bridgebot' key which`
+ "is deprecated behaviour. Using sender for now.");
content.bridgebot = this.props.ev.getSender();
}
const { channel, network, protocol } = content;
const protocolName = protocol.displayname || protocol.id;
const channelName = channel.displayname || channel.id;
let creator = null;
if (content.creator) {
creator = <li>{_t("This bridge was provisioned by <user />.", {}, {
user: () => <Pill
type={Pill.TYPE_USER_MENTION}
room={this.props.room}
url={makeUserPermalink(content.creator)}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>,
})}</li>;
}
const bot = <li>{_t("This bridge is managed by <user />.", {}, {
user: () => <Pill
type={Pill.TYPE_USER_MENTION}
room={this.props.room}
url={makeUserPermalink(content.bridgebot)}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>,
})}</li>;
let networkIcon;
if (protocol.avatar_url) {
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
protocol.avatar_url, 64, 64, "crop",
);
networkIcon = <BaseAvatar className="protocol-icon"
width={48}
height={48}
resizeMethod='crop'
name={ protocolName }
idName={ protocolName }
url={ avatarUrl }
/>;
} else {
networkIcon = <div className="noProtocolIcon"></div>;
}
let networkItem = null;
if (network) {
const networkName = network.displayname || network.id;
let networkLink = <span>{networkName}</span>;
if (typeof network.external_url === "string" && isUrlPermitted(network.external_url)) {
networkLink = <a href={network.external_url} target="_blank" rel="noreferrer noopener">{networkName}</a>
}
networkItem = _t("Workspace: <networkLink/>", {}, {
networkLink: () => networkLink,
});
}
let channelLink = <span>{channelName}</span>;
if (typeof channel.external_url === "string" && isUrlPermitted(channel.external_url)) {
channelLink = <a href={channel.external_url} target="_blank" rel="noreferrer noopener">{channelName}</a>
}
const id = this.props.ev.getId();
return (<li key={id}>
<div className="column-icon">
{networkIcon}
</div>
<div className="column-data">
<h3>{protocolName}</h3>
<p className="workspace-channel-details">
{networkItem}
<span className="channel">{_t("Channel: <channelLink/>", {}, {
channelLink: () => channelLink,
})}</span>
</p>
<ul className="metadata">
{creator} {bot}
</ul>
</div>
</li>);
}
}

View file

@ -52,19 +52,23 @@ export default class ProfileSettings extends React.Component {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.setState({
avatarUrl: undefined,
avatarFile: undefined,
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
});
};
_clearProfile = async (e) => {
_cancelProfileChanges = async (e) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this._removeAvatar();
this.setState({enableProfileSave: false, displayName: this.state.originalDisplayName});
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
});
};
_saveProfile = async (e) => {
@ -186,7 +190,7 @@ export default class ProfileSettings extends React.Component {
</div>
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._clearProfile}
onClick={this._cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>

View file

@ -424,7 +424,7 @@ export default class SecureBackupPanel extends React.PureComponent {
<p>{_t(
"Back up your encryption keys with your account data in case you " +
"lose access to your sessions. Your keys will be secured with a " +
"unique Recovery Key.",
"unique Security Key.",
)}</p>
{statusDescription}
<details>

View file

@ -28,15 +28,14 @@ import { FontWatcher } from "../../../../../settings/watchers/FontWatcher";
import { RecheckThemePayload } from '../../../../../dispatcher/payloads/RecheckThemePayload';
import { Action } from '../../../../../dispatcher/actions';
import { IValidationResult, IFieldState } from '../../../elements/Validation';
import StyledRadioButton from '../../../elements/StyledRadioButton';
import StyledCheckbox from '../../../elements/StyledCheckbox';
import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import classNames from 'classnames';
import { SettingLevel } from "../../../../../settings/SettingLevel";
import {UIFeature} from "../../../../../settings/UIFeature";
import {Layout} from "../../../../../settings/Layout";
interface IProps {
}
@ -62,7 +61,7 @@ interface IState extends IThemeState {
useSystemFont: boolean;
systemFont: string;
showAdvanced: boolean;
useIRCLayout: boolean;
layout: Layout;
}
@ -83,7 +82,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
};
}
@ -213,15 +212,15 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
this.setState({customThemeUrl: e.target.value});
};
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const val = e.target.value === "true";
this.setState({
useIRCLayout: val,
});
SettingsStore.setValue("useIRCLayout", null, SettingLevel.DEVICE, val);
};
private onIRCLayoutChange = (enabled: boolean) => {
if (enabled) {
this.setState({layout: Layout.IRC});
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
} else {
this.setState({layout: Layout.Group});
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
}
}
private renderThemeSection() {
const themeWatcher = new ThemeWatcher();
@ -306,7 +305,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<EventTilePreview
className="mx_AppearanceUserSettingsTab_fontSlider_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={this.state.useIRCLayout}
layout={this.state.layout}
/>
<div className="mx_AppearanceUserSettingsTab_fontSlider">
<div className="mx_AppearanceUserSettingsTab_fontSlider_smallText">Aa</div>
@ -342,50 +341,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div>;
}
private renderLayoutSection = () => {
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
<span className="mx_SettingsTab_subheading">{_t("Message layout")}</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.useIRCLayout,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={true}
/>
<StyledRadioButton
name="layout"
value="true"
checked={this.state.useIRCLayout}
onChange={this.onLayoutChange}
>
{_t("Compact")}
</StyledRadioButton>
</div>
<div className="mx_AppearanceUserSettingsTab_spacer" />
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: !this.state.useIRCLayout,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={false}
/>
<StyledRadioButton
name="layout"
value="false"
checked={!this.state.useIRCLayout}
onChange={this.onLayoutChange}
>
{_t("Modern")}
</StyledRadioButton>
</div>
</div>
</div>;
};
private renderAdvancedSection() {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
@ -409,14 +364,15 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.useIRCLayout}
/>
<SettingsFlag
name="useIRCLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({useIRCLayout: checked})}
disabled={this.state.layout == Layout.IRC}
/>
<StyledCheckbox
checked={this.state.layout == Layout.IRC}
onChange={(ev) => this.onIRCLayoutChange(ev.target.checked)}
>
{_t("Enable experimental, compact IRC style layout")}
</StyledCheckbox>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}

View file

@ -67,7 +67,6 @@ export default class LabsUserSettingsTab extends React.Component {
<SettingsFlag name={"enableWidgetScreenshots"} level={SettingLevel.ACCOUNT} />
<SettingsFlag name={"showHiddenEventsInTimeline"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"lowBandwidth"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"sendReadReceipts"} level={SettingLevel.ACCOUNT} />
<SettingsFlag name={"advancedRoomListLogging"} level={SettingLevel.DEVICE} />
</div>
</div>

View file

@ -34,6 +34,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.showStickersButton',
];
static TIMELINE_SETTINGS = [
@ -46,11 +47,15 @@ export default class PreferencesUserSettingsTab extends React.Component {
'alwaysShowTimestamps',
'showRedactions',
'enableSyntaxHighlightLanguageDetection',
'expandCodeByDefault',
'showCodeLineNumbers',
'showJoinLeaves',
'showAvatarChanges',
'showDisplaynameChanges',
'showImages',
'showChatEffects',
'Pill.shouldShowPillAvatar',
'ctrlFForSearch',
];
static GENERAL_SETTINGS = [

View file

@ -24,7 +24,8 @@ 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/src/webrtc/call';
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -40,9 +41,50 @@ interface IProps {
interface IState {
roomId: string;
activeCall: MatrixCall;
// The main call that we are displaying (ie. not including the call in the room being viewed, if any)
primaryCall: MatrixCall;
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to
secondaryCall: MatrixCall;
}
// Splits a list of calls into one 'primary' one and a list
// (which should be a single element) of other calls.
// The primary will be the one not on hold, or an arbitrary one
// if they're all on hold)
function getPrimarySecondaryCalls(calls: MatrixCall[]): [MatrixCall, MatrixCall[]] {
let primary: MatrixCall = null;
let secondaries: MatrixCall[] = [];
for (const call of calls) {
if (!SHOW_CALL_IN_STATES.includes(call.state)) continue;
if (!call.isRemoteOnHold() && primary === null) {
primary = call;
} else {
secondaries.push(call);
}
}
if (primary === null && secondaries.length > 0) {
primary = secondaries[0];
secondaries = secondaries.slice(1);
}
if (secondaries.length > 1) {
// We should never be in more than two calls so this shouldn't happen
console.log("Found more than 1 secondary call! Other calls will not be shown.");
}
return [primary, secondaries];
}
/**
* CallPreview shows a small version of CallView hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
*/
export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: any;
private dispatcherRef: string;
@ -51,18 +93,27 @@ export default class CallPreview extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const roomId = RoomViewStore.getRoomId();
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(roomId),
);
this.state = {
roomId: RoomViewStore.getRoomId(),
activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
roomId,
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
};
}
public componentDidMount() {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
}
public componentWillUnmount() {
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
@ -72,8 +123,16 @@ export default class CallPreview extends React.Component<IProps, IState> {
private onRoomViewStoreUpdate = (payload) => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
const roomId = RoomViewStore.getRoomId();
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(roomId),
);
this.setState({
roomId: RoomViewStore.getRoomId(),
roomId,
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
};
@ -81,38 +140,35 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
case 'call_state':
case 'call_state': {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
this.setState({
activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
break;
}
}
};
private onCallViewClick = () => {
const call = CallHandler.sharedInstance().getAnyActiveCall();
if (call) {
dis.dispatch({
action: 'view_room',
room_id: call.roomId,
});
}
};
public render() {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = (
this.state.activeCall &&
SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
!callForRoom
private onCallRemoteHold = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
if (showCall) {
this.setState({
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
}
public render() {
if (this.state.primaryCall) {
return (
<CallView
onClick={this.onCallViewClick}
showHangup={true}
/>
<CallView call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} pipMode={true} />
);
}

View file

@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import React, { createRef, CSSProperties } from 'react';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import VideoFeed, { VideoFeedType } from "./VideoFeed";
import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
@ -28,33 +27,41 @@ import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
import {alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton} from '../../structures/ContextMenu';
import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
interface IProps {
// js-sdk room object. If set, we will only show calls for the given
// room; if not, we will show any active call.
room?: Room;
// The call for us to display
call: MatrixCall,
// Another ongoing call to display information about
secondaryCall?: MatrixCall,
// maxHeight style attribute for the video panel
maxVideoHeight?: number;
// a callback which is called when the user clicks on the video div
onClick?: React.MouseEventHandler;
// a callback which is called when the content in the callview changes
// in a way that is likely to cause a resize.
onResize?: any;
// Whether to show the hang up icon:W
showHangup?: boolean;
// Whether this call view is for picture-in-pictue mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
}
interface IState {
call: MatrixCall;
isLocalOnHold: boolean,
isRemoteOnHold: boolean,
micMuted: boolean,
vidMuted: boolean,
callState: CallState,
controlsVisible: boolean,
showMoreMenu: boolean,
showDialpad: boolean,
}
function getFullScreenElement() {
@ -89,25 +96,32 @@ 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;
const BOTTOM_PADDING = 10;
const BOTTOM_MARGIN_TOP_BOTTOM = 10; // top margin plus bottom margin
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
constructor(props: IProps) {
super(props);
const call = this.getCall();
this.state = {
call,
isLocalOnHold: call ? call.isLocalOnHold() : null,
micMuted: call ? call.isMicrophoneMuted() : null,
vidMuted: call ? call.isLocalVideoMuted() : null,
callState: call ? call.state : null,
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
callState: this.props.call.state,
controlsVisible: true,
showMoreMenu: false,
showDialpad: false,
}
this.updateCallListeners(null, call);
this.updateCallListeners(null, this.props.call);
}
public componentDidMount() {
@ -116,11 +130,29 @@ export default class CallView extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
if (getFullScreenElement()) {
exitFullscreen();
}
document.removeEventListener("keydown", this.onNativeKeyDown);
this.updateCallListeners(this.state.call, null);
this.updateCallListeners(this.props.call, null);
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (this.props.call === prevProps.call) return;
this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
callState: this.props.call.state,
});
this.updateCallListeners(null, this.props.call);
}
private onAction = (payload) => {
switch (payload.action) {
case 'video_fullscreen': {
@ -134,66 +166,41 @@ export default class CallView extends React.Component<IProps, IState> {
}
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;
}
}
};
private getCall(): MatrixCall {
let call: MatrixCall;
if (this.props.room) {
const roomId = this.props.room.roomId;
call = CallHandler.sharedInstance().getCallForRoom(roomId);
} else {
call = CallHandler.sharedInstance().getAnyActiveCall();
// Ignore calls if we can't get the room associated with them.
// I think the underlying problem is that the js-sdk sends events
// for calls before it has made the rooms available in the store,
// although this isn't confirmed.
if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
call = null;
}
}
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
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);
if (oldCall) {
oldCall.removeListener(CallEvent.State, this.onCallState);
oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
}
if (newCall) {
newCall.on(CallEvent.State, this.onCallState);
newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
}
}
private onCallHoldUnhold = () => {
private onCallState = (state) => {
this.setState({
isLocalOnHold: this.state.call ? this.state.call.isLocalOnHold() : null,
callState: state,
});
};
private onCallLocalHoldUnhold = () => {
this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(),
});
};
private onCallRemoteHoldUnhold = () => {
this.setState({
isRemoteOnHold: this.props.call.isRemoteOnHold(),
// update both here because isLocalOnHold changes when we hold the call too
isLocalOnHold: this.props.call.isLocalOnHold(),
});
};
@ -205,9 +212,10 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onExpandClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
room_id: userFacingRoomId,
});
};
@ -223,6 +231,8 @@ export default class CallView extends React.Component<IProps, IState> {
}
private showControls() {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
@ -234,24 +244,69 @@ export default class CallView extends React.Component<IProps, IState> {
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onMicMuteClick = () => {
if (!this.state.call) return;
private onDialpadClick = () => {
if (!this.state.showDialpad) {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
this.setState({
showDialpad: true,
controlsVisible: true,
});
} else {
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
this.setState({
showDialpad: false,
});
}
}
private onMicMuteClick = () => {
const newVal = !this.state.micMuted;
this.state.call.setMicrophoneMuted(newVal);
this.props.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.props.call.setLocalVideoMuted(newVal);
this.setState({vidMuted: newVal});
}
private onMoreClick = () => {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
this.setState({
showMoreMenu: true,
controlsVisible: true,
});
}
private closeDialpad = () => {
this.setState({
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private closeContextMenu = () => {
this.setState({
showMoreMenu: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
// 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
@ -286,116 +341,234 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
room_id: userFacingRoomId,
});
}
private onSecondaryRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
}
private onCallResumeClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
}
public render() {
if (!this.state.call) return null;
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
const callRoomId = CallHandler.roomIdForCall(this.props.call);
const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
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,
});
let dialPad;
let contextMenu;
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>;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
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.props.call.type === CallType.Video ? <AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
// The dial pad & 'more' button actions are only relevant in a connected call
// When not connected, we have to put something there to make the flexbox alignment correct
const dialpadButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_dialpad_hidden" />;
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
// because something needs to have margin-right: auto to make the alignment correct.
const callControls = <div className={callControlsClasses}>
{dialpadButton}
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: callRoomId,
});
}}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{contextMenuButton}
</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) {
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
let onHoldText = null;
if (this.state.isRemoteOnHold) {
const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ?
_td("You held the call <a>Switch</a>") : _td("You held the call <a>Resume</a>");
onHoldText = _t(holdString, {}, {
a: sub => <AccessibleButton kind="link" onClick={this.onCallResumeClick}>
{sub}
</AccessibleButton>,
});
} else if (this.state.isLocalOnHold) {
onHoldText = _t("%(peerName)s held the call", {
peerName: this.props.call.getOpponentMember().name,
});
}
if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldContent = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const containerClasses = classNames({
mx_CallView_video: true,
mx_CallView_video_hold: isOnHold,
});
if (isOnHold) {
onHoldContent = <div className="mx_CallView_video_holdContent">
{onHoldText}
</div>;
const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here?
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
);
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
}
if (!this.state.vidMuted) {
localVideoFeed = <VideoFeed type={VideoFeedType.Local} call={this.props.call} />;
}
// 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}
const maxVideoHeight = getFullScreenElement() ? null : (
this.props.maxVideoHeight - (HEADER_HEIGHT + BOTTOM_PADDING + BOTTOM_MARGIN_TOP_BOTTOM)
);
contentView = <div className={containerClasses}
ref={this.contentRef} onMouseMove={this.onMouseMove}
// Put the max height on here too because this div is ended up 4px larger than the content
// and is causing it to scroll, and I am genuinely baffled as to why.
style={{maxHeight: maxVideoHeight}}
>
{onHoldBackground}
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
{localVideoFeed}
{onHoldContent}
{callControls}
</div>;
} else {
const avatarSize = this.props.room ? 200 : 75;
contentView = <div className="mx_CallView_voice" onMouseMove={this.onMouseMove}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
const avatarSize = this.props.pipMode ? 76 : 160;
const classes = classNames({
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
});
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
</div>
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
{callControls}
</div>;
}
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
const callTypeText = this.props.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.state.call.type === CallType.Video && this.props.room) {
if (this.props.call.type === CallType.Video && !this.props.pipMode) {
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
/>;
}
let expandButton;
if (!this.props.room) {
if (this.props.pipMode) {
expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} title={_t("Return to call")}
/>;
@ -407,7 +580,7 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
let header: React.ReactNode;
if (this.props.room) {
if (!this.props.pipMode) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon"></div>
<span className="mx_CallView_header_callType">{callTypeText}</span>
@ -415,13 +588,28 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
myClassName = 'mx_CallView_large';
} else {
let secondaryCallInfo;
if (this.props.secondaryCall) {
secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{_t("%(name)s on hold", { name: secCallRoom.name })}
</span>
</AccessibleButton>
</span>;
}
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div>
<div className="mx_CallView_header_callInfo">
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
<div className="mx_CallView_header_callTypeSmall">
{callTypeText}
{secondaryCallInfo}
</div>
</div>
{headerControls}
</div>;
@ -431,6 +619,8 @@ export default class CallView extends React.Component<IProps, IState> {
return <div className={"mx_CallView " + myClassName}>
{header}
{contentView}
{dialPad}
{contextMenu}
</div>;
}
}

View file

@ -0,0 +1,87 @@
/*
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 { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react';
import CallHandler from '../../../CallHandler';
import CallView from './CallView';
import dis from '../../../dispatcher/dispatcher';
interface IProps {
// What room we should display the call for
roomId: string,
// maxHeight style attribute for the video panel
maxVideoHeight?: number;
// a callback which is called when the content in the callview changes
// in a way that is likely to cause a resize.
onResize?: any;
}
interface IState {
call: MatrixCall,
}
/*
* Wrapper for CallView that always display the call in a given room,
* or nothing if there is no call in that room.
*/
export default class CallViewForRoom extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = {
call: this.getCall(),
};
}
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
private onAction = (payload) => {
switch (payload.action) {
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.setState({call: newCall});
}
break;
}
}
};
private getCall(): MatrixCall {
const call = CallHandler.sharedInstance().getCallForRoom(this.props.roomId);
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
public render() {
if (!this.state.call) return null;
return <CallView call={this.state.call} pipMode={false}
onResize={this.props.onResize} maxVideoHeight={this.props.maxVideoHeight}
/>;
}
}

View file

@ -0,0 +1,85 @@
/*
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 AccessibleButton from "../elements/AccessibleButton";
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
enum DialPadButtonKind {
Digit,
Delete,
Dial,
}
interface IButtonProps {
kind: DialPadButtonKind;
digit?: string;
onButtonPress: (string) => void;
}
class DialPadButton extends React.PureComponent<IButtonProps> {
onClick = () => {
this.props.onButtonPress(this.props.digit);
}
render() {
switch (this.props.kind) {
case DialPadButtonKind.Digit:
return <AccessibleButton className="mx_DialPad_button" onClick={this.onClick}>
{this.props.digit}
</AccessibleButton>;
case DialPadButtonKind.Delete:
return <AccessibleButton className="mx_DialPad_button mx_DialPad_deleteButton"
onClick={this.onClick}
/>;
case DialPadButtonKind.Dial:
return <AccessibleButton className="mx_DialPad_button mx_DialPad_dialButton" onClick={this.onClick} />;
}
}
}
interface IProps {
onDigitPress: (string) => void;
hasDialAndDelete: boolean;
onDeletePress?: (string) => void;
onDialPress?: (string) => void;
}
export default class Dialpad extends React.PureComponent<IProps> {
render() {
const buttonNodes = [];
for (const button of BUTTONS) {
buttonNodes.push(<DialPadButton key={button} kind={DialPadButtonKind.Digit}
digit={button} onButtonPress={this.props.onDigitPress}
/>);
}
if (this.props.hasDialAndDelete) {
buttonNodes.push(<DialPadButton key="del" kind={DialPadButtonKind.Delete}
onButtonPress={this.props.onDeletePress}
/>);
buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
onButtonPress={this.props.onDialPress}
/>);
}
return <div className="mx_DialPad">
{buttonNodes}
</div>;
}
}

View file

@ -0,0 +1,111 @@
/*
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 { ensureDMExists } from "../../../createRoom";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import dis from '../../../dispatcher/dispatcher';
import Modal from "../../../Modal";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import CallHandler from "../../../CallHandler";
interface IProps {
onFinished: (boolean) => void;
}
interface IState {
value: string;
}
export default class DialpadModal extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.state = {
value: '',
}
}
onCancelClick = () => {
this.props.onFinished(false);
}
onChange = (ev) => {
this.setState({value: ev.target.value});
}
onFormSubmit = (ev) => {
ev.preventDefault();
this.onDialPress();
}
onDigitPress = (digit) => {
this.setState({value: this.state.value + digit});
}
onDeletePress = () => {
if (this.state.value.length === 0) return;
this.setState({value: this.state.value.slice(0, -1)});
}
onDialPress = async () => {
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to look up phone number"),
description: _t("There was an error looking up the phone number"),
});
}
const userId = results[0].userid;
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
this.props.onFinished(true);
}
render() {
return <div className="mx_DialPadModal">
<div className="mx_DialPadModal_header">
<div>
<span className="mx_DialPadModal_title">{_t("Dial pad")}</span>
<AccessibleButton className="mx_DialPadModal_cancel" onClick={this.onCancelClick} />
</div>
<form onSubmit={this.onFormSubmit}>
<Field className="mx_DialPadModal_field" id="dialpad_number"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</form>
</div>
<div className="mx_DialPadModal_horizSep" />
<div className="mx_DialPadModal_dialPad">
<DialPad hasDialAndDelete={true}
onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
onDialPress={this.onDialPress}
/>
</div>
</div>;
}
}

View file

@ -24,7 +24,7 @@ import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler from '../../../CallHandler';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
interface IProps {
}
@ -70,7 +70,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
e.stopPropagation();
dis.dispatch({
action: 'answer',
room_id: this.state.incomingCall.roomId,
room_id: CallHandler.roomIdForCall(this.state.incomingCall),
});
};
@ -78,7 +78,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
e.stopPropagation();
dis.dispatch({
action: 'reject',
room_id: this.state.incomingCall.roomId,
room_id: CallHandler.roomIdForCall(this.state.incomingCall),
});
};
@ -89,7 +89,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
let room = null;
if (this.state.incomingCall) {
room = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId);
room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall));
}
const caller = room ? room.name : _t("Unknown caller");

View file

@ -42,10 +42,12 @@ export default class VideoFeed extends React.Component<IProps> {
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);
this.setVideoElement();
}
componentDidUpdate(prevProps) {
if (this.props.call !== prevProps.call) {
this.setVideoElement();
}
}
@ -53,6 +55,14 @@ export default class VideoFeed extends React.Component<IProps> {
this.vid.current.removeEventListener('resize', this.onResize);
}
private setVideoElement() {
if (this.props.type === VideoFeedType.Local) {
this.props.call.setLocalVideoElement(this.vid.current);
} else {
this.props.call.setRemoteVideoElement(this.vid.current);
}
}
onResize = (e) => {
if (this.props.onResize) {
this.props.onResize(e);