Merge remote-tracking branch 'upstream/develop' into feature/image-view-load-anim/18186

This commit is contained in:
Šimon Brandner 2021-09-21 17:36:26 +02:00
commit 7022ab4f8a
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
144 changed files with 4658 additions and 2660 deletions

View file

@ -15,43 +15,48 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component {
static propTypes = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
width: 40,
height: 40,
resizeMethod: 'crop',
};
private button = createRef<HTMLDivElement>();
constructor(props) {
constructor(props: IProps) {
super(props);
this.state = {
hasStatus: this.hasStatus,
menuDisplayed: false,
};
this._button = createRef();
}
componentDidMount() {
public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
componentWillUnmount() {
public componentWillUnmount(): void {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
this.onStatusMessageCommitted,
);
}
get hasStatus() {
private get hasStatus(): boolean {
const { user } = this.props.member;
if (!user) {
return false;
}
return !!user._unstable_statusMessage;
return !!user.unstable_statusMessage;
}
_onStatusMessageCommitted = () => {
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
hasStatus: this.hasStatus,
});
};
openMenu = () => {
private openMenu = (): void => {
this.setState({ menuDisplayed: true });
};
closeMenu = () => {
private closeMenu = (): void => {
this.setState({ menuDisplayed: false });
};
render() {
public render(): JSX.Element {
const avatar = <MemberAvatar
member={this.props.member}
width={this.props.width}
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect();
const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
contextMenu = (
<ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace="bottom"
chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226}
onFinished={this.closeMenu}
>
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} />
<StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu>
);
}
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
return <React.Fragment>
<ContextMenuButton
className={classes}
inputRef={this._button}
inputRef={this.button}
onClick={this.openMenu}
isExpanded={this.state.menuDisplayed}
label={_t("User Status")}

View file

@ -15,45 +15,41 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
/*
interface IProps {
element: React.ReactNode;
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize?: () => void;
}
/**
* This component can be used to display generic HTML content in a contextual
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component {
static propTypes = {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize: PropTypes.func,
};
constructor(props) {
export default class GenericElementContextMenu extends React.Component<IProps> {
constructor(props: IProps) {
super(props);
this.resize = this.resize.bind(this);
}
componentDidMount() {
this.resize = this.resize.bind(this);
public componentDidMount(): void {
window.addEventListener("resize", this.resize);
}
componentWillUnmount() {
public componentWillUnmount(): void {
window.removeEventListener("resize", this.resize);
}
resize() {
private resize = (): void => {
if (this.props.onResize) {
this.props.onResize();
}
}
};
render() {
public render(): JSX.Element {
return <div>{ this.props.element }</div>;
}
}

View file

@ -15,16 +15,15 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component {
static propTypes = {
message: PropTypes.string.isRequired,
};
interface IProps {
message: string;
}
render() {
@replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component<IProps> {
public render(): JSX.Element {
return <div>{ this.props.message }</div>;
}
}

View file

@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { ChangeEvent } from 'react';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { User } from "matrix-js-sdk/src/models/user";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
message: this.comittedStatusMessage,
waiting: false,
};
}
componentDidMount() {
public componentDidMount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
componentWillUnmount() {
public componentWillUnmount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
this.onStatusMessageCommitted,
);
}
get comittedStatusMessage() {
return this.props.user ? this.props.user._unstable_statusMessage : "";
get comittedStatusMessage(): string {
return this.props.user ? this.props.user.unstable_statusMessage : "";
}
_onStatusMessageCommitted = () => {
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
message: this.comittedStatusMessage,
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
});
};
_onClearClick = (e) => {
private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({
waiting: true,
});
};
_onSubmit = (e) => {
private onSubmit = (e: ButtonEvent): void => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({
@ -83,27 +89,25 @@ export default class StatusMessageContextMenu extends React.Component {
});
};
_onStatusChange = (e) => {
private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed.
this.setState({
message: e.target.value,
message: (e.target as HTMLInputElement).value,
});
};
render() {
const Spinner = sdk.getComponent('views.elements.Spinner');
public render(): JSX.Element {
let actionButton;
if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick}
onClick={this.onClearClick}
>
<span>{ _t("Clear status") }</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit}
onClick={this.onSubmit}
>
<span>{ _t("Update status") }</span>
</AccessibleButton>;
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this._onSubmit}
onClick={this.onSubmit}
>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
let spinner = null;
if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />;
spinner = <Spinner w={24} h={24} />;
}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this._onSubmit}
onSubmit={this.onSubmit}
>
<input
type="text"
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength="60"
maxLength={60}
value={this.state.message}
onChange={this._onStatusChange}
onChange={this.onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton }

View file

@ -258,7 +258,6 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">

View file

@ -23,10 +23,9 @@ import Modal from '../../../Modal';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import QuestionDialog from "./QuestionDialog";
import { IDialogProps } from "./IDialogProps";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IProps extends IDialogProps {}
const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
const brand = SdkConfig.get().brand;

View file

@ -243,7 +243,6 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search for rooms or people")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ForwardList_content">

View file

@ -57,7 +57,6 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
@ -98,13 +97,13 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
definitions={[
{
value: RoomsToLeave.None,
label: _t("Don't leave any"),
label: _t("Don't leave any rooms"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
label: _t("Leave all rooms"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
label: _t("Leave some rooms"),
},
]}
/>
@ -167,11 +166,13 @@ const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
{ _t("You are about to leave <spaceName/>.", {}, {
spaceName: () => <b>{ space.name }</b>,
}) }
&nbsp;
{ rejoinWarning }
{ rejoinWarning && (<>&nbsp;</>) }
{ spaceChildren.length > 0 && _t("Would you like to leave the rooms in this space?") }
</p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker

View file

@ -126,7 +126,6 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">

View file

@ -19,7 +19,7 @@ import { User } from "matrix-js-sdk/src/models/user";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon";
import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import AccessibleButton from "../elements/AccessibleButton";
import BaseDialog from "./BaseDialog";
import { IDialogProps } from "./IDialogProps";
@ -47,7 +47,7 @@ const UntrustedDeviceDialog: React.FC<IProps> = ({ device, user, onFinished }) =
onFinished={onFinished}
className="mx_UntrustedDeviceDialog"
title={<>
<E2EIcon status="warning" size={24} hideTooltip={true} />
<E2EIcon status={E2EState.Warning} size={24} hideTooltip={true} />
{ _t("Not Trusted") }
</>}
>

View file

@ -19,7 +19,6 @@ limitations under the License.
import url from 'url';
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
@ -39,33 +38,95 @@ import { MatrixCapabilities } from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
interface IProps {
app: IApp;
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: Room;
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth?: boolean;
// Optional. If set, renders a smaller view of the widget
miniMode?: boolean;
// UserId of the current user
userId: string;
// UserId of the entity that added / modified the widget
creatorUserId: string;
waitForIframeLoad: boolean;
showMenubar?: boolean;
// Optional onEditClickHandler (overrides default behaviour)
onEditClick?: () => void;
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick?: () => void;
// Optionally hide the tile title
showTitle?: boolean;
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents?: boolean;
// Optionally hide the popout widget icon
showPopout?: boolean;
// Is this an instance of a user widget
userWidget: boolean;
// sets the pointer-events property on the iframe
pointerEvents?: string;
widgetPageTitle?: string;
}
interface IState {
initialising: boolean; // True while we are mangling the widget URL
// True while the iframe content is loading
loading: boolean;
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: boolean;
error: Error;
menuDisplayed: boolean;
widgetPageTitle: string;
}
@replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component {
constructor(props) {
export default class AppTile extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
};
private contextMenuButton = createRef<any>();
private iframe: HTMLIFrameElement; // ref to the iframe (callback style)
private allowedWidgetsWatchRef: string;
private persistKey: string;
private sgWidget: StopGapWidget;
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
// The key used for PersistedElement
this._persistKey = getPersistKey(this.props.app.id);
this.persistKey = getPersistKey(this.props.app.id);
try {
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this.sgWidget = new StopGapWidget(this.props);
this.sgWidget.on("preparing", this.onWidgetPrepared);
this.sgWidget.on("ready", this.onWidgetReady);
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
this.sgWidget = null;
}
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props);
this._contextMenuButton = createRef();
this.state = this.getNewState(props);
this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
this.allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
}
// This is a function to make the impact of calling SettingsStore slightly less
hasPermissionToLoad = (props) => {
if (this._usingLocalWidget()) return true;
private hasPermissionToLoad = (props: IProps): boolean => {
if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
@ -81,34 +142,34 @@ export default class AppTile extends React.Component {
* @param {Object} newProps The new properties of the component
* @return {Object} Updated component state to be set with setState
*/
_getNewState(newProps) {
private getNewState(newProps: IProps): IState {
return {
initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this.persistKey),
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
error: null,
widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false,
widgetPageTitle: this.props.widgetPageTitle,
};
}
onAllowedWidgetsChange = () => {
private onAllowedWidgetsChange = (): void => {
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey);
if (this._sgWidget) this._sgWidget.stop();
PersistedElement.destroyElement(this.persistKey);
if (this.sgWidget) this.sgWidget.stop();
}
this.setState({ hasPermissionToLoad });
};
isMixedContent() {
private isMixedContent(): boolean {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url);
const childContentProtocol = u.protocol;
@ -120,69 +181,70 @@ export default class AppTile extends React.Component {
return false;
}
componentDidMount() {
public componentDidMount(): void {
// Only fetch IM token on mount if we're showing and have permission to load
if (this._sgWidget && this.state.hasPermissionToLoad) {
this._startWidget();
if (this.sgWidget && this.state.hasPermissionToLoad) {
this.startWidget();
}
// Widget action listeners
this.dispatcherRef = dis.register(this._onAction);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
public componentWillUnmount(): void {
// Widget action listeners
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey);
PersistedElement.destroyElement(this.persistKey);
}
if (this._sgWidget) {
this._sgWidget.stop();
if (this.sgWidget) {
this.sgWidget.stop();
}
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef);
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
}
_resetWidget(newProps) {
if (this._sgWidget) {
this._sgWidget.stop();
private resetWidget(newProps: IProps): void {
if (this.sgWidget) {
this.sgWidget.stop();
}
try {
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
this.sgWidget = new StopGapWidget(newProps);
this.sgWidget.on("preparing", this.onWidgetPrepared);
this.sgWidget.on("ready", this.onWidgetReady);
this.startWidget();
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
this.sgWidget = null;
}
}
_startWidget() {
this._sgWidget.prepare().then(() => {
private startWidget(): void {
this.sgWidget.prepare().then(() => {
this.setState({ initialising: false });
});
}
_iframeRefChange = (ref) => {
private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref;
if (ref) {
if (this._sgWidget) this._sgWidget.start(ref);
if (this.sgWidget) this.sgWidget.start(ref);
} else {
this._resetWidget(this.props);
this.resetWidget(this.props);
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
// eslint-disable-next-line @typescript-eslint/naming-convention
public UNSAFE_componentWillReceiveProps(nextProps: IProps): void { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps);
this.getNewState(nextProps);
if (this.state.hasPermissionToLoad) {
this._resetWidget(nextProps);
this.resetWidget(nextProps);
}
}
@ -198,7 +260,7 @@ export default class AppTile extends React.Component {
* @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/
async _endWidgetActions() { // widget migration dev note: async to maintain signature
private async endWidgetActions(): Promise<void> { // widget migration dev note: async to maintain signature
// HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
@ -217,27 +279,27 @@ export default class AppTile extends React.Component {
}
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
PersistedElement.destroyElement(this.persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true });
if (this.sgWidget) this.sgWidget.stop({ forceDestroy: true });
}
_onWidgetPrepared = () => {
private onWidgetPrepared = (): void => {
this.setState({ loading: false });
};
_onWidgetReady = () => {
private onWidgetReady = (): void => {
if (WidgetType.JITSI.matches(this.props.app.type)) {
this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
this.sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
};
_onAction = payload => {
private onAction = (payload): void => {
if (payload.widgetId === this.props.app.id) {
switch (payload.action) {
case 'm.sticker':
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
dis.dispatch({ action: 'stickerpicker_close' });
} else {
@ -248,7 +310,7 @@ export default class AppTile extends React.Component {
}
};
_grantWidgetPermission = () => {
private grantWidgetPermission = (): void => {
const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
@ -258,14 +320,14 @@ export default class AppTile extends React.Component {
this.setState({ hasPermissionToLoad: true });
// Fetch a token for the integration manager, now that we're allowed to
this._startWidget();
this.startWidget();
}).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
};
formatAppTileName() {
private formatAppTileName(): string {
let appTileName = "No name";
if (this.props.app.name && this.props.app.name.trim()) {
appTileName = this.props.app.name.trim();
@ -278,11 +340,11 @@ export default class AppTile extends React.Component {
* actual widget URL
* @returns {bool} true If using a local version of the widget
*/
_usingLocalWidget() {
private usingLocalWidget(): boolean {
return WidgetType.JITSI.matches(this.props.app.type);
}
_getTileTitle() {
private getTileTitle(): JSX.Element {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
@ -300,32 +362,32 @@ export default class AppTile extends React.Component {
}
// TODO replace with full screen interactions
_onPopoutWidgetClick = () => {
private onPopoutWidgetClick = (): void => {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type)) {
this._endWidgetActions().then(() => {
this.endWidgetActions().then(() => {
if (this.iframe) {
// Reload iframe
this.iframe.src = this._sgWidget.embedUrl;
this.iframe.src = this.sgWidget.embedUrl;
}
});
}
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
{ target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
};
_onContextMenuClick = () => {
private onContextMenuClick = (): void => {
this.setState({ menuDisplayed: true });
};
_closeContextMenu = () => {
private closeContextMenu = (): void => {
this.setState({ menuDisplayed: false });
};
render() {
public render(): JSX.Element {
let appTileBody;
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
@ -351,7 +413,7 @@ export default class AppTile extends React.Component {
<Spinner message={_t("Loading...")} />
</div>
);
if (this._sgWidget === null) {
if (this.sgWidget === null) {
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("Error loading Widget")} />
@ -365,9 +427,9 @@ export default class AppTile extends React.Component {
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl}
url={this.sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
onPermissionGranted={this.grantWidgetPermission}
/>
</div>
);
@ -390,8 +452,8 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
ref={this.iframeRefChange}
src={this.sgWidget.embedUrl}
allowFullScreen={true}
sandbox={sandboxFlags}
/>
@ -407,7 +469,7 @@ export default class AppTile extends React.Component {
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
<PersistedElement persistKey={this.persistKey}>
{ appTileBody }
</PersistedElement>
</div>;
@ -429,9 +491,9 @@ export default class AppTile extends React.Component {
if (this.state.menuDisplayed) {
contextMenu = (
<RoomWidgetContextMenu
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect(), null)}
app={this.props.app}
onFinished={this._closeContextMenu}
onFinished={this.closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
@ -444,21 +506,21 @@ export default class AppTile extends React.Component {
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : "none") }}>
{ this.props.showTitle && this.getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
onClick={this.onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
inputRef={this.contextMenuButton}
onClick={this.onContextMenuClick}
/>
</span>
</div> }
@ -469,49 +531,3 @@ export default class AppTile extends React.Component {
</React.Fragment>;
}
}
AppTile.displayName = 'AppTile';
AppTile.propTypes = {
app: PropTypes.object.isRequired,
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: PropTypes.object,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// Optional. If set, renders a smaller view of the widget
miniMode: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick: PropTypes.func,
// Optional onMinimiseClickHandler
onMinimiseClick: PropTypes.func,
// Optionally hide the tile title
showTitle: PropTypes.bool,
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
// sets the pointer-events property on the iframe
pointerEvents: PropTypes.string,
};
AppTile.defaultProps = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
};

View file

@ -1,24 +1,20 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import React from 'react';
const AppWarning = (props) => {
interface IProps {
errorMsg?: string;
}
const AppWarning: React.FC<IProps> = (props) => {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src={require("../../../../res/img/warning.svg")} alt='' />
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg }</span>
<span className='mx_AppPermissionWarningTextLabel'>{ props.errorMsg || "Error" }</span>
</div>
</div>
);
};
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning;

View file

@ -17,60 +17,61 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
// The primary button which is styled differently and has default focus.
primaryButton: React.ReactNode;
// A node to insert into the cancel button instead of default "Cancel"
cancelButton?: React.ReactNode;
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit?: boolean;
// onClick handler for the primary button.
onPrimaryButtonClick?: (ev: React.MouseEvent) => void;
// should there be a cancel button? default: true
hasCancel?: boolean;
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass?: string;
// onClick handler for the cancel button.
onCancel?: (...args: any[]) => void;
focus?: boolean;
// disables the primary and cancel buttons
disabled?: boolean;
// disables only the primary button
primaryDisabled?: boolean;
// something to stick next to the buttons, optionally
additive?: React.ReactNode;
primaryButtonClass?: string;
}
/**
* Basic container for buttons in modal dialogs.
*/
@replaceableComponent("views.elements.DialogButtons")
export default class DialogButtons extends React.Component {
static propTypes = {
// The primary button which is styled differently and has default focus.
primaryButton: PropTypes.node.isRequired,
// A node to insert into the cancel button instead of default "Cancel"
cancelButton: PropTypes.node,
// If true, make the primary button a form submit button (input type="submit")
primaryIsSubmit: PropTypes.bool,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func,
// should there be a cancel button? default: true
hasCancel: PropTypes.bool,
// The class of the cancel button, only used if a cancel button is
// enabled
cancelButtonClass: PropTypes.node,
// onClick handler for the cancel button.
onCancel: PropTypes.func,
focus: PropTypes.bool,
// disables the primary and cancel buttons
disabled: PropTypes.bool,
// disables only the primary button
primaryDisabled: PropTypes.bool,
// something to stick next to the buttons, optionally
additive: PropTypes.element,
};
static defaultProps = {
export default class DialogButtons extends React.Component<IProps> {
public static defaultProps: Partial<IProps> = {
hasCancel: true,
disabled: false,
};
_onCancelClick = () => {
this.props.onCancel();
private onCancelClick = (event: React.MouseEvent): void => {
this.props.onCancel(event);
};
render() {
public render(): JSX.Element {
let primaryButtonClassName = "mx_Dialog_primary";
if (this.props.primaryButtonClass) {
primaryButtonClassName += " " + this.props.primaryButtonClass;
@ -82,7 +83,7 @@ export default class DialogButtons extends React.Component {
// important: the default type is 'submit' and this button comes before the
// primary in the DOM so will get form submissions unless we make it not a submit.
type="button"
onClick={this._onCancelClick}
onClick={this.onCancelClick}
className={this.props.cancelButtonClass}
disabled={this.props.disabled}
>

View file

@ -14,71 +14,73 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import React, { ChangeEvent, createRef } from 'react';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "./AccessibleButton";
interface IProps {
className?: string;
onChange?: (value: string) => void;
onClear?: () => void;
onJoinClick?: (value: string) => void;
placeholder?: string;
showJoinButton?: boolean;
initialText?: string;
}
interface IState {
value: string;
}
@replaceableComponent("views.elements.DirectorySearchBox")
export default class DirectorySearchBox extends React.Component {
constructor(props) {
super(props);
this._collectInput = this._collectInput.bind(this);
this._onClearClick = this._onClearClick.bind(this);
this._onChange = this._onChange.bind(this);
this._onKeyUp = this._onKeyUp.bind(this);
this._onJoinButtonClick = this._onJoinButtonClick.bind(this);
export default class DirectorySearchBox extends React.Component<IProps, IState> {
private input = createRef<HTMLInputElement>();
this.input = null;
constructor(props: IProps) {
super(props);
this.state = {
value: this.props.initialText || '',
};
}
_collectInput(e) {
this.input = e;
}
_onClearClick() {
private onClearClick = (): void => {
this.setState({ value: '' });
if (this.input) {
this.input.focus();
if (this.input.current) {
this.input.current.focus();
if (this.props.onClear) {
this.props.onClear();
}
}
}
};
_onChange(ev) {
if (!this.input) return;
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
if (!this.input.current) return;
this.setState({ value: ev.target.value });
if (this.props.onChange) {
this.props.onChange(ev.target.value);
}
}
};
_onKeyUp(ev) {
private onKeyUp = (ev: React.KeyboardEvent): void => {
if (ev.key == 'Enter' && this.props.showJoinButton) {
if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value);
}
}
}
};
_onJoinButtonClick() {
private onJoinButtonClick = (): void => {
if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value);
}
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
};
public render(): JSX.Element {
const searchboxClasses = {
mx_DirectorySearchBox: true,
};
@ -87,7 +89,7 @@ export default class DirectorySearchBox extends React.Component {
let joinButton;
if (this.props.showJoinButton) {
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
onClick={this._onJoinButtonClick}
onClick={this.onJoinButtonClick}
>{ _t("Join") }</AccessibleButton>;
}
@ -97,24 +99,15 @@ export default class DirectorySearchBox extends React.Component {
name="dirsearch"
value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange}
onKeyUp={this._onKeyUp}
ref={this.input}
onChange={this.onChange}
onKeyUp={this.onKeyUp}
placeholder={this.props.placeholder}
autoFocus
/>
{ joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this.onClearClick} />
</div>;
}
}
DirectorySearchBox.propTypes = {
className: PropTypes.string,
onChange: PropTypes.func,
onClear: PropTypes.func,
onJoinClick: PropTypes.func,
placeholder: PropTypes.string,
showJoinButton: PropTypes.bool,
initialText: PropTypes.string,
};

View file

@ -16,33 +16,42 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
enum Phases {
Display = "display",
Edit = "edit",
}
interface IProps {
onValueChanged?: (value: string, shouldSubmit: boolean) => void;
initialValue?: string;
label?: string;
placeholder?: string;
className?: string;
labelClassName?: string;
placeholderClassName?: string;
// Overrides blurToSubmit if true
blurToCancel?: boolean;
// Will cause onValueChanged(value, true) to fire on blur
blurToSubmit?: boolean;
editable?: boolean;
}
interface IState {
phase: Phases;
}
@replaceableComponent("views.elements.EditableText")
export default class EditableText extends React.Component {
static propTypes = {
onValueChanged: PropTypes.func,
initialValue: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
className: PropTypes.string,
labelClassName: PropTypes.string,
placeholderClassName: PropTypes.string,
// Overrides blurToSubmit if true
blurToCancel: PropTypes.bool,
// Will cause onValueChanged(value, true) to fire on blur
blurToSubmit: PropTypes.bool,
editable: PropTypes.bool,
};
export default class EditableText extends React.Component<IProps, IState> {
// we track value as an JS object field rather than in React state
// as React doesn't play nice with contentEditable.
public value = '';
private placeholder = false;
private editableDiv = createRef<HTMLDivElement>();
static Phases = {
Display: "display",
Edit: "edit",
};
static defaultProps = {
public static defaultProps: Partial<IProps> = {
onValueChanged() {},
initialValue: '',
label: '',
@ -53,81 +62,61 @@ export default class EditableText extends React.Component {
blurToSubmit: false,
};
constructor(props) {
constructor(props: IProps) {
super(props);
// we track value as an JS object field rather than in React state
// as React doesn't play nice with contentEditable.
this.value = '';
this.placeholder = false;
this._editable_div = createRef();
this.state = {
phase: Phases.Display,
};
}
state = {
phase: EditableText.Phases.Display,
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps) {
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
public UNSAFE_componentWillReceiveProps(nextProps: IProps): void {
if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue;
if (this._editable_div.current) {
if (this.editableDiv.current) {
this.showPlaceholder(!this.value);
}
}
}
componentDidMount() {
public componentDidMount(): void {
this.value = this.props.initialValue;
if (this._editable_div.current) {
if (this.editableDiv.current) {
this.showPlaceholder(!this.value);
}
}
showPlaceholder = show => {
private showPlaceholder = (show: boolean): void => {
if (show) {
this._editable_div.current.textContent = this.props.placeholder;
this._editable_div.current.setAttribute("class", this.props.className
this.editableDiv.current.textContent = this.props.placeholder;
this.editableDiv.current.setAttribute("class", this.props.className
+ " " + this.props.placeholderClassName);
this.placeholder = true;
this.value = '';
} else {
this._editable_div.current.textContent = this.value;
this._editable_div.current.setAttribute("class", this.props.className);
this.editableDiv.current.textContent = this.value;
this.editableDiv.current.setAttribute("class", this.props.className);
this.placeholder = false;
}
};
getValue = () => this.value;
setValue = value => {
this.value = value;
this.showPlaceholder(!this.value);
};
edit = () => {
private cancelEdit = (): void => {
this.setState({
phase: EditableText.Phases.Edit,
});
};
cancelEdit = () => {
this.setState({
phase: EditableText.Phases.Display,
phase: Phases.Display,
});
this.value = this.props.initialValue;
this.showPlaceholder(!this.value);
this.onValueChanged(false);
this._editable_div.current.blur();
this.editableDiv.current.blur();
};
onValueChanged = shouldSubmit => {
private onValueChanged = (shouldSubmit: boolean): void => {
this.props.onValueChanged(this.value, shouldSubmit);
};
onKeyDown = ev => {
private onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (this.placeholder) {
@ -142,13 +131,13 @@ export default class EditableText extends React.Component {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
};
onKeyUp = ev => {
private onKeyUp = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (!ev.target.textContent) {
if (!(ev.target as HTMLDivElement).textContent) {
this.showPlaceholder(true);
} else if (!this.placeholder) {
this.value = ev.target.textContent;
this.value = (ev.target as HTMLDivElement).textContent;
}
if (ev.key === Key.ENTER) {
@ -160,22 +149,22 @@ export default class EditableText extends React.Component {
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
};
onClickDiv = ev => {
private onClickDiv = (): void => {
if (!this.props.editable) return;
this.setState({
phase: EditableText.Phases.Edit,
phase: Phases.Edit,
});
};
onFocus = ev => {
private onFocus = (ev: React.FocusEvent<HTMLDivElement>): void => {
//ev.target.setSelectionRange(0, ev.target.textContent.length);
const node = ev.target.childNodes[0];
if (node) {
const range = document.createRange();
range.setStart(node, 0);
range.setEnd(node, node.length);
range.setEnd(node, ev.target.childNodes.length);
const sel = window.getSelection();
sel.removeAllRanges();
@ -183,11 +172,15 @@ export default class EditableText extends React.Component {
}
};
onFinish = (ev, shouldSubmit) => {
private onFinish = (
ev: React.KeyboardEvent<HTMLDivElement> | React.FocusEvent<HTMLDivElement>,
shouldSubmit?: boolean,
): void => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const submit = (ev.key === Key.ENTER) || shouldSubmit;
const submit = ("key" in ev && ev.key === Key.ENTER) || shouldSubmit;
this.setState({
phase: EditableText.Phases.Display,
phase: Phases.Display,
}, () => {
if (this.value !== this.props.initialValue) {
self.onValueChanged(submit);
@ -195,7 +188,7 @@ export default class EditableText extends React.Component {
});
};
onBlur = ev => {
private onBlur = (ev: React.FocusEvent<HTMLDivElement>): void => {
const sel = window.getSelection();
sel.removeAllRanges();
@ -208,11 +201,11 @@ export default class EditableText extends React.Component {
this.showPlaceholder(!this.value);
};
render() {
public render(): JSX.Element {
const { className, editable, initialValue, label, labelClassName } = this.props;
let editableEl;
if (!editable || (this.state.phase === EditableText.Phases.Display &&
if (!editable || (this.state.phase === Phases.Display &&
(label || labelClassName) && !this.value)
) {
// show the label
@ -222,7 +215,7 @@ export default class EditableText extends React.Component {
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div
ref={this._editable_div}
ref={this.editableDiv}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}

View file

@ -15,9 +15,34 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "./Spinner";
import EditableText from "./EditableText";
interface IProps {
/* callback to retrieve the initial value. */
getInitialValue?: () => Promise<string>;
/* initial value; used if getInitialValue is not given */
initialValue?: string;
/* placeholder text to use when the value is empty (and not being
* edited) */
placeholder?: string;
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit?: (value: string) => Promise<{} | void>;
/* should the input submit when focus is lost? */
blurToSubmit?: boolean;
}
interface IState {
busy: boolean;
errorString: string;
value: string;
}
/**
* A component which wraps an EditableText, with a spinner while updates take
@ -31,50 +56,51 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
* taken from the 'initialValue' property.
*/
@replaceableComponent("views.elements.EditableTextContainer")
export default class EditableTextContainer extends React.Component {
constructor(props) {
export default class EditableTextContainer extends React.Component<IProps, IState> {
private unmounted = false;
public static defaultProps: Partial<IProps> = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: () => { return Promise.resolve(); },
};
constructor(props: IProps) {
super(props);
this._unmounted = false;
this.state = {
busy: false,
errorString: null,
value: props.initialValue,
};
this._onValueChanged = this._onValueChanged.bind(this);
}
componentDidMount() {
if (this.props.getInitialValue === undefined) {
// use whatever was given in the initialValue property.
return;
}
public async componentDidMount(): Promise<void> {
// use whatever was given in the initialValue property.
if (this.props.getInitialValue === undefined) return;
this.setState({ busy: true });
this.props.getInitialValue().then(
(result) => {
if (this._unmounted) { return; }
this.setState({
busy: false,
value: result,
});
},
(error) => {
if (this._unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
},
);
try {
const initialValue = await this.props.getInitialValue();
if (this.unmounted) return;
this.setState({
busy: false,
value: initialValue,
});
} catch (error) {
if (this.unmounted) return;
this.setState({
errorString: error.toString(),
busy: false,
});
}
}
componentWillUnmount() {
this._unmounted = true;
public componentWillUnmount(): void {
this.unmounted = true;
}
_onValueChanged(value, shouldSubmit) {
private onValueChanged = (value: string, shouldSubmit: boolean): void => {
if (!shouldSubmit) {
return;
}
@ -86,38 +112,36 @@ export default class EditableTextContainer extends React.Component {
this.props.onSubmit(value).then(
() => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
this.setState({
busy: false,
value: value,
});
},
(error) => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
},
);
}
};
render() {
public render(): JSX.Element {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
<Spinner />
);
} else if (this.state.errorString) {
return (
<div className="error">{ this.state.errorString }</div>
);
} else {
const EditableText = sdk.getComponent('elements.EditableText');
return (
<EditableText initialValue={this.state.value}
placeholder={this.props.placeholder}
onValueChanged={this._onValueChanged}
onValueChanged={this.onValueChanged}
blurToSubmit={this.props.blurToSubmit}
/>
);
@ -125,28 +149,3 @@ export default class EditableTextContainer extends React.Component {
}
}
EditableTextContainer.propTypes = {
/* callback to retrieve the initial value. */
getInitialValue: PropTypes.func,
/* initial value; used if getInitialValue is not given */
initialValue: PropTypes.string,
/* placeholder text to use when the value is empty (and not being
* edited) */
placeholder: PropTypes.string,
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit: PropTypes.func,
/* should the input submit when focus is lost? */
blurToSubmit: PropTypes.bool,
};
EditableTextContainer.defaultProps = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: function(v) {return Promise.resolve(); },
};

View file

@ -16,13 +16,13 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as languageHandler from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "./Spinner";
import Dropdown from "./Dropdown";
function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
@ -30,11 +30,22 @@ function languageMatchesSearchQuery(query, language) {
return false;
}
interface IProps {
className?: string;
onOptionChange: (language: string) => void;
value?: string;
disabled?: boolean;
}
interface IState {
searchQuery: string;
langs: string[];
}
@replaceableComponent("views.elements.LanguageDropdown")
export default class LanguageDropdown extends React.Component {
constructor(props) {
export default class LanguageDropdown extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.state = {
searchQuery: '',
@ -42,7 +53,7 @@ export default class LanguageDropdown extends React.Component {
};
}
componentDidMount() {
public componentDidMount(): void {
languageHandler.getAllLanguagesFromJson().then((langs) => {
langs.sort(function(a, b) {
if (a.label < b.label) return -1;
@ -63,20 +74,17 @@ export default class LanguageDropdown extends React.Component {
}
}
_onSearchChange(search) {
private onSearchChange = (search: string): void => {
this.setState({
searchQuery: search,
});
}
};
render() {
public render(): JSX.Element {
if (this.state.langs === null) {
const Spinner = sdk.getComponent('elements.Spinner');
return <Spinner />;
}
const Dropdown = sdk.getComponent('elements.Dropdown');
let displayedLanguages;
if (this.state.searchQuery) {
displayedLanguages = this.state.langs.filter((lang) => {
@ -107,7 +115,7 @@ export default class LanguageDropdown extends React.Component {
id="mx_LanguageDropdown"
className={this.props.className}
onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange}
onSearchChange={this.onSearchChange}
searchEnabled={true}
value={value}
label={_t("Language Dropdown")}
@ -118,8 +126,3 @@ export default class LanguageDropdown extends React.Component {
}
}
LanguageDropdown.propTypes = {
className: PropTypes.string,
onOptionChange: PropTypes.func.isRequired,
value: PropTypes.string,
};

View file

@ -15,17 +15,16 @@ limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent";
class ItemRange {
constructor(topCount, renderCount, bottomCount) {
this.topCount = topCount;
this.renderCount = renderCount;
this.bottomCount = bottomCount;
}
constructor(
public topCount: number,
public renderCount: number,
public bottomCount: number,
) { }
contains(range) {
public contains(range: ItemRange): boolean {
// don't contain empty ranges
// as it will prevent clearing the list
// once it is scrolled far enough out of view
@ -36,7 +35,7 @@ class ItemRange {
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
}
expand(amount) {
public expand(amount: number): ItemRange {
// don't expand ranges that won't render anything
if (this.renderCount === 0) {
return this;
@ -51,20 +50,55 @@ class ItemRange {
);
}
totalSize() {
public totalSize(): number {
return this.topCount + this.renderCount + this.bottomCount;
}
}
interface IProps<T> {
// height in pixels of the component returned by `renderItem`
itemHeight: number;
// function to turn an element of `items` into a react component
renderItem: (item: T) => JSX.Element;
// scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s)
scrollTop: number;
// the height of the viewport this content is scrolled in
height: number;
// all items for the list. These should not be react components, see `renderItem`.
items?: T[];
// the amount of items to scroll before causing a rerender,
// should typically be less than `overflowItems` unless applying
// margins in the parent component when using multiple LazyRenderList in one viewport.
// use 0 to only rerender when items will come into view.
overflowMargin?: number;
// the amount of items to add at the top and bottom to render,
// so not every scroll of causes a rerender.
overflowItems?: number;
element?: string;
className?: string;
}
interface IState {
renderRange: ItemRange;
}
@replaceableComponent("views.elements.LazyRenderList")
export default class LazyRenderList extends React.Component {
constructor(props) {
export default class LazyRenderList<T = any> extends React.Component<IProps<T>, IState> {
public static defaultProps: Partial<IProps<unknown>> = {
overflowItems: 20,
overflowMargin: 5,
};
constructor(props: IProps<T>) {
super(props);
this.state = {};
this.state = {
renderRange: null,
};
}
static getDerivedStateFromProps(props, state) {
public static getDerivedStateFromProps(props: IProps<unknown>, state: IState): Partial<IState> {
const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(props.overflowMargin);
const renderRange = range.expand(props.overflowItems);
@ -77,7 +111,7 @@ export default class LazyRenderList extends React.Component {
return null;
}
static getVisibleRangeFromProps(props) {
private static getVisibleRangeFromProps(props: IProps<unknown>): ItemRange {
const { items, itemHeight, scrollTop, height } = props;
const length = items ? items.length : 0;
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / itemHeight)), length);
@ -88,7 +122,7 @@ export default class LazyRenderList extends React.Component {
return new ItemRange(topCount, renderCount, bottomCount);
}
render() {
public render(): JSX.Element {
const { itemHeight, items, renderItem } = this.props;
const { renderRange } = this.state;
const { topCount, renderCount, bottomCount } = renderRange;
@ -109,28 +143,3 @@ export default class LazyRenderList extends React.Component {
}
}
LazyRenderList.defaultProps = {
overflowItems: 20,
overflowMargin: 5,
};
LazyRenderList.propTypes = {
// height in pixels of the component returned by `renderItem`
itemHeight: PropTypes.number.isRequired,
// function to turn an element of `items` into a react component
renderItem: PropTypes.func.isRequired,
// scrollTop of the viewport (minus the height of any content above this list like other `LazyRenderList`s)
scrollTop: PropTypes.number.isRequired,
// the height of the viewport this content is scrolled in
height: PropTypes.number.isRequired,
// all items for the list. These should not be react components, see `renderItem`.
items: PropTypes.array,
// the amount of items to scroll before causing a rerender,
// should typically be less than `overflowItems` unless applying
// margins in the parent component when using multiple LazyRenderList in one viewport.
// use 0 to only rerender when items will come into view.
overflowMargin: PropTypes.number,
// the amount of items to add at the top and bottom to render,
// so not every scroll of causes a rerender.
overflowItems: PropTypes.number,
};

View file

@ -135,7 +135,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc });
return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
});
if (!summaries) {

View file

@ -16,25 +16,26 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { throttle } from "lodash";
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";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from "../../../dispatcher/payloads";
export const getPersistKey = (appId: string) => 'widget_' + appId;
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
function getContainer(containerId) {
return document.getElementById(containerId);
function getContainer(containerId: string): HTMLDivElement {
return document.getElementById(containerId) as HTMLDivElement;
}
function getOrCreateContainer(containerId) {
function getOrCreateContainer(containerId: string): HTMLDivElement {
let container = getContainer(containerId);
if (!container) {
@ -46,7 +47,19 @@ function getOrCreateContainer(containerId) {
return container;
}
/*
interface IProps {
// Unique identifier for this PersistedElement instance
// Any PersistedElements with the same persistKey will use
// the same DOM container.
persistKey: string;
// z-index for the element. Defaults to 9.
zIndex?: number;
style?: React.StyleHTMLAttributes<HTMLDivElement>;
}
/**
* Class of component that renders its children in a separate ReactDOM virtual tree
* in a container element appended to document.body.
*
@ -58,42 +71,33 @@ function getOrCreateContainer(containerId) {
* bounding rect as the parent of PE.
*/
@replaceableComponent("views.elements.PersistedElement")
export default class PersistedElement extends React.Component {
static propTypes = {
// Unique identifier for this PersistedElement instance
// Any PersistedElements with the same persistKey will use
// the same DOM container.
persistKey: PropTypes.string.isRequired,
export default class PersistedElement extends React.Component<IProps> {
private resizeObserver: ResizeObserver;
private dispatcherRef: string;
private childContainer: HTMLDivElement;
private child: HTMLDivElement;
// z-index for the element. Defaults to 9.
zIndex: PropTypes.number,
};
constructor(props: IProps) {
super(props);
constructor() {
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this);
this._repositionChild = this._repositionChild.bind(this);
this._onAction = this._onAction.bind(this);
this.resizeObserver = new ResizeObserver(this._repositionChild);
this.resizeObserver = new ResizeObserver(this.repositionChild);
// Annoyingly, a resize observer is insufficient, since we also care
// about when the element moves on the screen without changing its
// dimensions. Doesn't look like there's a ResizeObserver equivalent
// for this, so we bodge it by listening for document resize and
// the timeline_resize action.
window.addEventListener('resize', this._repositionChild);
this._dispatcherRef = dis.register(this._onAction);
window.addEventListener('resize', this.repositionChild);
this.dispatcherRef = dis.register(this.onAction);
}
/**
* Removes the DOM elements created when a PersistedElement with the given
* persistKey was mounted. The DOM elements will be re-added if another
* PeristedElement is mounted in the future.
* PersistedElement is mounted in the future.
*
* @param {string} persistKey Key used to uniquely identify this PersistedElement
*/
static destroyElement(persistKey) {
public static destroyElement(persistKey: string): void {
const container = getContainer('mx_persistedElement_' + persistKey);
if (container) {
container.remove();
@ -104,7 +108,7 @@ export default class PersistedElement extends React.Component {
return Boolean(getContainer('mx_persistedElement_' + persistKey));
}
collectChildContainer(ref) {
private collectChildContainer = (ref: HTMLDivElement): void => {
if (this.childContainer) {
this.resizeObserver.unobserve(this.childContainer);
}
@ -112,48 +116,48 @@ export default class PersistedElement extends React.Component {
if (ref) {
this.resizeObserver.observe(ref);
}
}
};
collectChild(ref) {
private collectChild = (ref: HTMLDivElement): void => {
this.child = ref;
this.updateChild();
}
};
componentDidMount() {
public componentDidMount(): void {
this.updateChild();
this.renderApp();
}
componentDidUpdate() {
public componentDidUpdate(): void {
this.updateChild();
this.renderApp();
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.updateChildVisibility(this.child, false);
this.resizeObserver.disconnect();
window.removeEventListener('resize', this._repositionChild);
dis.unregister(this._dispatcherRef);
window.removeEventListener('resize', this.repositionChild);
dis.unregister(this.dispatcherRef);
}
_onAction(payload) {
private onAction = (payload: ActionPayload): void => {
if (payload.action === 'timeline_resize') {
this._repositionChild();
this.repositionChild();
} else if (payload.action === 'logout') {
PersistedElement.destroyElement(this.props.persistKey);
}
}
};
_repositionChild() {
private repositionChild = (): void => {
this.updateChildPosition(this.child, this.childContainer);
}
};
updateChild() {
private updateChild(): void {
this.updateChildPosition(this.child, this.childContainer);
this.updateChildVisibility(this.child, true);
}
renderApp() {
private renderApp(): void {
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
<div ref={this.collectChild} style={this.props.style}>
{ this.props.children }
@ -163,12 +167,12 @@ export default class PersistedElement extends React.Component {
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
}
updateChildVisibility(child, visible) {
private updateChildVisibility(child: HTMLDivElement, visible: boolean): void {
if (!child) return;
child.style.display = visible ? 'block' : 'none';
}
updateChildPosition = throttle((child, parent) => {
private updateChildPosition = throttle((child: HTMLDivElement, parent: HTMLDivElement): void => {
if (!child || !parent) return;
const parentRect = parent.getBoundingClientRect();
@ -182,9 +186,8 @@ export default class PersistedElement extends React.Component {
});
}, 100, { trailing: true, leading: true });
render() {
public render(): JSX.Element {
return <div ref={this.collectChildContainer} />;
}
}
export const getPersistKey = (appId) => 'widget_' + appId;

View file

@ -19,57 +19,70 @@ import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { EventSubscription } from 'fbemitter';
import AppTile from "./AppTile";
import { Room } from "matrix-js-sdk/src/models/room";
interface IState {
roomId: string;
persistentWidgetId: string;
}
@replaceableComponent("views.elements.PersistentApp")
export default class PersistentApp extends React.Component {
state = {
roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
};
export default class PersistentApp extends React.Component<{}, IState> {
private roomStoreToken: EventSubscription;
componentDidMount() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this._onMyMembership);
constructor() {
super({});
this.state = {
roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
};
}
componentWillUnmount() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
public componentDidMount(): void {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this.onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
}
public componentWillUnmount(): void {
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
ActiveWidgetStore.removeListener('update', this.onActiveWidgetStoreUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.myMembership", this._onMyMembership);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
}
}
_onRoomViewStoreUpdate = payload => {
private onRoomViewStoreUpdate = (): void => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
this.setState({
roomId: RoomViewStore.getRoomId(),
});
};
_onActiveWidgetStoreUpdate = () => {
private onActiveWidgetStoreUpdate = (): void => {
this.setState({
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
});
};
_onMyMembership = async (room, membership) => {
private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (membership !== "join") {
// we're not in the room anymore - delete
if (room.roomId === persistentWidgetInRoomId) {
if (room .roomId === persistentWidgetInRoomId) {
ActiveWidgetStore.destroyPersistentWidget(this.state.persistentWidgetId);
}
}
};
render() {
public render(): JSX.Element {
if (this.state.persistentWidgetId) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
@ -89,7 +102,6 @@ export default class PersistentApp extends React.Component {
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(),
);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
app={app}

View file

@ -15,40 +15,52 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler';
import Field from "./Field";
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
const CUSTOM_VALUE = "SELECT_VALUE_CUSTOM";
interface IProps {
value: number;
// The maximum value that can be set with the power selector
maxValue: number;
// Default user power level for the room
usersDefault: number;
// should the user be able to change the value? false by default.
disabled?: boolean;
onChange?: (value: number, powerLevelKey: string) => void;
// Optional key to pass as the second argument to `onChange`
powerLevelKey?: string;
// The name to annotate the selector with
label?: string;
}
interface IState {
levelRoleMap: {};
// List of power levels to show in the drop-down
options: number[];
customValue: number;
selectValue: number | string;
custom?: boolean;
customLevel?: number;
}
@replaceableComponent("views.elements.PowerSelector")
export default class PowerSelector extends React.Component {
static propTypes = {
value: PropTypes.number.isRequired,
// The maximum value that can be set with the power selector
maxValue: PropTypes.number.isRequired,
// Default user power level for the room
usersDefault: PropTypes.number.isRequired,
// should the user be able to change the value? false by default.
disabled: PropTypes.bool,
onChange: PropTypes.func,
// Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string,
// The name to annotate the selector with
label: PropTypes.string,
}
static defaultProps = {
export default class PowerSelector extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
maxValue: Infinity,
usersDefault: 0,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this.state = {
@ -62,26 +74,26 @@ export default class PowerSelector extends React.Component {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._initStateFromProps(this.props);
// eslint-disable-next-line camelcase, @typescript-eslint/naming-convention
public UNSAFE_componentWillMount(): void {
this.initStateFromProps(this.props);
}
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(newProps) {
this._initStateFromProps(newProps);
// eslint-disable-next-line camelcase, @typescript-eslint/naming-convention
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
this.initStateFromProps(newProps);
}
_initStateFromProps(newProps) {
private initStateFromProps(newProps: IProps): void {
// This needs to be done now because levelRoleMap has translated strings
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
const options = Object.keys(levelRoleMap).filter(level => {
return (
level === undefined ||
level <= newProps.maxValue ||
level == newProps.value
parseInt(level) <= newProps.maxValue ||
parseInt(level) == newProps.value
);
});
}).map(level => parseInt(level));
const isCustom = levelRoleMap[newProps.value] === undefined;
@ -90,32 +102,33 @@ export default class PowerSelector extends React.Component {
options,
custom: isCustom,
customLevel: newProps.value,
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value,
selectValue: isCustom ? CUSTOM_VALUE : newProps.value,
});
}
onSelectChange = event => {
const isCustom = event.target.value === "SELECT_VALUE_CUSTOM";
private onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>): void => {
const isCustom = event.target.value === CUSTOM_VALUE;
if (isCustom) {
this.setState({ custom: true });
} else {
this.props.onChange(event.target.value, this.props.powerLevelKey);
this.setState({ selectValue: event.target.value });
const powerLevel = parseInt(event.target.value);
this.props.onChange(powerLevel, this.props.powerLevelKey);
this.setState({ selectValue: powerLevel });
}
};
onCustomChange = event => {
this.setState({ customValue: event.target.value });
private onCustomChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ customValue: parseInt(event.target.value) });
};
onCustomBlur = event => {
private onCustomBlur = (event: React.FocusEvent): void => {
event.preventDefault();
event.stopPropagation();
this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey);
this.props.onChange(this.state.customValue, this.props.powerLevelKey);
};
onCustomKeyDown = event => {
private onCustomKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === Key.ENTER) {
event.preventDefault();
event.stopPropagation();
@ -125,11 +138,11 @@ export default class PowerSelector extends React.Component {
// raising a dialog which causes a blur which causes a dialog which causes a blur and
// so on. By not causing the onChange to be called here, we avoid the loop because we
// handle the onBlur safely.
event.target.blur();
(event.target as HTMLInputElement).blur();
}
};
render() {
public render(): JSX.Element {
let picker;
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
if (this.state.custom) {
@ -147,14 +160,14 @@ export default class PowerSelector extends React.Component {
);
} else {
// Each level must have a definition in this.state.levelRoleMap
let options = this.state.options.map((level) => {
const options = this.state.options.map((level) => {
return {
value: level,
value: String(level),
text: Roles.textualPowerLevel(level, this.props.usersDefault),
};
});
options.push({ value: "SELECT_VALUE_CUSTOM", text: _t("Custom level") });
options = options.map((op) => {
options.push({ value: CUSTOM_VALUE, text: _t("Custom level") });
const optionsElements = options.map((op) => {
return <option value={op.value} key={op.value}>{ op.text }</option>;
});
@ -166,7 +179,7 @@ export default class PowerSelector extends React.Component {
value={String(this.state.selectValue)}
disabled={this.props.disabled}
>
{ options }
{ optionsElements }
</Field>
);
}

View file

@ -88,7 +88,13 @@ export default class ReplyThread extends React.Component<IProps, IState> {
// could be used here for replies as well... However, the helper
// currently assumes the relation has a `rel_type`, which older replies
// do not, so this block is left as-is for now.
const mRelatesTo = ev.getWireContent()['m.relates_to'];
//
// We're prefer ev.getContent() over ev.getWireContent() to make sure
// we grab the latest edit with potentially new relations. But we also
// can't just rely on ev.getContent() by itself because historically we
// still show the reply from the original message even though the edit
// event does not include the relation reply.
const mRelatesTo = ev.getContent()['m.relates_to'] || ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];

View file

@ -17,25 +17,34 @@
import React from 'react';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason?: string;
contentHtml: string;
}
interface IState {
visible: boolean;
}
@replaceableComponent("views.elements.Spoiler")
export default class Spoiler extends React.Component {
constructor(props) {
export default class Spoiler extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
visible: false,
};
}
toggleVisible(e) {
private toggleVisible = (e: React.MouseEvent): void => {
if (!this.state.visible) {
// we are un-blurring, we don't want this click to propagate to potential child pills
e.preventDefault();
e.stopPropagation();
}
this.setState({ visible: !this.state.visible });
}
};
render() {
public render(): JSX.Element {
const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{ "(" + this.props.reason + ")" }</span>
) : null;
@ -43,7 +52,7 @@ export default class Spoiler extends React.Component {
// as such, we pass the this.props.contentHtml instead and then set the raw
// HTML content. This is secure as the contents have already been parsed previously
return (
<span className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} onClick={this.toggleVisible.bind(this)}>
<span className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} onClick={this.toggleVisible}>
{ reason }
&nbsp;
<span className="mx_EventTile_spoiler_content" dangerouslySetInnerHTML={{ __html: this.props.contentHtml }} />

View file

@ -15,40 +15,40 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { highlightBlock } from 'highlight.js';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
className?: string;
children?: React.ReactNode;
}
@replaceableComponent("views.elements.SyntaxHighlight")
export default class SyntaxHighlight extends React.Component {
static propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default class SyntaxHighlight extends React.Component<IProps> {
private el: HTMLPreElement = null;
constructor(props) {
constructor(props: IProps) {
super(props);
this._ref = this._ref.bind(this);
}
// componentDidUpdate used here for reusability
componentDidUpdate() {
if (this._el) highlightBlock(this._el);
public componentDidUpdate(): void {
if (this.el) highlightBlock(this.el);
}
// call componentDidUpdate because _ref is fired on initial render
// which does not fire componentDidUpdate
_ref(el) {
this._el = el;
private ref = (el: HTMLPreElement): void => {
this.el = el;
this.componentDidUpdate();
}
};
render() {
public render(): JSX.Element {
const { className, children } = this.props;
return <pre className={`${className} mx_SyntaxHighlight`} ref={this._ref}>
return <pre className={`${className} mx_SyntaxHighlight`} ref={this.ref}>
<code>{ children }</code>
</pre>;
}
}

View file

@ -15,42 +15,44 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "./Tooltip";
interface IProps {
class?: string;
tooltipClass?: string;
tooltip: React.ReactNode;
tooltipProps?: {};
onClick?: (ev?: React.MouseEvent) => void;
}
interface IState {
hover: boolean;
}
@replaceableComponent("views.elements.TextWithTooltip")
export default class TextWithTooltip extends React.Component {
static propTypes = {
class: PropTypes.string,
tooltipClass: PropTypes.string,
tooltip: PropTypes.node.isRequired,
tooltipProps: PropTypes.object,
};
constructor() {
super();
export default class TextWithTooltip extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
onMouseOver = () => {
private onMouseOver = (): void => {
this.setState({ hover: true });
};
onMouseLeave = () => {
private onMouseLeave = (): void => {
this.setState({ hover: false });
};
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
public render(): JSX.Element {
const { class: className, children, tooltip, tooltipClass, tooltipProps, ...props } = this.props;
return (
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} onClick={this.props.onClick} className={className}>
{ children }
{ this.state.hover && <Tooltip
{...tooltipProps}

View file

@ -15,20 +15,20 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { replaceableComponent } from "../../../../utils/replaceableComponent";
import QRCode from "../QRCode";
import { QRCodeData } from "matrix-js-sdk/src/crypto/verification/QRCode";
interface IProps {
qrCodeData: QRCodeData;
}
@replaceableComponent("views.elements.crypto.VerificationQRCode")
export default class VerificationQRCode extends React.PureComponent {
static propTypes = {
qrCodeData: PropTypes.object.isRequired,
};
render() {
export default class VerificationQRCode extends React.PureComponent<IProps> {
public render(): JSX.Element {
return (
<QRCode
data={[{ data: this.props.qrCodeData.buffer, mode: 'byte' }]}
data={[{ data: this.props.qrCodeData.getBuffer(), mode: 'byte' }]}
className="mx_VerificationQRCode"
width={196} />
);

View file

@ -106,31 +106,20 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
}
const room = this.context.getRoom(mxEvent.getRoomId());
let label;
let label: string;
if (room) {
const senders = [];
for (const reactionEvent of reactionEvents) {
const member = room.getMember(reactionEvent.getSender());
const name = member ? member.name : reactionEvent.getSender();
senders.push(name);
senders.push(member?.name || reactionEvent.getSender());
}
const reactors = formatCommaSeparatedList(senders, 6);
if (content) {
label = _t("%(reactors)s reacted with %(content)s", { reactors, content });
} else {
label = reactors;
}
label = _t(
"<reactors/><reactedWith> reacted with %(content)s</reactedWith>",
{
content,
},
{
reactors: () => {
return formatCommaSeparatedList(senders, 6);
},
reactedWith: (sub) => {
if (!content) {
return null;
}
return sub;
},
},
);
}
const isPeeking = room.getMyMembership() !== "join";
return <AccessibleButton

View file

@ -429,7 +429,7 @@ const UserOptionsSection: React.FC<{
if (!isMe) {
directMessageButton = (
<AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') }
{ _t("Message") }
</AccessibleButton>
);
}
@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (canAffectUser && me.powerLevel >= editPowerLevel) {
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
@ -1052,8 +1052,7 @@ const PowerLevelEditor: React.FC<{
const cli = useContext(MatrixClientContext);
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
const onPowerChange = useCallback(async (powerLevelStr: string) => {
const powerLevel = parseInt(powerLevelStr, 10);
const onPowerChange = useCallback(async (powerLevel: number) => {
setSelectedPowerLevel(powerLevel);
const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => {

View file

@ -28,7 +28,7 @@ import { SAS } from "matrix-js-sdk/src/crypto/verification/SAS";
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import E2EIcon from "../rooms/E2EIcon";
import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import { Phase } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import Spinner from "../elements/Spinner";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -189,7 +189,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
// Element Web doesn't support scanning yet, so assume here we're the client being scanned.
body = <React.Fragment>
<p>{ description }</p>
<E2EIcon isUser={true} status="verified" size={128} hideTooltip={true} />
<E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
<div className="mx_VerificationPanel_reciprocateButtons">
<AccessibleButton
kind="danger"
@ -252,7 +252,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
<div className="mx_UserInfo_container mx_VerificationPanel_verified_section">
<h3>{ _t("Verified") }</h3>
<p>{ description }</p>
<E2EIcon isUser={true} status="verified" size={128} hideTooltip={true} />
<E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
{ text ? <p>{ text }</p> : null }
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}>
{ _t("Got it") }

View file

@ -97,7 +97,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
<AppTile
app={app}
fullWidth
show
showMenubar={false}
room={room}
userId={cli.getUserId()}

View file

@ -15,27 +15,43 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field";
import * as sdk from "../../../index";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton from "../elements/AccessibleButton";
import AvatarSetting from "../settings/AvatarSetting";
interface IProps {
roomId: string;
}
interface IState {
originalDisplayName: string;
displayName: string;
originalAvatarUrl: string;
avatarUrl: string;
avatarFile: File;
originalTopic: string;
topic: string;
enableProfileSave: boolean;
canSetName: boolean;
canSetTopic: boolean;
canSetAvatar: boolean;
}
// TODO: Merge with ProfileSettings?
@replaceableComponent("views.room_settings.RoomProfileSettings")
export default class RoomProfileSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
};
export default class RoomProfileSettings extends React.Component<IProps, IState> {
private avatarUpload = createRef<HTMLInputElement>();
constructor(props) {
constructor(props: IProps) {
super(props);
const client = MatrixClientPeg.get();
const room = client.getRoom(props.roomId);
if (!room) throw new Error("Expected a room for ID: ", props.roomId);
if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`);
const avatarEvent = room.currentState.getStateEvents("m.room.avatar", "");
let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null;
@ -60,17 +76,15 @@ export default class RoomProfileSettings extends React.Component {
canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
};
this._avatarUpload = createRef();
}
_uploadAvatar = () => {
this._avatarUpload.current.click();
private uploadAvatar = (): void => {
this.avatarUpload.current.click();
};
_removeAvatar = () => {
private removeAvatar = (): void => {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.avatarUpload.current.value = "";
this.setState({
avatarUrl: null,
avatarFile: null,
@ -78,7 +92,7 @@ export default class RoomProfileSettings extends React.Component {
});
};
_cancelProfileChanges = async (e) => {
private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
@ -92,7 +106,7 @@ export default class RoomProfileSettings extends React.Component {
});
};
_saveProfile = async (e) => {
private saveProfile = async (e: React.FormEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
@ -100,35 +114,46 @@ export default class RoomProfileSettings extends React.Component {
this.setState({ enableProfileSave: false });
const client = MatrixClientPeg.get();
const newState = {};
let originalDisplayName: string;
let avatarUrl: string;
let originalAvatarUrl: string;
let originalTopic: string;
let avatarFile: File;
// TODO: What do we do about errors?
const displayName = this.state.displayName.trim();
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setRoomName(this.props.roomId, displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
originalDisplayName = displayName;
}
if (this.state.avatarFile) {
const uri = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, '');
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null;
avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
originalAvatarUrl = avatarUrl;
avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, '');
}
if (this.state.originalTopic !== this.state.topic) {
await client.setRoomTopic(this.props.roomId, this.state.topic);
newState.originalTopic = this.state.topic;
originalTopic = this.state.topic;
}
this.setState(newState);
this.setState({
originalAvatarUrl,
avatarUrl,
originalDisplayName,
originalTopic,
displayName,
avatarFile,
});
};
_onDisplayNameChanged = (e) => {
private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ displayName: e.target.value });
if (this.state.originalDisplayName === e.target.value) {
this.setState({ enableProfileSave: false });
@ -137,7 +162,7 @@ export default class RoomProfileSettings extends React.Component {
}
};
_onTopicChanged = (e) => {
private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ topic: e.target.value });
if (this.state.originalTopic === e.target.value) {
this.setState({ enableProfileSave: false });
@ -146,7 +171,7 @@ export default class RoomProfileSettings extends React.Component {
}
};
_onAvatarChanged = (e) => {
private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
avatarUrl: this.state.originalAvatarUrl,
@ -160,7 +185,7 @@ export default class RoomProfileSettings extends React.Component {
const reader = new FileReader();
reader.onload = (ev) => {
this.setState({
avatarUrl: ev.target.result,
avatarUrl: String(ev.target.result),
avatarFile: file,
enableProfileSave: true,
});
@ -168,10 +193,7 @@ export default class RoomProfileSettings extends React.Component {
reader.readAsDataURL(file);
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
public render(): JSX.Element {
let profileSettingsButtons;
if (
this.state.canSetName ||
@ -181,14 +203,14 @@ export default class RoomProfileSettings extends React.Component {
profileSettingsButtons = (
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._cancelProfileChanges}
onClick={this.cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this._saveProfile}
onClick={this.saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
>
@ -200,16 +222,16 @@ export default class RoomProfileSettings extends React.Component {
return (
<form
onSubmit={this._saveProfile}
onSubmit={this.saveProfile}
autoComplete="off"
noValidate={true}
className="mx_ProfileSettings_profileForm"
>
<input
type="file"
ref={this._avatarUpload}
ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged}
onChange={this.onAvatarChanged}
accept="image/*"
/>
<div className="mx_ProfileSettings_profile">
@ -219,7 +241,7 @@ export default class RoomProfileSettings extends React.Component {
type="text"
value={this.state.displayName}
autoComplete="off"
onChange={this._onDisplayNameChanged}
onChange={this.onDisplayNameChanged}
disabled={!this.state.canSetName}
/>
<Field
@ -230,7 +252,7 @@ export default class RoomProfileSettings extends React.Component {
type="text"
value={this.state.topic}
autoComplete="off"
onChange={this._onTopicChanged}
onChange={this.onTopicChanged}
element="textarea"
/>
</div>
@ -238,8 +260,8 @@ export default class RoomProfileSettings extends React.Component {
avatarUrl={this.state.avatarUrl}
avatarName={this.state.displayName || this.props.roomId}
avatarAltText={_t("Room avatar")}
uploadAvatar={this.state.canSetAvatar ? this._uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this._removeAvatar : undefined} />
uploadAvatar={this.state.canSetAvatar ? this.uploadAvatar : undefined}
removeAvatar={this.state.canSetAvatar ? this.removeAvatar : undefined} />
</div>
{ profileSettingsButtons }
</form>

View file

@ -18,8 +18,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t, _td } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher";
@ -27,21 +25,22 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions";
import { SettingLevel } from "../../../settings/SettingLevel";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import SettingsFlag from "../elements/SettingsFlag";
interface IProps {
room: Room;
}
@replaceableComponent("views.room_settings.UrlPreviewSettings")
export default class UrlPreviewSettings extends React.Component {
static propTypes = {
room: PropTypes.object,
};
_onClickUserSettings = (e) => {
export default class UrlPreviewSettings extends React.Component<IProps> {
private onClickUserSettings = (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
dis.fire(Action.ViewUserSettings);
};
render() {
const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
public render(): JSX.Element {
const roomId = this.props.room.roomId;
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
@ -54,18 +53,18 @@ export default class UrlPreviewSettings extends React.Component {
if (accountEnabled) {
previewsForAccount = (
_t("You have <a>enabled</a> URL previews by default.", {}, {
'a': (sub)=><a onClick={this._onClickUserSettings} href=''>{ sub }</a>,
'a': (sub)=><a onClick={this.onClickUserSettings} href=''>{ sub }</a>,
})
);
} else {
previewsForAccount = (
_t("You have <a>disabled</a> URL previews by default.", {}, {
'a': (sub)=><a onClick={this._onClickUserSettings} href=''>{ sub }</a>,
'a': (sub)=><a onClick={this.onClickUserSettings} href=''>{ sub }</a>,
})
);
}
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) {
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
previewsForRoom = (
<label>
<SettingsFlag

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Resizable } from "re-resizable";
@ -26,8 +25,6 @@ import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer";
@ -37,60 +34,74 @@ import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers";
import { useStateCallback } from "../../../hooks/useStateCallback";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
import { ActionPayload } from "../../../dispatcher/payloads";
interface IProps {
userId: string;
room: Room;
resizeNotifier: ResizeNotifier;
showApps?: boolean; // Should apps be rendered
maxHeight: number;
}
interface IState {
apps: IApp[];
resizingVertical: boolean; // true when changing the height of the apps drawer
resizingHorizontal: boolean; // true when chagning the distribution of the width between widgets
resizing: boolean;
}
@replaceableComponent("views.rooms.AppsDrawer")
export default class AppsDrawer extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired,
showApps: PropTypes.bool, // Should apps be rendered
};
static defaultProps = {
export default class AppsDrawer extends React.Component<IProps, IState> {
private resizeContainer: HTMLDivElement;
private resizer: Resizer;
private dispatcherRef: string;
public static defaultProps: Partial<IProps> = {
showApps: true,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this.state = {
apps: this._getApps(),
resizingVertical: false, // true when changing the height of the apps drawer
resizingHorizontal: false, // true when chagning the distribution of the width between widgets
apps: this.getApps(),
resizingVertical: false,
resizingHorizontal: false,
resizing: false,
};
this._resizeContainer = null;
this.resizer = this._createResizer();
this.resizer = this.createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
}
componentDidMount() {
public componentDidMount(): void {
ScalarMessaging.startListening();
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
public componentWillUnmount(): void {
ScalarMessaging.stopListening();
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this.updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this._resizeContainer) {
if (this.resizeContainer) {
this.resizer.detach();
}
this.props.resizeNotifier.off("isResizing", this.onIsResizing);
}
onIsResizing = (resizing) => {
private onIsResizing = (resizing: boolean): void => {
// This one is the vertical, ie. change height of apps drawer
this.setState({ resizingVertical: resizing });
if (!resizing) {
this._relaxResizer();
this.relaxResizer();
}
};
_createResizer() {
private createResizer(): Resizer {
// This is the horizontal one, changing the distribution of the width between the app tiles
// (ie. a vertical resize handle because, the handle itself is vertical...)
const classNames = {
@ -100,11 +111,11 @@ export default class AppsDrawer extends React.Component {
};
const collapseConfig = {
onResizeStart: () => {
this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.setState({ resizingHorizontal: true });
},
onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
this.resizeContainer.classList.remove("mx_AppsDrawer_resizing");
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room, Container.Top,
this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
@ -113,13 +124,13 @@ export default class AppsDrawer extends React.Component {
},
};
// pass a truthy container for now, we won't call attach until we update it
const resizer = new Resizer({}, PercentageDistributor, collapseConfig);
const resizer = new Resizer(null, PercentageDistributor, collapseConfig);
resizer.setClassNames(classNames);
return resizer;
}
_collectResizer = (ref) => {
if (this._resizeContainer) {
private collectResizer = (ref: HTMLDivElement): void => {
if (this.resizeContainer) {
this.resizer.detach();
}
@ -127,22 +138,22 @@ export default class AppsDrawer extends React.Component {
this.resizer.container = ref;
this.resizer.attach();
}
this._resizeContainer = ref;
this._loadResizerPreferences();
this.resizeContainer = ref;
this.loadResizerPreferences();
};
_getAppsHash = (apps) => apps.map(app => app.id).join("~");
private getAppsHash = (apps: IApp[]): string => apps.map(app => app.id).join("~");
componentDidUpdate(prevProps, prevState) {
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps
this._updateApps();
} else if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
this._loadResizerPreferences();
this.updateApps();
} else if (this.getAppsHash(this.state.apps) !== this.getAppsHash(prevState.apps)) {
this.loadResizerPreferences();
}
}
_relaxResizer = () => {
private relaxResizer = (): void => {
const distributors = this.resizer.getDistributors();
// relax all items if they had any overconstrained flexboxes
@ -150,7 +161,7 @@ export default class AppsDrawer extends React.Component {
distributors.forEach(d => d.finish());
};
_loadResizerPreferences = () => {
private loadResizerPreferences = (): void => {
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) => {
@ -168,11 +179,11 @@ export default class AppsDrawer extends React.Component {
}
};
isResizing() {
private isResizing(): boolean {
return this.state.resizingVertical || this.state.resizingHorizontal;
}
onAction = (action) => {
private onAction = (action: ActionPayload): void => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
case 'appsDrawer':
@ -190,23 +201,15 @@ export default class AppsDrawer extends React.Component {
}
};
_getApps = () => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
private getApps = (): IApp[] => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
_updateApps = () => {
private updateApps = (): void => {
this.setState({
apps: this._getApps(),
apps: this.getApps(),
});
};
_launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(this.props.room, 'add_integ');
}
}
render() {
public render(): JSX.Element {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => {
@ -257,7 +260,7 @@ export default class AppsDrawer extends React.Component {
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
<div className="mx_AppsContainer" ref={this._collectResizer}>
<div className="mx_AppsContainer" ref={this.collectResizer}>
{ apps.map((app, i) => {
if (i < 1) return app;
return <React.Fragment key={app.key}>
@ -273,7 +276,18 @@ export default class AppsDrawer extends React.Component {
}
}
const PersistentVResizer = ({
interface IPersistentResizerProps {
room: Room;
minHeight: number;
maxHeight: number;
className: string;
handleWrapperClass: string;
handleClass: string;
resizeNotifier: ResizeNotifier;
children: React.ReactNode;
}
const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
room,
minHeight,
maxHeight,
@ -303,7 +317,7 @@ const PersistentVResizer = ({
});
return <Resizable
size={{ height: Math.min(height, maxHeight) }}
size={{ height: Math.min(height, maxHeight), width: null }}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$');
export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
}
private replaceEmoticon = (caretPosition: DocumentPosition): number => {
public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number {
const { model } = this.props;
const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition,
@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
const emoticonMatch = regex.exec(range.text);
if (emoticonMatch) {
const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p
@ -180,18 +181,25 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) {
const { partCreator } = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
const firstMatch = emoticonMatch[0];
const moveStart = firstMatch[0] === " " ? 1 : 0;
// we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space.
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
range.moveStartForwards(emoticonMatch.index + moveStart);
// If the end is a trailing space/newline move end backwards, so that we don't replace it
if (["\n", " "].includes(firstMatch[firstMatch.length - 1])) {
range.moveEndBackwards(1);
}
// this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted.
return range.replace([partCreator.plain(data.unicode + " ")]);
return range.replace([partCreator.plain(data.unicode)]);
}
}
};
}
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model);
@ -607,8 +615,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
};
private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
this.props.model.setTransformCallback(this.transform);
};
private configureShouldShowPillAvatar = (): void => {
@ -621,6 +628,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith });
};
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

@ -16,41 +16,51 @@ limitations under the License.
*/
import React, { useState } from "react";
import PropTypes from "prop-types";
import classNames from 'classnames';
import { _t, _td } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import Tooltip from "../elements/Tooltip";
import { E2EStatus } from "../../../utils/ShieldUtils";
export const E2E_STATE = {
VERIFIED: "verified",
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
UNAUTHENTICATED: "unauthenticated",
export enum E2EState {
Verified = "verified",
Warning = "warning",
Unknown = "unknown",
Normal = "normal",
Unauthenticated = "unauthenticated",
}
const crossSigningUserTitles: { [key in E2EState]?: string } = {
[E2EState.Warning]: _td("This user has not verified all of their sessions."),
[E2EState.Normal]: _td("You have not verified this user."),
[E2EState.Verified]: _td("You have verified this user. This user has verified all of their sessions."),
};
const crossSigningRoomTitles: { [key in E2EState]?: string } = {
[E2EState.Warning]: _td("Someone is using an unknown session"),
[E2EState.Normal]: _td("This room is end-to-end encrypted"),
[E2EState.Verified]: _td("Everyone in this room is verified"),
};
const crossSigningUserTitles = {
[E2E_STATE.WARNING]: _td("This user has not verified all of their sessions."),
[E2E_STATE.NORMAL]: _td("You have not verified this user."),
[E2E_STATE.VERIFIED]: _td("You have verified this user. This user has verified all of their sessions."),
};
const crossSigningRoomTitles = {
[E2E_STATE.WARNING]: _td("Someone is using an unknown session"),
[E2E_STATE.NORMAL]: _td("This room is end-to-end encrypted"),
[E2E_STATE.VERIFIED]: _td("Everyone in this room is verified"),
};
interface IProps {
isUser?: boolean;
status?: E2EState | E2EStatus;
className?: string;
size?: number;
onClick?: () => void;
hideTooltip?: boolean;
bordered?: boolean;
}
const E2EIcon = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => {
const E2EIcon: React.FC<IProps> = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => {
const [hover, setHover] = useState(false);
const classes = classNames({
mx_E2EIcon: true,
mx_E2EIcon_bordered: bordered,
mx_E2EIcon_warning: status === E2E_STATE.WARNING,
mx_E2EIcon_normal: status === E2E_STATE.NORMAL,
mx_E2EIcon_verified: status === E2E_STATE.VERIFIED,
mx_E2EIcon_warning: status === E2EState.Warning,
mx_E2EIcon_normal: status === E2EState.Normal,
mx_E2EIcon_verified: status === E2EState.Verified,
}, className);
let e2eTitle;
@ -92,12 +102,4 @@ const E2EIcon = ({ isUser, status, className, size, onClick, hideTooltip, border
</div>;
};
E2EIcon.propTypes = {
isUser: PropTypes.bool,
status: PropTypes.oneOf(Object.values(E2E_STATE)),
className: PropTypes.string,
size: PropTypes.number,
onClick: PropTypes.func,
};
export default E2EIcon;

View file

@ -27,7 +27,7 @@ import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions";
@ -42,6 +42,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton';
import SettingsStore from "../../../settings/SettingsStore";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
@ -315,6 +316,14 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private sendEdit = async (): Promise<void> => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent();
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"];

View file

@ -20,7 +20,7 @@ import React from 'react';
import AccessibleButton from '../elements/AccessibleButton';
import { _td } from '../../../languageHandler';
import classNames from "classnames";
import E2EIcon from './E2EIcon';
import E2EIcon, { E2EState } from './E2EIcon';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseAvatar from '../avatars/BaseAvatar';
import PresenceLabel from "./PresenceLabel";
@ -75,7 +75,7 @@ interface IProps {
suppressOnHover?: boolean;
showPresence?: boolean;
subtextLabel?: string;
e2eStatus?: string;
e2eStatus?: E2EState;
powerStatus?: PowerStatus;
}

View file

@ -21,7 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -33,7 +33,7 @@ import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { E2E_STATE } from "./E2EIcon";
import { E2EState } from "./E2EIcon";
import { toRem } from "../../../utils/units";
import { WidgetType } from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
@ -464,8 +464,8 @@ export default class EventTile extends React.Component<IProps, IState> {
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
this.props.mxEvent.once(ThreadEvent.Ready, this.updateThread);
this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
}
}
@ -521,7 +521,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) {
if (!thread || this.props.showThreadInfo === false || thread.length <= 1) {
return null;
}
@ -605,7 +605,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (encryptionInfo.mismatchedSender) {
// something definitely wrong is going on here
this.setState({
verified: E2E_STATE.WARNING,
verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
@ -613,7 +613,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal
this.setState({
verified: E2E_STATE.NORMAL,
verified: E2EState.Normal,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
@ -623,27 +623,27 @@ export default class EventTile extends React.Component<IProps, IState> {
);
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
verified: E2EState.Unknown,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!eventSenderTrust.isVerified()) {
this.setState({
verified: E2E_STATE.WARNING,
verified: E2EState.Warning,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
if (!encryptionInfo.authenticated) {
this.setState({
verified: E2E_STATE.UNAUTHENTICATED,
verified: E2EState.Unauthenticated,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
this.setState({
verified: E2E_STATE.VERIFIED,
verified: E2EState.Verified,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}
@ -850,13 +850,13 @@ export default class EventTile extends React.Component<IProps, IState> {
// event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) {
if (this.state.verified === E2E_STATE.NORMAL) {
if (this.state.verified === E2EState.Normal) {
return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) {
} else if (this.state.verified === E2EState.Verified) {
return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) {
} else if (this.state.verified === E2EState.Unauthenticated) {
return (<E2ePadlockUnauthenticated />);
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
} else if (this.state.verified === E2EState.Unknown) {
return (<E2ePadlockUnknown />);
} else {
return (<E2ePadlockUnverified />);
@ -961,9 +961,9 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2EState.Verified,
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2EState.Warning,
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2EState.Unknown,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_noSender: this.props.hideSender,
@ -1192,14 +1192,19 @@ export default class EventTile extends React.Component<IProps, IState> {
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
let thread;
// When the "showHiddenEventsInTimeline" lab is enabled,
// avoid showing replies for hidden events (events without tiles)
if (haveTileForEvent(this.props.mxEvent)) {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();

View file

@ -32,6 +32,7 @@ import {
ContextMenu,
useContextMenu,
MenuItem,
AboveLeftOf,
} from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
@ -56,7 +57,7 @@ let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps {
me: object;
me: RoomMember;
}
function ComposerAvatar(props: IComposerAvatarProps) {
@ -511,7 +512,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
null,
];
let menuPosition;
let menuPosition: AboveLeftOf | undefined;
if (this.ref.current) {
const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { MouseEvent } from "react";
import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps {
notification: NotificationState;
@ -39,6 +42,7 @@ interface IProps {
}
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/**
* If specified will return an AccessibleButton instead of a div.
*/
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
showTooltip: boolean;
}
@replaceableComponent("views.rooms.NotificationBadge")
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
showTooltip: false,
};
this.countWatcherRef = SettingsStore.watchSetting(
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.forceUpdate(); // notification state changed - update
};
private onMouseOver = (e: MouseEvent) => {
e.stopPropagation();
this.setState({
showTooltip: true,
});
};
private onMouseLeave = () => {
this.setState({
showTooltip: false,
});
};
public render(): React.ReactElement {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, forceCount, roomId, onClick, ...props } = this.props;
const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
// Don't show a badge if we don't need to
if (notification.isIdle) return null;
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
});
if (onClick) {
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<AccessibleButton
aria-label={label}
{...props}
className={classes}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span>
{ tooltip }
</AccessibleButton>
);
}

View file

@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
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 && unfilteredFavourite < 1) {
if (unfilteredRooms.length < 1 && unfilteredHistorical.length < 1 && unfilteredFavourite.length < 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

@ -14,8 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IJoinRuleEventContent, JoinRule } from "matrix-js-sdk/src/@types/partials";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
@ -27,91 +32,102 @@ import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
Loading: "Loading",
Rejecting: "Rejecting",
Kicked: "Kicked",
Banned: "Banned",
OtherThreePIDError: "OtherThreePIDError",
InvitedEmailNotFoundInAccount: "InvitedEmailNotFoundInAccount",
InvitedEmailNoIdentityServer: "InvitedEmailNoIdentityServer",
InvitedEmailMismatch: "InvitedEmailMismatch",
Invite: "Invite",
ViewingRoom: "ViewingRoom",
RoomNotFound: "RoomNotFound",
OtherError: "OtherError",
});
enum MessageCase {
NotLoggedIn = "NotLoggedIn",
Joining = "Joining",
Loading = "Loading",
Rejecting = "Rejecting",
Kicked = "Kicked",
Banned = "Banned",
OtherThreePIDError = "OtherThreePIDError",
InvitedEmailNotFoundInAccount = "InvitedEmailNotFoundInAccount",
InvitedEmailNoIdentityServer = "InvitedEmailNoIdentityServer",
InvitedEmailMismatch = "InvitedEmailMismatch",
Invite = "Invite",
ViewingRoom = "ViewingRoom",
RoomNotFound = "RoomNotFound",
OtherError = "OtherError",
}
interface IProps {
// if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifying inviterName
inviterName?: string;
// If invited by 3rd party invite, the email address the invite was sent to
invitedEmail?: string;
// For third party invites, information passed about the room out-of-band
oobData?: IOOBData;
// For third party invites, a URL for a 3pid invite signing service
signUrl?: string;
// A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason.
error?: MatrixError;
canPreview?: boolean;
previewLoading?: boolean;
room?: Room;
loading?: boolean;
joining?: boolean;
rejecting?: boolean;
// The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg.
// in error messages).
roomAlias?: string;
onJoinClick?(): void;
onRejectClick?(): void;
onRejectAndIgnoreClick?(): void;
onForgetClick?(): void;
}
interface IState {
busy: boolean;
accountEmails?: string[];
invitedEmailMxid?: string;
threePidFetchError?: MatrixError;
}
@replaceableComponent("views.rooms.RoomPreviewBar")
export default class RoomPreviewBar extends React.Component {
static propTypes = {
onJoinClick: PropTypes.func,
onRejectClick: PropTypes.func,
onRejectAndIgnoreClick: PropTypes.func,
onForgetClick: PropTypes.func,
// if inviterName is specified, the preview bar will shown an invite to the room.
// You should also specify onRejectClick if specifiying inviterName
inviterName: PropTypes.string,
// If invited by 3rd party invite, the email address the invite was sent to
invitedEmail: PropTypes.string,
// For third party invites, information passed about the room out-of-band
oobData: PropTypes.object,
// For third party invites, a URL for a 3pid invite signing service
signUrl: PropTypes.string,
// A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason.
error: PropTypes.object,
canPreview: PropTypes.bool,
previewLoading: PropTypes.bool,
room: PropTypes.object,
// When a spinner is present, a spinnerState can be specified to indicate the
// purpose of the spinner.
spinner: PropTypes.bool,
spinnerState: PropTypes.oneOf(["joining"]),
loading: PropTypes.bool,
joining: PropTypes.bool,
rejecting: PropTypes.bool,
// The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg.
// in error messages).
roomAlias: PropTypes.string,
};
export default class RoomPreviewBar extends React.Component<IProps, IState> {
static defaultProps = {
onJoinClick() {},
};
state = {
busy: false,
};
constructor(props) {
super(props);
this.state = {
busy: false,
};
}
componentDidMount() {
this._checkInvitedEmail();
CommunityPrototypeStore.instance.on(UPDATE_EVENT, this._onCommunityUpdate);
this.checkInvitedEmail();
CommunityPrototypeStore.instance.on(UPDATE_EVENT, this.onCommunityUpdate);
}
componentDidUpdate(prevProps, prevState) {
if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
this._checkInvitedEmail();
this.checkInvitedEmail();
}
}
componentWillUnmount() {
CommunityPrototypeStore.instance.off(UPDATE_EVENT, this._onCommunityUpdate);
CommunityPrototypeStore.instance.off(UPDATE_EVENT, this.onCommunityUpdate);
}
async _checkInvitedEmail() {
private async checkInvitedEmail() {
// If this is an invite and we've been told what email address was
// invited, fetch the user's account emails and discovery bindings so we
// can check them against the email that was invited.
@ -121,8 +137,7 @@ export default class RoomPreviewBar extends React.Component {
// Gather the account 3PIDs
const account3pids = await MatrixClientPeg.get().getThreePids();
this.setState({
accountEmails: account3pids.threepids
.filter(b => b.medium === 'email').map(b => b.address),
accountEmails: account3pids.threepids.filter(b => b.medium === 'email').map(b => b.address),
});
// If we have an IS connected, use that to lookup the email and
// check the bound MXID.
@ -146,21 +161,21 @@ export default class RoomPreviewBar extends React.Component {
}
}
_onCommunityUpdate = (roomId) => {
private onCommunityUpdate = (roomId: string): void => {
if (this.props.room && this.props.room.roomId !== roomId) {
return;
}
this.forceUpdate(); // we have nothing to update
};
_getMessageCase() {
private getMessageCase(): MessageCase {
const isGuest = MatrixClientPeg.get().isGuest();
if (isGuest) {
return MessageCase.NotLoggedIn;
}
const myMember = this._getMyMember();
const myMember = this.getMyMember();
if (myMember) {
if (myMember.isKicked()) {
@ -195,7 +210,7 @@ export default class RoomPreviewBar extends React.Component {
}
return MessageCase.Invite;
} else if (this.props.error) {
if (this.props.error.errcode == 'M_NOT_FOUND') {
if ((this.props.error as MatrixError).errcode == 'M_NOT_FOUND') {
return MessageCase.RoomNotFound;
} else {
return MessageCase.OtherError;
@ -205,8 +220,8 @@ export default class RoomPreviewBar extends React.Component {
}
}
_getKickOrBanInfo() {
const myMember = this._getMyMember();
private getKickOrBanInfo(): { memberName?: string, reason?: string } {
const myMember = this.getMyMember();
if (!myMember) {
return {};
}
@ -219,24 +234,19 @@ export default class RoomPreviewBar extends React.Component {
return { memberName, reason };
}
_joinRule() {
const room = this.props.room;
if (room) {
const joinRules = room.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
return joinRules.getContent().join_rule;
}
}
private joinRule(): JoinRule {
return this.props.room?.currentState
.getStateEvents(EventType.RoomJoinRules, "")?.getContent<IJoinRuleEventContent>().join_rule;
}
_communityProfile() {
private communityProfile(): { displayName?: string, avatarMxc?: string } {
if (this.props.room) return CommunityPrototypeStore.instance.getInviteProfile(this.props.room.roomId);
return { displayName: null, avatarMxc: null };
}
_roomName(atStart = false) {
private roomName(atStart = false): string {
let name = this.props.room ? this.props.room.name : this.props.roomAlias;
const profile = this._communityProfile();
const profile = this.communityProfile();
if (profile.displayName) name = profile.displayName;
if (name) {
return name;
@ -247,14 +257,11 @@ export default class RoomPreviewBar extends React.Component {
}
}
_getMyMember() {
return (
this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId())
);
private getMyMember(): RoomMember {
return this.props.room?.getMember(MatrixClientPeg.get().getUserId());
}
_getInviteMember() {
private getInviteMember(): RoomMember {
const { room } = this.props;
if (!room) {
return;
@ -268,8 +275,8 @@ export default class RoomPreviewBar extends React.Component {
return room.currentState.getMember(inviterUserId);
}
_isDMInvite() {
const myMember = this._getMyMember();
private isDMInvite(): boolean {
const myMember = this.getMyMember();
if (!myMember) {
return false;
}
@ -278,7 +285,7 @@ export default class RoomPreviewBar extends React.Component {
return memberContent.membership === "invite" && memberContent.is_direct;
}
_makeScreenAfterLogin() {
private makeScreenAfterLogin(): { screen: string, params: Record<string, any> } {
return {
screen: 'room',
params: {
@ -291,18 +298,16 @@ export default class RoomPreviewBar extends React.Component {
};
}
onLoginClick = () => {
dis.dispatch({ action: 'start_login', screenAfterLogin: this._makeScreenAfterLogin() });
private onLoginClick = () => {
dis.dispatch({ action: 'start_login', screenAfterLogin: this.makeScreenAfterLogin() });
};
onRegisterClick = () => {
dis.dispatch({ action: 'start_registration', screenAfterLogin: this._makeScreenAfterLogin() });
private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration', screenAfterLogin: this.makeScreenAfterLogin() });
};
render() {
const brand = SdkConfig.get().brand;
const Spinner = sdk.getComponent('elements.Spinner');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let showSpinner = false;
let title;
@ -315,10 +320,10 @@ export default class RoomPreviewBar extends React.Component {
let footer;
const extraComponents = [];
const messageCase = this._getMessageCase();
const messageCase = this.getMessageCase();
switch (messageCase) {
case MessageCase.Joining: {
title = _t("Joining room …");
title = this.props.oobData?.roomType === RoomType.Space ? _t("Joining space …") : _t("Joining room …");
showSpinner = true;
break;
}
@ -349,12 +354,12 @@ export default class RoomPreviewBar extends React.Component {
break;
}
case MessageCase.Kicked: {
const { memberName, reason } = this._getKickOrBanInfo();
const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were kicked from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() });
{ memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
if (this._joinRule() === "invite") {
if (this.joinRule() === "invite") {
primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick;
} else {
@ -366,9 +371,9 @@ export default class RoomPreviewBar extends React.Component {
break;
}
case MessageCase.Banned: {
const { memberName, reason } = this._getKickOrBanInfo();
const { memberName, reason } = this.getKickOrBanInfo();
title = _t("You were banned from %(roomName)s by %(memberName)s",
{ memberName, roomName: this._roomName() });
{ memberName, roomName: this.roomName() });
subTitle = reason ? _t("Reason: %(reason)s", { reason }) : null;
primaryActionLabel = _t("Forget this room");
primaryActionHandler = this.props.onForgetClick;
@ -376,8 +381,8 @@ export default class RoomPreviewBar extends React.Component {
}
case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to %(roomName)s",
{ roomName: this._roomName() });
const joinRule = this._joinRule();
{ roomName: this.roomName() });
const joinRule = this.joinRule();
const errCodeMessage = _t(
"An error (%(errcode)s) was returned while trying to validate your " +
"invite. You could try to pass this information on to a room admin.",
@ -410,7 +415,7 @@ export default class RoomPreviewBar extends React.Component {
"This invite to %(roomName)s was sent to %(email)s which is not " +
"associated with your account",
{
roomName: this._roomName(),
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
@ -427,7 +432,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName: this._roomName(),
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
@ -443,7 +448,7 @@ export default class RoomPreviewBar extends React.Component {
title = _t(
"This invite to %(roomName)s was sent to %(email)s",
{
roomName: this._roomName(),
roomName: this.roomName(),
email: this.props.invitedEmail,
},
);
@ -458,11 +463,11 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const oobData = Object.assign({}, this.props.oobData, {
avatarUrl: this._communityProfile().avatarMxc,
avatarUrl: this.communityProfile().avatarMxc,
});
const avatar = <RoomAvatar room={this.props.room} oobData={oobData} />;
const inviteMember = this._getInviteMember();
const inviteMember = this.getInviteMember();
let inviterElement;
if (inviteMember) {
inviterElement = <span>
@ -474,7 +479,7 @@ export default class RoomPreviewBar extends React.Component {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{ this.props.inviterName }</span>);
}
const isDM = this._isDMInvite();
const isDM = this.isDMInvite();
if (isDM) {
title = _t("Do you want to chat with %(user)s?",
{ user: inviteMember.name });
@ -485,7 +490,7 @@ export default class RoomPreviewBar extends React.Component {
primaryActionLabel = _t("Start chatting");
} else {
title = _t("Do you want to join %(roomName)s?",
{ roomName: this._roomName() });
{ roomName: this.roomName() });
subTitle = [
avatar,
_t("<userName/> invited you", {}, { userName: () => inviterElement }),
@ -519,22 +524,22 @@ export default class RoomPreviewBar extends React.Component {
case MessageCase.ViewingRoom: {
if (this.props.canPreview) {
title = _t("You're previewing %(roomName)s. Want to join it?",
{ roomName: this._roomName() });
{ roomName: this.roomName() });
} else {
title = _t("%(roomName)s can't be previewed. Do you want to join it?",
{ roomName: this._roomName(true) });
{ roomName: this.roomName(true) });
}
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
break;
}
case MessageCase.RoomNotFound: {
title = _t("%(roomName)s does not exist.", { roomName: this._roomName(true) });
title = _t("%(roomName)s does not exist.", { roomName: this.roomName(true) });
subTitle = _t("This room doesn't exist. Are you sure you're at the right place?");
break;
}
case MessageCase.OtherError: {
title = _t("%(roomName)s is not accessible at this time.", { roomName: this._roomName(true) });
title = _t("%(roomName)s is not accessible at this time.", { roomName: this.roomName(true) });
subTitle = [
_t("Try again later, or ask a room admin to check if you have access."),
_t(

View file

@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
showUnsentTooltip={true}
/>
);

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,8 +50,6 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
interface IProps {
room: Room;
@ -68,7 +65,6 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
hasUnsentEvents: boolean;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
@ -106,11 +101,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
private onRoomNameUpdate = (room: Room) => {
this.forceUpdate();
};
@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room?.roomId !== this.props.room.roomId) return;
this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 });
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
@ -178,12 +164,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate);
this.props.room?.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
public componentWillUnmount() {
@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
private onAction = (payload: ActionPayload) => {
@ -587,30 +571,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>;
let badge: React.ReactNode;
if (!this.props.isMinimized) {
if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
let messagePreview = null;

View file

@ -31,8 +31,8 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
public async sendMessage(): Promise<void> {
if (this.model.isEmpty) {
const model = this.model;
if (model.isEmpty) {
return;
}
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent;
let shouldSend = true;
let content;
if (!containsEmote(this.model) && this.isSlashCommand()) {
if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) {
if (cmd.category === CommandCategories.messages) {
@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
}
if (isQuickReaction(this.model)) {
if (isQuickReaction(model)) {
shouldSend = false;
this.sendQuickReaction();
}
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(
this.model,
model,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
}
this.sendHistoryManager.save(this.model, replyToEvent);
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
this.model.reset([]);
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();

View file

@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import ScalarAuthClient from '../../../ScalarAuthClient';
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
import { IApp } from "../../../stores/WidgetStore";
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu.
@ -256,12 +257,16 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp = {
const stickerApp: IApp = {
id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type,
data: stickerpickerWidget.content.data,
roomId: stickerpickerWidget.content.roomId,
eventId: stickerpickerWidget.content.eventId,
avatar_url: stickerpickerWidget.content.avatar_url,
creatorUserId: stickerpickerWidget.content.creatorUserId,
};
stickersContent = (
@ -287,9 +292,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this.onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
@ -345,16 +348,6 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
});
};
/**
* Trigger hiding of the sticker picker overlay
* @param {Event} ev Event that triggered the function call
*/
private onHideStickersClick = (ev: React.MouseEvent): void => {
if (this.props.showStickers) {
this.props.setShowStickers(false);
}
};
/**
* Called when the window is resized
*/

View file

@ -28,6 +28,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
@ -207,27 +208,50 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
} else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
const modal = Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId: room.roomId,
targetVersion,
description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."),
onFinished: async (resp) => {
if (!resp?.continue) return;
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
},
});
const [resp] = await modal.finished;
if (!resp?.continue) return;
const userId = cli.getUserId();
const unableToUpdateSomeParents = Array.from(SpaceStore.instance.getKnownParents(room.roomId))
.some(roomId => !cli.getRoom(roomId)?.currentState.maySendStateEvent(EventType.SpaceChild, userId));
if (unableToUpdateSomeParents) {
const modal = Modal.createTrackedDialog<[boolean]>('Parent relink warning', '', QuestionDialog, {
title: _t("Before you upgrade"),
description: (
<div>{ _t("This room is in some spaces youre not an admin of. " +
"In those spaces, the old room will still be shown, " +
"but people will be prompted to join the new one.") }</div>
),
hasCancelButton: true,
button: _t("Upgrade anyway"),
danger: true,
});
const [shouldUpgrade] = await modal.finished;
if (!shouldUpgrade) return;
}
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
return;
}

View file

@ -137,7 +137,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
}
}
private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => {
private onPowerLevelsChanged = (value: number, powerLevelKey: string) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
@ -148,8 +148,6 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const eventsLevelPrefix = "event_levels_";
const value = parseInt(inputValue);
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy
plContent["events"] = Object.assign({}, plContent["events"] || {});
@ -181,7 +179,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
});
};
private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => {
private onUserPowerLevelChanged = (value: number, powerLevelKey: string) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');

View file

@ -28,7 +28,6 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
@ -145,7 +144,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
];
static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
"showCommunitiesInsteadOfSpaces",
];
static KEYBINDINGS_SETTINGS = [
@ -286,9 +285,17 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
private renderGroup(settingIds: string[]): React.ReactNodeArray {
return settingIds.filter(SettingsStore.isEnabled).map(i => {
return <SettingsFlag key={i} name={i} level={SettingLevel.ACCOUNT} />;
private renderGroup(
settingIds: string[],
level = SettingLevel.ACCOUNT,
includeDisabled = false,
): React.ReactNodeArray {
if (!includeDisabled) {
settingIds = settingIds.filter(SettingsStore.isEnabled);
}
return settingIds.map(i => {
return <SettingsFlag key={i} name={i} level={level} />;
});
}
@ -334,10 +341,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT, true) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Communities") }</span>
@ -349,7 +356,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
<CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS, SettingLevel.DEVICE) }
</div>
<div className="mx_SettingsTab_section">

View file

@ -97,9 +97,8 @@ const spaceNameValidator = withValidation({
],
});
const nameToAlias = (name: string, domain: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
const nameToLocalpart = (name: string): string => {
return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
};
// XXX: Temporary for the Spaces release only
@ -118,9 +117,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
"Your feedback will help inform the next versions."),
rageshakeLabel: "spaces-feedback",
rageshakeData: Object.fromEntries([
"feature_spaces.all_rooms",
"feature_spaces.space_member_dms",
"feature_spaces.space_dm_badges",
"Spaces.allRoomsInHome",
].map(k => [k, SettingsStore.getValue(k)])),
});
}}
@ -176,8 +173,9 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
value={name}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) {
setAlias(nameToAlias(newName, domain));
if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
setAlias(`#${nameToLocalpart(newName)}:${domain}`);
aliasFieldRef.current?.validate({ allowEmpty: true });
}
setName(newName);
}}
@ -194,7 +192,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
onKeyDown={onKeyDown}
@ -217,6 +215,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
};
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
const [busy, setBusy] = useState<boolean>(false);
@ -233,14 +232,18 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
// validate the space alias field but do not require it
const aliasLocalpart = alias.substring(1, alias.length - cli.getDomain().length - 1);
if (visibility === Visibility.Public && aliasLocalpart &&
(await spaceAliasField.current.validate({ allowEmpty: true })) === false
) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
@ -248,7 +251,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
}
try {
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
await createSpace(
name,
visibility === Visibility.Public,
aliasLocalpart ? alias : undefined,
topic,
avatar,
);
onFinished();
} catch (e) {
@ -290,13 +299,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
/>
<p>
{ _t("You can also create a Space from a <a>community</a>.", {}, {
{ _t("You can also make Spaces from <a>communities</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub }
</AccessibleButton>,
}) }
<br />
{ _t("To join an existing space you'll need an invite.") }
{ _t("To join a space you'll need an invite.") }
</p>
<SpaceFeedbackPrompt onClick={onFinished} />

View file

@ -151,12 +151,19 @@ const CreateSpaceButton = ({
}
const onNewClick = menuDisplayed ? closeMenu : () => {
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpaces", "1");
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
let betaDot: JSX.Element;
if (!localStorage.getItem("mx_seenSpaces") && !SpaceStore.instance.spacePanelSpaces.length) {
betaDot = <div className="mx_BetaDot" />;
}
return <li
className={classNames("mx_SpaceItem", {
className={classNames("mx_SpaceItem mx_SpaceItem_new", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
@ -169,6 +176,7 @@ const CreateSpaceButton = ({
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ betaDot }
{ contextMenu }
</li>;

View file

@ -93,6 +93,7 @@ export const SpaceButton: React.FC<IButtonProps> = ({
notification={notificationState}
aria-label={ariaLabel}
tabIndex={tabIndex}
showUnsentTooltip={true}
/>
</div>;
}

View file

@ -214,6 +214,8 @@ export default class CallView extends React.Component<IProps, IState> {
this.setState({
primaryFeed: primary,
secondaryFeeds: secondary,
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
});
};
@ -258,18 +260,14 @@ export default class CallView extends React.Component<IProps, IState> {
return { primary, secondary };
}
private onMicMuteClick = (): void => {
private onMicMuteClick = async (): Promise<void> => {
const newVal = !this.state.micMuted;
this.props.call.setMicrophoneMuted(newVal);
this.setState({ micMuted: newVal });
this.setState({ micMuted: await this.props.call.setMicrophoneMuted(newVal) });
};
private onVidMuteClick = (): void => {
private onVidMuteClick = async (): Promise<void> => {
const newVal = !this.state.vidMuted;
this.props.call.setLocalVideoMuted(newVal);
this.setState({ vidMuted: newVal });
this.setState({ vidMuted: await this.props.call.setLocalVideoMuted(newVal) });
};
private onScreenshareClick = async (): Promise<void> => {
@ -277,9 +275,13 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.state.screensharing) {
isScreensharing = await this.props.call.setScreensharingEnabled(false);
} else {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
if (window.electron?.getDesktopCapturerSources) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
}
this.setState({