Merge branch 'develop' into sort-imports

Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
Aaron Raimist 2021-12-09 08:34:20 +00:00
commit 7b94e13a84
642 changed files with 30052 additions and 8035 deletions

View 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;

View file

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

View file

@ -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')}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

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

View file

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

View 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;

View file

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

View 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;

View file

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

View file

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

View file

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

View file

@ -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 cant disable this later. Bridges & most bots wont 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.");
}

View file

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

View file

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

View 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)}
/>
);
}
}

View file

@ -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}
/>);
};

View file

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

View file

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

View file

@ -39,7 +39,7 @@ const PHASE_VERIFIED = 3;
const PHASE_CANCELLED = 4;
interface IProps extends IDialogProps {
verifier: VerificationBase; // TODO types
verifier: VerificationBase;
}
interface IState {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
});
}
}

View file

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

View 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>;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View 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;

View file

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

View file

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

View file

@ -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} />;
}
}

View 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>;
}
}

View 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;
}

View file

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

View file

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

View file

@ -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}
/>);
}

View file

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

View file

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

View file

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

View file

@ -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'],

View file

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

View file

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

View file

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

View file

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

View file

@ -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)))
}
</>;
}
}

View file

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

View 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>
);
}
}

View file

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

View file

@ -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 its secure.");
text = _t("In encrypted rooms, verify all users to ensure it's secure.");
}
}

View file

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

View file

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

View file

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

View file

@ -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}
/>
);

View file

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

View file

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

View file

@ -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} />;

View file

@ -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}
/>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

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

View file

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