Merge branch 'develop' into gsouquet/threads-action-bar-19127

This commit is contained in:
Germain Souquet 2021-09-28 09:34:43 +01:00
commit e52a33e93c
45 changed files with 668 additions and 468 deletions

View file

@ -38,6 +38,7 @@ import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
import ErrorDialog from '../dialogs/ErrorDialog';
import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -52,7 +53,8 @@ export interface IOperableEventTile {
getEventTileOps(): IEventTileOps;
}
interface IProps {
interface IProps extends IPosition {
chevronFace: ChevronFace;
/* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent;
/* an optional EventTileOps implementation that can be used to unhide preview widgets */

View file

@ -131,8 +131,13 @@ interface IProps {
}
const isOnlyAdmin = (room: Room): boolean => {
return !room.getJoinedMembers().some(member => {
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
const userId = room.client.getUserId();
if (room.getMember(userId).powerLevelNorm !== 100) {
return false; // user is not an admin
}
return room.getJoinedMembers().every(member => {
// return true if every other member has a lower power level (we are highest)
return member.userId === userId || member.powerLevelNorm < 100;
});
};

View file

@ -163,7 +163,7 @@ export default class AppTile extends React.Component<IProps, IState> {
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
ActiveWidgetStore.instance.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this.persistKey);
if (this.sgWidget) this.sgWidget.stop();
}
@ -198,8 +198,8 @@ export default class AppTile extends React.Component<IProps, IState> {
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (!ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.instance.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this.persistKey);
}
@ -282,7 +282,7 @@ export default class AppTile extends React.Component<IProps, IState> {
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this.persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
ActiveWidgetStore.instance.destroyPersistentWidget(this.props.app.id);
if (this.sgWidget) this.sgWidget.stop({ forceDestroy: true });
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -39,13 +39,13 @@ export default class PersistentApp extends React.Component<{}, IState> {
this.state = {
roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
};
}
public componentDidMount(): void {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
ActiveWidgetStore.on('update', this.onActiveWidgetStoreUpdate);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
}
@ -53,7 +53,7 @@ export default class PersistentApp extends React.Component<{}, IState> {
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
ActiveWidgetStore.removeListener('update', this.onActiveWidgetStoreUpdate);
ActiveWidgetStore.instance.removeListener(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
}
@ -68,23 +68,23 @@ export default class PersistentApp extends React.Component<{}, IState> {
private onActiveWidgetStoreUpdate = (): void => {
this.setState({
persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(),
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
});
};
private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId);
if (membership !== "join") {
// we're not in the room anymore - delete
if (room .roomId === persistentWidgetInRoomId) {
ActiveWidgetStore.destroyPersistentWidget(this.state.persistentWidgetId);
ActiveWidgetStore.instance.destroyPersistentWidget(this.state.persistentWidgetId);
}
}
};
public render(): JSX.Element {
if (this.state.persistentWidgetId) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId);
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
@ -96,7 +96,7 @@ export default class PersistentApp extends React.Component<{}, IState> {
if (this.state.roomId !== persistentWidgetInRoomId && myMembership === "join") {
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId();
});
const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),

View file

@ -16,6 +16,8 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
@ -35,6 +37,12 @@ import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
/**
* This number is based on the previous behavior - if we have message of height
* over 60px then we want to show button that will allow to expand it.
*/
const SHOW_EXPAND_QUOTE_PIXELS = 60;
interface IProps {
// the latest event in this chain of replies
parentEv?: MatrixEvent;
@ -45,6 +53,8 @@ interface IProps {
layout?: Layout;
// Whether to always show a timestamp
alwaysShowTimestamps?: boolean;
isQuoteExpanded?: boolean;
setQuoteExpanded: (isExpanded: boolean) => void;
}
interface IState {
@ -66,6 +76,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private unmounted = false;
private room: Room;
private blockquoteRef = React.createRef<HTMLElement>();
constructor(props, context) {
super(props, context);
@ -80,7 +91,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
}
public static getParentEventId(ev: MatrixEvent): string {
public static getParentEventId(ev: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now
@ -137,7 +148,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } {
): { body: string, html: string } | null {
if (!ev) return null;
let { body, formatted_body: html } = ev.getContent();
@ -237,37 +248,38 @@ export default class ReplyThread extends React.Component<IProps, IState> {
return replyMixin;
}
public static makeThread(
parentEv: MatrixEvent,
onHeightChanged: () => void,
permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread
parentEv={parentEv}
onHeightChanged={onHeightChanged}
ref={ref}
permalinkCreator={permalinkCreator}
layout={layout}
alwaysShowTimestamps={alwaysShowTimestamps}
/>;
public static hasThreadReply(event: MatrixEvent) {
return Boolean(ReplyThread.getParentEventId(event));
}
componentDidMount() {
this.initialize();
this.trySetExpandableQuotes();
}
componentDidUpdate() {
this.props.onHeightChanged();
this.trySetExpandableQuotes();
}
componentWillUnmount() {
this.unmounted = true;
}
private trySetExpandableQuotes() {
if (this.props.isQuoteExpanded === undefined && this.blockquoteRef.current) {
const el: HTMLElement | null = this.blockquoteRef.current.querySelector('.mx_EventTile_body');
if (el) {
const code: HTMLElement | null = el.querySelector('code');
const isCodeEllipsisShown = code ? code.offsetHeight >= SHOW_EXPAND_QUOTE_PIXELS : false;
const isElipsisShown = el.offsetHeight >= SHOW_EXPAND_QUOTE_PIXELS || isCodeEllipsisShown;
if (isElipsisShown) {
this.props.setQuoteExpanded(false);
}
}
}
}
private async initialize(): Promise<void> {
const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
@ -321,7 +333,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
this.initialize();
};
private onQuoteClick = async (): Promise<void> => {
private onQuoteClick = async (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
@ -373,14 +385,26 @@ export default class ReplyThread extends React.Component<IProps, IState> {
header = <Spinner w={16} h={16} />;
}
const { isQuoteExpanded } = this.props;
const evTiles = this.state.events.map((ev) => {
return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
<ReplyTile
mxEvent={ev}
onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator}
/>
</blockquote>;
const classname = classNames({
'mx_ReplyThread': true,
[this.getReplyThreadColorClass(ev)]: true,
// We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false
'mx_ReplyThread--expanded': isQuoteExpanded === true,
// We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false
'mx_ReplyThread--collapsed': isQuoteExpanded === false,
});
return (
<blockquote ref={this.blockquoteRef} className={classname} key={ev.getId()}>
<ReplyTile
mxEvent={ev}
onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator}
toggleExpandedQuote={() => this.props.setQuoteExpanded(!this.props.isQuoteExpanded)}
/>
</blockquote>
);
});
return <div className="mx_ReplyThread_wrapper">

View file

@ -15,107 +15,112 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import * as HtmlUtils from '../../../HtmlUtils';
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
import { formatTime } from '../../../DateUtils';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import classNames from 'classnames';
import RedactedBody from "./RedactedBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from "../elements/AccessibleButton";
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
function getReplacedContent(event) {
const originalContent = event.getOriginalContent();
return originalContent["m.new_content"] || originalContent;
}
@replaceableComponent("views.messages.EditHistoryMessage")
export default class EditHistoryMessage extends React.PureComponent {
static propTypes = {
// the message event being edited
mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired,
previousEdit: PropTypes.instanceOf(MatrixEvent),
isBaseEvent: PropTypes.bool,
};
interface IProps {
// the message event being edited
mxEvent: MatrixEvent;
previousEdit?: MatrixEvent;
isBaseEvent?: boolean;
isTwelveHour?: boolean;
}
constructor(props) {
interface IState {
canRedact: boolean;
sendStatus: EventStatus;
}
@replaceableComponent("views.messages.EditHistoryMessage")
export default class EditHistoryMessage extends React.PureComponent<IProps, IState> {
private content = createRef<HTMLDivElement>();
private pills: Element[] = [];
constructor(props: IProps) {
super(props);
const cli = MatrixClientPeg.get();
const { userId } = cli.credentials;
const event = this.props.mxEvent;
const room = cli.getRoom(event.getRoomId());
if (event.localRedactionEvent()) {
event.localRedactionEvent().on("status", this._onAssociatedStatusChanged);
event.localRedactionEvent().on("status", this.onAssociatedStatusChanged);
}
const canRedact = room.currentState.maySendRedactionForEvent(event, userId);
this.state = { canRedact, sendStatus: event.getAssociatedStatus() };
this._content = createRef();
this._pills = [];
}
_onAssociatedStatusChanged = () => {
private onAssociatedStatusChanged = (): void => {
this.setState({ sendStatus: this.props.mxEvent.getAssociatedStatus() });
};
_onRedactClick = async () => {
private onRedactClick = async (): Promise<void> => {
const event = this.props.mxEvent;
const cli = MatrixClientPeg.get();
const ConfirmAndWaitRedactDialog = sdk.getComponent("dialogs.ConfirmAndWaitRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', 'Edit history', ConfirmAndWaitRedactDialog, {
redact: () => cli.redactEvent(event.getRoomId(), event.getId()),
}, 'mx_Dialog_confirmredact');
};
_onViewSourceClick = () => {
const ViewSource = sdk.getComponent('structures.ViewSource');
private onViewSourceClick = (): void => {
Modal.createTrackedDialog('View Event Source', 'Edit history', ViewSource, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_viewsource');
};
pillifyLinks() {
private pillifyLinks(): void {
// not present for redacted events
if (this._content.current) {
pillifyLinks(this._content.current.children, this.props.mxEvent, this._pills);
if (this.content.current) {
pillifyLinks(this.content.current.children, this.props.mxEvent, this.pills);
}
}
componentDidMount() {
public componentDidMount(): void {
this.pillifyLinks();
}
componentWillUnmount() {
unmountPills(this._pills);
public componentWillUnmount(): void {
unmountPills(this.pills);
const event = this.props.mxEvent;
if (event.localRedactionEvent()) {
event.localRedactionEvent().off("status", this._onAssociatedStatusChanged);
event.localRedactionEvent().off("status", this.onAssociatedStatusChanged);
}
}
componentDidUpdate() {
public componentDidUpdate(): void {
this.pillifyLinks();
}
_renderActionBar() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
private renderActionBar(): JSX.Element {
// hide the button when already redacted
let redactButton;
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
redactButton = (
<AccessibleButton onClick={this._onRedactClick}>
<AccessibleButton onClick={this.onRedactClick}>
{ _t("Remove") }
</AccessibleButton>
);
}
const viewSourceButton = (
<AccessibleButton onClick={this._onViewSourceClick}>
<AccessibleButton onClick={this.onViewSourceClick}>
{ _t("View Source") }
</AccessibleButton>
);
@ -128,7 +133,7 @@ export default class EditHistoryMessage extends React.PureComponent {
);
}
render() {
public render(): JSX.Element {
const { mxEvent } = this.props;
const content = getReplacedContent(mxEvent);
let contentContainer;
@ -139,18 +144,22 @@ export default class EditHistoryMessage extends React.PureComponent {
if (this.props.previousEdit) {
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
} else {
contentElements = HtmlUtils.bodyToHtml(content, null, { stripReplyFallback: true });
contentElements = HtmlUtils.bodyToHtml(
content,
null,
{ stripReplyFallback: true, returnString: false },
);
}
if (mxEvent.getContent().msgtype === "m.emote") {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
contentContainer = (
<div className="mx_EventTile_content" ref={this._content}>*&nbsp;
<div className="mx_EventTile_content" ref={this.content}>*&nbsp;
<span className="mx_MEmoteBody_sender">{ name }</span>
&nbsp;{ contentElements }
</div>
);
} else {
contentContainer = <div className="mx_EventTile_content" ref={this._content}>{ contentElements }</div>;
contentContainer = <div className="mx_EventTile_content" ref={this.content}>{ contentElements }</div>;
}
}
@ -167,7 +176,7 @@ export default class EditHistoryMessage extends React.PureComponent {
<div className="mx_EventTile_line">
<span className="mx_MessageTimestamp">{ timestamp }</span>
{ contentContainer }
{ this._renderActionBar() }
{ this.renderActionBar() }
</div>
</div>
</li>

View file

@ -16,44 +16,50 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import { getNameForEventRoom, userLabelForEventRoom }
from '../../../utils/KeyVerificationStateObserver';
import { getNameForEventRoom, userLabelForEventRoom } from '../../../utils/KeyVerificationStateObserver';
import EventTileBubble from "./EventTileBubble";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { EventType } from "matrix-js-sdk/src/@types/event";
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MKeyVerificationConclusion")
export default class MKeyVerificationConclusion extends React.Component {
constructor(props) {
export default class MKeyVerificationConclusion extends React.Component<IProps> {
constructor(props: IProps) {
super(props);
}
componentDidMount() {
public componentDidMount(): void {
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.on("change", this._onRequestChanged);
request.on("change", this.onRequestChanged);
}
MatrixClientPeg.get().on("userTrustStatusChanged", this._onTrustChanged);
MatrixClientPeg.get().on("userTrustStatusChanged", this.onTrustChanged);
}
componentWillUnmount() {
public componentWillUnmount(): void {
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.off("change", this._onRequestChanged);
request.off("change", this.onRequestChanged);
}
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("userTrustStatusChanged", this._onTrustChanged);
cli.removeListener("userTrustStatusChanged", this.onTrustChanged);
}
}
_onRequestChanged = () => {
private onRequestChanged = (): void => {
this.forceUpdate();
};
_onTrustChanged = (userId, status) => {
private onTrustChanged = (userId: string): void => {
const { mxEvent } = this.props;
const request = mxEvent.verificationRequest;
if (!request || request.otherUserId !== userId) {
@ -62,17 +68,17 @@ export default class MKeyVerificationConclusion extends React.Component {
this.forceUpdate();
};
_shouldRender(mxEvent, request) {
public static shouldRender(mxEvent: MatrixEvent, request: VerificationRequest): boolean {
// normally should not happen
if (!request) {
return false;
}
// .cancel event that was sent after the verification finished, ignore
if (mxEvent.getType() === "m.key.verification.cancel" && !request.cancelled) {
if (mxEvent.getType() === EventType.KeyVerificationCancel && !request.cancelled) {
return false;
}
// .done event that was sent after the verification cancelled, ignore
if (mxEvent.getType() === "m.key.verification.done" && !request.done) {
if (mxEvent.getType() === EventType.KeyVerificationDone && !request.done) {
return false;
}
@ -89,11 +95,11 @@ export default class MKeyVerificationConclusion extends React.Component {
return true;
}
render() {
public render(): JSX.Element {
const { mxEvent } = this.props;
const request = mxEvent.verificationRequest;
if (!this._shouldRender(mxEvent, request)) {
if (!MKeyVerificationConclusion.shouldRender(mxEvent, request)) {
return null;
}
@ -103,15 +109,18 @@ export default class MKeyVerificationConclusion extends React.Component {
let title;
if (request.done) {
title = _t("You verified %(name)s", { name: getNameForEventRoom(request.otherUserId, mxEvent) });
title = _t(
"You verified %(name)s",
{ name: getNameForEventRoom(request.otherUserId, mxEvent.getRoomId()) },
);
} else if (request.cancelled) {
const userId = request.cancellingUserId;
if (userId === myUserId) {
title = _t("You cancelled verifying %(name)s",
{ name: getNameForEventRoom(request.otherUserId, mxEvent) });
{ name: getNameForEventRoom(request.otherUserId, mxEvent.getRoomId()) });
} else {
title = _t("%(name)s cancelled verifying",
{ name: getNameForEventRoom(userId, mxEvent) });
{ name: getNameForEventRoom(userId, mxEvent.getRoomId()) });
}
}
@ -129,8 +138,3 @@ export default class MKeyVerificationConclusion extends React.Component {
return null;
}
}
MKeyVerificationConclusion.propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};

View file

@ -17,7 +17,8 @@ limitations under the License.
*/
import React, { useEffect } from 'react';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import type { Relations } from 'matrix-js-sdk/src/models/relations';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
@ -35,13 +36,17 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import classNames from 'classnames';
import SettingsStore from '../../../settings/SettingsStore';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ReplyThread from '../elements/ReplyThread';
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here
// TODO: Types
getTile: () => any | null;
getReplyThread: () => ReplyThread;
permalinkCreator: RoomPermalinkCreator;
onFocusChange: (menuDisplayed: boolean) => void;
@ -57,8 +62,6 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
let contextMenu;
if (menuDisplayed) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
@ -90,7 +93,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
interface IReactButtonProps {
mxEvent: MatrixEvent;
reactions: any; // TODO: types
reactions: Relations;
onFocusChange: (menuDisplayed: boolean) => void;
}
@ -127,12 +130,15 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
interface IMessageActionBarProps {
mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: any; // TODO: types
reactions?: Relations;
// TODO: Types
getTile: () => any | null;
getReplyThread: () => ReplyThread | undefined;
permalinkCreator?: RoomPermalinkCreator;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here
getReplyThread?: () => ReplyThread;
onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void;
isInThreadTimeline?: boolean;
}
@ -329,6 +335,20 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
toolbarOpts.push(cancelSendingButton);
}
if (this.props.isQuoteExpanded !== undefined && ReplyThread.hasThreadReply(this.props.mxEvent)) {
const expandClassName = classNames({
'mx_MessageActionBar_maskButton': true,
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,
'mx_MessageActionBar_collapseMessageButton': this.props.isQuoteExpanded,
});
toolbarOpts.push(<RovingAccessibleTooltipButton
className={expandClassName}
title={this.props.isQuoteExpanded ? _t("Collapse quotes │ ⇧+click") : _t("Expand quotes │ ⇧+click")}
onClick={this.props.toggleThreadExpanded}
key="expand"
/>);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent}

View file

@ -15,22 +15,18 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
interface IProps {
mxEvent: MatrixEvent;
onMessageAllowed: () => void;
}
@replaceableComponent("views.messages.MjolnirBody")
export default class MjolnirBody extends React.Component {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
onMessageAllowed: PropTypes.func.isRequired,
};
constructor() {
super();
}
_onAllowClick = (e) => {
export default class MjolnirBody extends React.Component<IProps> {
private onAllowClick = (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
@ -39,11 +35,11 @@ export default class MjolnirBody extends React.Component {
this.props.onMessageAllowed();
};
render() {
public render(): JSX.Element {
return (
<div className='mx_MjolnirBody'><i>{ _t(
"You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
{}, { a: (sub) => <a href="#" onClick={this._onAllowClick}>{ sub }</a> },
{}, { a: (sub) => <a href="#" onClick={this.onAllowClick}>{ sub }</a> },
) }</i></div>
);
}

View file

@ -16,13 +16,18 @@ limitations under the License.
import React, { useContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { IBodyProps } from "./IBodyProps";
const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
interface IProps {
mxEvent: MatrixEvent;
}
const RedactedBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted");

View file

@ -17,23 +17,24 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import RoomAvatar from "../avatars/RoomAvatar";
import ImageView from "../elements/ImageView";
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.RoomAvatarEvent")
export default class RoomAvatarEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};
onAvatarClick = () => {
export default class RoomAvatarEvent extends React.Component<IProps> {
private onAvatarClick = (): void => {
const cli = MatrixClientPeg.get();
const ev = this.props.mxEvent;
const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp;
@ -44,7 +45,6 @@ export default class RoomAvatarEvent extends React.Component {
roomName: room ? room.name : '',
});
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: httpUrl,
name: text,
@ -52,10 +52,9 @@ export default class RoomAvatarEvent extends React.Component {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
};
render() {
public render(): JSX.Element {
const ev = this.props.mxEvent;
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
if (!ev.getContent().url || ev.getContent().url.trim().length === 0) {
return (

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
@ -24,15 +23,16 @@ import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.RoomCreate")
export default class RoomCreate extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};
_onLinkClicked = e => {
export default class RoomCreate extends React.Component<IProps> {
private onLinkClicked = (e: React.MouseEvent): void => {
e.preventDefault();
const predecessor = this.props.mxEvent.getContent()['predecessor'];
@ -45,7 +45,7 @@ export default class RoomCreate extends React.Component {
});
};
render() {
public render(): JSX.Element {
const predecessor = this.props.mxEvent.getContent()['predecessor'];
if (predecessor === undefined) {
return <div />; // We should never have been instantiated in this case
@ -55,7 +55,7 @@ export default class RoomCreate extends React.Component {
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
const link = (
<a href={predecessorPermalink} onClick={this._onLinkClicked}>
<a href={predecessorPermalink} onClick={this.onLinkClicked}>
{ _t("Click here to see older messages.") }
</a>
);

View file

@ -138,6 +138,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
// If it's less than 30% we don't add the expansion button.
// We also round the number as it sometimes can be 29.99...
const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
// TODO: additionally show the button if it's an expanded quoted message
if (percentageOfViewport < 30) return;
const button = document.createElement("span");

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { lexicographicCompare } from 'matrix-js-sdk/src/utils';
import { Room } from 'matrix-js-sdk/src/models/room';
@ -35,16 +34,6 @@ interface IProps {
room: Room;
userId: string;
showApps: boolean; // Render apps
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number;
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: () => void;
fullHeight: boolean;
resizeNotifier: ResizeNotifier;
}
@ -92,13 +81,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
return objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState);
}
componentDidUpdate(prevProps, prevState) {
// most changes are likely to cause a resize
if (this.props.onResize) {
this.props.onResize();
}
}
private rateLimitedUpdate = throttle(() => {
this.setState({ counters: this.computeCounters() });
}, 500, { leading: true, trailing: true });
@ -138,7 +120,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
const callView = (
<CallViewForRoom
roomId={this.props.room.roomId}
maxVideoHeight={this.props.maxHeight}
resizeNotifier={this.props.resizeNotifier}
/>
);
@ -148,7 +129,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
appsDrawer = <AppsDrawer
room={this.props.room}
userId={this.props.userId}
maxHeight={this.props.maxHeight}
showApps={this.props.showApps}
resizeNotifier={this.props.resizeNotifier}
/>;
@ -204,21 +184,12 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
}
const classes = classNames({
"mx_RoomView_auxPanel": true,
"mx_RoomView_auxPanel_fullHeight": this.props.fullHeight,
});
const style: React.CSSProperties = {};
if (!this.props.fullHeight) {
style.maxHeight = this.props.maxHeight;
}
return (
<AutoHideScrollbar className={classes} style={style}>
<AutoHideScrollbar className="mx_RoomView_auxPanel">
{ stateViews }
{ this.props.children }
{ appsDrawer }
{ callView }
{ this.props.children }
</AutoHideScrollbar>
);
}

View file

@ -58,6 +58,7 @@ import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
import MKeyVerificationConclusion from "../messages/MKeyVerificationConclusion";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -144,8 +145,7 @@ export function getHandlerTile(ev) {
// XXX: This is extremely a hack. Possibly these components should have an interface for
// declining to render?
if (type === "m.key.verification.cancel" || type === "m.key.verification.done") {
const MKeyVerificationConclusion = sdk.getComponent("messages.MKeyVerificationConclusion");
if (!MKeyVerificationConclusion.prototype._shouldRender.call(null, ev, ev.request)) {
if (!MKeyVerificationConclusion.shouldRender(ev, ev.request)) {
return;
}
}
@ -323,7 +323,7 @@ interface IState {
reactions: Relations;
hover: boolean;
isQuoteExpanded?: boolean;
thread?: Thread;
}
@ -331,7 +331,8 @@ interface IState {
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
// TODO: Types
private tile = React.createRef<unknown>();
private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>();
@ -889,8 +890,8 @@ export default class EventTile extends React.Component<IProps, IState> {
actionBarFocused: focused,
});
};
getTile = () => this.tile.current;
// TODO: Types
getTile: () => any | null = () => this.tile.current;
getReplyThread = () => this.replyThread.current;
@ -915,6 +916,11 @@ export default class EventTile extends React.Component<IProps, IState> {
});
};
private setQuoteExpanded = (expanded: boolean) => {
this.setState({
isQuoteExpanded: expanded,
});
};
render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType;
@ -924,6 +930,7 @@ export default class EventTile extends React.Component<IProps, IState> {
isInfoMessage,
isLeftAlignedBubbleMessage,
} = getEventDisplayInfo(this.props.mxEvent);
const { isQuoteExpanded } = this.state;
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
@ -936,6 +943,7 @@ export default class EventTile extends React.Component<IProps, IState> {
</div>
</div>;
}
const EventTileType = sdk.getComponent(tileHandler);
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
@ -1057,6 +1065,7 @@ export default class EventTile extends React.Component<IProps, IState> {
getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange}
isInThreadTimeline={isInThreadTimeline}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
/> : undefined;
const showTimestamp = this.props.mxEvent.getTs()
@ -1229,20 +1238,18 @@ export default class EventTile extends React.Component<IProps, IState> {
}
default: {
let thread;
// When the "showHiddenEventsInTimeline" lab is enabled,
// avoid showing replies for hidden events (events without tiles)
if (haveTileForEvent(this.props.mxEvent)) {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
const thread = haveTileForEvent(this.props.mxEvent) &&
ReplyThread.hasThreadReply(this.props.mxEvent) ? (
<ReplyThread
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyThread}
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

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { useContext, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IPreviewUrlResponse } from "matrix-js-sdk/src/client";
import { IPreviewUrlResponse, MatrixClient } from "matrix-js-sdk/src/client";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LinkPreviewWidget from "./LinkPreviewWidget";
@ -40,13 +40,7 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
try {
return [link, await cli.getUrlPreview(link, ts)];
} catch (error) {
console.error("Failed to get URL preview: " + error);
}
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
return fetchPreviews(cli, links, ts);
}, [links, ts], []);
useEffect(() => {
@ -89,4 +83,18 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
</div>;
};
const fetchPreviews = (cli: MatrixClient, links: string[], ts: number):
Promise<[string, IPreviewUrlResponse][]> => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
try {
const preview = await cli.getUrlPreview(link, ts);
if (preview && Object.keys(preview).length > 0) {
return [link, preview];
}
} catch (error) {
console.error("Failed to get URL preview: " + error);
}
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
};
export default LinkPreviewGroup;

View file

@ -35,6 +35,7 @@ interface IProps {
highlights?: string[];
highlightLink?: string;
onHeightChanged?(): void;
toggleExpandedQuote?: () => void;
}
@replaceableComponent("views.rooms.ReplyTile")
@ -82,12 +83,17 @@ export default class ReplyTile extends React.PureComponent<IProps> {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
// Expand thread on shift key
if (this.props.toggleExpandedQuote && e.shiftKey) {
this.props.toggleExpandedQuote();
} else {
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
}
}
};

View file

@ -164,6 +164,20 @@ export default class SendMessageComposer extends React.Component<IProps> {
window.addEventListener("beforeunload", this.saveStoredEditorState);
}
public componentDidUpdate(prevProps: IProps): void {
const replyToEventChanged = this.props.replyInThread && (this.props.replyToEvent !== prevProps.replyToEvent);
if (replyToEventChanged) {
this.model.reset([]);
}
if (this.props.replyInThread && this.props.replyToEvent && (!prevProps.replyToEvent || replyToEventChanged)) {
const partCreator = new CommandPartCreator(this.props.room, this.context);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model.reset(parts);
this.editorRef.current?.focus();
}
}
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this.editorRef.current?.isComposing(event)) {
@ -484,7 +498,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
private get editorStateKey() {
return `mx_cider_state_${this.props.room.roomId}`;
let key = `mx_cider_state_${this.props.room.roomId}`;
const thread = this.props.replyToEvent?.getThread();
if (thread) {
key += `_${thread.id}`;
}
return key;
}
private clearStoredEditorState(): void {
@ -492,6 +511,10 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
private restoreStoredEditorState(partCreator: PartCreator): Part[] {
if (this.props.replyInThread && !this.props.replyToEvent) {
return null;
}
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {

View file

@ -27,9 +27,6 @@ interface IProps {
// What room we should display the call for
roomId: string;
// maxHeight style attribute for the video panel
maxVideoHeight?: number;
resizeNotifier: ResizeNotifier;
}
@ -99,14 +96,12 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
public render() {
if (!this.state.call) return null;
// We subtract 8 as it the margin-bottom of the mx_CallViewForRoom_ResizeWrapper
const maxHeight = this.props.maxVideoHeight - 8;
return (
<div className="mx_CallViewForRoom">
<Resizable
minHeight={380}
maxHeight={maxHeight}
maxHeight="80vh"
enable={{
top: false,
right: false,