Merge branch 'develop' into sort-imports
Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
commit
7b94e13a84
642 changed files with 30052 additions and 8035 deletions
83
src/components/views/auth/PassphraseConfirmField.tsx
Normal file
83
src/components/views/auth/PassphraseConfirmField.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { PureComponent, RefCallback, RefObject } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Field, { IInputProps } from "../elements/Field";
|
||||
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
|
||||
interface IProps extends Omit<IInputProps, "onValidate"> {
|
||||
id?: string;
|
||||
fieldRef?: RefCallback<Field> | RefObject<Field>;
|
||||
autoComplete?: string;
|
||||
value: string;
|
||||
password: string; // The password we're confirming
|
||||
|
||||
labelRequired?: string;
|
||||
labelInvalid?: string;
|
||||
|
||||
onChange(ev: React.FormEvent<HTMLElement>);
|
||||
onValidate?(result: IValidationResult);
|
||||
}
|
||||
|
||||
@replaceableComponent("views.auth.EmailField")
|
||||
class PassphraseConfirmField extends PureComponent<IProps> {
|
||||
static defaultProps = {
|
||||
label: _td("Confirm password"),
|
||||
labelRequired: _td("Confirm password"),
|
||||
labelInvalid: _td("Passwords don't match"),
|
||||
};
|
||||
|
||||
private validate = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t(this.props.labelRequired),
|
||||
},
|
||||
{
|
||||
key: "match",
|
||||
test: ({ value }) => !value || value === this.props.password,
|
||||
invalid: () => _t(this.props.labelInvalid),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onValidate = async (fieldState: IFieldState) => {
|
||||
const result = await this.validate(fieldState);
|
||||
if (this.props.onValidate) {
|
||||
this.props.onValidate(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Field
|
||||
id={this.props.id}
|
||||
ref={this.props.fieldRef}
|
||||
type="password"
|
||||
label={_t(this.props.label)}
|
||||
autoComplete={this.props.autoComplete}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
onValidate={this.onValidate}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
export default PassphraseConfirmField;
|
|
@ -38,7 +38,7 @@ interface IProps extends Omit<IInputProps, "onValidate"> {
|
|||
labelAllowedButUnsafe?: string;
|
||||
|
||||
onChange(ev: React.FormEvent<HTMLElement>);
|
||||
onValidate(result: IValidationResult);
|
||||
onValidate?(result: IValidationResult);
|
||||
}
|
||||
|
||||
@replaceableComponent("views.auth.PassphraseField")
|
||||
|
@ -98,7 +98,9 @@ class PassphraseField extends PureComponent<IProps> {
|
|||
|
||||
onValidate = async (fieldState: IFieldState) => {
|
||||
const result = await this.validate(fieldState);
|
||||
this.props.onValidate(result);
|
||||
if (this.props.onValidate) {
|
||||
this.props.onValidate(result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
|
@ -317,6 +317,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
|||
return <EmailField
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
autoComplete="email"
|
||||
type="email"
|
||||
key="email_input"
|
||||
placeholder="joe@example.com"
|
||||
value={this.props.username}
|
||||
|
@ -333,6 +335,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
|||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
autoComplete="username"
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
|
@ -359,6 +362,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
|||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
autoComplete="tel-national"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
|
@ -444,6 +448,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
|||
{ loginField }
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
autoComplete="password"
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
|
|
|
@ -16,12 +16,12 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
||||
import withValidation, { IValidationResult } from '../elements/Validation';
|
||||
|
@ -34,6 +34,9 @@ import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDia
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import PassphraseConfirmField from "./PassphraseConfirmField";
|
||||
|
||||
enum RegistrationField {
|
||||
Email = "field_email",
|
||||
PhoneNumber = "field_phone_number",
|
||||
|
@ -56,6 +59,7 @@ interface IProps {
|
|||
}[];
|
||||
serverConfig: ValidatedServerConfig;
|
||||
canSubmit?: boolean;
|
||||
matrixClient: MatrixClient;
|
||||
|
||||
onRegisterClick(params: {
|
||||
username: string;
|
||||
|
@ -292,29 +296,10 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
});
|
||||
};
|
||||
|
||||
private onPasswordConfirmValidate = async fieldState => {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
private onPasswordConfirmValidate = (result: IValidationResult) => {
|
||||
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordConfirmRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Confirm password"),
|
||||
},
|
||||
{
|
||||
key: "match",
|
||||
test(this: RegistrationForm, { value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneCountryChange = newVal => {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
|
@ -365,7 +350,11 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
};
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
description: (_, results) => {
|
||||
// omit the description if the only failing result is the `available` one as it makes no sense for it.
|
||||
if (results.every(({ key, valid }) => key === "available" || valid)) return;
|
||||
return _t("Use lowercase letters, numbers, dashes and underscores only");
|
||||
},
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
|
@ -378,6 +367,23 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
|
||||
invalid: () => _t("Some characters not allowed"),
|
||||
},
|
||||
{
|
||||
key: "available",
|
||||
final: true,
|
||||
test: async ({ value }) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.props.matrixClient.isUsernameAvailable(value);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
invalid: () => _t("Someone already has that username. Try another or if it is you, sign in below."),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -425,8 +431,8 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
return null;
|
||||
}
|
||||
const emailLabel = this.authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
_td("Email") :
|
||||
_td("Email (optional)");
|
||||
return <EmailField
|
||||
fieldRef={field => this[RegistrationField.Email] = field}
|
||||
label={emailLabel}
|
||||
|
@ -453,13 +459,12 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
|
|||
}
|
||||
|
||||
renderPasswordConfirm() {
|
||||
return <Field
|
||||
return <PassphraseConfirmField
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[RegistrationField.PasswordConfirm] = field}
|
||||
type="password"
|
||||
fieldRef={field => this[RegistrationField.PasswordConfirm] = field}
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm password")}
|
||||
value={this.state.passwordConfirm}
|
||||
password={this.state.password}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
|
||||
|
|
|
@ -150,6 +150,7 @@ const BaseAvatar = (props: IProps) => {
|
|||
return (
|
||||
<AccessibleButton
|
||||
aria-label={_t("Avatar")}
|
||||
aria-live="off"
|
||||
{...otherProps}
|
||||
element="span"
|
||||
className={classNames("mx_BaseAvatar", className)}
|
||||
|
|
|
@ -1,160 +1 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
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<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
private button = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasStatus: this.hasStatus,
|
||||
menuDisplayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
|
||||
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
|
||||
}
|
||||
if (!SettingsStore.getValue("feature_custom_status")) {
|
||||
return;
|
||||
}
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
private get hasStatus(): boolean {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return !!user.unstable_statusMessage;
|
||||
}
|
||||
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
hasStatus: this.hasStatus,
|
||||
});
|
||||
};
|
||||
|
||||
private openMenu = (): void => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
};
|
||||
|
||||
private closeMenu = (): void => {
|
||||
this.setState({ menuDisplayed: false });
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const avatar = <MemberAvatar
|
||||
member={this.props.member}
|
||||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
resizeMethod={this.props.resizeMethod}
|
||||
/>;
|
||||
|
||||
if (!SettingsStore.getValue("feature_custom_status")) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
"mx_MemberStatusMessageAvatar": true,
|
||||
"mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus,
|
||||
});
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this.button.current.getBoundingClientRect();
|
||||
|
||||
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
|
||||
const chevronMargin = 1; // Add some spacing away from target
|
||||
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronOffset={(elementRect.width - chevronWidth) / 2}
|
||||
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} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
inputRef={this.button}
|
||||
onClick={this.openMenu}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
label={_t("User Status")}
|
||||
>
|
||||
{ avatar }
|
||||
</ContextMenuButton>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,10 +16,9 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
|
||||
import ContextMenu, { IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
|
||||
import Modal from '../../../Modal';
|
||||
|
@ -46,7 +45,7 @@ export default class CallContextMenu extends React.Component<IProps> {
|
|||
};
|
||||
|
||||
onUnholdClick = () => {
|
||||
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
|
||||
CallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
|
||||
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
|
|
@ -16,10 +16,9 @@ limitations under the License.
|
|||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import ContextMenu, { IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import Field from "../elements/Field";
|
||||
import DialPad from '../voip/DialPad';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
|
|
@ -17,13 +17,13 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import {
|
||||
import ContextMenu, {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
IProps as IContextMenuProps,
|
||||
MenuItem,
|
||||
MenuItemCheckbox, MenuItemRadio,
|
||||
} from "../../structures/ContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
className?: string;
|
||||
|
@ -42,6 +42,7 @@ interface IOptionProps extends React.ComponentProps<typeof MenuItem> {
|
|||
|
||||
interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
|
||||
iconClassName: string;
|
||||
words?: boolean;
|
||||
}
|
||||
|
||||
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
|
||||
|
@ -74,8 +75,21 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
|||
iconClassName,
|
||||
active,
|
||||
className,
|
||||
words,
|
||||
...props
|
||||
}) => {
|
||||
let marker: JSX.Element;
|
||||
if (words) {
|
||||
marker = <span className="mx_IconizedContextMenu_activeText">
|
||||
{ active ? _t("On") : _t("Off") }
|
||||
</span>;
|
||||
} else {
|
||||
marker = <span className={classNames("mx_IconizedContextMenu_icon", {
|
||||
mx_IconizedContextMenu_checked: active,
|
||||
mx_IconizedContextMenu_unchecked: !active,
|
||||
})} />;
|
||||
}
|
||||
|
||||
return <MenuItemCheckbox
|
||||
{...props}
|
||||
className={classNames(className, {
|
||||
|
@ -86,10 +100,7 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
|||
>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", {
|
||||
mx_IconizedContextMenu_checked: active,
|
||||
mx_IconizedContextMenu_unchecked: !active,
|
||||
})} />
|
||||
{ marker }
|
||||
</MenuItemCheckbox>;
|
||||
};
|
||||
|
||||
|
|
|
@ -39,6 +39,12 @@ import ShareDialog from '../dialogs/ShareDialog';
|
|||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
|
||||
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
|
||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
|
||||
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import EndPollDialog from '../dialogs/EndPollDialog';
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
import { isPollEnded } from '../messages/MPollBody';
|
||||
|
||||
export function canCancel(eventStatus: EventStatus): boolean {
|
||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||
|
@ -66,6 +72,11 @@ interface IProps extends IPosition {
|
|||
onFinished(): void;
|
||||
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
|
||||
onCloseDialog?(): void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -76,6 +87,7 @@ interface IState {
|
|||
@replaceableComponent("views.context_menus.MessageContextMenu")
|
||||
export default class MessageContextMenu extends React.Component<IProps, IState> {
|
||||
static contextType = RoomContext;
|
||||
public context!: React.ContextType<typeof RoomContext>;
|
||||
|
||||
state = {
|
||||
canRedact: false,
|
||||
|
@ -120,6 +132,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
private canEndPoll(mxEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
mxEvent.getType() === POLL_START_EVENT_TYPE.name &&
|
||||
this.state.canRedact &&
|
||||
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent)
|
||||
);
|
||||
}
|
||||
|
||||
private onResendReactionsClick = (): void => {
|
||||
for (const reaction of this.getUnsentReactions()) {
|
||||
Resend.resend(reaction);
|
||||
|
@ -190,9 +210,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
};
|
||||
|
||||
private onQuoteClick = (): void => {
|
||||
dis.dispatch({
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
event: this.props.mxEvent,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
this.closeMenu();
|
||||
};
|
||||
|
@ -211,6 +232,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
this.closeMenu();
|
||||
};
|
||||
|
||||
private onEndPollClick = (): void => {
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
|
||||
matrixClient,
|
||||
event: this.props.mxEvent,
|
||||
getRelationsForEvent: this.props.getRelationsForEvent,
|
||||
}, 'mx_Dialog_endPoll');
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
@ -231,7 +262,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
|
||||
private viewInRoom = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
|
@ -246,6 +277,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
const eventStatus = mxEvent.status;
|
||||
const unsentReactionsCount = this.getUnsentReactions().length;
|
||||
|
||||
let endPollButton: JSX.Element;
|
||||
let resendReactionsButton: JSX.Element;
|
||||
let redactButton: JSX.Element;
|
||||
let forwardButton: JSX.Element;
|
||||
|
@ -341,6 +373,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
/>
|
||||
);
|
||||
|
||||
if (this.canEndPoll(mxEvent)) {
|
||||
endPollButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconEndPoll"
|
||||
label={_t("End Poll")}
|
||||
onClick={this.onEndPollClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.eventTileOps) { // this event is rendered using TextualBody
|
||||
quoteButton = (
|
||||
<IconizedContextMenuOption
|
||||
|
@ -401,13 +443,17 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
);
|
||||
const isThreadRootEvent = isThread && this.props.mxEvent?.getThread()?.rootEvent === this.props.mxEvent;
|
||||
|
||||
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
|
||||
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
|
||||
);
|
||||
const commonItemsList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{ isThreadRootEvent && <IconizedContextMenuOption
|
||||
{ (isThreadRootEvent && isMainSplitTimelineShown) && <IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconViewInRoom"
|
||||
label={_t("View in room")}
|
||||
onClick={this.viewInRoom}
|
||||
/> }
|
||||
{ endPollButton }
|
||||
{ quoteButton }
|
||||
{ forwardButton }
|
||||
{ pinButton }
|
||||
|
|
313
src/components/views/context_menus/RoomContextMenu.tsx
Normal file
313
src/components/views/context_menus/RoomContextMenu.tsx
Normal file
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuCheckbox,
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "./IconizedContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import RoomListActions from "../../../actions/RoomListActions";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
|
||||
import { RoomNotifState } from "../../../RoomNotifs";
|
||||
import Modal from "../../../Modal";
|
||||
import ExportDialog from "../dialogs/ExportDialog";
|
||||
import { onRoomFilesClick, onRoomMembersClick } from "../right_panel/RoomSummaryCard";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
|
||||
import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomTags = useEventEmitterState(
|
||||
RoomListStore.instance,
|
||||
LISTS_UPDATE_EVENT,
|
||||
() => RoomListStore.instance.getTagsForRoom(room),
|
||||
);
|
||||
|
||||
let leaveOption: JSX.Element;
|
||||
if (roomTags.includes(DefaultTagID.Archived)) {
|
||||
const onForgetRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "forget_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
leaveOption = <IconizedContextMenuOption
|
||||
iconClassName="mx_RoomTile_iconSignOut"
|
||||
label={_t("Forget")}
|
||||
className="mx_IconizedContextMenu_option_red"
|
||||
onClick={onForgetRoomClick}
|
||||
/>;
|
||||
} else {
|
||||
const onLeaveRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
leaveOption = <IconizedContextMenuOption
|
||||
onClick={onLeaveRoomClick}
|
||||
label={_t("Leave")}
|
||||
className="mx_IconizedContextMenu_option_red"
|
||||
iconClassName="mx_RoomTile_iconSignOut"
|
||||
/>;
|
||||
}
|
||||
|
||||
let inviteOption: JSX.Element;
|
||||
if (room.canInvite(cli.getUserId())) {
|
||||
const onInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "view_invite",
|
||||
roomId: room.roomId,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
inviteOption = <IconizedContextMenuOption
|
||||
onClick={onInviteClick}
|
||||
label={_t("Invite")}
|
||||
iconClassName="mx_RoomTile_iconInvite"
|
||||
/>;
|
||||
}
|
||||
|
||||
let favouriteOption: JSX.Element;
|
||||
let lowPriorityOption: JSX.Element;
|
||||
let notificationOption: JSX.Element;
|
||||
if (room.getMyMembership() === "join") {
|
||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
||||
favouriteOption = <IconizedContextMenuCheckbox
|
||||
onClick={(e) => onTagRoom(e, DefaultTagID.Favourite)}
|
||||
active={isFavorite}
|
||||
label={isFavorite ? _t("Favourited") : _t("Favourite")}
|
||||
iconClassName="mx_RoomTile_iconStar"
|
||||
/>;
|
||||
|
||||
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
|
||||
lowPriorityOption = <IconizedContextMenuCheckbox
|
||||
onClick={(e) => onTagRoom(e, DefaultTagID.LowPriority)}
|
||||
active={isLowPriority}
|
||||
label={_t("Low priority")}
|
||||
iconClassName="mx_RoomTile_iconArrowDown"
|
||||
/>;
|
||||
|
||||
const echoChamber = EchoChamber.forRoom(room);
|
||||
let notificationLabel: string;
|
||||
let iconClassName: string;
|
||||
switch (echoChamber.notificationVolume) {
|
||||
case RoomNotifState.AllMessages:
|
||||
notificationLabel = _t("Default");
|
||||
iconClassName = "mx_RoomTile_iconNotificationsDefault";
|
||||
break;
|
||||
case RoomNotifState.AllMessagesLoud:
|
||||
notificationLabel = _t("All messages");
|
||||
iconClassName = "mx_RoomTile_iconNotificationsAllMessages";
|
||||
break;
|
||||
case RoomNotifState.MentionsOnly:
|
||||
notificationLabel = _t("Mentions only");
|
||||
iconClassName = "mx_RoomTile_iconNotificationsMentionsKeywords";
|
||||
break;
|
||||
case RoomNotifState.Mute:
|
||||
notificationLabel = _t("Mute");
|
||||
iconClassName = "mx_RoomTile_iconNotificationsNone";
|
||||
break;
|
||||
}
|
||||
|
||||
notificationOption = <IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "open_room_settings",
|
||||
room_id: room.roomId,
|
||||
initial_tab_id: ROOM_NOTIFICATIONS_TAB,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Notifications")}
|
||||
iconClassName={iconClassName}
|
||||
>
|
||||
<span className="mx_IconizedContextMenu_sublabel">
|
||||
{ notificationLabel }
|
||||
</span>
|
||||
</IconizedContextMenuOption>;
|
||||
}
|
||||
|
||||
const onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
|
||||
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
|
||||
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId);
|
||||
const removeTag = isApplied ? tagId : inverseTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0));
|
||||
} else {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
}
|
||||
|
||||
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||
onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureViewingRoom = () => {
|
||||
if (RoomViewStore.getRoomId() === room.roomId) return;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.roomId,
|
||||
}, true);
|
||||
};
|
||||
|
||||
return <IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomTile_contextMenu" compact>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ inviteOption }
|
||||
{ notificationOption }
|
||||
{ favouriteOption }
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
ensureViewingRoom();
|
||||
onRoomMembersClick(false);
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("People")}
|
||||
iconClassName="mx_RoomTile_iconPeople"
|
||||
>
|
||||
<span className="mx_IconizedContextMenu_sublabel">
|
||||
{ room.getJoinedMemberCount() }
|
||||
</span>
|
||||
</IconizedContextMenuOption>
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
ensureViewingRoom();
|
||||
onRoomFilesClick(false);
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Files")}
|
||||
iconClassName="mx_RoomTile_iconFiles"
|
||||
/>
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
ensureViewingRoom();
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomSummary,
|
||||
allowClose: false,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Widgets")}
|
||||
iconClassName="mx_RoomTile_iconWidgets"
|
||||
/>
|
||||
|
||||
{ lowPriorityOption }
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "copy_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Copy link")}
|
||||
iconClassName="mx_RoomTile_iconCopyLink"
|
||||
/>
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: "open_room_settings",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Settings")}
|
||||
iconClassName="mx_RoomTile_iconSettings"
|
||||
/>
|
||||
|
||||
<IconizedContextMenuOption
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Export room dialog', '', ExportDialog, { room });
|
||||
onFinished();
|
||||
}}
|
||||
label={_t("Export chat")}
|
||||
iconClassName="mx_RoomTile_iconExport"
|
||||
/>
|
||||
|
||||
{ leaveOption }
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
export default RoomContextMenu;
|
||||
|
|
@ -26,7 +26,6 @@ import { _t } from "../../../languageHandler";
|
|||
import {
|
||||
leaveSpace,
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceInvite,
|
||||
|
@ -35,18 +34,16 @@ import {
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
space: Room;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
||||
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
|
||||
|
@ -64,14 +61,14 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
|||
<IconizedContextMenuOption
|
||||
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||
iconClassName="mx_SpacePanel_iconInvite"
|
||||
label={_t("Invite people")}
|
||||
label={_t("Invite")}
|
||||
onClick={onInviteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let settingsOption;
|
||||
let leaveSection;
|
||||
let leaveOption;
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
const onSettingsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
|
@ -97,36 +94,37 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
|||
onFinished();
|
||||
};
|
||||
|
||||
leaveSection = <IconizedContextMenuOptionList red first>
|
||||
leaveOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconLeave"
|
||||
className="mx_IconizedContextMenu_option_red"
|
||||
label={_t("Leave space")}
|
||||
onClick={onLeaveClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
);
|
||||
}
|
||||
|
||||
let devtoolsSection;
|
||||
let devtoolsOption;
|
||||
if (SettingsStore.getValue("developerMode")) {
|
||||
const onViewTimelineClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
room_id: space.roomId,
|
||||
forceTimeline: true,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
devtoolsSection = <IconizedContextMenuOptionList first>
|
||||
devtoolsOption = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconSettings"
|
||||
label={_t("See room timeline (devtools)")}
|
||||
onClick={onViewTimelineClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>;
|
||||
);
|
||||
}
|
||||
|
||||
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
@ -141,14 +139,6 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
|||
onFinished();
|
||||
};
|
||||
|
||||
const onAddExistingRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showAddExistingRooms(space);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onNewSubspaceClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -157,46 +147,25 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
|||
onFinished();
|
||||
};
|
||||
|
||||
newRoomSection = <IconizedContextMenuOptionList first>
|
||||
newRoomSection = <>
|
||||
<div className="mx_SpacePanel_contextMenu_separatorLabel">
|
||||
{ _t("Add") }
|
||||
</div>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Create new room")}
|
||||
label={_t("Room")}
|
||||
onClick={onNewRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconHash"
|
||||
label={_t("Add existing room")}
|
||||
onClick={onAddExistingRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Add space")}
|
||||
label={_t("Space")}
|
||||
onClick={onNewSubspaceClick}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>;
|
||||
</>;
|
||||
}
|
||||
|
||||
const onMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!RoomViewStore.getRoomId()) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
}, true);
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberList,
|
||||
refireParams: { space },
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
const onExploreRoomsClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -214,26 +183,21 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
|||
className="mx_SpacePanel_contextMenu"
|
||||
compact
|
||||
>
|
||||
<div className="mx_SpacePanel_contextMenu_header">
|
||||
{ !hideHeader && <div className="mx_SpacePanel_contextMenu_header">
|
||||
{ space.name }
|
||||
</div>
|
||||
</div> }
|
||||
<IconizedContextMenuOptionList first>
|
||||
{ inviteOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconMembers"
|
||||
label={_t("Members")}
|
||||
onClick={onMembersClick}
|
||||
/>
|
||||
{ settingsOption }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconExplore"
|
||||
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
|
||||
onClick={onExploreRoomsClick}
|
||||
/>
|
||||
{ settingsOption }
|
||||
{ leaveOption }
|
||||
{ devtoolsOption }
|
||||
{ newRoomSection }
|
||||
</IconizedContextMenuOptionList>
|
||||
{ newRoomSection }
|
||||
{ leaveSection }
|
||||
{ devtoolsSection }
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
|
|
|
@ -1,156 +1 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
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<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
message: this.comittedStatusMessage,
|
||||
waiting: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
get comittedStatusMessage(): string {
|
||||
return this.props.user ? this.props.user.unstable_statusMessage : "";
|
||||
}
|
||||
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
message: this.comittedStatusMessage,
|
||||
waiting: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onClearClick = (): void=> {
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage("");
|
||||
this.setState({
|
||||
waiting: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onSubmit = (e: ButtonEvent): void => {
|
||||
e.preventDefault();
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
|
||||
this.setState({
|
||||
waiting: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onStatusChange = (e: ChangeEvent): void => {
|
||||
// The input field's value was changed.
|
||||
this.setState({
|
||||
message: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
let actionButton;
|
||||
if (this.comittedStatusMessage) {
|
||||
if (this.state.message === this.comittedStatusMessage) {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
|
||||
onClick={this.onClearClick}
|
||||
>
|
||||
<span>{ _t("Clear status") }</span>
|
||||
</AccessibleButton>;
|
||||
} else {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
<span>{ _t("Update status") }</span>
|
||||
</AccessibleButton>;
|
||||
}
|
||||
} else {
|
||||
actionButton = <AccessibleButton
|
||||
className="mx_StatusMessageContextMenu_submit"
|
||||
disabled={!this.state.message}
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
<span>{ _t("Set status") }</span>
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let spinner = null;
|
||||
if (this.state.waiting) {
|
||||
spinner = <Spinner w={24} h={24} />;
|
||||
}
|
||||
|
||||
const form = <form
|
||||
className="mx_StatusMessageContextMenu_form"
|
||||
autoComplete="off"
|
||||
onSubmit={this.onSubmit}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="mx_StatusMessageContextMenu_message"
|
||||
key="message"
|
||||
placeholder={_t("Set a new status...")}
|
||||
autoFocus={true}
|
||||
maxLength={60}
|
||||
value={this.state.message}
|
||||
onChange={this.onStatusChange}
|
||||
/>
|
||||
<div className="mx_StatusMessageContextMenu_actionContainer">
|
||||
{ actionButton }
|
||||
{ spinner }
|
||||
</div>
|
||||
</form>;
|
||||
|
||||
return <div className="mx_StatusMessageContextMenu">
|
||||
{ form }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
119
src/components/views/context_menus/ThreadListContextMenu.tsx
Normal file
119
src/components/views/context_menus/ThreadListContextMenu.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onMenuToggle?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const contextMenuBelow = (elementRect: DOMRect) => {
|
||||
// align the context menu's icons with the icon which opened the context menu
|
||||
const left = elementRect.left + window.pageXOffset + elementRect.width;
|
||||
const top = elementRect.bottom + window.pageYOffset;
|
||||
const chevronFace = ChevronFace.None;
|
||||
return { left, top, chevronFace };
|
||||
};
|
||||
|
||||
const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, onMenuToggle }) => {
|
||||
const [optionsPosition, setOptionsPosition] = useState(null);
|
||||
const closeThreadOptions = useCallback(() => {
|
||||
setOptionsPosition(null);
|
||||
}, []);
|
||||
|
||||
const viewInRoom = useCallback((evt: ButtonEvent): void => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: Action.ViewRoom,
|
||||
event_id: mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: mxEvent.getRoomId(),
|
||||
});
|
||||
closeThreadOptions();
|
||||
}, [mxEvent, closeThreadOptions]);
|
||||
|
||||
const copyLinkToThread = useCallback(async (evt: ButtonEvent) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId());
|
||||
await copyPlaintext(matrixToUrl);
|
||||
closeThreadOptions();
|
||||
}, [mxEvent, closeThreadOptions, permalinkCreator]);
|
||||
|
||||
const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => {
|
||||
if (!!optionsPosition) {
|
||||
closeThreadOptions();
|
||||
} else {
|
||||
const position = ev.currentTarget.getBoundingClientRect();
|
||||
setOptionsPosition(position);
|
||||
}
|
||||
}, [closeThreadOptions, optionsPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onMenuToggle) {
|
||||
onMenuToggle(!!optionsPosition);
|
||||
}
|
||||
}, [optionsPosition, onMenuToggle]);
|
||||
|
||||
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
|
||||
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
|
||||
);
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
onClick={toggleOptionsMenu}
|
||||
title={_t("Thread options")}
|
||||
isExpanded={!!optionsPosition}
|
||||
/>
|
||||
{ !!optionsPosition && (<IconizedContextMenu
|
||||
onFinished={closeThreadOptions}
|
||||
className="mx_RoomTile_contextMenu"
|
||||
compact
|
||||
rightAligned
|
||||
{...contextMenuBelow(optionsPosition)}
|
||||
>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ isMainSplitTimelineShown &&
|
||||
<IconizedContextMenuOption
|
||||
onClick={(e) => viewInRoom(e)}
|
||||
label={_t("View in room")}
|
||||
iconClassName="mx_ThreadPanel_viewInRoom"
|
||||
/> }
|
||||
<IconizedContextMenuOption
|
||||
onClick={(e) => copyLinkToThread(e)}
|
||||
label={_t("Copy link to thread")}
|
||||
iconClassName="mx_ThreadPanel_copyLinkToThread"
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>) }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
export default ThreadListContextMenu;
|
|
@ -25,7 +25,7 @@ import { _t } from '../../../languageHandler';
|
|||
import BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { getDisplayAliasForRoom } from "../../../Rooms";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
|
109
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal file
109
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import React from "react";
|
||||
import Modal from "../../../Modal";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
export enum ButtonClicked {
|
||||
Primary,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onFinished?(buttonClicked?: ButtonClicked): void;
|
||||
analyticsOwner: string;
|
||||
privacyPolicyUrl?: string;
|
||||
primaryButton?: string;
|
||||
cancelButton?: string;
|
||||
hasCancel?: boolean;
|
||||
}
|
||||
|
||||
const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
|
||||
onFinished,
|
||||
analyticsOwner,
|
||||
privacyPolicyUrl,
|
||||
primaryButton,
|
||||
cancelButton,
|
||||
hasCancel,
|
||||
}) => {
|
||||
const onPrimaryButtonClick = () => onFinished && onFinished(ButtonClicked.Primary);
|
||||
const onCancelButtonClick = () => onFinished && onFinished(ButtonClicked.Cancel);
|
||||
const privacyPolicyLink = privacyPolicyUrl ?
|
||||
<span>
|
||||
{
|
||||
_t("You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>", {}, {
|
||||
"PrivacyPolicyUrl": (sub) => {
|
||||
return <a href={privacyPolicyUrl}
|
||||
rel="norefferer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{ sub }
|
||||
<span className="mx_AnalyticsPolicyLink" />
|
||||
</a>;
|
||||
},
|
||||
})
|
||||
}
|
||||
</span> : "";
|
||||
return <BaseDialog
|
||||
className="mx_AnalyticsLearnMoreDialog"
|
||||
contentId="mx_AnalyticsLearnMore"
|
||||
title={_t("Help improve %(analyticsOwner)s", { analyticsOwner })}
|
||||
onFinished={onFinished}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_AnalyticsLearnMore_image_holder" />
|
||||
<div className="mx_AnalyticsLearnMore_copy">
|
||||
{ _t("Help us identify issues and improve Element by sharing anonymous usage data. " +
|
||||
"To understand how people use multiple devices, we'll generate a random identifier, " +
|
||||
"shared by your devices.",
|
||||
) }
|
||||
</div>
|
||||
<ul className="mx_AnalyticsLearnMore_bullets">
|
||||
<li>{ _t("We <Bold>don't</Bold> record or profile any account data",
|
||||
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
|
||||
<li>{ _t("We <Bold>don't</Bold> share information with third parties",
|
||||
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
|
||||
<li>{ _t("You can turn this off anytime in settings") }</li>
|
||||
</ul>
|
||||
{ privacyPolicyLink }
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={primaryButton}
|
||||
cancelButton={cancelButton}
|
||||
onPrimaryButtonClick={onPrimaryButtonClick}
|
||||
onCancel={onCancelButtonClick}
|
||||
hasCancel={hasCancel}
|
||||
/>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): void => {
|
||||
const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl;
|
||||
const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand;
|
||||
Modal.createTrackedDialog(
|
||||
"Analytics Learn More",
|
||||
"",
|
||||
AnalyticsLearnMoreDialog,
|
||||
{ privacyPolicyUrl, analyticsOwner, ...props },
|
||||
"mx_AnalyticsLearnMoreDialog_wrapper",
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsLearnMoreDialog;
|
|
@ -15,10 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { ComponentProps, useMemo, useState } from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
|
||||
|
||||
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, ReactNode } from 'react';
|
||||
import React, { ChangeEvent, FormEvent, ReactNode } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import classNames from "classnames";
|
||||
|
@ -76,7 +76,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
|
|||
};
|
||||
}
|
||||
|
||||
private onOk = (): void => {
|
||||
private onOk = (ev: FormEvent): void => {
|
||||
ev.preventDefault();
|
||||
this.props.onFinished(true, this.state.reason);
|
||||
};
|
||||
|
||||
|
@ -144,7 +145,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
|
|||
{ reasonBox }
|
||||
{ this.props.children }
|
||||
</div>
|
||||
<DialogButtons primaryButton={this.props.action}
|
||||
<DialogButtons
|
||||
primaryButton={this.props.action}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
primaryButtonClass={confirmButtonClass}
|
||||
focus={!this.props.askReason}
|
||||
|
|
|
@ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
|
|||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import InfoTooltip from "../elements/InfoTooltip";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { showCommunityRoomInviteDialog } from "../../../RoomInvite";
|
||||
import GroupStore from "../../../stores/GroupStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
@ -100,7 +101,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
|
|||
// Force the group store to update as it might have missed the general chat
|
||||
await GroupStore.refreshGroupRooms(result.group_id);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
room_id: result.room_id,
|
||||
});
|
||||
showCommunityRoomInviteDialog(result.room_id, this.state.name);
|
||||
|
|
|
@ -32,7 +32,7 @@ import RoomAliasField from "../elements/RoomAliasField";
|
|||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
interface IProps {
|
||||
|
@ -284,7 +284,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
let microcopy;
|
||||
if (privateShouldBeEncrypted()) {
|
||||
if (this.state.canChangeEncryption) {
|
||||
microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet.");
|
||||
microcopy = _t("You can't disable this later. Bridges & most bots won't work yet.");
|
||||
} else {
|
||||
microcopy = _t("Your server requires encryption to be enabled in private rooms.");
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/P
|
|||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
|
|
|
@ -26,7 +26,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|||
import { BetaPill } from "../beta/BetaCard";
|
||||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
|
103
src/components/views/dialogs/EndPollDialog.tsx
Normal file
103
src/components/views/dialogs/EndPollDialog.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts";
|
||||
import { findTopAnswer } from "../messages/MPollBody";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
event: MatrixEvent;
|
||||
onFinished: (success: boolean) => void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
export default class EndPollDialog extends React.Component<IProps> {
|
||||
private onFinished = (endPoll: boolean) => {
|
||||
const topAnswer = findTopAnswer(
|
||||
this.props.event,
|
||||
this.props.matrixClient,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
|
||||
const message = (
|
||||
(topAnswer === "")
|
||||
? _t("The poll has ended. No votes were cast.")
|
||||
: _t(
|
||||
"The poll has ended. Top answer: %(topAnswer)s",
|
||||
{ topAnswer },
|
||||
)
|
||||
);
|
||||
|
||||
if (endPoll) {
|
||||
const endContent: IPollEndContent = {
|
||||
[POLL_END_EVENT_TYPE.name]: {},
|
||||
"m.relates_to": {
|
||||
"event_id": this.props.event.getId(),
|
||||
"rel_type": "m.reference",
|
||||
},
|
||||
[TEXT_NODE_TYPE.name]: message,
|
||||
};
|
||||
|
||||
this.props.matrixClient.sendEvent(
|
||||
this.props.event.getRoomId(), POLL_END_EVENT_TYPE.name, endContent,
|
||||
).catch((e: any) => {
|
||||
console.error("Failed to submit poll response event:", e);
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to end poll',
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t("Failed to end poll"),
|
||||
description: _t(
|
||||
"Sorry, the poll did not end. Please try again."),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
this.props.onFinished(endPoll);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<QuestionDialog
|
||||
title={_t("End Poll")}
|
||||
description={
|
||||
_t(
|
||||
"Are you sure you want to end this poll? " +
|
||||
"This will show the final results of the poll and " +
|
||||
"stop people from being able to vote.",
|
||||
)
|
||||
}
|
||||
button={_t("End Poll")}
|
||||
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
|
@ -27,6 +26,9 @@ import BugReportDialog from "./BugReportDialog";
|
|||
import InfoDialog from "./InfoDialog";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { submitFeedback } from "../../../rageshake/submit-rageshake";
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
|
||||
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
|
||||
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
|
||||
|
@ -35,18 +37,33 @@ const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose"
|
|||
interface IProps extends IDialogProps {}
|
||||
|
||||
const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
|
||||
const feedbackRef = useRef<Field>();
|
||||
const [rating, setRating] = useState<Rating>();
|
||||
const [comment, setComment] = useState<string>("");
|
||||
const [canContact, toggleCanContact] = useStateToggle(false);
|
||||
|
||||
useEffect(() => {
|
||||
// autofocus doesn't work on textareas
|
||||
feedbackRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const onDebugLogsLinkClick = (): void => {
|
||||
props.onFinished();
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
|
||||
};
|
||||
|
||||
const hasFeedback = CountlyAnalytics.instance.canEnable();
|
||||
const countlyEnabled = CountlyAnalytics.instance.canEnable();
|
||||
const rageshakeUrl = SdkConfig.get().bug_report_endpoint_url;
|
||||
|
||||
const hasFeedback = countlyEnabled || rageshakeUrl;
|
||||
const onFinished = (sendFeedback: boolean): void => {
|
||||
if (hasFeedback && sendFeedback) {
|
||||
CountlyAnalytics.instance.reportFeedback(rating, comment);
|
||||
if (rageshakeUrl) {
|
||||
submitFeedback(rageshakeUrl, "feedback", comment, canContact);
|
||||
} else if (countlyEnabled) {
|
||||
CountlyAnalytics.instance.reportFeedback(rating, comment);
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
|
||||
title: _t('Feedback sent'),
|
||||
description: _t('Thank you!'),
|
||||
|
@ -57,56 +74,73 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
|
|||
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
let countlyFeedbackSection;
|
||||
if (hasFeedback) {
|
||||
countlyFeedbackSection = <React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
|
||||
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
|
||||
let feedbackSection;
|
||||
if (rageshakeUrl) {
|
||||
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
|
||||
<h3>{ _t("Comment") }</h3>
|
||||
|
||||
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
|
||||
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
|
||||
<p>{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }</p>
|
||||
|
||||
<StyledRadioGroup
|
||||
name="feedbackRating"
|
||||
value={String(rating)}
|
||||
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
|
||||
definitions={[
|
||||
{ value: "1", label: "😠" },
|
||||
{ value: "2", label: "😞" },
|
||||
{ value: "3", label: "😑" },
|
||||
{ value: "4", label: "😄" },
|
||||
{ value: "5", label: "😍" },
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
ref={feedbackRef}
|
||||
/>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Add comment")}
|
||||
placeholder={_t("Comment")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onChange={toggleCanContact}
|
||||
>
|
||||
{ _t("You may contact me if you want to follow up or to let me test out upcoming ideas") }
|
||||
</StyledCheckbox>
|
||||
</div>;
|
||||
} else if (countlyEnabled) {
|
||||
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
|
||||
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
|
||||
|
||||
let subheading;
|
||||
if (hasFeedback) {
|
||||
subheading = (
|
||||
<h2>{ _t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand }) }</h2>
|
||||
);
|
||||
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
|
||||
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
|
||||
|
||||
<StyledRadioGroup
|
||||
name="feedbackRating"
|
||||
value={String(rating)}
|
||||
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
|
||||
definitions={[
|
||||
{ value: "1", label: "😠" },
|
||||
{ value: "2", label: "😞" },
|
||||
{ value: "3", label: "😑" },
|
||||
{ value: "4", label: "😄" },
|
||||
{ value: "5", label: "😍" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Add comment")}
|
||||
placeholder={_t("Comment")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
ref={feedbackRef}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let bugReports = null;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
if (rageshakeUrl) {
|
||||
bugReports = (
|
||||
<p>{
|
||||
<p className="mx_FeedbackDialog_section_microcopy">{
|
||||
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
|
||||
"to help us track down the problem.", {}, {
|
||||
debugLogsLink: sub => (
|
||||
|
@ -122,8 +156,6 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
|
|||
hasCancelButton={!!hasFeedback}
|
||||
title={_t("Feedback")}
|
||||
description={<React.Fragment>
|
||||
{ subheading }
|
||||
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
|
||||
<h3>{ _t("Report a bug") }</h3>
|
||||
<p>{
|
||||
|
@ -139,10 +171,10 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
|
|||
}</p>
|
||||
{ bugReports }
|
||||
</div>
|
||||
{ countlyFeedbackSection }
|
||||
{ feedbackSection }
|
||||
</React.Fragment>}
|
||||
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
|
||||
buttonDisabled={hasFeedback && !rating}
|
||||
buttonDisabled={hasFeedback && !rating && !comment}
|
||||
onFinished={onFinished}
|
||||
/>);
|
||||
};
|
||||
|
|
|
@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler";
|
|||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { avatarUrlForUser } from "../../../Avatar";
|
||||
|
@ -43,7 +43,8 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
|||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { roomContextDetailsText } from "../../../Rooms";
|
||||
|
||||
const AVATAR_SIZE = 30;
|
||||
|
||||
|
@ -121,6 +122,8 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
|
|||
/>;
|
||||
}
|
||||
|
||||
const detailsText = roomContextDetailsText(room);
|
||||
|
||||
return <div className="mx_ForwardList_entry">
|
||||
<AccessibleTooltipButton
|
||||
className="mx_ForwardList_roomButton"
|
||||
|
@ -131,6 +134,9 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
|
|||
>
|
||||
<DecoratedRoomAvatar room={room} avatarSize={32} />
|
||||
<span className="mx_ForwardList_entry_name">{ room.name }</span>
|
||||
{ detailsText && <span className="mx_ForwardList_entry_detail">
|
||||
{ detailsText }
|
||||
</span> }
|
||||
</AccessibleTooltipButton>
|
||||
<AccessibleTooltipButton
|
||||
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}
|
||||
|
|
|
@ -253,8 +253,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
|
|||
<AccessibleButton
|
||||
className="mx_HostSignup_maximize_button"
|
||||
onClick={this.maximizeDialog}
|
||||
aria-label={_t("Maximize dialog")}
|
||||
title={_t("Maximize dialog")}
|
||||
aria-label={_t("Maximise dialog")}
|
||||
title={_t("Maximise dialog")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
@ -263,8 +263,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
|
|||
<AccessibleButton
|
||||
onClick={this.minimizeDialog}
|
||||
className="mx_HostSignup_minimize_button"
|
||||
aria-label={_t("Minimize dialog")}
|
||||
title={_t("Minimize dialog")}
|
||||
aria-label={_t("Minimise dialog")}
|
||||
title={_t("Minimise dialog")}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this.onCloseClick}
|
||||
|
|
|
@ -39,7 +39,7 @@ const PHASE_VERIFIED = 3;
|
|||
const PHASE_CANCELLED = 4;
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
verifier: VerificationBase; // TODO types
|
||||
verifier: VerificationBase;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
|
|
@ -44,6 +44,7 @@ export default class InfoDialog extends React.Component<IProps> {
|
|||
};
|
||||
|
||||
render() {
|
||||
// FIXME: Using a regular import will break the app
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return (
|
||||
|
|
|
@ -18,9 +18,10 @@ import React from 'react';
|
|||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as sdk from "../../../index";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
|
||||
interface IProps extends IDialogProps {}
|
||||
|
||||
|
@ -32,8 +33,6 @@ export default class IntegrationsImpossibleDialog extends React.Component<IProps
|
|||
|
||||
public render(): JSX.Element {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
|
|
|
@ -63,7 +63,6 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
|||
import * as ContextMenu from "../../structures/ContextMenu";
|
||||
import { toRightOf } from "../../structures/ContextMenu";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||
import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload';
|
||||
import Field from '../elements/Field';
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
import Dialpad from '../voip/DialPad';
|
||||
|
@ -71,7 +70,8 @@ import QuestionDialog from "./QuestionDialog";
|
|||
import Spinner from "../elements/Spinner";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import CallHandler from "../../../CallHandler";
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -675,7 +675,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
if (existingRoom) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
room_id: existingRoom.roomId,
|
||||
should_peek: false,
|
||||
joining: false,
|
||||
|
@ -804,19 +804,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
return;
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: Action.TransferCallToMatrixID,
|
||||
call: this.props.call,
|
||||
destination: targetIds[0],
|
||||
consultFirst: this.state.consultFirst,
|
||||
} as TransferCallPayload);
|
||||
CallHandler.instance.startTransferToMatrixID(
|
||||
this.props.call,
|
||||
targetIds[0],
|
||||
this.state.consultFirst,
|
||||
);
|
||||
} else {
|
||||
dis.dispatch({
|
||||
action: Action.TransferCallToPhoneNumber,
|
||||
call: this.props.call,
|
||||
destination: this.state.dialPadValue,
|
||||
consultFirst: this.state.consultFirst,
|
||||
} as TransferCallPayload);
|
||||
CallHandler.instance.startTransferToPhoneNumber(
|
||||
this.props.call,
|
||||
this.state.dialPadValue,
|
||||
this.state.consultFirst,
|
||||
);
|
||||
}
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
@ -1410,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
goButtonFn = this.startDm;
|
||||
extraSection = <div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
|
||||
<span>{ _t("Some suggestions may be hidden for privacy.") }</span>
|
||||
<p>{ _t("If you can't see who you’re looking for, send them your invite link below.") }</p>
|
||||
<p>{ _t("If you can't see who you're looking for, send them your invite link below.") }</p>
|
||||
</div>;
|
||||
const link = makeUserPermalink(MatrixClientPeg.get().getUserId());
|
||||
footer = <div className="mx_InviteDialog_footer">
|
||||
|
|
|
@ -21,7 +21,7 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
|||
import { _t } from '../../../languageHandler';
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -17,16 +17,19 @@ limitations under the License.
|
|||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
}
|
||||
|
@ -133,8 +136,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
|||
|
||||
render() {
|
||||
if (this.state.shouldLoadBackupStatus) {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
const description = <div>
|
||||
<p>{ _t(
|
||||
"Encrypted messages are secured with end-to-end encryption. " +
|
||||
|
@ -145,11 +146,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
|||
|
||||
let dialogContent;
|
||||
if (this.state.loading) {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
|
||||
dialogContent = <Spinner />;
|
||||
} else {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
let setupButtonCaption;
|
||||
if (this.state.backupInfo) {
|
||||
setupButtonCaption = _t("Connect this session to Key Backup");
|
||||
|
@ -192,7 +190,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
|||
{ dialogContent }
|
||||
</BaseDialog>);
|
||||
} else {
|
||||
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
|
||||
return (<QuestionDialog
|
||||
hasCancelButton={true}
|
||||
title={_t("Sign out")}
|
||||
|
|
|
@ -21,7 +21,7 @@ import { _t } from '../../../languageHandler';
|
|||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
|
@ -66,6 +66,17 @@ const Entry = ({ room, checked, onChange }) => {
|
|||
</label>;
|
||||
};
|
||||
|
||||
const addAllParents = (set: Set<Room>, room: Room): void => {
|
||||
const cli = room.client;
|
||||
const parents = Array.from(SpaceStore.instance.getKnownParents(room.roomId)).map(parentId => cli.getRoom(parentId));
|
||||
|
||||
parents.forEach(parent => {
|
||||
if (set.has(parent)) return;
|
||||
set.add(parent);
|
||||
addAllParents(set, parent);
|
||||
});
|
||||
};
|
||||
|
||||
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
|
||||
const cli = room.client;
|
||||
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
|
||||
|
@ -73,9 +84,10 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
|
|||
const lcQuery = query.toLowerCase().trim();
|
||||
|
||||
const [spacesContainingRoom, otherEntries] = useMemo(() => {
|
||||
const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
|
||||
const parents = new Set<Room>();
|
||||
addAllParents(parents, room);
|
||||
return [
|
||||
spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
|
||||
Array.from(parents),
|
||||
selected.map(roomId => {
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) {
|
||||
|
@ -86,9 +98,9 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
|
|||
}
|
||||
}).filter(Boolean),
|
||||
];
|
||||
}, [cli, selected, room.roomId]);
|
||||
}, [cli, selected, room]);
|
||||
|
||||
const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
|
||||
const [filteredSpacesContainingRoom, filteredOtherEntries] = useMemo(() => [
|
||||
spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
|
||||
otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
|
||||
], [spacesContainingRoom, otherEntries, lcQuery]);
|
||||
|
@ -129,10 +141,14 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
|
|||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
|
||||
{ filteredSpacesContainingRooms.length > 0 ? (
|
||||
{ filteredSpacesContainingRoom.length > 0 ? (
|
||||
<div className="mx_ManageRestrictedJoinRuleDialog_section">
|
||||
<h3>{ _t("Spaces you know that contain this room") }</h3>
|
||||
{ filteredSpacesContainingRooms.map(space => {
|
||||
<h3>
|
||||
{ room.isSpaceRoom()
|
||||
? _t("Spaces you know that contain this space")
|
||||
: _t("Spaces you know that contain this room") }
|
||||
</h3>
|
||||
{ filteredSpacesContainingRoom.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
|
@ -164,7 +180,7 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
|
|||
</div>
|
||||
) : null }
|
||||
|
||||
{ filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
|
||||
{ filteredSpacesContainingRoom.length + filteredOtherEntries.length < 1
|
||||
? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
|
||||
{ _t("No results") }
|
||||
</span>
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import Field from "../elements/Field";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
@ -64,7 +64,7 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
|
|||
<EmailField
|
||||
fieldRef={fieldRef}
|
||||
autoFocus={true}
|
||||
label={_t("Email (optional)")}
|
||||
label={_td("Email (optional)")}
|
||||
value={email}
|
||||
onChange={ev => {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
|
|
|
@ -114,7 +114,7 @@ export default class RoomSettingsDialog extends React.Component<IProps, IState>
|
|||
ROOM_NOTIFICATIONS_TAB,
|
||||
_td("Notifications"),
|
||||
"mx_RoomSettingsDialog_notificationsIcon",
|
||||
<NotificationSettingsTab roomId={this.props.roomId} />,
|
||||
<NotificationSettingsTab roomId={this.props.roomId} closeSettingsFn={() => this.props.onFinished(true)} />,
|
||||
));
|
||||
|
||||
if (SettingsStore.getValue("feature_bridge_state")) {
|
||||
|
|
116
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal file
116
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FormEvent } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
||||
export interface IScrollableBaseState {
|
||||
canSubmit: boolean;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable dialog base from Compound (Web Components).
|
||||
*/
|
||||
export default abstract class ScrollableBaseModal<TProps extends IDialogProps, TState extends IScrollableBaseState>
|
||||
extends React.PureComponent<TProps, TState> {
|
||||
protected constructor(props: TProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
protected get matrixClient(): MatrixClient {
|
||||
return MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
|
||||
if (e.key === Key.ESCAPE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
private onCancel = () => {
|
||||
this.cancel();
|
||||
};
|
||||
|
||||
private onSubmit = (e: MouseEvent | FormEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!this.state.canSubmit) return; // pretend the submit button was disabled
|
||||
this.submit();
|
||||
};
|
||||
|
||||
protected abstract cancel(): void;
|
||||
protected abstract submit(): void;
|
||||
protected abstract renderContent(): React.ReactNode;
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this.matrixClient}>
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
["aria-labelledby"]: "mx_CompoundDialog_title",
|
||||
|
||||
// Like BaseDialog, we'll just point this at the whole content
|
||||
["aria-describedby"]: "mx_CompoundDialog_content",
|
||||
}}
|
||||
className="mx_CompoundDialog mx_ScrollableBaseDialog"
|
||||
>
|
||||
<div className="mx_CompoundDialog_header">
|
||||
<h1>{ this.state.title }</h1>
|
||||
<AccessibleButton
|
||||
onClick={this.onCancel}
|
||||
className="mx_CompoundDialog_cancelButton"
|
||||
aria-label={_t("Close dialog")}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_CompoundDialog_content">
|
||||
{ this.renderContent() }
|
||||
</div>
|
||||
<div className="mx_CompoundDialog_footer">
|
||||
<AccessibleButton onClick={this.onCancel} kind="primary_outline">
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
onClick={this.onSubmit}
|
||||
kind="primary"
|
||||
disabled={!this.state.canSubmit}
|
||||
type="submit"
|
||||
element="button"
|
||||
className="mx_Dialog_nonDialogButton"
|
||||
>
|
||||
{ this.state.actionLabel }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</form>
|
||||
</FocusLock>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -166,7 +166,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
public render() {
|
||||
let text;
|
||||
if (this.defaultServer.hsName === "matrix.org") {
|
||||
text = _t("Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.");
|
||||
text = _t("Matrix.org is the biggest public homeserver in the world, so it's a good place for many.");
|
||||
}
|
||||
|
||||
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
|
||||
|
@ -188,7 +188,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
>
|
||||
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
|
||||
<p>
|
||||
{ _t("We call the places where you can host your account ‘homeservers’.") } { text }
|
||||
{ _t("We call the places where you can host your account 'homeservers'.") } { text }
|
||||
</p>
|
||||
|
||||
<StyledRadioButton
|
||||
|
|
|
@ -29,13 +29,15 @@ import DialogButtons from "../elements/DialogButtons";
|
|||
import { IDialogProps } from "./IDialogProps";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
error: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.dialogs.SessionRestoreErrorDialog")
|
||||
export default class SessionRestoreErrorDialog extends React.Component<IProps> {
|
||||
private sendBugReport = (): void => {
|
||||
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
|
||||
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {
|
||||
error: this.props.error,
|
||||
});
|
||||
};
|
||||
|
||||
private onClearStorageClick = (): void => {
|
||||
|
|
|
@ -35,6 +35,7 @@ import { UIFeature } from "../../../settings/UIFeature";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import SidebarUserSettingsTab from "../settings/tabs/user/SidebarUserSettingsTab";
|
||||
|
||||
export enum UserTab {
|
||||
General = "USER_GENERAL_TAB",
|
||||
|
@ -42,6 +43,7 @@ export enum UserTab {
|
|||
Flair = "USER_FLAIR_TAB",
|
||||
Notifications = "USER_NOTIFICATIONS_TAB",
|
||||
Preferences = "USER_PREFERENCES_TAB",
|
||||
Sidebar = "USER_SIDEBAR_TAB",
|
||||
Voice = "USER_VOICE_TAB",
|
||||
Security = "USER_SECURITY_TAB",
|
||||
Labs = "USER_LABS_TAB",
|
||||
|
@ -118,6 +120,15 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
|
|||
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
|
||||
if (SettingsStore.getValue("feature_spaces_metaspaces")) {
|
||||
tabs.push(new Tab(
|
||||
UserTab.Sidebar,
|
||||
_td("Sidebar"),
|
||||
"mx_UserSettingsDialog_sidebarIcon",
|
||||
<SidebarUserSettingsTab />,
|
||||
));
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||
tabs.push(new Tab(
|
||||
UserTab.Voice,
|
||||
|
|
|
@ -21,9 +21,8 @@ import { IProtocol } from "matrix-js-sdk/src/client";
|
|||
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
|
||||
import {
|
||||
import ContextMenu, {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Tooltip, { Alignment } from './Tooltip';
|
||||
|
@ -70,13 +69,12 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, ...props } = this.props;
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
|
||||
const tip = this.state.hover && <Tooltip
|
||||
tooltipClassName={tooltipClassName}
|
||||
label={tooltip || title}
|
||||
yOffset={yOffset}
|
||||
alignment={alignment}
|
||||
/> : null;
|
||||
/>;
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
|
|
|
@ -19,11 +19,6 @@ limitations under the License.
|
|||
|
||||
import url from 'url';
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixCapabilities } from "matrix-widget-api";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
@ -32,22 +27,27 @@ import AppWarning from './AppWarning';
|
|||
import Spinner from './Spinner';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import classNames from 'classnames';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import PersistedElement, { getPersistKey } from "./PersistedElement";
|
||||
import { WidgetType } from "../../../widgets/WidgetType";
|
||||
import { StopGapWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
|
||||
import { MatrixCapabilities } from "matrix-widget-api";
|
||||
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { IApp } from "../../../stores/WidgetStore";
|
||||
|
||||
import { WidgetLayoutStore, Container } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
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;
|
||||
threadId?: string | null;
|
||||
// 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;
|
||||
|
@ -89,6 +89,8 @@ interface IState {
|
|||
requiresClient: boolean;
|
||||
}
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@replaceableComponent("views.elements.AppTile")
|
||||
export default class AppTile extends React.Component<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
|
@ -99,6 +101,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
handleMinimisePointerEvents: false,
|
||||
userWidget: false,
|
||||
miniMode: false,
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
private contextMenuButton = createRef<any>();
|
||||
|
@ -227,7 +230,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
this.sgWidget.on("ready", this.onWidgetReady);
|
||||
this.startWidget();
|
||||
} catch (e) {
|
||||
logger.log("Failed to construct widget", e);
|
||||
logger.error("Failed to construct widget", e);
|
||||
this.sgWidget = null;
|
||||
}
|
||||
}
|
||||
|
@ -241,7 +244,13 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
private iframeRefChange = (ref: HTMLIFrameElement): void => {
|
||||
this.iframe = ref;
|
||||
if (ref) {
|
||||
if (this.sgWidget) this.sgWidget.start(ref);
|
||||
try {
|
||||
if (this.sgWidget) {
|
||||
this.sgWidget.start(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Failed to start widget", e);
|
||||
}
|
||||
} else {
|
||||
this.resetWidget(this.props);
|
||||
}
|
||||
|
@ -284,7 +293,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
dis.dispatch({ action: 'hangup_conference' });
|
||||
CallHandler.instance.hangupCallApp(this.props.room.roomId);
|
||||
}
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
|
@ -315,7 +324,13 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
|
||||
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
|
||||
dis.dispatch({
|
||||
action: 'post_sticker_message',
|
||||
data: {
|
||||
...payload.data,
|
||||
threadId: this.props.threadId,
|
||||
},
|
||||
});
|
||||
dis.dispatch({ action: 'stickerpicker_close' });
|
||||
} else {
|
||||
logger.warn('Ignoring sticker message. Invalid capability');
|
||||
|
@ -394,6 +409,14 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
{ target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
|
||||
};
|
||||
|
||||
private onMaxMinWidgetClick = (): void => {
|
||||
const targetContainer =
|
||||
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center)
|
||||
? Container.Right
|
||||
: Container.Center;
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
|
||||
};
|
||||
|
||||
private onContextMenuClick = (): void => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
};
|
||||
|
@ -420,7 +443,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
|
||||
const appTileBodyStyles = {};
|
||||
if (this.props.pointerEvents) {
|
||||
appTileBodyStyles['pointer-events'] = this.props.pointerEvents;
|
||||
appTileBodyStyles['pointerEvents'] = this.props.pointerEvents;
|
||||
}
|
||||
|
||||
const loadingElement = (
|
||||
|
@ -516,6 +539,23 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
/>
|
||||
);
|
||||
}
|
||||
let maxMinButton;
|
||||
if (SettingsStore.getValue("feature_maximised_widgets")) {
|
||||
const widgetIsMaximised = WidgetLayoutStore.instance.
|
||||
isInContainer(this.props.room, this.props.app, Container.Center);
|
||||
maxMinButton = <AccessibleButton
|
||||
className={
|
||||
"mx_AppTileMenuBar_iconButton"
|
||||
+ (widgetIsMaximised
|
||||
? " mx_AppTileMenuBar_iconButton_minWidget"
|
||||
: " mx_AppTileMenuBar_iconButton_maxWidget")
|
||||
}
|
||||
title={
|
||||
widgetIsMaximised ? _t('Close'): _t('Maximise widget')
|
||||
}
|
||||
onClick={this.onMaxMinWidgetClick}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<div className={appTileClasses} id={this.props.app.id}>
|
||||
|
@ -525,6 +565,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||
{ this.props.showTitle && this.getTileTitle() }
|
||||
</span>
|
||||
<span className="mx_AppTileMenuBarWidgets">
|
||||
{ maxMinButton }
|
||||
{ (this.props.showPopout && !this.state.requiresClient) && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
|
||||
title={_t('Popout widget')}
|
||||
|
|
|
@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import TagTile from './TagTile';
|
||||
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
|
||||
|
||||
import React from 'react';
|
||||
import ContextMenu, { toRightOf, useContextMenu } from "../../structures/ContextMenu";
|
||||
import * as sdk from '../../../index';
|
||||
|
||||
export default function DNDTagTile(props) {
|
||||
|
|
|
@ -178,26 +178,20 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
this.ignoreEvent = ev;
|
||||
};
|
||||
|
||||
private onChevronClick = (ev: React.MouseEvent) => {
|
||||
if (this.state.expanded) {
|
||||
this.setState({ expanded: false });
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onAccessibleButtonClick = (ev: ButtonEvent) => {
|
||||
if (this.props.disabled) return;
|
||||
|
||||
if (!this.state.expanded) {
|
||||
this.setState({
|
||||
expanded: true,
|
||||
});
|
||||
this.setState({ expanded: true });
|
||||
ev.preventDefault();
|
||||
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
|
||||
this.props.onOptionChange(this.state.highlightedOption);
|
||||
this.close();
|
||||
} else if (!(ev as React.KeyboardEvent).key) {
|
||||
// collapse on other non-keyboard event activations
|
||||
this.setState({ expanded: false });
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -228,14 +222,22 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
this.close();
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this.setState({
|
||||
highlightedOption: this.nextOption(this.state.highlightedOption),
|
||||
});
|
||||
if (this.state.expanded) {
|
||||
this.setState({
|
||||
highlightedOption: this.nextOption(this.state.highlightedOption),
|
||||
});
|
||||
} else {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this.setState({
|
||||
highlightedOption: this.prevOption(this.state.highlightedOption),
|
||||
});
|
||||
if (this.state.expanded) {
|
||||
this.setState({
|
||||
highlightedOption: this.prevOption(this.state.highlightedOption),
|
||||
});
|
||||
} else {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
|
@ -383,7 +385,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{ currentValue }
|
||||
<span onClick={this.onChevronClick} className="mx_Dropdown_arrow" />
|
||||
<span className="mx_Dropdown_arrow" />
|
||||
{ menu }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
|
|
|
@ -105,13 +105,19 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
|
|||
</React.Fragment>;
|
||||
}
|
||||
|
||||
let clearCacheButton: JSX.Element;
|
||||
// we only show this button if there is an initialised MatrixClient otherwise we can't clear the cache
|
||||
if (MatrixClientPeg.get()) {
|
||||
clearCacheButton = <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
|
||||
{ _t("Clear cache and reload") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className="mx_ErrorBoundary">
|
||||
<div className="mx_ErrorBoundary_body">
|
||||
<h1>{ _t("Something went wrong!") }</h1>
|
||||
{ bugReportSection }
|
||||
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
|
||||
{ _t("Clear cache and reload") }
|
||||
</AccessibleButton>
|
||||
{ clearCacheButton }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { uniqBy } from "lodash";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
|
@ -22,7 +23,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import { Layout } from '../../../settings/Layout';
|
||||
import { Layout } from '../../../settings/enums/Layout';
|
||||
|
||||
interface IProps {
|
||||
// An array of member events to summarise
|
||||
|
@ -80,7 +81,8 @@ const EventListSummary: React.FC<IProps> = ({
|
|||
{ children }
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
const avatars = summaryMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
|
||||
const uniqueMembers = uniqBy(summaryMembers, member => member.getMxcAvatarUrl());
|
||||
const avatars = uniqueMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
|
||||
body = (
|
||||
<div className="mx_EventTile_line">
|
||||
<div className="mx_EventTile_info">
|
||||
|
|
|
@ -22,7 +22,7 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
|||
import * as Avatar from '../../../Avatar';
|
||||
import EventTile from '../rooms/EventTile';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Spinner from './Spinner';
|
||||
|
|
|
@ -46,6 +46,9 @@ interface IProps {
|
|||
label?: string;
|
||||
// The field's placeholder string. Defaults to the label.
|
||||
placeholder?: string;
|
||||
// When true (default false), the placeholder will be shown instead of the label when
|
||||
// the component is unfocused & empty.
|
||||
usePlaceholderAsHint?: boolean;
|
||||
// Optional component to include inside the field before the input.
|
||||
prefixComponent?: React.ReactNode;
|
||||
// Optional component to include inside the field after the input.
|
||||
|
@ -227,6 +230,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
|
||||
usePlaceholderAsHint,
|
||||
...inputProps } = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
|
@ -257,7 +261,8 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent,
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
|
||||
mx_Field_placeholderIsHint: usePlaceholderAsHint,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? !forceValidity
|
||||
|
|
|
@ -31,6 +31,7 @@ import MessageTimestamp from "../messages/MessageTimestamp";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { formatFullDate } from "../../../DateUtils";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { normalizeWheelEvent } from "../../../utils/Mouse";
|
||||
|
@ -333,7 +334,7 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
ev.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
|
|
496
src/components/views/elements/InteractiveTooltip.tsx
Normal file
496
src/components/views/elements/InteractiveTooltip.tsx
Normal file
|
@ -0,0 +1,496 @@
|
|||
/*
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, MouseEventHandler, ReactNode, RefCallback } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { ChevronFace } from "../../structures/ContextMenu";
|
||||
|
||||
const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container";
|
||||
|
||||
// If the distance from tooltip to window edge is below this value, the tooltip
|
||||
// will flip around to the other side of the target.
|
||||
const MIN_SAFE_DISTANCE_TO_WINDOW_EDGE = 20;
|
||||
|
||||
function getOrCreateContainer(): HTMLElement {
|
||||
let container = document.getElementById(InteractiveTooltipContainerId);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = InteractiveTooltipContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
interface IRect {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
function isInRect(x: number, y: number, rect: IRect): boolean {
|
||||
const { top, right, bottom, left } = rect;
|
||||
return x >= left && x <= right && y >= top && y <= bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the positive slope of the diagonal of the rect.
|
||||
*
|
||||
* @param {DOMRect} rect
|
||||
* @return {number}
|
||||
*/
|
||||
function getDiagonalSlope(rect: IRect): number {
|
||||
const { top, right, bottom, left } = rect;
|
||||
return (bottom - top) / (right - left);
|
||||
}
|
||||
|
||||
function isInUpperLeftHalf(x: number, y: number, rect: IRect): boolean {
|
||||
const { bottom, left } = rect;
|
||||
// Negative slope because Y values grow downwards and for this case, the
|
||||
// diagonal goes from larger to smaller Y values.
|
||||
const diagonalSlope = getDiagonalSlope(rect) * -1;
|
||||
return isInRect(x, y, rect) && (y <= bottom + diagonalSlope * (x - left));
|
||||
}
|
||||
|
||||
function isInLowerRightHalf(x: number, y: number, rect: IRect): boolean {
|
||||
const { bottom, left } = rect;
|
||||
// Negative slope because Y values grow downwards and for this case, the
|
||||
// diagonal goes from larger to smaller Y values.
|
||||
const diagonalSlope = getDiagonalSlope(rect) * -1;
|
||||
return isInRect(x, y, rect) && (y >= bottom + diagonalSlope * (x - left));
|
||||
}
|
||||
|
||||
function isInUpperRightHalf(x: number, y: number, rect: IRect): boolean {
|
||||
const { top, left } = rect;
|
||||
// Positive slope because Y values grow downwards and for this case, the
|
||||
// diagonal goes from smaller to larger Y values.
|
||||
const diagonalSlope = getDiagonalSlope(rect) * 1;
|
||||
return isInRect(x, y, rect) && (y <= top + diagonalSlope * (x - left));
|
||||
}
|
||||
|
||||
function isInLowerLeftHalf(x: number, y: number, rect: IRect): boolean {
|
||||
const { top, left } = rect;
|
||||
// Positive slope because Y values grow downwards and for this case, the
|
||||
// diagonal goes from smaller to larger Y values.
|
||||
const diagonalSlope = getDiagonalSlope(rect) * 1;
|
||||
return isInRect(x, y, rect) && (y >= top + diagonalSlope * (x - left));
|
||||
}
|
||||
|
||||
export enum Direction {
|
||||
Top,
|
||||
Left,
|
||||
Bottom,
|
||||
Right,
|
||||
}
|
||||
|
||||
// exported for tests
|
||||
export function mouseWithinRegion(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: Direction,
|
||||
targetRect: DOMRect,
|
||||
contentRect: DOMRect,
|
||||
): boolean {
|
||||
// When moving the mouse from the target to the tooltip, we create a safe area
|
||||
// that includes the tooltip, the target, and the trapezoid ABCD between them:
|
||||
// ┌───────────┐
|
||||
// │ │
|
||||
// │ │
|
||||
// A └───E───F───┘ B
|
||||
// V
|
||||
// ┌─┐
|
||||
// │ │
|
||||
// C└─┘D
|
||||
//
|
||||
// As long as the mouse remains inside the safe area, the tooltip will stay open.
|
||||
const buffer = 50;
|
||||
if (isInRect(x, y, targetRect)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (direction) {
|
||||
case Direction.Left: {
|
||||
const contentRectWithBuffer = {
|
||||
top: contentRect.top - buffer,
|
||||
right: contentRect.right,
|
||||
bottom: contentRect.bottom + buffer,
|
||||
left: contentRect.left - buffer,
|
||||
};
|
||||
const trapezoidTop = {
|
||||
top: contentRect.top - buffer,
|
||||
right: targetRect.right,
|
||||
bottom: targetRect.top,
|
||||
left: contentRect.right,
|
||||
};
|
||||
const trapezoidCenter = {
|
||||
top: targetRect.top,
|
||||
right: targetRect.left,
|
||||
bottom: targetRect.bottom,
|
||||
left: contentRect.right,
|
||||
};
|
||||
const trapezoidBottom = {
|
||||
top: targetRect.bottom,
|
||||
right: targetRect.right,
|
||||
bottom: contentRect.bottom + buffer,
|
||||
left: contentRect.right,
|
||||
};
|
||||
|
||||
if (
|
||||
isInRect(x, y, contentRectWithBuffer) ||
|
||||
isInLowerLeftHalf(x, y, trapezoidTop) ||
|
||||
isInRect(x, y, trapezoidCenter) ||
|
||||
isInUpperLeftHalf(x, y, trapezoidBottom)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Direction.Right: {
|
||||
const contentRectWithBuffer = {
|
||||
top: contentRect.top - buffer,
|
||||
right: contentRect.right + buffer,
|
||||
bottom: contentRect.bottom + buffer,
|
||||
left: contentRect.left,
|
||||
};
|
||||
const trapezoidTop = {
|
||||
top: contentRect.top - buffer,
|
||||
right: contentRect.left,
|
||||
bottom: targetRect.top,
|
||||
left: targetRect.left,
|
||||
};
|
||||
const trapezoidCenter = {
|
||||
top: targetRect.top,
|
||||
right: contentRect.left,
|
||||
bottom: targetRect.bottom,
|
||||
left: targetRect.right,
|
||||
};
|
||||
const trapezoidBottom = {
|
||||
top: targetRect.bottom,
|
||||
right: contentRect.left,
|
||||
bottom: contentRect.bottom + buffer,
|
||||
left: targetRect.left,
|
||||
};
|
||||
|
||||
if (
|
||||
isInRect(x, y, contentRectWithBuffer) ||
|
||||
isInLowerRightHalf(x, y, trapezoidTop) ||
|
||||
isInRect(x, y, trapezoidCenter) ||
|
||||
isInUpperRightHalf(x, y, trapezoidBottom)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Direction.Top: {
|
||||
const contentRectWithBuffer = {
|
||||
top: contentRect.top - buffer,
|
||||
right: contentRect.right + buffer,
|
||||
bottom: contentRect.bottom,
|
||||
left: contentRect.left - buffer,
|
||||
};
|
||||
const trapezoidLeft = {
|
||||
top: contentRect.bottom,
|
||||
right: targetRect.left,
|
||||
bottom: targetRect.bottom,
|
||||
left: contentRect.left - buffer,
|
||||
};
|
||||
const trapezoidCenter = {
|
||||
top: contentRect.bottom,
|
||||
right: targetRect.right,
|
||||
bottom: targetRect.top,
|
||||
left: targetRect.left,
|
||||
};
|
||||
const trapezoidRight = {
|
||||
top: contentRect.bottom,
|
||||
right: contentRect.right + buffer,
|
||||
bottom: targetRect.bottom,
|
||||
left: targetRect.right,
|
||||
};
|
||||
|
||||
if (
|
||||
isInRect(x, y, contentRectWithBuffer) ||
|
||||
isInUpperRightHalf(x, y, trapezoidLeft) ||
|
||||
isInRect(x, y, trapezoidCenter) ||
|
||||
isInUpperLeftHalf(x, y, trapezoidRight)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Direction.Bottom: {
|
||||
const contentRectWithBuffer = {
|
||||
top: contentRect.top,
|
||||
right: contentRect.right + buffer,
|
||||
bottom: contentRect.bottom + buffer,
|
||||
left: contentRect.left - buffer,
|
||||
};
|
||||
const trapezoidLeft = {
|
||||
top: targetRect.top,
|
||||
right: targetRect.left,
|
||||
bottom: contentRect.top,
|
||||
left: contentRect.left - buffer,
|
||||
};
|
||||
const trapezoidCenter = {
|
||||
top: targetRect.bottom,
|
||||
right: targetRect.right,
|
||||
bottom: contentRect.top,
|
||||
left: targetRect.left,
|
||||
};
|
||||
const trapezoidRight = {
|
||||
top: targetRect.top,
|
||||
right: contentRect.right + buffer,
|
||||
bottom: contentRect.top,
|
||||
left: targetRect.right,
|
||||
};
|
||||
|
||||
if (
|
||||
isInRect(x, y, contentRectWithBuffer) ||
|
||||
isInLowerRightHalf(x, y, trapezoidLeft) ||
|
||||
isInRect(x, y, trapezoidCenter) ||
|
||||
isInLowerLeftHalf(x, y, trapezoidRight)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
children(props: {
|
||||
ref: RefCallback<HTMLElement>;
|
||||
onMouseOver: MouseEventHandler;
|
||||
}): ReactNode;
|
||||
// Content to show in the tooltip
|
||||
content: ReactNode;
|
||||
direction?: Direction;
|
||||
// Function to call when visibility of the tooltip changes
|
||||
onVisibilityChange?(visible: boolean): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contentRect: DOMRect;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* This style of tooltip takes a "target" element as its child and centers the
|
||||
* tooltip along one edge of the target.
|
||||
*/
|
||||
export default class InteractiveTooltip extends React.Component<IProps, IState> {
|
||||
private target: HTMLElement;
|
||||
|
||||
public static defaultProps = {
|
||||
side: Direction.Top,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
contentRect: null,
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// Whenever this passthrough component updates, also render the tooltip
|
||||
// in a separate DOM tree. This allows the tooltip content to participate
|
||||
// the normal React rendering cycle: when this component re-renders, the
|
||||
// tooltip content re-renders.
|
||||
// Once we upgrade to React 16, this could be done a bit more naturally
|
||||
// using the portals feature instead.
|
||||
this.renderTooltip();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("mousemove", this.onMouseMove);
|
||||
}
|
||||
|
||||
private collectContentRect = (element: HTMLElement): void => {
|
||||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
this.setState({
|
||||
contentRect: element.getBoundingClientRect(),
|
||||
});
|
||||
};
|
||||
|
||||
private collectTarget = (element: HTMLElement) => {
|
||||
this.target = element;
|
||||
};
|
||||
|
||||
private onLeftOfTarget(): boolean {
|
||||
const { contentRect } = this.state;
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
|
||||
if (this.props.direction === Direction.Left) {
|
||||
const targetLeft = targetRect.left + window.pageXOffset;
|
||||
return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
|
||||
} else {
|
||||
const targetRight = targetRect.right + window.pageXOffset;
|
||||
const spaceOnRight = UIStore.instance.windowWidth - targetRight;
|
||||
return !contentRect || (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
|
||||
}
|
||||
}
|
||||
|
||||
private aboveTarget(): boolean {
|
||||
const { contentRect } = this.state;
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
|
||||
if (this.props.direction === Direction.Top) {
|
||||
const targetTop = targetRect.top + window.pageYOffset;
|
||||
return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
|
||||
} else {
|
||||
const targetBottom = targetRect.bottom + window.pageYOffset;
|
||||
const spaceBelow = UIStore.instance.windowHeight - targetBottom;
|
||||
return !contentRect || (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
|
||||
}
|
||||
}
|
||||
|
||||
private get isOnTheSide(): boolean {
|
||||
return this.props.direction === Direction.Left || this.props.direction === Direction.Right;
|
||||
}
|
||||
|
||||
private onMouseMove = (ev: MouseEvent) => {
|
||||
const { clientX: x, clientY: y } = ev;
|
||||
const { contentRect } = this.state;
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
|
||||
let direction: Direction;
|
||||
if (this.isOnTheSide) {
|
||||
direction = this.onLeftOfTarget() ? Direction.Left : Direction.Right;
|
||||
} else {
|
||||
direction = this.aboveTarget() ? Direction.Top : Direction.Bottom;
|
||||
}
|
||||
|
||||
if (!mouseWithinRegion(x, y, direction, targetRect, contentRect)) {
|
||||
this.hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
private onTargetMouseOver = (): void => {
|
||||
this.showTooltip();
|
||||
};
|
||||
|
||||
private showTooltip(): void {
|
||||
// Don't enter visible state if we haven't collected the target yet
|
||||
if (!this.target) return;
|
||||
|
||||
this.setState({
|
||||
visible: true,
|
||||
});
|
||||
this.props.onVisibilityChange?.(true);
|
||||
document.addEventListener("mousemove", this.onMouseMove);
|
||||
}
|
||||
|
||||
public hideTooltip() {
|
||||
this.setState({
|
||||
visible: false,
|
||||
});
|
||||
this.props.onVisibilityChange?.(false);
|
||||
document.removeEventListener("mousemove", this.onMouseMove);
|
||||
}
|
||||
|
||||
private renderTooltip() {
|
||||
const { contentRect, visible } = this.state;
|
||||
if (!visible) {
|
||||
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetRect = this.target.getBoundingClientRect();
|
||||
|
||||
// The window X and Y offsets are to adjust position when zoomed in to page
|
||||
const targetLeft = targetRect.left + window.pageXOffset;
|
||||
const targetRight = targetRect.right + window.pageXOffset;
|
||||
const targetBottom = targetRect.bottom + window.pageYOffset;
|
||||
const targetTop = targetRect.top + window.pageYOffset;
|
||||
|
||||
// Place the tooltip above the target by default. If we find that the
|
||||
// tooltip content would extend past the safe area towards the window
|
||||
// edge, flip around to below the target.
|
||||
const position: Partial<IRect> = {};
|
||||
let chevronFace: ChevronFace = null;
|
||||
if (this.isOnTheSide) {
|
||||
if (this.onLeftOfTarget()) {
|
||||
position.left = targetLeft;
|
||||
chevronFace = ChevronFace.Right;
|
||||
} else {
|
||||
position.left = targetRight;
|
||||
chevronFace = ChevronFace.Left;
|
||||
}
|
||||
|
||||
position.top = targetTop;
|
||||
} else {
|
||||
if (this.aboveTarget()) {
|
||||
position.bottom = UIStore.instance.windowHeight - targetTop;
|
||||
chevronFace = ChevronFace.Bottom;
|
||||
} else {
|
||||
position.top = targetBottom;
|
||||
chevronFace = ChevronFace.Top;
|
||||
}
|
||||
|
||||
// Center the tooltip horizontally with the target's center.
|
||||
position.left = targetLeft + targetRect.width / 2;
|
||||
}
|
||||
|
||||
const chevron = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />;
|
||||
|
||||
const menuClasses = classNames({
|
||||
'mx_InteractiveTooltip': true,
|
||||
'mx_InteractiveTooltip_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
'mx_InteractiveTooltip_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_InteractiveTooltip_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_InteractiveTooltip_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
||||
});
|
||||
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (contentRect && !this.isOnTheSide) {
|
||||
menuStyle.left = `-${contentRect.width / 2}px`;
|
||||
}
|
||||
|
||||
const tooltip = <div className="mx_InteractiveTooltip_wrapper" style={{ ...position }}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContentRect}>
|
||||
{ chevron }
|
||||
{ this.props.content }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
ReactDOM.render(tooltip, getOrCreateContainer());
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children({
|
||||
ref: this.collectTarget,
|
||||
onMouseOver: this.onTargetMouseOver,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
|||
import React, { ComponentProps } from 'react';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
|
@ -31,7 +30,8 @@ import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
|
|||
import { Action } from '../../../dispatcher/actions';
|
||||
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { jsxJoin } from '../../../utils/ReactUtils';
|
||||
import { Layout } from '../../../settings/Layout';
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { Layout } from '../../../settings/enums/Layout';
|
||||
|
||||
const onPinnedMessagesClick = (): void => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
|
|
180
src/components/views/elements/PollCreateDialog.tsx
Normal file
180
src/components/views/elements/PollCreateDialog.tsx
Normal file
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal";
|
||||
import { IDialogProps } from "../dialogs/IDialogProps";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import React, { ChangeEvent, createRef } from "react";
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { arrayFastClone, arraySeed } from "../../../utils/arrays";
|
||||
import Field from "./Field";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
interface IState extends IScrollableBaseState {
|
||||
question: string;
|
||||
options: string[];
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const MIN_OPTIONS = 2;
|
||||
const MAX_OPTIONS = 20;
|
||||
const DEFAULT_NUM_OPTIONS = 2;
|
||||
const MAX_QUESTION_LENGTH = 340;
|
||||
const MAX_OPTION_LENGTH = 340;
|
||||
|
||||
export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState> {
|
||||
private addOptionRef = createRef<HTMLDivElement>();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
title: _t("Create poll"),
|
||||
actionLabel: _t("Create Poll"),
|
||||
canSubmit: false, // need to add a question and at least one option first
|
||||
|
||||
question: "",
|
||||
options: arraySeed("", DEFAULT_NUM_OPTIONS),
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
|
||||
private checkCanSubmit() {
|
||||
this.setState({
|
||||
canSubmit:
|
||||
!this.state.busy &&
|
||||
this.state.question.trim().length > 0 &&
|
||||
this.state.options.filter(op => op.trim().length > 0).length >= MIN_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
private onQuestionChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ question: e.target.value }, () => this.checkCanSubmit());
|
||||
};
|
||||
|
||||
private onOptionChange = (i: number, e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newOptions = arrayFastClone(this.state.options);
|
||||
newOptions[i] = e.target.value;
|
||||
this.setState({ options: newOptions }, () => this.checkCanSubmit());
|
||||
};
|
||||
|
||||
private onOptionRemove = (i: number) => {
|
||||
const newOptions = arrayFastClone(this.state.options);
|
||||
newOptions.splice(i, 1);
|
||||
this.setState({ options: newOptions }, () => this.checkCanSubmit());
|
||||
};
|
||||
|
||||
private onOptionAdd = () => {
|
||||
const newOptions = arrayFastClone(this.state.options);
|
||||
newOptions.push("");
|
||||
this.setState({ options: newOptions }, () => {
|
||||
// Scroll the button into view after the state update to ensure we don't experience
|
||||
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
|
||||
this.addOptionRef.current?.scrollIntoView?.();
|
||||
});
|
||||
};
|
||||
|
||||
protected submit(): void {
|
||||
this.setState({ busy: true, canSubmit: false });
|
||||
this.matrixClient.sendEvent(
|
||||
this.props.room.roomId,
|
||||
POLL_START_EVENT_TYPE.name,
|
||||
makePollContent(
|
||||
this.state.question, this.state.options, POLL_KIND_DISCLOSED.name,
|
||||
),
|
||||
).then(
|
||||
() => this.props.onFinished(true),
|
||||
).catch(e => {
|
||||
console.error("Failed to post poll:", e);
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to post poll',
|
||||
'',
|
||||
QuestionDialog,
|
||||
{
|
||||
title: _t("Failed to post poll"),
|
||||
description: _t(
|
||||
"Sorry, the poll you tried to create was not posted."),
|
||||
button: _t('Try again'),
|
||||
cancelButton: _t('Cancel'),
|
||||
onFinished: (tryAgain: boolean) => {
|
||||
if (!tryAgain) {
|
||||
this.cancel();
|
||||
} else {
|
||||
this.setState({ busy: false, canSubmit: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected cancel(): void {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
protected renderContent(): React.ReactNode {
|
||||
return <div className="mx_PollCreateDialog">
|
||||
<h2>{ _t("What is your poll question or topic?") }</h2>
|
||||
<Field
|
||||
value={this.state.question}
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
label={_t("Question or topic")}
|
||||
placeholder={_t("Write something...")}
|
||||
onChange={this.onQuestionChange}
|
||||
usePlaceholderAsHint={true}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
<h2>{ _t("Create options") }</h2>
|
||||
{
|
||||
this.state.options.map((op, i) => <div key={`option_${i}`} className="mx_PollCreateDialog_option">
|
||||
<Field
|
||||
value={op}
|
||||
maxLength={MAX_OPTION_LENGTH}
|
||||
label={_t("Option %(number)s", { number: i + 1 })}
|
||||
placeholder={_t("Write an option")}
|
||||
onChange={e => this.onOptionChange(i, e)}
|
||||
usePlaceholderAsHint={true}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={() => this.onOptionRemove(i)}
|
||||
className="mx_PollCreateDialog_removeOption"
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
<AccessibleButton
|
||||
onClick={this.onOptionAdd}
|
||||
disabled={this.state.busy || this.state.options.length >= MAX_OPTIONS}
|
||||
kind="secondary"
|
||||
className="mx_PollCreateDialog_addOption"
|
||||
inputRef={this.addOptionRef}
|
||||
>{ _t("Add option") }</AccessibleButton>
|
||||
{
|
||||
this.state.busy &&
|
||||
<div className="mx_PollCreateDialog_busy"><Spinner /></div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -17,25 +17,25 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import escapeHtml from "escape-html";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import escapeHtml from "escape-html";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Spinner from './Spinner';
|
||||
import ReplyTile from "../rooms/ReplyTile";
|
||||
import Pill from './Pill';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
/**
|
||||
* This number is based on the previous behavior - if we have message of height
|
||||
|
@ -110,6 +110,8 @@ export default class ReplyChain extends React.Component<IProps, IState> {
|
|||
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
||||
const mInReplyTo = mRelatesTo['m.in_reply_to'];
|
||||
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
|
||||
} else if (!SettingsStore.getValue("feature_thread") && ev.isThreadRelation) {
|
||||
return ev.threadRootId;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -53,8 +53,10 @@ const onHelpClick = () => {
|
|||
};
|
||||
|
||||
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
|
||||
const disableCustomUrls = SdkConfig.get()["disable_custom_urls"];
|
||||
|
||||
let editBtn;
|
||||
if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) {
|
||||
if (!disableCustomUrls && onServerConfigChange) {
|
||||
const onClick = () => {
|
||||
showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => {
|
||||
if (config) {
|
||||
|
@ -83,7 +85,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
|
|||
|
||||
return <div className="mx_ServerPicker">
|
||||
<h3>{ title || _t("Homeserver") }</h3>
|
||||
<AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} />
|
||||
{ !disableCustomUrls ? <AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} /> : null }
|
||||
<span className="mx_ServerPicker_server">{ serverName }</span>
|
||||
{ editBtn }
|
||||
{ desc }
|
||||
|
|
|
@ -18,8 +18,15 @@ import React from "react";
|
|||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import classnames from 'classnames';
|
||||
|
||||
export enum CheckboxStyle {
|
||||
Solid = "solid",
|
||||
Outline = "outline",
|
||||
}
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
kind?: CheckboxStyle;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -41,13 +48,21 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
|
|||
|
||||
public render() {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { children, className, ...otherProps } = this.props;
|
||||
return <span className={"mx_Checkbox " + className}>
|
||||
const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props;
|
||||
const newClassName = classnames(
|
||||
"mx_Checkbox",
|
||||
className,
|
||||
{
|
||||
"mx_Checkbox_hasKind": kind,
|
||||
[`mx_Checkbox_kind_${kind}`]: kind,
|
||||
},
|
||||
);
|
||||
return <span className={newClassName}>
|
||||
<input id={this.id} {...otherProps} type="checkbox" />
|
||||
<label htmlFor={this.id}>
|
||||
{ /* Using the div to center the image */ }
|
||||
<div className="mx_Checkbox_background">
|
||||
<img src={require("../../../../res/img/feather-customised/check.svg")} />
|
||||
<div className="mx_Checkbox_checkmark" />
|
||||
</div>
|
||||
<div>
|
||||
{ this.props.children }
|
||||
|
|
|
@ -40,13 +40,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
public render() {
|
||||
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
|
||||
const _className = classnames(
|
||||
'mx_RadioButton',
|
||||
'mx_StyledRadioButton',
|
||||
className,
|
||||
{
|
||||
"mx_RadioButton_disabled": disabled,
|
||||
"mx_RadioButton_enabled": !disabled,
|
||||
"mx_RadioButton_checked": this.props.checked,
|
||||
"mx_RadioButton_outlined": outlined,
|
||||
"mx_StyledRadioButton_disabled": disabled,
|
||||
"mx_StyledRadioButton_enabled": !disabled,
|
||||
"mx_StyledRadioButton_checked": this.props.checked,
|
||||
"mx_StyledRadioButton_outlined": outlined,
|
||||
});
|
||||
|
||||
const radioButton = <React.Fragment>
|
||||
|
@ -58,16 +58,16 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
if (childrenInLabel) {
|
||||
return <label className={_className}>
|
||||
{ radioButton }
|
||||
<div className="mx_RadioButton_content">{ children }</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
<div className="mx_StyledRadioButton_content">{ children }</div>
|
||||
<div className="mx_StyledRadioButton_spacer" />
|
||||
</label>;
|
||||
} else {
|
||||
return <div className={_className}>
|
||||
<label className="mx_RadioButton_innerLabel">
|
||||
<label className="mx_StyledRadioButton_innerLabel">
|
||||
{ radioButton }
|
||||
</label>
|
||||
<div className="mx_RadioButton_content">{ children }</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
<div className="mx_StyledRadioButton_content">{ children }</div>
|
||||
<div className="mx_StyledRadioButton_spacer" />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,12 @@ import classNames from "classnames";
|
|||
|
||||
type Data = Pick<IFieldState, "value" | "allowEmpty">;
|
||||
|
||||
interface IResult {
|
||||
key: string;
|
||||
valid: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface IRule<T, D = void> {
|
||||
key: string;
|
||||
final?: boolean;
|
||||
|
@ -32,7 +38,7 @@ interface IRule<T, D = void> {
|
|||
|
||||
interface IArgs<T, D = void> {
|
||||
rules: IRule<T, D>[];
|
||||
description?(this: T, derivedData: D): React.ReactChild;
|
||||
description?(this: T, derivedData: D, results: IResult[]): React.ReactChild;
|
||||
hideDescriptionIfValid?: boolean;
|
||||
deriveData?(data: Data): Promise<D>;
|
||||
}
|
||||
|
@ -88,7 +94,7 @@ export default function withValidation<T = undefined, D = void>({
|
|||
const data = { value, allowEmpty };
|
||||
const derivedData = deriveData ? await deriveData(data) : undefined;
|
||||
|
||||
const results = [];
|
||||
const results: IResult[] = [];
|
||||
let valid = true;
|
||||
if (rules && rules.length) {
|
||||
for (const rule of rules) {
|
||||
|
@ -164,8 +170,8 @@ export default function withValidation<T = undefined, D = void>({
|
|||
if (description && (details || !hideDescriptionIfValid)) {
|
||||
// We're setting `this` to whichever component holds the validation
|
||||
// function. That allows rules to access the state of the component.
|
||||
const content = description.call(this, derivedData);
|
||||
summary = <div className="mx_Validation_description">{ content }</div>;
|
||||
const content = description.call(this, derivedData, results);
|
||||
summary = content ? <div className="mx_Validation_description">{ content }</div> : undefined;
|
||||
}
|
||||
|
||||
let feedback;
|
||||
|
|
|
@ -28,8 +28,8 @@ import QuickReactions from "./QuickReactions";
|
|||
import Category, { ICategory, CategoryKey } from "./Category";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
export const CATEGORY_HEADER_HEIGHT = 22;
|
||||
export const EMOJI_HEIGHT = 37;
|
||||
export const CATEGORY_HEADER_HEIGHT = 20;
|
||||
export const EMOJI_HEIGHT = 35;
|
||||
export const EMOJIS_PER_ROW = 8;
|
||||
|
||||
const ZERO_WIDTH_JOINER = "\u200D";
|
||||
|
|
|
@ -94,7 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
|||
this.props.mxEvent.getRoomId(),
|
||||
myReactions[reaction],
|
||||
);
|
||||
dis.dispatch({ action: Action.FocusAComposer });
|
||||
dis.fire(Action.FocusAComposer);
|
||||
// Tell the emoji picker not to bump this in the more frequently used list.
|
||||
return false;
|
||||
} else {
|
||||
|
@ -106,7 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
|||
},
|
||||
});
|
||||
dis.dispatch({ action: "message_sent" });
|
||||
dis.dispatch({ action: Action.FocusAComposer });
|
||||
dis.fire(Action.FocusAComposer);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -63,7 +63,7 @@ class Search extends React.PureComponent<IProps> {
|
|||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
placeholder={_t("Search")}
|
||||
value={this.props.query}
|
||||
onChange={ev => this.props.onChange(ev.target.value)}
|
||||
onKeyDown={this.onKeyDown}
|
||||
|
|
|
@ -24,7 +24,7 @@ import * as sdk from '../../../index';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { ContextMenu, ContextMenuButton, toRightOf } from "../../structures/ContextMenu";
|
||||
import ContextMenu, { ContextMenuButton, toRightOf } from "../../structures/ContextMenu";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
|
266
src/components/views/location/LocationPicker.tsx
Normal file
266
src/components/views/location/LocationPicker.tsx
Normal file
|
@ -0,0 +1,266 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import Field from "../elements/Field";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import LocationShareType from "./LocationShareType";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IDropdownProps {
|
||||
value: LocationShareType;
|
||||
label: string;
|
||||
width?: number;
|
||||
onChange(type: LocationShareType): void;
|
||||
}
|
||||
|
||||
const LocationShareTypeDropdown = ({
|
||||
value,
|
||||
label,
|
||||
width,
|
||||
onChange,
|
||||
}: IDropdownProps) => {
|
||||
const options = [
|
||||
<div key={LocationShareType.Custom}>{ _t("Share custom location") }</div>,
|
||||
<div key={LocationShareType.OnceOff}>{ _t("Share my current location as a once off") }</div>,
|
||||
// <div key={LocationShareType.OneMin}>{ _t("Share my current location for one minute") }</div>,
|
||||
// <div key={LocationShareType.FiveMins}>{ _t("Share my current location for five minutes") }</div>,
|
||||
// <div key={LocationShareType.ThirtyMins}>{ _t("Share my current location for thirty minutes") }</div>,
|
||||
// <div key={LocationShareType.OneHour}>{ _t("Share my current location for one hour") }</div>,
|
||||
// <div key={LocationShareType.ThreeHours}>{ _t("Share my current location for three hours") }</div>,
|
||||
// <div key={LocationShareType.SixHours}>{ _t("Share my current location for six hours") }</div>,
|
||||
// <div key={LocationShareType.OneDay}>{ _t("Share my current location for one day") }</div>,
|
||||
// <div key={LocationShareType.Forever}>{ _t("Share my current location until I disable it") }</div>,
|
||||
];
|
||||
|
||||
return <Dropdown
|
||||
id="mx_LocationShareTypeDropdown"
|
||||
className="mx_LocationShareTypeDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
onChange(LocationShareType[LocationShareType[parseInt(key)]]);
|
||||
}}
|
||||
menuWidth={width}
|
||||
label={label}
|
||||
value={value.toString()}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
onChoose(uri: string, ts: number, type: LocationShareType, description: string): boolean;
|
||||
onFinished();
|
||||
}
|
||||
|
||||
interface IState {
|
||||
description: string;
|
||||
type: LocationShareType;
|
||||
position?: GeolocationPosition;
|
||||
manualPosition?: GeolocationPosition;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.location.LocationPicker")
|
||||
class LocationPicker extends React.Component<IProps, IState> {
|
||||
private map: maplibregl.Map;
|
||||
private marker: maplibregl.Marker;
|
||||
private geolocate: maplibregl.GeolocateControl;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
description: _t("My location"),
|
||||
type: LocationShareType.OnceOff,
|
||||
position: undefined,
|
||||
manualPosition: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const config = SdkConfig.get();
|
||||
this.map = new maplibregl.Map({
|
||||
container: 'mx_LocationPicker_map',
|
||||
style: config.map_style_url,
|
||||
center: [0, 0],
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
// Add geolocate control to the map.
|
||||
this.geolocate = new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
});
|
||||
this.map.addControl(this.geolocate);
|
||||
|
||||
this.map.on('error', (e) => {
|
||||
logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key",
|
||||
e.error);
|
||||
this.setState({ error: e.error });
|
||||
});
|
||||
|
||||
this.map.on('load', () => {
|
||||
this.geolocate.trigger();
|
||||
});
|
||||
|
||||
this.map.on('click', (e) => {
|
||||
this.addMarker(e.lngLat);
|
||||
this.storeManualPosition(e.lngLat);
|
||||
this.setState({ type: LocationShareType.Custom });
|
||||
});
|
||||
|
||||
this.geolocate.on('geolocate', this.onGeolocate);
|
||||
} catch (e) {
|
||||
logger.error("Failed to render map", e.error);
|
||||
this.setState({ error: e.error });
|
||||
}
|
||||
}
|
||||
|
||||
private addMarker(lngLat: maplibregl.LngLat): void {
|
||||
if (this.marker) return;
|
||||
this.marker = new maplibregl.Marker({
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(lngLat)
|
||||
.addTo(this.map)
|
||||
.on('dragend', () => {
|
||||
this.storeManualPosition(this.marker.getLngLat());
|
||||
});
|
||||
}
|
||||
|
||||
private removeMarker(): void {
|
||||
if (!this.marker) return;
|
||||
this.marker.remove();
|
||||
this.marker = undefined;
|
||||
}
|
||||
|
||||
private storeManualPosition(lngLat: maplibregl.LngLat): void {
|
||||
const manualPosition: GeolocationPosition = {
|
||||
coords: {
|
||||
longitude: lngLat.lng,
|
||||
latitude: lngLat.lat,
|
||||
altitude: undefined,
|
||||
accuracy: undefined,
|
||||
altitudeAccuracy: undefined,
|
||||
heading: undefined,
|
||||
speed: undefined,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.setState({ manualPosition });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.geolocate?.off('geolocate', this.onGeolocate);
|
||||
}
|
||||
|
||||
private onGeolocate = (position: GeolocationPosition) => {
|
||||
this.setState({ position });
|
||||
};
|
||||
|
||||
private onDescriptionChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ description: ev.target.value });
|
||||
};
|
||||
|
||||
private getGeoUri = (position) => {
|
||||
return (`geo:${ position.coords.latitude },` +
|
||||
position.coords.longitude +
|
||||
( position.coords.altitude != null ?
|
||||
`,${ position.coords.altitude }` : '' ) +
|
||||
`;u=${ position.coords.accuracy }`);
|
||||
};
|
||||
|
||||
private onOk = () => {
|
||||
const position = (this.state.type == LocationShareType.Custom) ?
|
||||
this.state.manualPosition : this.state.position;
|
||||
|
||||
this.props.onChoose(
|
||||
position ? this.getGeoUri(position) : undefined,
|
||||
position ? position.timestamp : undefined,
|
||||
this.state.type,
|
||||
this.state.description,
|
||||
);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
private onTypeChange= (type: LocationShareType) => {
|
||||
if (type == LocationShareType.Custom) {
|
||||
if (!this.state.manualPosition) {
|
||||
this.setState({ manualPosition: this.state.position });
|
||||
}
|
||||
if (this.state.manualPosition) {
|
||||
this.addMarker(new maplibregl.LngLat(
|
||||
this.state.manualPosition?.coords.longitude,
|
||||
this.state.manualPosition?.coords.latitude,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
this.removeMarker();
|
||||
}
|
||||
|
||||
this.setState({ type });
|
||||
};
|
||||
|
||||
render() {
|
||||
const error = this.state.error ?
|
||||
<div className="mx_LocationPicker_error">
|
||||
{ _t("Failed to load map") }
|
||||
</div> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_LocationPicker">
|
||||
<div id="mx_LocationPicker_map" />
|
||||
{ error }
|
||||
<div className="mx_LocationPicker_footer">
|
||||
<form onSubmit={this.onOk}>
|
||||
<LocationShareTypeDropdown
|
||||
value={this.state.type}
|
||||
label={_t("Type of location share")}
|
||||
onChange={this.onTypeChange}
|
||||
width={400}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={_t('Description')}
|
||||
onChange={this.onDescriptionChange}
|
||||
value={this.state.description}
|
||||
width={400}
|
||||
className="mx_LocationPicker_description"
|
||||
/>
|
||||
|
||||
<DialogButtons primaryButton={_t('Share')}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.props.onFinished}
|
||||
primaryDisabled={!this.state.position && !this.state.manualPosition} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LocationPicker;
|
30
src/components/views/location/LocationShareType.tsx
Normal file
30
src/components/views/location/LocationShareType.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
enum LocationShareType {
|
||||
Custom = -1,
|
||||
OnceOff = 0,
|
||||
OneMine = 60,
|
||||
FiveMins = 5 * 60,
|
||||
ThirtyMins = 30 * 60,
|
||||
OneHour = 60 * 60,
|
||||
ThreeHours = 3 * 60 * 60,
|
||||
SixHours = 6 * 60 * 60,
|
||||
OneDay = 24 * 60 * 60,
|
||||
Forever = Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
|
||||
export default LocationShareType;
|
|
@ -65,9 +65,9 @@ export default class DateSeparator extends React.Component<IProps> {
|
|||
render() {
|
||||
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1}>
|
||||
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1} aria-label={this.getLabel()}>
|
||||
<hr role="none" />
|
||||
<div>{ this.getLabel() }</div>
|
||||
<div aria-hidden="true">{ this.getLabel() }</div>
|
||||
<hr role="none" />
|
||||
</h2>;
|
||||
}
|
||||
|
|
|
@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
export interface IBodyProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -42,4 +42,7 @@ export interface IBodyProps {
|
|||
onMessageAllowed: () => void; // TODO: Docs
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
mediaEventHelper: MediaEventHelper;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
|
||||
}
|
||||
|
|
|
@ -17,10 +17,6 @@ limitations under the License.
|
|||
|
||||
import React, { ComponentProps, createRef } from 'react';
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
import classNames from 'classnames';
|
||||
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import MFileBody from './MFileBody';
|
||||
import Modal from '../../../Modal';
|
||||
|
@ -33,7 +29,14 @@ import { Media, mediaFromContent } from "../../../customisations/Media";
|
|||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import classNames from 'classnames';
|
||||
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { TileShape } from '../rooms/EventTile';
|
||||
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -57,6 +60,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
private unmounted = true;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
private timeout?: number;
|
||||
private sizeWatcher: string;
|
||||
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
@ -316,12 +320,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener('sync', this.onClientSync);
|
||||
this.clearBlurhashTimeout();
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
|
@ -366,11 +375,28 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
infoHeight = this.state.loadedImageDimensions.naturalHeight;
|
||||
}
|
||||
|
||||
// The maximum height of the thumbnail as it is rendered as an <img>
|
||||
const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
|
||||
// The maximum width of the thumbnail, as dictated by its natural
|
||||
// maximum height.
|
||||
const maxWidth = infoWidth * maxHeight / infoHeight;
|
||||
// The maximum size of the thumbnail as it is rendered as an <img>
|
||||
// check for any height constraints
|
||||
const imageSize = SettingsStore.getValue("Images.size") as ImageSize;
|
||||
const isPortrait = infoWidth < infoHeight;
|
||||
const suggestedAndPossibleWidth = Math.min(suggestedImageSize(imageSize, isPortrait).w, infoWidth);
|
||||
const suggestedAndPossibleHeight = Math.min(suggestedImageSize(imageSize, isPortrait).h, infoHeight);
|
||||
const aspectRatio = infoWidth / infoHeight;
|
||||
|
||||
let maxWidth;
|
||||
let maxHeight;
|
||||
const maxHeightConstraint = forcedHeight || this.props.maxImageHeight || suggestedAndPossibleHeight;
|
||||
if (maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth || imageSize === ImageSize.Large) {
|
||||
// The width is dictated by the maximum height that was defined by the props or the function param `forcedHeight`
|
||||
// If the thumbnail size is set to Large, we always let the size be dictated by the height.
|
||||
maxWidth = maxHeightConstraint * aspectRatio;
|
||||
// there is no need to check for infoHeight here since this is done with `maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth`
|
||||
maxHeight = maxHeightConstraint;
|
||||
} else {
|
||||
// height is dictated by suggestedWidth (based on the Image.size setting)
|
||||
maxWidth = suggestedAndPossibleWidth;
|
||||
maxHeight = suggestedAndPossibleWidth / aspectRatio;
|
||||
}
|
||||
|
||||
let img = null;
|
||||
let placeholder = null;
|
||||
|
@ -495,8 +521,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
// Overidden by MStickerBody
|
||||
protected getFileBody(): string | JSX.Element {
|
||||
if (this.props.forExport) return null;
|
||||
// We only ever need the download bar if we're appearing outside of the timeline
|
||||
if (this.props.tileShape) {
|
||||
/*
|
||||
* In the room timeline or the thread context we don't need the download
|
||||
* link as the message action bar will fullfil that
|
||||
*/
|
||||
const hasMessageActionBar = !this.props.tileShape
|
||||
|| this.props.tileShape === TileShape.Thread
|
||||
|| this.props.tileShape === TileShape.ThreadPanel;
|
||||
if (!hasMessageActionBar) {
|
||||
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||
}
|
||||
}
|
||||
|
|
118
src/components/views/messages/MLocationBody.tsx
Normal file
118
src/components/views/messages/MLocationBody.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
interface IState {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MLocationBody")
|
||||
export default class MLocationBody extends React.Component<IBodyProps, IState> {
|
||||
private map: maplibregl.Map;
|
||||
private coords: GeolocationCoordinates;
|
||||
private description: string;
|
||||
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
// unfortunately we're stuck supporting legacy `content.geo_uri`
|
||||
// events until the end of days, or until we figure out mutable
|
||||
// events - so folks can read their old chat history correctly.
|
||||
// https://github.com/matrix-org/matrix-doc/issues/3516
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const uri = content['org.matrix.msc3488.location'] ?
|
||||
content['org.matrix.msc3488.location'].uri :
|
||||
content['geo_uri'];
|
||||
|
||||
this.coords = this.parseGeoUri(uri);
|
||||
this.state = {
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
this.description =
|
||||
content['org.matrix.msc3488.location']?.description ?? content['body'];
|
||||
}
|
||||
|
||||
private parseGeoUri = (uri: string): GeolocationCoordinates => {
|
||||
const m = uri.match(/^\s*geo:(.*?)\s*$/);
|
||||
if (!m) return;
|
||||
const parts = m[1].split(';');
|
||||
const coords = parts[0].split(',');
|
||||
let uncertainty: number;
|
||||
for (const param of parts.slice(1)) {
|
||||
const m = param.match(/u=(.*)/);
|
||||
if (m) uncertainty = parseFloat(m[1]);
|
||||
}
|
||||
return {
|
||||
latitude: parseFloat(coords[0]),
|
||||
longitude: parseFloat(coords[1]),
|
||||
altitude: parseFloat(coords[2]),
|
||||
accuracy: uncertainty,
|
||||
altitudeAccuracy: undefined,
|
||||
heading: undefined,
|
||||
speed: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const config = SdkConfig.get();
|
||||
const coordinates = new maplibregl.LngLat(this.coords.longitude, this.coords.latitude);
|
||||
|
||||
this.map = new maplibregl.Map({
|
||||
container: this.getBodyId(),
|
||||
style: config.map_style_url,
|
||||
center: coordinates,
|
||||
zoom: 13,
|
||||
});
|
||||
|
||||
new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
closeOnMove: false,
|
||||
})
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(this.description)
|
||||
.addTo(this.map);
|
||||
|
||||
this.map.on('error', (e)=>{
|
||||
logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key", e.error);
|
||||
this.setState({ error: e.error });
|
||||
});
|
||||
}
|
||||
|
||||
private getBodyId = () => {
|
||||
return `mx_MLocationBody_${this.props.mxEvent.getId()}`;
|
||||
};
|
||||
|
||||
render() {
|
||||
const error = this.state.error ?
|
||||
<div className="mx_EventTile_tileError mx_EventTile_body">
|
||||
{ _t("Failed to load map") }
|
||||
</div> : null;
|
||||
|
||||
return <div className="mx_MLocationBody">
|
||||
<div id={this.getBodyId()} className="mx_MLocationBody_map" />
|
||||
{ error }
|
||||
</div>;
|
||||
}
|
||||
}
|
635
src/components/views/messages/MPollBody.tsx
Normal file
635
src/components/views/messages/MPollBody.tsx
Normal file
|
@ -0,0 +1,635 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Modal from '../../../Modal';
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import {
|
||||
IPollAnswer,
|
||||
IPollContent,
|
||||
IPollResponseContent,
|
||||
POLL_END_EVENT_TYPE,
|
||||
POLL_RESPONSE_EVENT_TYPE,
|
||||
POLL_START_EVENT_TYPE,
|
||||
TEXT_NODE_TYPE,
|
||||
} from '../../../polls/consts';
|
||||
import StyledRadioButton from '../elements/StyledRadioButton';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import ErrorDialog from '../dialogs/ErrorDialog';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
|
||||
interface IState {
|
||||
selected?: string; // Which option was clicked by the local user
|
||||
voteRelations: Relations; // Voting (response) events
|
||||
endRelations: Relations; // Poll end events
|
||||
}
|
||||
|
||||
export function findTopAnswer(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations,
|
||||
): string {
|
||||
if (!getRelationsForEvent) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pollContents: IPollContent = pollEvent.getContent();
|
||||
|
||||
const findAnswerText = (answerId: string) => {
|
||||
for (const answer of pollContents[POLL_START_EVENT_TYPE.name].answers) {
|
||||
if (answer.id == answerId) {
|
||||
return answer[TEXT_NODE_TYPE.name];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const voteRelations: Relations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
const endRelations: Relations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_END_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
const userVotes: Map<string, UserVote> = collectUserVotes(
|
||||
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
|
||||
matrixClient.getUserId(),
|
||||
null,
|
||||
);
|
||||
|
||||
const votes: Map<string, number> = countVotes(userVotes, pollEvent.getContent());
|
||||
const highestScore: number = Math.max(...votes.values());
|
||||
|
||||
const bestAnswerIds: string[] = [];
|
||||
for (const [answerId, score] of votes) {
|
||||
if (score == highestScore) {
|
||||
bestAnswerIds.push(answerId);
|
||||
}
|
||||
}
|
||||
|
||||
const bestAnswerTexts = bestAnswerIds.map(findAnswerText);
|
||||
|
||||
return formatCommaSeparatedList(bestAnswerTexts, 3);
|
||||
}
|
||||
|
||||
export function isPollEnded(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations,
|
||||
): boolean {
|
||||
if (!getRelationsForEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
|
||||
function userCanRedact(endEvent: MatrixEvent) {
|
||||
return roomCurrentState.maySendRedactionForEvent(
|
||||
pollEvent,
|
||||
endEvent.getSender(),
|
||||
);
|
||||
}
|
||||
|
||||
const endRelations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_END_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
if (!endRelations) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
|
||||
|
||||
return authorisedRelations.length > 0;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MPollBody")
|
||||
export default class MPollBody extends React.Component<IBodyProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public context!: React.ContextType<typeof MatrixClientContext>;
|
||||
private seenEventIds: string[] = []; // Events we have already seen
|
||||
private voteRelationsReceived = false;
|
||||
private endRelationsReceived = false;
|
||||
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: null,
|
||||
voteRelations: this.fetchVoteRelations(),
|
||||
endRelations: this.fetchEndRelations(),
|
||||
};
|
||||
|
||||
this.addListeners(this.state.voteRelations, this.state.endRelations);
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.mxEvent.off("Event.relationsCreated", this.onRelationsCreated);
|
||||
this.removeListeners(this.state.voteRelations, this.state.endRelations);
|
||||
}
|
||||
|
||||
private addListeners(voteRelations?: Relations, endRelations?: Relations) {
|
||||
if (voteRelations) {
|
||||
voteRelations.on("Relations.add", this.onRelationsChange);
|
||||
voteRelations.on("Relations.remove", this.onRelationsChange);
|
||||
voteRelations.on("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
if (endRelations) {
|
||||
endRelations.on("Relations.add", this.onRelationsChange);
|
||||
endRelations.on("Relations.remove", this.onRelationsChange);
|
||||
endRelations.on("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
}
|
||||
|
||||
private removeListeners(voteRelations?: Relations, endRelations?: Relations) {
|
||||
if (voteRelations) {
|
||||
voteRelations.off("Relations.add", this.onRelationsChange);
|
||||
voteRelations.off("Relations.remove", this.onRelationsChange);
|
||||
voteRelations.off("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
if (endRelations) {
|
||||
endRelations.off("Relations.add", this.onRelationsChange);
|
||||
endRelations.off("Relations.remove", this.onRelationsChange);
|
||||
endRelations.off("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
}
|
||||
|
||||
private onRelationsCreated = (relationType: string, eventType: string) => {
|
||||
if (relationType !== "m.reference") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
|
||||
this.voteRelationsReceived = true;
|
||||
const newVoteRelations = this.fetchVoteRelations();
|
||||
this.addListeners(newVoteRelations);
|
||||
this.removeListeners(this.state.voteRelations);
|
||||
this.setState({ voteRelations: newVoteRelations });
|
||||
} else if (POLL_END_EVENT_TYPE.matches(eventType)) {
|
||||
this.endRelationsReceived = true;
|
||||
const newEndRelations = this.fetchEndRelations();
|
||||
this.addListeners(newEndRelations);
|
||||
this.removeListeners(this.state.endRelations);
|
||||
this.setState({ endRelations: newEndRelations });
|
||||
}
|
||||
|
||||
if (this.voteRelationsReceived && this.endRelationsReceived) {
|
||||
this.props.mxEvent.removeListener(
|
||||
"Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
};
|
||||
|
||||
private onRelationsChange = () => {
|
||||
// We hold Relations in our state, and they changed under us.
|
||||
// Check whether we should delete our selection, and then
|
||||
// re-render.
|
||||
// Note: re-rendering is a side effect of unselectIfNewEventFromMe().
|
||||
this.unselectIfNewEventFromMe();
|
||||
};
|
||||
|
||||
private selectOption(answerId: string) {
|
||||
if (answerId === this.state.selected) {
|
||||
return;
|
||||
}
|
||||
if (this.isEnded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseContent: IPollResponseContent = {
|
||||
[POLL_RESPONSE_EVENT_TYPE.name]: {
|
||||
"answers": [answerId],
|
||||
},
|
||||
"m.relates_to": {
|
||||
"event_id": this.props.mxEvent.getId(),
|
||||
"rel_type": "m.reference",
|
||||
},
|
||||
};
|
||||
|
||||
this.context.sendEvent(
|
||||
this.props.mxEvent.getRoomId(),
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
responseContent,
|
||||
).catch((e: any) => {
|
||||
console.error("Failed to submit poll response event:", e);
|
||||
|
||||
Modal.createTrackedDialog(
|
||||
'Vote not registered',
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t("Vote not registered"),
|
||||
description: _t(
|
||||
"Sorry, your vote was not registered. Please try again."),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.setState({ selected: answerId });
|
||||
}
|
||||
|
||||
private onOptionSelected = (e: React.FormEvent<HTMLInputElement>): void => {
|
||||
this.selectOption(e.currentTarget.value);
|
||||
};
|
||||
|
||||
private fetchVoteRelations(): Relations | null {
|
||||
return this.fetchRelations(POLL_RESPONSE_EVENT_TYPE.name);
|
||||
}
|
||||
|
||||
private fetchEndRelations(): Relations | null {
|
||||
return this.fetchRelations(POLL_END_EVENT_TYPE.name);
|
||||
}
|
||||
|
||||
private fetchRelations(eventType: string): Relations | null {
|
||||
if (this.props.getRelationsForEvent) {
|
||||
return this.props.getRelationsForEvent(
|
||||
this.props.mxEvent.getId(),
|
||||
"m.reference",
|
||||
eventType,
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns userId -> UserVote
|
||||
*/
|
||||
private collectUserVotes(): Map<string, UserVote> {
|
||||
return collectUserVotes(
|
||||
allVotes(
|
||||
this.props.mxEvent,
|
||||
this.context,
|
||||
this.state.voteRelations,
|
||||
this.state.endRelations,
|
||||
),
|
||||
this.context.getUserId(),
|
||||
this.state.selected,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we've just received a new event that we hadn't seen
|
||||
* before, and that event is me voting (e.g. from a different
|
||||
* device) then forget when the local user selected.
|
||||
*
|
||||
* Either way, calls setState to update our list of events we
|
||||
* have already seen.
|
||||
*/
|
||||
private unselectIfNewEventFromMe() {
|
||||
const newEvents: MatrixEvent[] = this.state.voteRelations.getRelations()
|
||||
.filter(isPollResponse)
|
||||
.filter((mxEvent: MatrixEvent) =>
|
||||
!this.seenEventIds.includes(mxEvent.getId()));
|
||||
let newSelected = this.state.selected;
|
||||
|
||||
if (newEvents.length > 0) {
|
||||
for (const mxEvent of newEvents) {
|
||||
if (mxEvent.getSender() === this.context.getUserId()) {
|
||||
newSelected = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId());
|
||||
this.seenEventIds = this.seenEventIds.concat(newEventIds);
|
||||
this.setState( { selected: newSelected } );
|
||||
}
|
||||
|
||||
private totalVotes(collectedVotes: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const v of collectedVotes.values()) {
|
||||
sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private isEnded(): boolean {
|
||||
return isPollEnded(
|
||||
this.props.mxEvent,
|
||||
this.context,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const pollStart: IPollContent = this.props.mxEvent.getContent();
|
||||
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
|
||||
|
||||
if (pollInfo.answers.length < 1 || pollInfo.answers.length > 20) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ended = this.isEnded();
|
||||
const pollId = this.props.mxEvent.getId();
|
||||
const userVotes = this.collectUserVotes();
|
||||
const votes = countVotes(userVotes, this.props.mxEvent.getContent());
|
||||
const totalVotes = this.totalVotes(votes);
|
||||
const winCount = Math.max(...votes.values());
|
||||
const userId = this.context.getUserId();
|
||||
const myVote = userVotes.get(userId)?.answers[0];
|
||||
|
||||
let totalText: string;
|
||||
if (ended) {
|
||||
totalText = _t(
|
||||
"Final result based on %(count)s votes",
|
||||
{ count: totalVotes },
|
||||
);
|
||||
} else if (myVote === undefined) {
|
||||
if (totalVotes === 0) {
|
||||
totalText = _t("No votes cast");
|
||||
} else {
|
||||
totalText = _t(
|
||||
"%(count)s votes cast. Vote to see the results",
|
||||
{ count: totalVotes },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
totalText = _t( "Based on %(count)s votes", { count: totalVotes } );
|
||||
}
|
||||
|
||||
return <div className="mx_MPollBody">
|
||||
<h2>{ pollInfo.question[TEXT_NODE_TYPE.name] }</h2>
|
||||
<div className="mx_MPollBody_allOptions">
|
||||
{
|
||||
pollInfo.answers.map((answer: IPollAnswer) => {
|
||||
let answerVotes = 0;
|
||||
let votesText = "";
|
||||
|
||||
// Votes are hidden until I vote or the poll ends
|
||||
if (ended || myVote !== undefined) {
|
||||
answerVotes = votes.get(answer.id) ?? 0;
|
||||
votesText = _t("%(count)s votes", { count: answerVotes });
|
||||
}
|
||||
|
||||
const checked = (
|
||||
(!ended && myVote === answer.id) ||
|
||||
(ended && answerVotes === winCount)
|
||||
);
|
||||
const cls = classNames({
|
||||
"mx_MPollBody_option": true,
|
||||
"mx_MPollBody_option_checked": checked,
|
||||
});
|
||||
|
||||
const answerPercent = (
|
||||
totalVotes === 0
|
||||
? 0
|
||||
: Math.round(100.0 * answerVotes / totalVotes)
|
||||
);
|
||||
return <div
|
||||
key={answer.id}
|
||||
className={cls}
|
||||
onClick={() => this.selectOption(answer.id)}
|
||||
>
|
||||
{ (
|
||||
ended
|
||||
? <EndedPollOption
|
||||
answer={answer}
|
||||
checked={checked}
|
||||
votesText={votesText} />
|
||||
: <LivePollOption
|
||||
pollId={pollId}
|
||||
answer={answer}
|
||||
checked={checked}
|
||||
votesText={votesText}
|
||||
onOptionSelected={this.onOptionSelected} />
|
||||
) }
|
||||
<div className="mx_MPollBody_popularityBackground">
|
||||
<div
|
||||
className="mx_MPollBody_popularityAmount"
|
||||
style={{ "width": `${answerPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div className="mx_MPollBody_totalVotes">
|
||||
{ totalText }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
interface IEndedPollOptionProps {
|
||||
answer: IPollAnswer;
|
||||
checked: boolean;
|
||||
votesText: string;
|
||||
}
|
||||
|
||||
function EndedPollOption(props: IEndedPollOptionProps) {
|
||||
const cls = classNames({
|
||||
"mx_MPollBody_endedOption": true,
|
||||
"mx_MPollBody_endedOptionWinner": props.checked,
|
||||
});
|
||||
return <div className={cls} data-value={props.answer.id}>
|
||||
<div className="mx_MPollBody_optionDescription">
|
||||
<div className="mx_MPollBody_optionText">
|
||||
{ props.answer[TEXT_NODE_TYPE.name] }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ props.votesText }
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ILivePollOptionProps {
|
||||
pollId: string;
|
||||
answer: IPollAnswer;
|
||||
checked: boolean;
|
||||
votesText: string;
|
||||
onOptionSelected: (e: React.FormEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function LivePollOption(props: ILivePollOptionProps) {
|
||||
return <StyledRadioButton
|
||||
name={`poll_answer_select-${props.pollId}`}
|
||||
value={props.answer.id}
|
||||
checked={props.checked}
|
||||
onChange={props.onOptionSelected}
|
||||
>
|
||||
<div className="mx_MPollBody_optionDescription">
|
||||
<div className="mx_MPollBody_optionText">
|
||||
{ props.answer[TEXT_NODE_TYPE.name] }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ props.votesText }
|
||||
</div>
|
||||
</div>
|
||||
</StyledRadioButton>;
|
||||
}
|
||||
|
||||
export class UserVote {
|
||||
constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
|
||||
const pr = event.getContent() as IPollResponseContent;
|
||||
const answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers;
|
||||
|
||||
return new UserVote(
|
||||
event.getTs(),
|
||||
event.getSender(),
|
||||
answers,
|
||||
);
|
||||
}
|
||||
|
||||
export function allVotes(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
voteRelations: Relations,
|
||||
endRelations: Relations,
|
||||
): Array<UserVote> {
|
||||
const endTs = pollEndTs(pollEvent, matrixClient, endRelations);
|
||||
|
||||
function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean {
|
||||
// From MSC3381:
|
||||
// "Votes sent on or before the end event's timestamp are valid votes"
|
||||
return (
|
||||
endTs === null ||
|
||||
responseEvent.getTs() <= endTs
|
||||
);
|
||||
}
|
||||
|
||||
if (voteRelations) {
|
||||
return voteRelations.getRelations()
|
||||
.filter(isPollResponse)
|
||||
.filter(isOnOrBeforeEnd)
|
||||
.map(userResponseFromPollResponseEvent);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the earliest timestamp from the supplied list of end_poll events
|
||||
* or null if there are no authorised events.
|
||||
*/
|
||||
export function pollEndTs(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
endRelations: Relations,
|
||||
): number | null {
|
||||
if (!endRelations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
|
||||
function userCanRedact(endEvent: MatrixEvent) {
|
||||
return roomCurrentState.maySendRedactionForEvent(
|
||||
pollEvent,
|
||||
endEvent.getSender(),
|
||||
);
|
||||
}
|
||||
|
||||
const tss: number[] = (
|
||||
endRelations
|
||||
.getRelations()
|
||||
.filter(userCanRedact)
|
||||
.map((evt: MatrixEvent) => evt.getTs())
|
||||
);
|
||||
|
||||
if (tss.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return Math.min(...tss);
|
||||
}
|
||||
}
|
||||
|
||||
function isPollResponse(responseEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
POLL_RESPONSE_EVENT_TYPE.matches(responseEvent.getType()) &&
|
||||
POLL_RESPONSE_EVENT_TYPE.findIn(responseEvent.getContent())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out the correct vote for each user.
|
||||
* @returns a Map of user ID to their vote info
|
||||
*/
|
||||
function collectUserVotes(
|
||||
userResponses: Array<UserVote>,
|
||||
userId: string,
|
||||
selected?: string,
|
||||
): Map<string, UserVote> {
|
||||
const userVotes: Map<string, UserVote> = new Map();
|
||||
|
||||
for (const response of userResponses) {
|
||||
const otherResponse = userVotes.get(response.sender);
|
||||
if (!otherResponse || otherResponse.ts < response.ts) {
|
||||
userVotes.set(response.sender, response);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
userVotes.set(userId, new UserVote(0, userId, [selected]));
|
||||
}
|
||||
|
||||
return userVotes;
|
||||
}
|
||||
|
||||
function countVotes(
|
||||
userVotes: Map<string, UserVote>,
|
||||
pollStart: IPollContent,
|
||||
): Map<string, number> {
|
||||
const collected = new Map<string, number>();
|
||||
|
||||
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
|
||||
const maxSelections = 1; // See MSC3381 - later this will be in pollInfo
|
||||
|
||||
const allowedAnswerIds = pollInfo.answers.map((ans: IPollAnswer) => ans.id);
|
||||
function isValidAnswer(answerId: string) {
|
||||
return allowedAnswerIds.includes(answerId);
|
||||
}
|
||||
|
||||
for (const response of userVotes.values()) {
|
||||
if (response.answers.every(isValidAnswer)) {
|
||||
for (const [index, answerId] of response.answers.entries()) {
|
||||
if (index >= maxSelections) {
|
||||
break;
|
||||
}
|
||||
if (collected.has(answerId)) {
|
||||
collected.set(answerId, collected.get(answerId) + 1);
|
||||
} else {
|
||||
collected.set(answerId, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
|
@ -17,9 +17,9 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
|
||||
import MImageBody from './MImageBody';
|
||||
import * as sdk from '../../../index';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
|
||||
@replaceableComponent("views.messages.MStickerBody")
|
||||
export default class MStickerBody extends MImageBody {
|
||||
|
@ -54,7 +54,6 @@ export default class MStickerBody extends MImageBody {
|
|||
|
||||
if (!content || !content.body || !content.info || !content.info.w) return null;
|
||||
|
||||
const Tooltip = sdk.getComponent('elements.Tooltip');
|
||||
return <div style={{ left: content.info.w + 'px' }} className="mx_MStickerBody_tooltip">
|
||||
<Tooltip label={content.body} />
|
||||
</div>;
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { decode } from "blurhash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -28,6 +27,9 @@ import { IMediaEventContent } from "../../../customisations/models/IMediaEventCo
|
|||
import { IBodyProps } from "./IBodyProps";
|
||||
import MFileBody from "./MFileBody";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ImageSize, suggestedSize as suggestedVideoSize } from "../../../settings/enums/ImageSize";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
decryptedThumbnailUrl?: string;
|
||||
|
@ -41,6 +43,7 @@ interface IState {
|
|||
@replaceableComponent("views.messages.MVideoBody")
|
||||
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
|
||||
private videoRef = React.createRef<HTMLVideoElement>();
|
||||
private sizeWatcher: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -56,25 +59,36 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
};
|
||||
}
|
||||
|
||||
thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) {
|
||||
private suggestedDimensions(isPortrait): { w: number, h: number } {
|
||||
return suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize);
|
||||
}
|
||||
|
||||
private thumbScale(
|
||||
fullWidth: number,
|
||||
fullHeight: number,
|
||||
thumbWidth?: number,
|
||||
thumbHeight?: number,
|
||||
): number {
|
||||
if (!fullWidth || !fullHeight) {
|
||||
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
|
||||
// log this because it's spammy
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!thumbWidth || !thumbHeight) {
|
||||
const dims = this.suggestedDimensions(fullWidth < fullHeight);
|
||||
thumbWidth = dims.w;
|
||||
thumbHeight = dims.h;
|
||||
}
|
||||
|
||||
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
|
||||
// no scaling needs to be applied
|
||||
return 1;
|
||||
}
|
||||
|
||||
// always scale the videos based on their width.
|
||||
const widthMulti = thumbWidth / fullWidth;
|
||||
const heightMulti = thumbHeight / fullHeight;
|
||||
if (widthMulti < heightMulti) {
|
||||
// width is the dominant dimension so scaling will be fixed on that
|
||||
return widthMulti;
|
||||
} else {
|
||||
// height is the dominant dimension so scaling will be fixed on that
|
||||
return heightMulti;
|
||||
}
|
||||
return widthMulti;
|
||||
}
|
||||
|
||||
private getContentUrl(): string|null {
|
||||
|
@ -151,12 +165,16 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
|
||||
public async componentDidMount() {
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
|
||||
});
|
||||
|
||||
this.loadBlurhash();
|
||||
|
||||
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
|
||||
try {
|
||||
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
|
||||
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
|
||||
if (autoplay) {
|
||||
logger.log("Preloading video");
|
||||
|
@ -188,6 +206,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
}
|
||||
|
||||
private videoOnPlay = async () => {
|
||||
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
|
||||
// We have the file, we are fetching the file, or there is an error.
|
||||
|
@ -248,8 +270,9 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
|
||||
const contentUrl = this.getContentUrl();
|
||||
const thumbUrl = this.getThumbUrl();
|
||||
let height = null;
|
||||
let width = null;
|
||||
const defaultDims = this.suggestedDimensions(false);
|
||||
let height = defaultDims.h;
|
||||
let width = defaultDims.w;
|
||||
let poster = null;
|
||||
let preload = "metadata";
|
||||
if (content.info) {
|
||||
|
|
|
@ -16,17 +16,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
|
||||
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
|
||||
import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
|
||||
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
|
@ -40,6 +38,9 @@ import DownloadActionButton from "./DownloadActionButton";
|
|||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import ReplyChain from '../elements/ReplyChain';
|
||||
import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/threads';
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import { MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
interface IOptionsButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -48,46 +49,58 @@ interface IOptionsButtonProps {
|
|||
getReplyChain: () => ReplyChain;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onFocusChange: (menuDisplayed: boolean) => void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
const OptionsButton: React.FC<IOptionsButtonProps> =
|
||||
({ mxEvent, getTile, getReplyChain, permalinkCreator, onFocusChange }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
const OptionsButton: React.FC<IOptionsButtonProps> = ({
|
||||
mxEvent,
|
||||
getTile,
|
||||
getReplyChain,
|
||||
permalinkCreator,
|
||||
onFocusChange,
|
||||
getRelationsForEvent,
|
||||
}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const tile = getTile && getTile();
|
||||
const replyChain = getReplyChain && getReplyChain();
|
||||
let contextMenu: ReactElement | null;
|
||||
if (menuDisplayed) {
|
||||
const tile = getTile && getTile();
|
||||
const replyChain = getReplyChain && getReplyChain();
|
||||
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <MessageContextMenu
|
||||
{...aboveLeftOf(buttonRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
|
||||
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
|
||||
onFinished={closeMenu}
|
||||
/>;
|
||||
}
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <MessageContextMenu
|
||||
{...aboveLeftOf(buttonRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
|
||||
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
|
||||
onFinished={closeMenu}
|
||||
getRelationsForEvent={getRelationsForEvent}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("Options")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("Options")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IReactButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -105,7 +118,6 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
|
|||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker');
|
||||
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
|
||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
|
||||
</ContextMenu>;
|
||||
|
@ -136,6 +148,11 @@ interface IMessageActionBarProps {
|
|||
onFocusChange?: (menuDisplayed: boolean) => void;
|
||||
toggleThreadExpanded: () => void;
|
||||
isQuoteExpanded?: boolean;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageActionBar")
|
||||
|
@ -194,13 +211,10 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
};
|
||||
|
||||
private onThreadClick = (): void => {
|
||||
dispatchShowThreadEvent(this.props.mxEvent);
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.ThreadView,
|
||||
allowClose: false,
|
||||
refireParams: {
|
||||
event: this.props.mxEvent,
|
||||
},
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: TimelineRenderingType.Thread,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -212,6 +226,21 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
});
|
||||
};
|
||||
|
||||
private readonly forbiddenThreadHeadMsgType = [
|
||||
MsgType.KeyVerificationRequest,
|
||||
];
|
||||
|
||||
private get showReplyInThreadAction(): boolean {
|
||||
const isThreadEnabled = SettingsStore.getValue("feature_thread");
|
||||
const inNotThreadTimeline = this.context.timelineRenderingType !== TimelineRenderingType.Thread;
|
||||
|
||||
const isAllowedMessageType = !this.forbiddenThreadHeadMsgType.includes(
|
||||
this.props.mxEvent.getContent().msgtype as MsgType,
|
||||
);
|
||||
|
||||
return isThreadEnabled && inNotThreadTimeline && isAllowedMessageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a given fn on the set of possible events to test. The first event
|
||||
* that passes the checkFn will have fn executed on it. Both functions take
|
||||
|
@ -296,11 +325,10 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
onClick={this.onReplyClick}
|
||||
key="reply"
|
||||
/>
|
||||
{ (SettingsStore.getValue("feature_thread")
|
||||
&& this.context.timelineRenderingType !== TimelineRenderingType.Thread) && (
|
||||
{ (this.showReplyInThreadAction) && (
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
|
||||
title={_t("Thread")}
|
||||
title={_t("Reply in thread")}
|
||||
onClick={this.onThreadClick}
|
||||
key="thread"
|
||||
/>
|
||||
|
@ -333,7 +361,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
) {
|
||||
toolbarOpts.unshift(<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
|
||||
title={_t("Thread")}
|
||||
title={_t("Reply in thread")}
|
||||
onClick={this.onThreadClick}
|
||||
key="thread"
|
||||
/>);
|
||||
|
@ -365,6 +393,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFocusChange={this.onFocusChange}
|
||||
key="menu"
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>);
|
||||
}
|
||||
|
||||
|
|
|
@ -28,12 +28,17 @@ import { IOperableEventTile } from "../context_menus/MessageContextMenu";
|
|||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { ReactAnyComponent } from "../../../@types/common";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
|
||||
// onMessageAllowed is handled internally
|
||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes?: Record<string, React.Component>;
|
||||
overrideEventTypes?: Record<string, React.Component>;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageEvent")
|
||||
|
@ -112,6 +117,23 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
// Fallback to UnknownBody otherwise if not redacted
|
||||
BodyType = UnknownBody;
|
||||
}
|
||||
|
||||
if (type && type === POLL_START_EVENT_TYPE.name) {
|
||||
// TODO: this can all disappear when Polls comes out of labs -
|
||||
// instead, add something like this into this.evTypes:
|
||||
// [EventType.Poll]: "messages.MPollBody"
|
||||
if (SettingsStore.getValue("feature_polls")) {
|
||||
BodyType = sdk.getComponent('messages.MPollBody');
|
||||
}
|
||||
}
|
||||
|
||||
if ((type && type === "org.matrix.msc3488.location") ||
|
||||
(type && type === EventType.RoomMessage && msgtype && msgtype === MsgType.Location)) {
|
||||
// TODO: tidy this up once location sharing is out of labs
|
||||
if (SettingsStore.getValue("feature_location_share")) {
|
||||
BodyType = sdk.getComponent('messages.MLocationBody');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_mjolnir")) {
|
||||
|
@ -145,6 +167,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
mediaEventHelper={this.mediaHelper}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/> : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { formatFullDate, formatTime, formatFullTime } from '../../../DateUtils';
|
||||
import { formatFullDate, formatTime, formatFullTime, formatRelativeTime } from '../../../DateUtils';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
|
@ -25,6 +24,7 @@ interface IProps {
|
|||
showTwelveHour?: boolean;
|
||||
showFullDate?: boolean;
|
||||
showSeconds?: boolean;
|
||||
showRelative?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageTimestamp")
|
||||
|
@ -32,7 +32,9 @@ export default class MessageTimestamp extends React.Component<IProps> {
|
|||
public render() {
|
||||
const date = new Date(this.props.ts);
|
||||
let timestamp;
|
||||
if (this.props.showFullDate) {
|
||||
if (this.props.showRelative) {
|
||||
timestamp = formatRelativeTime(date, this.props.showTwelveHour);
|
||||
} else if (this.props.showFullDate) {
|
||||
timestamp = formatFullDate(date, this.props.showTwelveHour, this.props.showSeconds);
|
||||
} else if (this.props.showSeconds) {
|
||||
timestamp = formatFullTime(date, this.props.showTwelveHour);
|
||||
|
|
|
@ -24,7 +24,7 @@ import { _t } from '../../../languageHandler';
|
|||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu";
|
||||
import ContextMenu, { aboveLeftOf, useContextMenu } from "../../structures/ContextMenu";
|
||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
||||
import ReactionsRowButton from "./ReactionsRowButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
|
|
@ -19,6 +19,7 @@ import React from 'react';
|
|||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
@ -38,7 +39,7 @@ export default class RoomCreate extends React.Component<IProps> {
|
|||
const predecessor = this.props.mxEvent.getContent()['predecessor'];
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: predecessor['event_id'],
|
||||
highlighted: true,
|
||||
room_id: predecessor['room_id'],
|
||||
|
|
|
@ -31,6 +31,7 @@ interface IProps {
|
|||
className?: string;
|
||||
withoutScrollContainer?: boolean;
|
||||
previousPhase?: RightPanelPhases;
|
||||
previousPhaseLabel?: string;
|
||||
closeLabel?: string;
|
||||
onClose?(): void;
|
||||
refireParams?;
|
||||
|
@ -56,6 +57,7 @@ const BaseCard: React.FC<IProps> = ({
|
|||
footer,
|
||||
withoutScrollContainer,
|
||||
previousPhase,
|
||||
previousPhaseLabel,
|
||||
children,
|
||||
refireParams,
|
||||
}) => {
|
||||
|
@ -68,7 +70,8 @@ const BaseCard: React.FC<IProps> = ({
|
|||
refireParams: refireParams,
|
||||
});
|
||||
};
|
||||
backButton = <AccessibleButton className="mx_BaseCard_back" onClick={onBackClick} title={_t("Back")} />;
|
||||
const label = previousPhaseLabel ?? _t("Back");
|
||||
backButton = <AccessibleButton className="mx_BaseCard_back" onClick={onBackClick} title={label} />;
|
||||
}
|
||||
|
||||
let closeButton;
|
||||
|
|
|
@ -67,7 +67,11 @@ const EncryptionInfo: React.FC<IProps> = ({
|
|||
content = <PendingActionSpinner text={text} />;
|
||||
} else {
|
||||
content = (
|
||||
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={onStartVerification}>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
className="mx_UserInfo_wideButton mx_UserInfo_startVerification"
|
||||
onClick={onStartVerification}
|
||||
>
|
||||
{ _t("Start Verification") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
|
|
|
@ -93,9 +93,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
|||
{ _t("One of the following may be compromised:") }
|
||||
<ul>
|
||||
<li>{ _t("Your homeserver") }</li>
|
||||
<li>{ _t("The homeserver the user you’re verifying is connected to") }</li>
|
||||
<li>{ _t("Yours, or the other users’ internet connection") }</li>
|
||||
<li>{ _t("Yours, or the other users’ session") }</li>
|
||||
<li>{ _t("The homeserver the user you're verifying is connected to") }</li>
|
||||
<li>{ _t("Yours, or the other users' internet connection") }</li>
|
||||
<li>{ _t("Yours, or the other users' session") }</li>
|
||||
</ul>
|
||||
</div>,
|
||||
onFinished: onClose,
|
||||
|
|
|
@ -121,24 +121,26 @@ const PinnedMessagesCard = ({ room, onClose }: IProps) => {
|
|||
if (!pinnedEvents) {
|
||||
content = <Spinner />;
|
||||
} else if (pinnedEvents.length > 0) {
|
||||
let onUnpinClicked;
|
||||
if (canUnpin) {
|
||||
onUnpinClicked = async (event: MatrixEvent) => {
|
||||
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (pinnedEvents?.getContent()?.pinned) {
|
||||
const pinned = pinnedEvents.getContent().pinned;
|
||||
const index = pinned.indexOf(event.getId());
|
||||
if (index !== -1) {
|
||||
pinned.splice(index, 1);
|
||||
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||
}
|
||||
const onUnpinClicked = async (event: MatrixEvent) => {
|
||||
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (pinnedEvents?.getContent()?.pinned) {
|
||||
const pinned = pinnedEvents.getContent().pinned;
|
||||
const index = pinned.indexOf(event.getId());
|
||||
if (index !== -1) {
|
||||
pinned.splice(index, 1);
|
||||
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// show them in reverse, with latest pinned at the top
|
||||
content = pinnedEvents.filter(Boolean).reverse().map(ev => (
|
||||
<PinnedEventTile key={ev.getId()} room={room} event={ev} onUnpinClicked={() => onUnpinClicked(ev)} />
|
||||
<PinnedEventTile
|
||||
key={ev.getId()}
|
||||
room={room}
|
||||
event={ev}
|
||||
onUnpinClicked={canUnpin ? () => onUnpinClicked(ev) : undefined}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
content = <div className="mx_PinnedMessagesCard_empty">
|
||||
|
|
|
@ -33,6 +33,9 @@ import { useSettingValue } from "../../../hooks/useSettings";
|
|||
import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
|
||||
import { dispatchShowThreadsPanelEvent } from "../../../dispatcher/dispatch-actions/threads";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
const ROOM_INFO_PHASES = [
|
||||
RightPanelPhases.RoomSummary,
|
||||
|
@ -44,7 +47,24 @@ const ROOM_INFO_PHASES = [
|
|||
RightPanelPhases.Room3pidMemberInfo,
|
||||
];
|
||||
|
||||
const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
|
||||
interface IUnreadIndicatorProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
const UnreadIndicator = ({ className }: IUnreadIndicatorProps) => {
|
||||
return <React.Fragment>
|
||||
<div className="mx_RightPanel_headerButton_unreadIndicator_bg" />
|
||||
<div className={className} />
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IHeaderButtonProps {
|
||||
room: Room;
|
||||
isHighlighted: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }: IHeaderButtonProps) => {
|
||||
const pinningEnabled = useSettingValue("feature_pinning");
|
||||
const pinnedEvents = usePinnedEvents(pinningEnabled && room);
|
||||
const readPinnedEvents = useReadPinnedEvents(pinningEnabled && room);
|
||||
|
@ -52,7 +72,7 @@ const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
|
|||
|
||||
let unreadIndicator;
|
||||
if (pinnedEvents.some(id => !readPinnedEvents.has(id))) {
|
||||
unreadIndicator = <div className="mx_RightPanel_pinnedMessagesButton_unreadIndicator" />;
|
||||
unreadIndicator = <UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator" />;
|
||||
}
|
||||
|
||||
return <HeaderButton
|
||||
|
@ -66,12 +86,44 @@ const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
|
|||
</HeaderButton>;
|
||||
};
|
||||
|
||||
const TimelineCardHeaderButton = ({ room, isHighlighted, onClick }: IHeaderButtonProps) => {
|
||||
if (!SettingsStore.getValue("feature_maximised_widgets")) return null;
|
||||
let unreadIndicator;
|
||||
switch (RoomNotificationStateStore.instance.getRoomState(room).color) {
|
||||
case NotificationColor.Grey:
|
||||
unreadIndicator =
|
||||
<UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator mx_Indicator_gray" />;
|
||||
break;
|
||||
case NotificationColor.Red:
|
||||
unreadIndicator =
|
||||
<UnreadIndicator className="mx_RightPanel_headerButton_unreadIndicator" />;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return <HeaderButton
|
||||
name="timelineCardButton"
|
||||
title={_t("Chat")}
|
||||
isHighlighted={isHighlighted}
|
||||
onClick={onClick}
|
||||
analytics={["Right Panel", "Timeline Panel Button", "click"]}
|
||||
>
|
||||
{ unreadIndicator }
|
||||
</HeaderButton>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
room?: Room;
|
||||
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.right_panel.RoomHeaderButtons")
|
||||
export default class RoomHeaderButtons extends HeaderButtons<IProps> {
|
||||
private static readonly THREAD_PHASES = [
|
||||
RightPanelPhases.ThreadPanel,
|
||||
RightPanelPhases.ThreadView,
|
||||
];
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props, HeaderKind.Room);
|
||||
}
|
||||
|
@ -116,35 +168,70 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
|
|||
// This toggles for us, if needed
|
||||
this.setPhase(RightPanelPhases.PinnedMessages);
|
||||
};
|
||||
private onTimelineCardClicked = () => {
|
||||
this.setPhase(RightPanelPhases.Timeline);
|
||||
};
|
||||
|
||||
private onThreadsPanelClicked = () => {
|
||||
if (RoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) {
|
||||
dis.dispatch({
|
||||
action: Action.ToggleRightPanel,
|
||||
type: "room",
|
||||
});
|
||||
} else {
|
||||
dispatchShowThreadsPanelEvent();
|
||||
}
|
||||
};
|
||||
|
||||
public renderButtons() {
|
||||
return <>
|
||||
const rightPanelPhaseButtons: Map<RightPanelPhases, any> = new Map();
|
||||
|
||||
rightPanelPhaseButtons.set(RightPanelPhases.PinnedMessages,
|
||||
<PinnedMessagesHeaderButton
|
||||
room={this.props.room}
|
||||
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
|
||||
onClick={this.onPinnedMessagesClicked}
|
||||
/>
|
||||
{ SettingsStore.getValue("feature_thread") && <HeaderButton
|
||||
name="threadsButton"
|
||||
title={_t("Threads")}
|
||||
onClick={dispatchShowThreadsPanelEvent}
|
||||
isHighlighted={this.isPhase(RightPanelPhases.ThreadPanel)}
|
||||
analytics={['Right Panel', 'Threads List Button', 'click']}
|
||||
/> }
|
||||
onClick={this.onPinnedMessagesClicked} />,
|
||||
);
|
||||
rightPanelPhaseButtons.set(RightPanelPhases.Timeline,
|
||||
<TimelineCardHeaderButton
|
||||
room={this.props.room}
|
||||
isHighlighted={this.isPhase(RightPanelPhases.Timeline)}
|
||||
onClick={this.onTimelineCardClicked} />,
|
||||
);
|
||||
rightPanelPhaseButtons.set(RightPanelPhases.ThreadPanel,
|
||||
SettingsStore.getValue("feature_thread")
|
||||
? <HeaderButton
|
||||
name="threadsButton"
|
||||
title={_t("Threads")}
|
||||
onClick={this.onThreadsPanelClicked}
|
||||
isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
|
||||
analytics={['Right Panel', 'Threads List Button', 'click']} />
|
||||
: null,
|
||||
);
|
||||
rightPanelPhaseButtons.set(RightPanelPhases.NotificationPanel,
|
||||
<HeaderButton
|
||||
name="notifsButton"
|
||||
title={_t('Notifications')}
|
||||
isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
|
||||
onClick={this.onNotificationsClicked}
|
||||
analytics={['Right Panel', 'Notification List Button', 'click']}
|
||||
/>
|
||||
analytics={['Right Panel', 'Notification List Button', 'click']} />,
|
||||
);
|
||||
rightPanelPhaseButtons.set(RightPanelPhases.RoomSummary,
|
||||
<HeaderButton
|
||||
name="roomSummaryButton"
|
||||
title={_t('Room Info')}
|
||||
isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
|
||||
onClick={this.onRoomSummaryClicked}
|
||||
analytics={['Right Panel', 'Room Summary Button', 'click']}
|
||||
/>
|
||||
analytics={['Right Panel', 'Room Summary Button', 'click']} />,
|
||||
);
|
||||
|
||||
return <>
|
||||
{
|
||||
Array.from(rightPanelPhaseButtons.keys()).map((phase) =>
|
||||
( this.props.excludedRightPanelPhaseButtons.includes(phase)
|
||||
? null
|
||||
: rightPanelPhaseButtons.get(phase)))
|
||||
}
|
||||
</>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,6 @@ import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widget
|
|||
import RoomName from "../elements/RoomName";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import ExportDialog from "../dialogs/ExportDialog";
|
||||
import { dispatchShowThreadsPanelEvent } from "../../../dispatcher/dispatch-actions/threads";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -139,14 +138,28 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||
mx_RoomSummaryCard_Button_pinned: isPinned,
|
||||
});
|
||||
|
||||
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center);
|
||||
const toggleMaximised = isMaximised
|
||||
? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); }
|
||||
: () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); };
|
||||
|
||||
const maximiseTitle = isMaximised ? _t("Close") : _t("Maximise widget");
|
||||
|
||||
let openTitle = "";
|
||||
if (isPinned) {
|
||||
openTitle = _t("Unpin this widget to view it in this panel");
|
||||
} else if (isMaximised) {
|
||||
openTitle =_t("Close this widget to view it in this panel");
|
||||
}
|
||||
|
||||
return <div className={classes} ref={handle}>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomSummaryCard_icon_app"
|
||||
onClick={onOpenWidgetClick}
|
||||
// only show a tooltip if the widget is pinned
|
||||
title={isPinned ? _t("Unpin a widget to view it in this panel") : ""}
|
||||
forceHide={!isPinned}
|
||||
disabled={isPinned}
|
||||
title={openTitle}
|
||||
forceHide={!(isPinned || isMaximised)}
|
||||
disabled={isPinned || isMaximised}
|
||||
yOffset={-48}
|
||||
>
|
||||
<WidgetAvatar app={app} />
|
||||
|
@ -155,7 +168,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||
</AccessibleTooltipButton>
|
||||
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_RoomSummaryCard_app_options"
|
||||
className={classNames({
|
||||
"mx_RoomSummaryCard_app_options": true,
|
||||
"mx_RoomSummaryCard_maximised_widget": SettingsStore.getValue("feature_maximised_widgets"),
|
||||
})}
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={openMenu}
|
||||
title={_t("Options")}
|
||||
|
@ -169,6 +185,13 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||
disabled={cannotPin}
|
||||
yOffset={-24}
|
||||
/>
|
||||
{ SettingsStore.getValue("feature_maximised_widgets") &&
|
||||
<AccessibleTooltipButton
|
||||
className={isMaximised ? "mx_RoomSummaryCard_app_minimise" : "mx_RoomSummaryCard_app_maximise"}
|
||||
onClick={toggleMaximised}
|
||||
title={maximiseTitle}
|
||||
yOffset={-24}
|
||||
/> }
|
||||
|
||||
{ contextMenu }
|
||||
</div>;
|
||||
|
@ -208,17 +231,19 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
|
|||
</Group>;
|
||||
};
|
||||
|
||||
const onRoomMembersClick = () => {
|
||||
export const onRoomMembersClick = (allowClose = true) => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomMemberList,
|
||||
allowClose,
|
||||
});
|
||||
};
|
||||
|
||||
const onRoomFilesClick = () => {
|
||||
export const onRoomFilesClick = (allowClose = true) => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.FilePanel,
|
||||
allowClose,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -276,19 +301,17 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
|||
return <BaseCard header={header} className="mx_RoomSummaryCard" onClose={onClose}>
|
||||
<Group title={_t("About")} className="mx_RoomSummaryCard_aboutGroup">
|
||||
<Button className="mx_RoomSummaryCard_icon_people" onClick={onRoomMembersClick}>
|
||||
{ _t("%(count)s people", { count: memberCount }) }
|
||||
{ _t("People") }
|
||||
<span className="mx_BaseCard_Button_sublabel">
|
||||
{ memberCount }
|
||||
</span>
|
||||
</Button>
|
||||
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
|
||||
{ _t("Show files") }
|
||||
{ _t("Files") }
|
||||
</Button>
|
||||
<Button className="mx_RoomSummaryCard_icon_export" onClick={onRoomExportClick}>
|
||||
{ _t("Export chat") }
|
||||
</Button>
|
||||
{ SettingsStore.getValue("feature_thread") && (
|
||||
<Button className="mx_RoomSummaryCard_icon_threads" onClick={dispatchShowThreadsPanelEvent}>
|
||||
{ _t("Show threads") }
|
||||
</Button>
|
||||
) }
|
||||
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
|
||||
{ _t("Share room") }
|
||||
</Button>
|
||||
|
|
207
src/components/views/right_panel/TimelineCard.tsx
Normal file
207
src/components/views/right_panel/TimelineCard.tsx
Normal file
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EventSubscription } from "fbemitter";
|
||||
import { EventTimelineSet, IEventRelation, MatrixEvent, Room } from 'matrix-js-sdk/src';
|
||||
import { Thread } from 'matrix-js-sdk/src/models/thread';
|
||||
|
||||
import BaseCard from "./BaseCard";
|
||||
|
||||
import ResizeNotifier from '../../../utils/ResizeNotifier';
|
||||
import MessageComposer from '../rooms/MessageComposer';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { Layout } from '../../../settings/enums/Layout';
|
||||
import TimelinePanel from '../../structures/TimelinePanel';
|
||||
import { E2EStatus } from '../../../utils/ShieldUtils';
|
||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from '../../../utils/replaceableComponent';
|
||||
import { ActionPayload } from '../../../dispatcher/payloads';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
import UploadBar from '../../structures/UploadBar';
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
timelineSet?: EventTimelineSet;
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
showComposer?: boolean;
|
||||
composerRelation?: IEventRelation;
|
||||
}
|
||||
interface IState {
|
||||
thread?: Thread;
|
||||
editState?: EditorStateTransfer;
|
||||
replyToEvent?: MatrixEvent;
|
||||
initialEventId?: string;
|
||||
initialEventHighlighted?: boolean;
|
||||
|
||||
// settings:
|
||||
showReadReceipts?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.TimelineCard")
|
||||
export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
static contextType = RoomContext;
|
||||
|
||||
private dispatcherRef: string;
|
||||
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
|
||||
private roomStoreToken: EventSubscription;
|
||||
private settingWatchers: string[];
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showReadReceipts: false,
|
||||
};
|
||||
this.settingWatchers = [];
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
// Remove RoomStore listener
|
||||
if (this.roomStoreToken) {
|
||||
this.roomStoreToken.remove();
|
||||
}
|
||||
dis.unregister(this.dispatcherRef);
|
||||
for (const watcher of this.settingWatchers) {
|
||||
SettingsStore.unwatchSetting(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomViewStoreUpdate = async (initial?: boolean): Promise<void> => {
|
||||
const roomId = this.props.room.roomId;
|
||||
const newState: Pick<IState, any> = {
|
||||
// roomLoading: RoomViewStore.isRoomLoading(),
|
||||
// roomLoadError: RoomViewStore.getRoomLoadError(),
|
||||
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
|
||||
initialEventId: RoomViewStore.getInitialEventId(),
|
||||
initialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
|
||||
replyToEvent: RoomViewStore.getQuotingEvent(),
|
||||
};
|
||||
|
||||
this.settingWatchers = this.settingWatchers.concat([
|
||||
SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) =>
|
||||
this.setState({ showReadReceipts: value as boolean }),
|
||||
),
|
||||
]);
|
||||
this.setState(newState);
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case Action.EditEvent:
|
||||
this.setState({
|
||||
editState: payload.event ? new EditorStateTransfer(payload.event) : null,
|
||||
}, () => {
|
||||
if (payload.event) {
|
||||
this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId());
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onScroll = (): void => {
|
||||
if (this.state.initialEventId && this.state.initialEventHighlighted) {
|
||||
dis.dispatch({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
event_id: this.state.initialEventId,
|
||||
highlighted: false,
|
||||
replyingToEvent: this.state.replyToEvent,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private renderTimelineCardHeader = (): JSX.Element => {
|
||||
return <div className="mx_TimelineCard__header">
|
||||
<span>{ _t("Chat") }</span>
|
||||
</div>;
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const highlightedEventId = this.state.initialEventHighlighted
|
||||
? this.state.initialEventId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={{
|
||||
...this.context,
|
||||
timelineRenderingType: this.props.timelineRenderingType ?? this.context.timelineRenderingType,
|
||||
liveTimeline: this.props.timelineSet.getLiveTimeline(),
|
||||
}}>
|
||||
<BaseCard
|
||||
className="mx_ThreadPanel mx_TimelineCard"
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderTimelineCardHeader()}
|
||||
>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanelRef}
|
||||
showReadReceipts={/*this.state.showReadReceipts*/ false} // TODO: RR's cause issues with limited horizontal space
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={true}
|
||||
layout={Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel mx_GroupLayout"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onUserScroll={this.onScroll}
|
||||
/>
|
||||
|
||||
{ ContentMessages.sharedInstance().getCurrentUploads(this.props.composerRelation).length > 0 && (
|
||||
<UploadBar room={this.props.room} relation={this.props.composerRelation} />
|
||||
) }
|
||||
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -70,7 +70,7 @@ import RoomName from "../elements/RoomName";
|
|||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog";
|
||||
import { bulkSpaceBehaviour } from "../../../utils/space";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
|
@ -125,7 +125,7 @@ async function openDMForUser(matrixClient: MatrixClient, userId: string) {
|
|||
|
||||
if (lastActiveRoom) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
room_id: lastActiveRoom.roomId,
|
||||
});
|
||||
return;
|
||||
|
@ -367,7 +367,7 @@ const UserOptionsSection: React.FC<{
|
|||
const onReadReceiptButton = function() {
|
||||
const room = cli.getRoom(member.roomId);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
highlighted: true,
|
||||
event_id: room.getEventReadUpTo(member.userId),
|
||||
room_id: member.roomId,
|
||||
|
@ -1121,6 +1121,10 @@ const PowerLevelEditor: React.FC<{
|
|||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel);
|
||||
useEffect(() => {
|
||||
setSelectedPowerLevel(user.powerLevel);
|
||||
}, [user]);
|
||||
|
||||
const onPowerChange = useCallback(async (powerLevel: number) => {
|
||||
setSelectedPowerLevel(powerLevel);
|
||||
|
||||
|
|
|
@ -222,7 +222,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
|
|||
if (this.props.isRoomEncrypted) {
|
||||
text = _t("Verify all users in a room to ensure it's secure.");
|
||||
} else {
|
||||
text = _t("In encrypted rooms, verify all users to ensure it’s secure.");
|
||||
text = _t("In encrypted rooms, verify all users to ensure it's secure.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -122,43 +122,32 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
|
|||
this.setState({ profileFieldsTouched: {} });
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
let originalDisplayName: string;
|
||||
let avatarUrl: string;
|
||||
let originalAvatarUrl: string;
|
||||
let originalTopic: string;
|
||||
let avatarFile: File;
|
||||
const newState: Partial<IState> = {};
|
||||
|
||||
// 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);
|
||||
originalDisplayName = displayName;
|
||||
newState.originalDisplayName = displayName;
|
||||
newState.displayName = displayName;
|
||||
}
|
||||
|
||||
if (this.state.avatarFile) {
|
||||
const uri = await client.uploadContent(this.state.avatarFile);
|
||||
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, '');
|
||||
avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
|
||||
originalAvatarUrl = avatarUrl;
|
||||
avatarFile = null;
|
||||
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
|
||||
newState.originalAvatarUrl = newState.avatarUrl;
|
||||
newState.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);
|
||||
originalTopic = this.state.topic;
|
||||
newState.originalTopic = this.state.topic;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
originalAvatarUrl,
|
||||
avatarUrl,
|
||||
originalDisplayName,
|
||||
originalTopic,
|
||||
displayName,
|
||||
avatarFile,
|
||||
});
|
||||
this.setState(newState as IState);
|
||||
};
|
||||
|
||||
private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
|
|
|
@ -22,7 +22,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
|
||||
import AppTile from '../elements/AppTile';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as sdk from '../../../index';
|
||||
import * as ScalarMessaging from '../../../ScalarMessaging';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
|
||||
|
@ -37,6 +36,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import UIStore from "../../../stores/UIStore";
|
||||
import { IApp } from "../../../stores/WidgetStore";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
interface IProps {
|
||||
userId: string;
|
||||
|
@ -47,7 +47,8 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
apps: IApp[];
|
||||
// @ts-ignore - TS wants a string key, but we know better
|
||||
apps: {[id: Container]: 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;
|
||||
|
@ -118,7 +119,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
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),
|
||||
this.topApps().slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
|
||||
);
|
||||
this.setState({ resizingHorizontal: false });
|
||||
},
|
||||
|
@ -148,7 +149,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
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)) {
|
||||
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) {
|
||||
this.loadResizerPreferences();
|
||||
}
|
||||
}
|
||||
|
@ -163,7 +164,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
|
||||
private loadResizerPreferences = (): void => {
|
||||
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
|
||||
if (this.state.apps && (this.state.apps.length - 1) === distributions.length) {
|
||||
if (this.state.apps && (this.topApps().length - 1) === distributions.length) {
|
||||
distributions.forEach((size, i) => {
|
||||
const distributor = this.resizer.forHandleAt(i);
|
||||
if (distributor) {
|
||||
|
@ -200,8 +201,16 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private getApps = (): IApp[] => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
|
||||
// @ts-ignore - TS wants a string key, but we know better
|
||||
private getApps = (): { [id: Container]: IApp[] } => {
|
||||
// @ts-ignore
|
||||
const appsDict: { [id: Container]: IApp[] } = {};
|
||||
appsDict[Container.Top] = WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
|
||||
appsDict[Container.Center] = WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center);
|
||||
return appsDict;
|
||||
};
|
||||
private topApps = (): IApp[] => this.state.apps[Container.Top];
|
||||
private centerApps = (): IApp[] => this.state.apps[Container.Center];
|
||||
|
||||
private updateApps = (): void => {
|
||||
this.setState({
|
||||
|
@ -211,8 +220,9 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
|
||||
public render(): JSX.Element {
|
||||
if (!this.props.showApps) return <div />;
|
||||
|
||||
const apps = this.state.apps.map((app, index, arr) => {
|
||||
const widgetIsMaxmised: boolean = this.centerApps().length > 0;
|
||||
const appsToDisplay = widgetIsMaxmised ? this.centerApps() : this.topApps();
|
||||
const apps = appsToDisplay.map((app, index, arr) => {
|
||||
return (<AppTile
|
||||
key={app.id}
|
||||
app={app}
|
||||
|
@ -237,39 +247,47 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
|||
WidgetUtils.getRoomWidgets(this.props.room),
|
||||
)
|
||||
) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
spinner = <Loader />;
|
||||
spinner = <Spinner />;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
mx_AppsDrawer: true,
|
||||
mx_AppsDrawer_maximise: widgetIsMaxmised,
|
||||
mx_AppsDrawer_fullWidth: apps.length < 2,
|
||||
mx_AppsDrawer_resizing: this.state.resizing,
|
||||
mx_AppsDrawer_2apps: apps.length === 2,
|
||||
mx_AppsDrawer_3apps: apps.length === 3,
|
||||
});
|
||||
const appConatiners =
|
||||
<div className="mx_AppsContainer" ref={this.collectResizer}>
|
||||
{ apps.map((app, i) => {
|
||||
if (i < 1) return app;
|
||||
return <React.Fragment key={app.key}>
|
||||
<ResizeHandle reverse={i > apps.length / 2} />
|
||||
{ app }
|
||||
</React.Fragment>;
|
||||
}) }
|
||||
</div>;
|
||||
|
||||
let drawer;
|
||||
if (widgetIsMaxmised) {
|
||||
drawer = appConatiners;
|
||||
} else {
|
||||
drawer = <PersistentVResizer
|
||||
room={this.props.room}
|
||||
minHeight={100}
|
||||
maxHeight={(this.props.maxHeight || !widgetIsMaxmised) ? this.props.maxHeight - 50 : undefined}
|
||||
handleClass="mx_AppsContainer_resizerHandle"
|
||||
handleWrapperClass="mx_AppsContainer_resizerHandleContainer"
|
||||
className="mx_AppsContainer_resizer"
|
||||
resizeNotifier={this.props.resizeNotifier}>
|
||||
{ appConatiners }
|
||||
</PersistentVResizer>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<PersistentVResizer
|
||||
room={this.props.room}
|
||||
minHeight={100}
|
||||
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
|
||||
handleClass="mx_AppsContainer_resizerHandle"
|
||||
handleWrapperClass="mx_AppsContainer_resizerHandleContainer"
|
||||
className="mx_AppsContainer_resizer"
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
<div className="mx_AppsContainer" ref={this.collectResizer}>
|
||||
{ apps.map((app, i) => {
|
||||
if (i < 1) return app;
|
||||
return <React.Fragment key={app.key}>
|
||||
<ResizeHandle reverse={i > apps.length / 2} />
|
||||
{ app }
|
||||
</React.Fragment>;
|
||||
}) }
|
||||
</div>
|
||||
</PersistentVResizer>
|
||||
{ drawer }
|
||||
{ spinner }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -23,6 +23,7 @@ import { Room } from 'matrix-js-sdk/src/models/room';
|
|||
import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from '../../../autocomplete/Autocompleter';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import RoomContext from '../../../contexts/RoomContext';
|
||||
|
||||
const MAX_PROVIDER_MATCHES = 20;
|
||||
|
||||
|
@ -56,11 +57,11 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
debounceCompletionsRequest: number;
|
||||
private containerRef = createRef<HTMLDivElement>();
|
||||
|
||||
public static contextType = RoomContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.autocompleter = new Autocompleter(props.room);
|
||||
|
||||
this.state = {
|
||||
// list of completionResults, each containing completions
|
||||
completions: [],
|
||||
|
@ -81,6 +82,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.autocompleter = new Autocompleter(this.props.room, this.context.timelineRenderingType);
|
||||
this.applyNewProps();
|
||||
}
|
||||
|
||||
|
|
|
@ -121,6 +121,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
|
|||
<CallViewForRoom
|
||||
roomId={this.props.room.roomId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showApps={this.props.showApps}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
|
|||
interface IProps {
|
||||
model: EditorModel;
|
||||
room: Room;
|
||||
threadId: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
initialCaret?: DocumentOffset;
|
||||
|
@ -242,7 +243,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
isTyping = false;
|
||||
}
|
||||
}
|
||||
TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, isTyping);
|
||||
TypingStore.sharedInstance().setSelfTyping(
|
||||
this.props.room.roomId,
|
||||
this.props.threadId,
|
||||
isTyping,
|
||||
);
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange();
|
||||
|
|
|
@ -206,7 +206,10 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
event: null,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
dis.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
|
@ -236,7 +239,10 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
event: null,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
dis.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: this.context.timelineRenderingType,
|
||||
});
|
||||
};
|
||||
|
||||
private get shouldSaveStoredEditorState(): boolean {
|
||||
|
@ -314,7 +320,9 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
}
|
||||
|
||||
private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise<void> {
|
||||
const result = cmd.run(roomId, args);
|
||||
const threadId = this.props.editState?.getEvent()?.getThread()?.id || null;
|
||||
|
||||
const result = cmd.run(roomId, threadId, args);
|
||||
let messageContent;
|
||||
let error = result.error;
|
||||
if (result.promise) {
|
||||
|
@ -417,7 +425,11 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
}
|
||||
if (shouldSend) {
|
||||
this.cancelPreviousPendingEdit();
|
||||
const prom = this.props.mxClient.sendMessage(roomId, editContent);
|
||||
|
||||
const event = this.props.editState.getEvent();
|
||||
const threadId = event.threadRootId || null;
|
||||
|
||||
const prom = this.props.mxClient.sendMessage(roomId, threadId, editContent);
|
||||
this.clearStoredEditorState();
|
||||
dis.dispatch({ action: "message_sent" });
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
|
||||
|
@ -430,7 +442,10 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
event: null,
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
dis.dispatch({
|
||||
action: Action.FocusSendMessageComposer,
|
||||
context: this.context.timelineRenderingType,
|
||||
});
|
||||
};
|
||||
|
||||
private cancelPreviousPendingEdit(): void {
|
||||
|
@ -523,6 +538,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
|
|||
ref={this.editorRef}
|
||||
model={this.model}
|
||||
room={this.getRoom()}
|
||||
threadId={this.props.editState?.getEvent()?.getThread()?.id}
|
||||
initialCaret={this.props.editState.getCaret()}
|
||||
label={_t("Edit message")}
|
||||
onChange={this.onChange}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from "classnames";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { EventType, MsgType } 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";
|
||||
|
@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler';
|
|||
import { hasText } from "../../../TextForEvent";
|
||||
import * as sdk from "../../../index";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";
|
||||
|
@ -63,10 +63,15 @@ import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/th
|
|||
import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewStore';
|
||||
import { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import Toolbar from '../../../accessibility/Toolbar';
|
||||
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton';
|
||||
import ThreadListContextMenu from '../context_menus/ThreadListContextMenu';
|
||||
|
||||
const eventTileTypes = {
|
||||
[EventType.RoomMessage]: 'messages.MessageEvent',
|
||||
[EventType.Sticker]: 'messages.MessageEvent',
|
||||
[POLL_START_EVENT_TYPE.name]: 'messages.MessageEvent',
|
||||
[EventType.KeyVerificationCancel]: 'messages.MKeyVerificationConclusion',
|
||||
[EventType.KeyVerificationDone]: 'messages.MKeyVerificationConclusion',
|
||||
[EventType.CallInvite]: 'messages.CallEvent',
|
||||
|
@ -124,7 +129,7 @@ export function getHandlerTile(ev) {
|
|||
// not even when showing hidden events
|
||||
if (type === "m.room.message") {
|
||||
const content = ev.getContent();
|
||||
if (content && content.msgtype === "m.key.verification.request") {
|
||||
if (content && content.msgtype === MsgType.KeyVerificationRequest) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const me = client && client.getUserId();
|
||||
if (ev.getSender() !== me && content.to !== me) {
|
||||
|
@ -546,7 +551,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private renderThreadInfo(): React.ReactNode {
|
||||
private get thread(): Thread | null {
|
||||
if (!SettingsStore.getValue("feature_thread")) {
|
||||
return null;
|
||||
}
|
||||
|
@ -560,19 +565,55 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
const thread = room?.threads.get(this.props.mxEvent.getId());
|
||||
|
||||
if (thread && !thread.ready) {
|
||||
thread.addEvent(this.props.mxEvent, true);
|
||||
}
|
||||
|
||||
if (!thread || this.props.showThreadInfo === false || thread.length === 0) {
|
||||
if (!thread || thread.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [lastEvent] = thread.events
|
||||
return thread;
|
||||
}
|
||||
|
||||
private renderThreadPanelSummary(): JSX.Element | null {
|
||||
if (!this.thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="mx_ThreadPanel_replies">
|
||||
<span className="mx_ThreadPanel_repliesSummary">
|
||||
{ this.thread.length }
|
||||
</span>
|
||||
{ this.renderThreadLastMessagePreview() }
|
||||
</div>;
|
||||
}
|
||||
|
||||
private renderThreadLastMessagePreview(): JSX.Element | null {
|
||||
if (!this.thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [lastEvent] = this.thread.events
|
||||
.filter(event => event.isThreadRelation)
|
||||
.slice(-1);
|
||||
const threadMessagePreview = MessagePreviewStore.instance.generatePreviewForEvent(lastEvent);
|
||||
|
||||
if (!threadMessagePreview || !lastEvent.sender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>
|
||||
<MemberAvatar member={lastEvent.sender} width={24} height={24} className="mx_ThreadInfo_avatar" />
|
||||
<div className="mx_ThreadInfo_content">
|
||||
<span className="mx_ThreadInfo_message-preview">
|
||||
{ threadMessagePreview }
|
||||
</span>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
private renderThreadInfo(): React.ReactNode {
|
||||
if (!this.thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx_ThreadInfo"
|
||||
|
@ -582,20 +623,12 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
);
|
||||
}}
|
||||
>
|
||||
<span className="mx_ThreadInfo_thread-icon" />
|
||||
<span className="mx_ThreadInfo_threads-amount">
|
||||
{ _t("%(count)s reply", {
|
||||
count: thread.length,
|
||||
count: this.thread.length,
|
||||
}) }
|
||||
</span>
|
||||
{ (threadMessagePreview && lastEvent.sender) && <>
|
||||
<MemberAvatar member={lastEvent.sender} width={24} height={24} />
|
||||
<div className="mx_ThreadInfo_content">
|
||||
<span className="mx_ThreadInfo_message-preview">
|
||||
{ threadMessagePreview }
|
||||
</span>
|
||||
</div>
|
||||
</> }
|
||||
{ this.renderThreadLastMessagePreview() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -842,7 +875,8 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
if (remainder > 0) {
|
||||
remText = <span className="mx_EventTile_readAvatarRemainder"
|
||||
onClick={this.toggleAllReadAvatars}
|
||||
style={{ right: "calc(" + toRem(-left) + " + " + receiptOffset + "px)" }}>{ remainder }+
|
||||
style={{ right: "calc(" + toRem(-left) + " + " + receiptOffset + "px)" }}
|
||||
aria-live="off">{ remainder }+
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
|
@ -884,7 +918,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
// matrix.to, but also for it to enable routing within Element when clicked.
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
|
@ -955,7 +989,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
|
||||
};
|
||||
|
||||
private onReactionsCreated = (relationType, eventType) => {
|
||||
private onReactionsCreated = (relationType: string, eventType: string) => {
|
||||
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
|
||||
return;
|
||||
}
|
||||
|
@ -1031,6 +1065,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
mx_EventTile_bad: isEncryptionFailure,
|
||||
mx_EventTile_emote: msgtype === 'm.emote',
|
||||
mx_EventTile_noSender: this.props.hideSender,
|
||||
mx_EventTile_clamp: this.props.tileShape === TileShape.ThreadPanel,
|
||||
});
|
||||
|
||||
// If the tile is in the Sending state, don't speak the message.
|
||||
|
@ -1121,6 +1156,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
onFocusChange={this.onActionBarFocusChange}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/> : undefined;
|
||||
|
||||
const showTimestamp = this.props.mxEvent.getTs()
|
||||
|
@ -1129,8 +1165,20 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
|| this.state.hover
|
||||
|| this.state.actionBarFocused);
|
||||
|
||||
const timestamp = showTimestamp ?
|
||||
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
const thread = room?.findThreadForEvent?.(this.props.mxEvent);
|
||||
|
||||
// Thread panel shows the timestamp of the last reply in that thread
|
||||
const ts = this.props.tileShape !== TileShape.ThreadPanel
|
||||
? this.props.mxEvent.getTs()
|
||||
: thread?.lastReply.getTs();
|
||||
|
||||
const timestamp = showTimestamp && ts ?
|
||||
<MessageTimestamp
|
||||
showRelative={this.props.tileShape === TileShape.ThreadPanel}
|
||||
showTwelveHour={this.props.isTwelveHour}
|
||||
ts={ts}
|
||||
/> : null;
|
||||
|
||||
const keyRequestHelpText =
|
||||
<div className="mx_EventTile_keyRequestInfo_tooltip_contents">
|
||||
|
@ -1193,6 +1241,20 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
msgOption = readAvatars;
|
||||
}
|
||||
|
||||
const replyChain = haveTileForEvent(this.props.mxEvent) &&
|
||||
ReplyChain.hasReply(this.props.mxEvent) ? (
|
||||
<ReplyChain
|
||||
parentEv={this.props.mxEvent}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
ref={this.replyChain}
|
||||
forExport={this.props.forExport}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
setQuoteExpanded={this.setQuoteExpanded}
|
||||
/>) : null;
|
||||
|
||||
switch (this.props.tileShape) {
|
||||
case TileShape.Notif: {
|
||||
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
@ -1224,31 +1286,22 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
onHeightChanged={this.props.onHeightChanged}
|
||||
tileShape={this.props.tileShape}
|
||||
editState={this.props.editState}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
case TileShape.Thread: {
|
||||
const replyChain = haveTileForEvent(this.props.mxEvent) &&
|
||||
ReplyChain.hasReply(this.props.mxEvent) ? (
|
||||
<ReplyChain
|
||||
parentEv={this.props.mxEvent}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
ref={this.replyChain}
|
||||
forExport={this.props.forExport}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
setQuoteExpanded={this.setQuoteExpanded}
|
||||
/>) : null;
|
||||
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
|
||||
return React.createElement(this.props.as || "li", {
|
||||
"ref": this.ref,
|
||||
"className": classes,
|
||||
"aria-live": ariaLive,
|
||||
"aria-atomic": true,
|
||||
"data-scroll-tokens": scrollToken,
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
}, [
|
||||
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
|
||||
<RoomAvatar room={room} width={28} height={28} />
|
||||
|
@ -1260,7 +1313,6 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
{ avatar }
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{ sender }
|
||||
{ timestamp }
|
||||
</a>
|
||||
</div>,
|
||||
<div className={lineClasses} key="mx_EventTile_line">
|
||||
|
@ -1274,12 +1326,65 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
tileShape={this.props.tileShape}
|
||||
editState={this.props.editState}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
{ actionBar }
|
||||
{ timestamp }
|
||||
</div>,
|
||||
reactionsRow,
|
||||
]);
|
||||
}
|
||||
case TileShape.ThreadPanel: {
|
||||
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
|
||||
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
return (
|
||||
React.createElement(this.props.as || "li", {
|
||||
"ref": this.ref,
|
||||
"className": classes,
|
||||
"tabIndex": -1,
|
||||
"aria-live": ariaLive,
|
||||
"aria-atomic": "true",
|
||||
"data-scroll-tokens": scrollToken,
|
||||
"data-layout": this.props.layout,
|
||||
"data-shape": this.props.tileShape,
|
||||
"data-self": isOwnEvent,
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
|
||||
}, <>
|
||||
{ sender }
|
||||
{ avatar }
|
||||
<div
|
||||
className={lineClasses}
|
||||
onClick={() => dispatchShowThreadEvent(this.props.mxEvent)}
|
||||
key="mx_EventTile_line"
|
||||
>
|
||||
{ linkedTimestamp }
|
||||
{ this.renderE2EPadlock() }
|
||||
<div className="mx_EventTile_body">
|
||||
{ MessagePreviewStore.instance.generatePreviewForEvent(this.props.mxEvent) }
|
||||
</div>
|
||||
{ this.renderThreadPanelSummary() }
|
||||
</div>
|
||||
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
<RovingAccessibleTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
|
||||
title={_t("Reply in thread")}
|
||||
onClick={() => dispatchShowThreadEvent(this.props.mxEvent)}
|
||||
key="thread"
|
||||
/>
|
||||
<ThreadListContextMenu
|
||||
mxEvent={this.props.mxEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
onMenuToggle={this.onActionBarFocusChange}
|
||||
/>
|
||||
</Toolbar>
|
||||
{ msgOption }
|
||||
</>)
|
||||
);
|
||||
}
|
||||
case TileShape.FileGrid: {
|
||||
return React.createElement(this.props.as || "li", {
|
||||
"className": classes,
|
||||
|
@ -1296,6 +1401,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
tileShape={this.props.tileShape}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
editState={this.props.editState}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
</div>,
|
||||
<a
|
||||
|
@ -1313,19 +1419,6 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
default: {
|
||||
const replyChain = haveTileForEvent(this.props.mxEvent) &&
|
||||
ReplyChain.hasReply(this.props.mxEvent) ? (
|
||||
<ReplyChain
|
||||
parentEv={this.props.mxEvent}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
ref={this.replyChain}
|
||||
forExport={this.props.forExport}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
setQuoteExpanded={this.setQuoteExpanded}
|
||||
/>) : null;
|
||||
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
|
||||
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
|
@ -1362,13 +1455,19 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
callEventGrouper={this.props.callEventGrouper}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
{ keyRequestInfo }
|
||||
{ actionBar }
|
||||
{ this.props.layout === Layout.IRC && (reactionsRow) }
|
||||
{ this.props.layout === Layout.IRC && <>
|
||||
{ reactionsRow }
|
||||
{ this.renderThreadInfo() }
|
||||
</> }
|
||||
</div>
|
||||
{ this.renderThreadInfo() }
|
||||
{ this.props.layout !== Layout.IRC && (reactionsRow) }
|
||||
{ this.props.layout !== Layout.IRC && <>
|
||||
{ reactionsRow }
|
||||
{ this.renderThreadInfo() }
|
||||
</> }
|
||||
{ msgOption }
|
||||
</>)
|
||||
);
|
||||
|
@ -1403,25 +1502,25 @@ export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
|
|||
|
||||
function E2ePadlockUndecryptable(props) {
|
||||
return (
|
||||
<E2ePadlock title={_t("This message cannot be decrypted")} icon="undecryptable" {...props} />
|
||||
<E2ePadlock title={_t("This message cannot be decrypted")} icon={E2ePadlockIcon.Warning} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function E2ePadlockUnverified(props) {
|
||||
return (
|
||||
<E2ePadlock title={_t("Encrypted by an unverified session")} icon="unverified" {...props} />
|
||||
<E2ePadlock title={_t("Encrypted by an unverified session")} icon={E2ePadlockIcon.Warning} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function E2ePadlockUnencrypted(props) {
|
||||
return (
|
||||
<E2ePadlock title={_t("Unencrypted")} icon="unencrypted" {...props} />
|
||||
<E2ePadlock title={_t("Unencrypted")} icon={E2ePadlockIcon.Warning} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function E2ePadlockUnknown(props) {
|
||||
return (
|
||||
<E2ePadlock title={_t("Encrypted by a deleted session")} icon="unknown" {...props} />
|
||||
<E2ePadlock title={_t("Encrypted by a deleted session")} icon={E2ePadlockIcon.Normal} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1429,14 +1528,19 @@ function E2ePadlockUnauthenticated(props) {
|
|||
return (
|
||||
<E2ePadlock
|
||||
title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")}
|
||||
icon="unauthenticated"
|
||||
icon={E2ePadlockIcon.Normal}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
enum E2ePadlockIcon {
|
||||
Normal = "normal",
|
||||
Warning = "warning",
|
||||
}
|
||||
|
||||
interface IE2ePadlockProps {
|
||||
icon: string;
|
||||
icon: E2ePadlockIcon;
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
@ -1445,7 +1549,7 @@ interface IE2ePadlockState {
|
|||
}
|
||||
|
||||
class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
|
||||
constructor(props) {
|
||||
constructor(props: IE2ePadlockProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
@ -1453,15 +1557,15 @@ class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
|
|||
};
|
||||
}
|
||||
|
||||
onHoverStart = () => {
|
||||
private onHoverStart = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
onHoverEnd = () => {
|
||||
private onHoverEnd = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
let tooltip = null;
|
||||
if (this.state.hover) {
|
||||
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} />;
|
||||
|
|
|
@ -18,15 +18,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { throttle } from 'lodash';
|
||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -39,6 +30,11 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
|||
import RoomName from "../elements/RoomName";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import TruncatedList from '../elements/TruncatedList';
|
||||
import Spinner from "../elements/Spinner";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
|
@ -46,11 +42,11 @@ import AccessibleButton from '../elements/AccessibleButton';
|
|||
import EntityTile from "./EntityTile";
|
||||
import MemberTile from "./MemberTile";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
|
||||
const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`;
|
||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
const INITIAL_LOAD_NUM_MEMBERS = 30;
|
||||
const INITIAL_LOAD_NUM_INVITED = 5;
|
||||
|
@ -62,7 +58,9 @@ const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
|
|||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
searchQuery: string;
|
||||
onClose(): void;
|
||||
onSearchQueryChanged: (query: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -73,7 +71,6 @@ interface IState {
|
|||
canInvite: boolean;
|
||||
truncateAtJoined: number;
|
||||
truncateAtInvited: number;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.MemberList")
|
||||
|
@ -182,27 +179,19 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private getMembersState(members: Array<RoomMember>): IState {
|
||||
let searchQuery;
|
||||
try {
|
||||
searchQuery = window.localStorage.getItem(getSearchQueryLSKey(this.props.roomId));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get last the MemberList search query", error);
|
||||
}
|
||||
|
||||
// set the state after determining showPresence to make sure it's
|
||||
// taken into account while rendering
|
||||
return {
|
||||
loading: false,
|
||||
members: members,
|
||||
filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
|
||||
filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
|
||||
filteredJoinedMembers: this.filterMembers(members, 'join', this.props.searchQuery),
|
||||
filteredInvitedMembers: this.filterMembers(members, 'invite', this.props.searchQuery),
|
||||
canInvite: this.canInvite,
|
||||
|
||||
// ideally we'd size this to the page height, but
|
||||
// in practice I find that a little constraining
|
||||
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
|
||||
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
|
||||
searchQuery: searchQuery ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -266,8 +255,8 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
this.setState({
|
||||
loading: false,
|
||||
members: members,
|
||||
filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery),
|
||||
filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery),
|
||||
filteredJoinedMembers: this.filterMembers(members, 'join', this.props.searchQuery),
|
||||
filteredInvitedMembers: this.filterMembers(members, 'invite', this.props.searchQuery),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -432,14 +421,8 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onSearchQueryChanged = (searchQuery: string): void => {
|
||||
try {
|
||||
window.localStorage.setItem(getSearchQueryLSKey(this.props.roomId), searchQuery);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to set the last MemberList search query", error);
|
||||
}
|
||||
|
||||
this.props.onSearchQueryChanged(searchQuery);
|
||||
this.setState({
|
||||
searchQuery,
|
||||
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
|
||||
filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
|
||||
});
|
||||
|
@ -579,7 +562,7 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t('Filter room members')}
|
||||
onSearch={this.onSearchQueryChanged}
|
||||
initialValue={this.state.searchQuery}
|
||||
initialValue={this.props.searchQuery}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -15,13 +15,11 @@ limitations under the License.
|
|||
*/
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import Stickerpicker from './Stickerpicker';
|
||||
|
@ -29,16 +27,14 @@ import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalin
|
|||
import ContentMessages from '../../../ContentMessages';
|
||||
import E2EIcon from './E2EIcon';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {
|
||||
import ContextMenu, {
|
||||
aboveLeftOf,
|
||||
ContextMenu,
|
||||
useContextMenu,
|
||||
MenuItem,
|
||||
AboveLeftOf,
|
||||
} from "../../structures/ContextMenu";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import ReplyPreview from "./ReplyPreview";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
||||
|
@ -52,25 +48,22 @@ import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInse
|
|||
import { Action } from "../../../dispatcher/actions";
|
||||
import EditorModel from "../../../editor/model";
|
||||
import EmojiPicker from '../emojipicker/EmojiPicker';
|
||||
import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar";
|
||||
import LocationPicker from '../location/LocationPicker';
|
||||
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
import { RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
import RoomContext from '../../../contexts/RoomContext';
|
||||
import { POLL_START_EVENT_TYPE } from "../../../polls/consts";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import LocationShareType from "../location/LocationShareType";
|
||||
import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload";
|
||||
|
||||
let instanceCount = 0;
|
||||
const NARROW_MODE_BREAKPOINT = 500;
|
||||
|
||||
interface IComposerAvatarProps {
|
||||
me: RoomMember;
|
||||
}
|
||||
|
||||
function ComposerAvatar(props: IComposerAvatarProps) {
|
||||
return <div className="mx_MessageComposer_avatar">
|
||||
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ISendButtonProps {
|
||||
onClick: () => void;
|
||||
title?: string; // defaults to something generic
|
||||
|
@ -88,7 +81,7 @@ function SendButton(props: ISendButtonProps) {
|
|||
|
||||
interface IEmojiButtonProps {
|
||||
addEmoji: (unicode: string) => boolean;
|
||||
menuPosition: any; // TODO: Types
|
||||
menuPosition: AboveLeftOf;
|
||||
narrowMode: boolean;
|
||||
}
|
||||
|
||||
|
@ -125,8 +118,49 @@ const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition, narr
|
|||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface ILocationButtonProps {
|
||||
room: Room;
|
||||
shareLocation: (uri: string, ts: number, type: LocationShareType, description: string) => boolean;
|
||||
menuPosition: AboveLeftOf;
|
||||
narrowMode: boolean;
|
||||
}
|
||||
|
||||
const LocationButton: React.FC<ILocationButtonProps> = ({ shareLocation, menuPosition, narrowMode }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect());
|
||||
contextMenu = <ContextMenu {...position} onFinished={closeMenu} managed={false}>
|
||||
<LocationPicker onChoose={shareLocation} onFinished={closeMenu} />
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
||||
const className = classNames(
|
||||
"mx_MessageComposer_button",
|
||||
"mx_MessageComposer_location",
|
||||
{
|
||||
"mx_MessageComposer_button_highlight": menuDisplayed,
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: replace ContextMenuTooltipButton with a unified representation of
|
||||
// the header buttons and the right panel buttons
|
||||
return <React.Fragment>
|
||||
<AccessibleTooltipButton
|
||||
className={className}
|
||||
onClick={openMenu}
|
||||
title={!narrowMode && _t('Share location')}
|
||||
label={narrowMode ? _t('Share location') : null}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
relation?: IEventRelation | null;
|
||||
}
|
||||
|
||||
class UploadButton extends React.Component<IUploadButtonProps> {
|
||||
|
@ -168,7 +202,7 @@ class UploadButton extends React.Component<IUploadButtonProps> {
|
|||
}
|
||||
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
tfiles, this.props.roomId, MatrixClientPeg.get(),
|
||||
tfiles, this.props.roomId, this.props.relation, MatrixClientPeg.get(),
|
||||
);
|
||||
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
|
@ -198,18 +232,34 @@ class UploadButton extends React.Component<IUploadButtonProps> {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: [polls] Make this component actually do something
|
||||
class PollButton extends React.PureComponent {
|
||||
interface IPollButtonProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
class PollButton extends React.PureComponent<IPollButtonProps> {
|
||||
private onCreateClick = () => {
|
||||
Modal.createTrackedDialog('Polls', 'Not Yet Implemented', InfoDialog, {
|
||||
// XXX: Deliberately not translated given this dialog is meant to be replaced and we don't
|
||||
// want to clutter the language files with short-lived strings.
|
||||
title: "Polls are currently in development",
|
||||
description: "" +
|
||||
"Thanks for testing polls! We haven't quite gotten a chance to write the feature yet " +
|
||||
"though. Check back later for updates.",
|
||||
hasCloseButton: true,
|
||||
});
|
||||
const canSend = this.props.room.currentState.maySendEvent(
|
||||
POLL_START_EVENT_TYPE.name,
|
||||
MatrixClientPeg.get().getUserId(),
|
||||
);
|
||||
if (!canSend) {
|
||||
Modal.createTrackedDialog('Polls', 'permissions error: cannot start', ErrorDialog, {
|
||||
title: _t("Permission Required"),
|
||||
description: _t("You do not have permission to start polls in this room."),
|
||||
});
|
||||
} else {
|
||||
Modal.createTrackedDialog(
|
||||
'Polls',
|
||||
'create',
|
||||
PollCreateDialog,
|
||||
{
|
||||
room: this.props.room,
|
||||
},
|
||||
'mx_CompoundDialog',
|
||||
false, // isPriorityModal
|
||||
true, // isStaticModal
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -243,6 +293,8 @@ interface IState {
|
|||
narrowMode?: boolean;
|
||||
isMenuOpen: boolean;
|
||||
showStickers: boolean;
|
||||
showStickersButton: boolean;
|
||||
showPollsButton: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.MessageComposer")
|
||||
|
@ -272,9 +324,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
|
||||
isMenuOpen: false,
|
||||
showStickers: false,
|
||||
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
|
||||
showPollsButton: SettingsStore.getValue("feature_polls"),
|
||||
};
|
||||
|
||||
this.instanceId = instanceCount++;
|
||||
|
||||
SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
|
||||
SettingsStore.monitorSetting("feature_polls", null);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -297,14 +354,39 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'reply_to_event' && payload.context === this.context.timelineRenderingType) {
|
||||
// add a timeout for the reply preview to be rendered, so
|
||||
// that the ScrollPanel listening to the resizeNotifier can
|
||||
// correctly measure it's new height and scroll down to keep
|
||||
// at the bottom if it already is
|
||||
setTimeout(() => {
|
||||
this.props.resizeNotifier.notifyTimelineHeightChanged();
|
||||
}, 100);
|
||||
switch (payload.action) {
|
||||
case "reply_to_event":
|
||||
if (payload.context === this.context.timelineRenderingType) {
|
||||
// add a timeout for the reply preview to be rendered, so
|
||||
// that the ScrollPanel listening to the resizeNotifier can
|
||||
// correctly measure it's new height and scroll down to keep
|
||||
// at the bottom if it already is
|
||||
setTimeout(() => {
|
||||
this.props.resizeNotifier.notifyTimelineHeightChanged();
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action.SettingUpdated: {
|
||||
const settingUpdatedPayload = payload as SettingUpdatedPayload;
|
||||
switch (settingUpdatedPayload.settingName) {
|
||||
case "MessageComposerInput.showStickersButton": {
|
||||
const showStickersButton = SettingsStore.getValue("MessageComposerInput.showStickersButton");
|
||||
if (this.state.showStickersButton !== showStickersButton) {
|
||||
this.setState({ showStickersButton });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "feature_polls": {
|
||||
const showPollsButton = SettingsStore.getValue("feature_polls");
|
||||
if (this.state.showPollsButton !== showPollsButton) {
|
||||
this.setState({ showPollsButton });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -360,9 +442,9 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
|
||||
}
|
||||
|
||||
const viaServers = [this.state.tombstone.getSender().split(':').splice(1).join(':')];
|
||||
const viaServers = [this.state.tombstone.getSender().split(':').slice(1).join(':')];
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
highlighted: true,
|
||||
event_id: createEventId,
|
||||
room_id: replacementRoomId,
|
||||
|
@ -409,6 +491,25 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
return true;
|
||||
};
|
||||
|
||||
private shareLocation = (uri: string, ts: number, type: LocationShareType, description: string): boolean => {
|
||||
if (!uri) return false;
|
||||
try {
|
||||
const text = `${description ? description : 'Location'} at ${uri} as of ${new Date(ts).toISOString()}`;
|
||||
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||
"body": text,
|
||||
"msgtype": MsgType.Location,
|
||||
"geo_uri": uri,
|
||||
"org.matrix.msc3488.location": { uri, description },
|
||||
"org.matrix.msc3488.ts": ts,
|
||||
// TODO: MSC1767 fallbacks for text & thumbnail
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Error sending location:", e);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private sendMessage = async () => {
|
||||
if (this.state.haveRecording && this.voiceRecordingButton.current) {
|
||||
// There shouldn't be any text message to send when a voice recording is active, so
|
||||
|
@ -446,9 +547,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private shouldShowStickerPicker = (): boolean => {
|
||||
return SettingsStore.getValue(UIFeature.Widgets)
|
||||
&& SettingsStore.getValue("MessageComposerInput.showStickersButton")
|
||||
&& !this.state.haveRecording;
|
||||
return this.state.showStickersButton && !this.state.haveRecording;
|
||||
};
|
||||
|
||||
private showStickers = (showStickers: boolean) => {
|
||||
|
@ -462,16 +561,33 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private renderButtons(menuPosition): JSX.Element | JSX.Element[] {
|
||||
let uploadButtonIndex = 0;
|
||||
const buttons: JSX.Element[] = [];
|
||||
if (!this.state.haveRecording) {
|
||||
if (SettingsStore.getValue("feature_polls")) {
|
||||
if (this.state.showPollsButton) {
|
||||
buttons.push(
|
||||
<PollButton key="polls" />,
|
||||
<PollButton key="polls" room={this.props.room} />,
|
||||
);
|
||||
}
|
||||
uploadButtonIndex = buttons.length;
|
||||
buttons.push(
|
||||
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
|
||||
<UploadButton
|
||||
key="controls_upload"
|
||||
roomId={this.props.room.roomId}
|
||||
relation={this.props.relation}
|
||||
/>,
|
||||
);
|
||||
if (SettingsStore.getValue("feature_location_share")) {
|
||||
buttons.push(
|
||||
<LocationButton
|
||||
key="location"
|
||||
room={this.props.room}
|
||||
shareLocation={this.shareLocation}
|
||||
menuPosition={menuPosition}
|
||||
narrowMode={this.state.narrowMode}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(
|
||||
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} menuPosition={menuPosition} narrowMode={this.state.narrowMode} />,
|
||||
);
|
||||
|
@ -514,7 +630,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
});
|
||||
|
||||
return <>
|
||||
{ buttons[0] }
|
||||
{ buttons[uploadButtonIndex] }
|
||||
<AccessibleTooltipButton
|
||||
className={classnames}
|
||||
onClick={this.toggleButtonMenu}
|
||||
|
@ -544,7 +660,6 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
render() {
|
||||
const controls = [
|
||||
this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
|
||||
this.props.e2eStatus ?
|
||||
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
|
||||
null,
|
||||
|
@ -615,9 +730,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
yOffset={-50}
|
||||
/>;
|
||||
}
|
||||
|
||||
const threadId = this.props.relation?.rel_type === RelationType.Thread
|
||||
? this.props.relation.event_id
|
||||
: null;
|
||||
|
||||
controls.push(
|
||||
<Stickerpicker
|
||||
room={this.props.room}
|
||||
threadId={threadId}
|
||||
showStickers={this.state.showStickers}
|
||||
setShowStickers={this.showStickers}
|
||||
menuPosition={menuPosition}
|
||||
|
@ -631,6 +752,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
"mx_MessageComposer": true,
|
||||
"mx_GroupLayout": true,
|
||||
"mx_MessageComposer--compact": this.props.compact,
|
||||
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -31,7 +31,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
|
|||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { showSpaceInvite } from "../../../utils/space";
|
||||
import { privateShouldBeEncrypted } from "../../../createRoom";
|
||||
import EventTileBubble from "../messages/EventTileBubble";
|
||||
|
@ -126,12 +126,12 @@ const NewRoomIntro = () => {
|
|||
});
|
||||
}
|
||||
|
||||
let parentSpace;
|
||||
let parentSpace: Room;
|
||||
if (
|
||||
SpaceStore.instance.activeSpace?.canInvite(cli.getUserId()) &&
|
||||
SpaceStore.instance.activeSpaceRoom?.canInvite(cli.getUserId()) &&
|
||||
SpaceStore.instance.getSpaceFilteredRoomIds(SpaceStore.instance.activeSpace).has(room.roomId)
|
||||
) {
|
||||
parentSpace = SpaceStore.instance.activeSpace;
|
||||
parentSpace = SpaceStore.instance.activeSpaceRoom;
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
|
|
@ -21,7 +21,7 @@ import { formatCount } from "../../../utils/FormattingUtils";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { XOR } from "../../../@types/common";
|
||||
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
@ -61,7 +61,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
|
|||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate);
|
||||
|
||||
this.state = {
|
||||
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
|
||||
|
@ -81,15 +81,15 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
|
|||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.countWatcherRef);
|
||||
this.props.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
this.props.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
||||
if (prevProps.notification) {
|
||||
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
prevProps.notification.off(NotificationStateEvents.Update, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
this.props.notification.on(NotificationStateEvents.Update, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
private countPreferenceChanged = () => {
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
|||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MessageEvent from "../messages/MessageEvent";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
|
@ -45,7 +46,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
|
|||
|
||||
private onTileClicked = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.event.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.event.getRoomId(),
|
||||
|
|
|
@ -204,6 +204,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
|
|||
member={this.props.member}
|
||||
fallbackUserId={this.props.fallbackUserId}
|
||||
aria-hidden="true"
|
||||
aria-live="off"
|
||||
width={14}
|
||||
height={14}
|
||||
resizeMethod="crop"
|
||||
|
|
73
src/components/views/rooms/RecentlyViewedButton.tsx
Normal file
73
src/components/views/rooms/RecentlyViewedButton.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { MenuItem } from "../../structures/ContextMenu";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import InteractiveTooltip, { Direction } from "../elements/InteractiveTooltip";
|
||||
import { roomContextDetailsText } from "../../../Rooms";
|
||||
|
||||
const RecentlyViewedButton = () => {
|
||||
const tooltipRef = useRef<InteractiveTooltip>();
|
||||
const crumbs = useEventEmitterState(BreadcrumbsStore.instance, UPDATE_EVENT, () => BreadcrumbsStore.instance.rooms);
|
||||
|
||||
const content = <div className="mx_RecentlyViewedButton_ContextMenu">
|
||||
<h4>{ _t("Recently viewed") }</h4>
|
||||
<div>
|
||||
{ crumbs.map(crumb => {
|
||||
const contextDetails = roomContextDetailsText(crumb);
|
||||
|
||||
return <MenuItem
|
||||
key={crumb.roomId}
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: crumb.roomId,
|
||||
});
|
||||
tooltipRef.current?.hideTooltip();
|
||||
}}
|
||||
>
|
||||
<RoomAvatar room={crumb} width={24} height={24} />
|
||||
<span className="mx_RecentlyViewedButton_entry_label">
|
||||
<div>{ crumb.name }</div>
|
||||
{ contextDetails && <div className="mx_RecentlyViewedButton_entry_spaces">
|
||||
{ contextDetails }
|
||||
</div> }
|
||||
</span>
|
||||
</MenuItem>;
|
||||
}) }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return <InteractiveTooltip content={content} direction={Direction.Right} ref={tooltipRef}>
|
||||
{ ({ ref, onMouseOver }) => (
|
||||
<span
|
||||
className="mx_LeftPanel_recentsButton"
|
||||
title={_t("Recently viewed")}
|
||||
ref={ref}
|
||||
onMouseOver={onMouseOver}
|
||||
/>
|
||||
) }
|
||||
</InteractiveTooltip>;
|
||||
};
|
||||
|
||||
export default RecentlyViewedButton;
|
|
@ -16,21 +16,22 @@ limitations under the License.
|
|||
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import SenderProfile from "../messages/SenderProfile";
|
||||
import MImageReplyBody from "../messages/MImageReplyBody";
|
||||
import * as sdk from '../../../index';
|
||||
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { replaceableComponent } from '../../../utils/replaceableComponent';
|
||||
import { getEventDisplayInfo, isVoiceMessage } from '../../../utils/EventUtils';
|
||||
import MFileBody from "../messages/MFileBody";
|
||||
import MVoiceMessageBody from "../messages/MVoiceMessageBody";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
|
@ -90,7 +91,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||
this.props.toggleExpandedQuote();
|
||||
} else {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
|
|
|
@ -19,6 +19,7 @@ import { Room } from 'matrix-js-sdk/src';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import RoomDetailRow from "./RoomDetailRow";
|
||||
|
@ -39,7 +40,7 @@ export default class RoomDetailList extends React.Component<IProps> {
|
|||
|
||||
private onDetailsClick = (ev: React.MouseEvent, room: Room): void => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
room_alias: room.getCanonicalAlias() || (room.getAltAliases() || [])[0],
|
||||
});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue