Merge branch 'develop' into feature-surround-with

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-02-12 07:56:44 +01:00
commit 8eb0707bba
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
227 changed files with 17274 additions and 6142 deletions

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

@ -20,6 +20,8 @@ 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;
@ -46,14 +48,30 @@ export default class CallContextMenu extends React.Component<IProps> {
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

@ -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

@ -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

@ -41,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
@ -310,6 +312,9 @@ interface IInviteDialogProps {
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: string,
// The call to transfer. Only required for KIND_CALL_TRANSFER.
call: MatrixCall,
// Initial value to populate the filter with
initialText: string,
}
@ -345,6 +350,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
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']]);
@ -702,6 +709,29 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
});
};
_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();
@ -1217,7 +1247,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
buttonText = _t("Go");
goButtonFn = this._startDm;
} else { // KIND_INVITE
} else if (this.props.kind === KIND_INVITE) {
title = _t("Invite to this room");
if (identityServersEnabled) {
@ -1251,6 +1281,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
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

@ -151,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() {

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

@ -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

@ -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,7 +58,6 @@ 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 = () => {
@ -45,30 +65,35 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
};
let icon;
if (typeof idp?.icon === "string" && (idp.icon.startsWith("mxc://") || idp.icon.startsWith("https://"))) {
icon = <img
src={matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true)}
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>
@ -83,6 +108,8 @@ interface IProps {
primary?: boolean;
}
const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow["org.matrix.msc2858.identity_providers"] || [];
if (providers.length < 2) {
@ -97,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

@ -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

@ -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,13 +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;
@ -76,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;
};
@ -102,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;
@ -120,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) {
@ -184,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>

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

@ -562,7 +562,7 @@ 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);

View file

@ -37,6 +37,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 +66,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',

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

@ -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

@ -156,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();
}
};
@ -402,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

@ -35,6 +35,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.surroundWith',
'MessageComposerInput.showStickersButton',
];
static TIMELINE_SETTINGS = [
@ -47,12 +48,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

@ -15,11 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, CSSProperties, ReactNode } from 'react';
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';
@ -27,9 +27,10 @@ import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
import {aboveLeftOf, ChevronFace, ContextMenuButton} from '../../structures/ContextMenu';
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 {
// The call for us to display
@ -60,6 +61,7 @@ interface IState {
callState: CallState,
controlsVisible: boolean,
showMoreMenu: boolean,
showDialpad: boolean,
}
function getFullScreenElement() {
@ -102,6 +104,7 @@ 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) {
@ -115,6 +118,7 @@ export default class CallView extends React.Component<IProps, IState> {
callState: this.props.call.state,
controlsVisible: true,
showMoreMenu: false,
showDialpad: false,
}
this.updateCallListeners(null, this.props.call);
@ -208,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.props.call.roomId,
room_id: userFacingRoomId,
});
};
@ -226,7 +231,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
private showControls() {
if (this.state.showMoreMenu) return;
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
@ -239,6 +244,29 @@ export default class CallView extends React.Component<IProps, IState> {
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
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;
@ -265,6 +293,13 @@ export default class CallView extends React.Component<IProps, IState> {
});
}
private closeDialpad = () => {
this.setState({
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private closeContextMenu = () => {
this.setState({
showMoreMenu: false,
@ -306,37 +341,52 @@ 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.props.call.roomId,
room_id: userFacingRoomId,
});
}
private onSecondaryRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
dis.dispatch({
action: 'view_room',
room_id: this.props.secondaryCall.roomId,
room_id: userFacingRoomId,
});
}
private onCallResumeClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
}
private onSecondaryCallResumeClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.secondaryCall.roomId);
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
}
public render() {
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.props.call.roomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(this.props.secondaryCall.roomId) : null;
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 dialPad;
let contextMenu;
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
{...aboveLeftOf(
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
@ -384,8 +434,15 @@ export default class CallView extends React.Component<IProps, IState> {
onClick={this.onVidMuteClick}
/> : null;
// The 'more' button actions are only relevant in a connected call
// 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}
@ -396,7 +453,7 @@ export default class CallView extends React.Component<IProps, IState> {
// 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}>
<div className="mx_CallView_callControls_button mx_CallView_callControls_nothing" />
{dialpadButton}
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
@ -406,7 +463,7 @@ export default class CallView extends React.Component<IProps, IState> {
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.props.call.roomId,
room_id: callRoomId,
});
}}
/>
@ -423,7 +480,9 @@ export default class CallView extends React.Component<IProps, IState> {
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
let onHoldText = null;
if (this.state.isRemoteOnHold) {
onHoldText = _t("You held the call <a>Resume</a>", {}, {
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>,
@ -435,6 +494,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldContent = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
@ -453,6 +513,9 @@ export default class CallView extends React.Component<IProps, IState> {
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 : (
@ -468,7 +531,7 @@ export default class CallView extends React.Component<IProps, IState> {
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.props.call} />
{localVideoFeed}
{onHoldContent}
{callControls}
</div>;
@ -478,20 +541,6 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
});
let secondaryCallAvatar: ReactNode;
if (this.props.secondaryCall) {
const secAvatarSize = this.props.pipMode ? 40 : 100;
secondaryCallAvatar = <div className="mx_CallView_voice_secondaryAvatarContainer"
style={{width: secAvatarSize, height: secAvatarSize}}
>
<RoomAvatar
room={secCallRoom}
height={secAvatarSize}
width={secAvatarSize}
/>
</div>;
}
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
@ -502,7 +551,6 @@ export default class CallView extends React.Component<IProps, IState> {
width={avatarSize}
/>
</div>
{secondaryCallAvatar}
</div>
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
{callControls}
@ -546,7 +594,7 @@ export default class CallView extends React.Component<IProps, IState> {
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{_t("%(name)s paused", { name: secCallRoom.name })}
{_t("%(name)s on hold", { name: secCallRoom.name })}
</span>
</AccessibleButton>
</span>;
@ -571,6 +619,7 @@ 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,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,112 @@
/*
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";
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 MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
'm.id.phone': 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

@ -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");