Allow quote-reply in thread view element-web (#6959)

This commit is contained in:
Germain 2021-10-19 16:05:34 +01:00 committed by GitHub
parent d39002338d
commit 694ec946e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 84 additions and 88 deletions

View file

@ -779,7 +779,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}); });
break; break;
case 'reply_to_event': case 'reply_to_event':
if (this.state.searchResults && payload.event.getRoomId() === this.state.roomId && !this.unmounted) { if (this.state.searchResults
&& payload.event.getRoomId() === this.state.roomId
&& !this.unmounted
&& payload.context === TimelineRenderingType.Room) {
this.onCancelSearchClick(); this.onCancelSearchClick();
} }
break; break;

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
@ -24,7 +23,6 @@ import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ResizeNotifier from '../../utils/ResizeNotifier'; import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile, { TileShape } from '../views/rooms/EventTile';
import MatrixClientContext from '../../contexts/MatrixClientContext'; import MatrixClientContext from '../../contexts/MatrixClientContext';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton'; import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
@ -34,6 +32,7 @@ import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/Layout'; import { Layout } from '../../settings/Layout';
import { useEventEmitter } from '../../hooks/useEventEmitter'; import { useEventEmitter } from '../../hooks/useEventEmitter';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import { TileShape } from '../views/rooms/EventTile';
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -41,18 +40,6 @@ interface IProps {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
} }
export const ThreadPanelItem: React.FC<{ event: MatrixEvent }> = ({ event }) => {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
tileShape={TileShape.Thread}
alwaysShowTimestamps={true}
/>;
};
export enum ThreadFilterType { export enum ThreadFilterType {
"My", "My",
"All" "All"
@ -230,7 +217,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
showReactions={true} showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout" className="mx_RoomView_messagePanel mx_GroupLayout"
membersLoaded={true} membersLoaded={true}
tileShape={TileShape.ThreadPanel} tileShape={TileShape.Thread}
/> />
</BaseCard> </BaseCard>
</RoomContext.Provider> </RoomContext.Provider>

View file

@ -50,6 +50,7 @@ interface IProps {
interface IState { interface IState {
thread?: Thread; thread?: Thread;
editState?: EditorStateTransfer; editState?: EditorStateTransfer;
replyToEvent?: MatrixEvent;
} }
@replaceableComponent("structures.ThreadView") @replaceableComponent("structures.ThreadView")
@ -114,6 +115,13 @@ export default class ThreadView extends React.Component<IProps, IState> {
}); });
break; break;
} }
case 'reply_to_event':
if (payload.context === TimelineRenderingType.Thread) {
this.setState({
replyToEvent: payload.event,
});
}
break;
default: default:
break; break;
} }
@ -199,7 +207,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
rel_type: RelationType.Thread, rel_type: RelationType.Thread,
event_id: this.state.thread.id, event_id: this.state.thread.id,
}} }}
showReplyPreview={false} replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} e2eStatus={this.props.e2eStatus}
compact={true} compact={true}

View file

@ -63,7 +63,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
let contextMenu; let contextMenu;
if (menuDisplayed) { if (menuDisplayed) {
const tile = getTile && getTile(); const tile = getTile && getTile();
const replyThread = getReplyChain && getReplyChain(); const replyChain = getReplyChain && getReplyChain();
const buttonRect = button.current.getBoundingClientRect(); const buttonRect = button.current.getBoundingClientRect();
contextMenu = <MessageContextMenu contextMenu = <MessageContextMenu
@ -71,7 +71,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
mxEvent={mxEvent} mxEvent={mxEvent}
permalinkCreator={permalinkCreator} permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined} eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyChain={replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined} collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
onFinished={closeMenu} onFinished={closeMenu}
/>; />;
} }
@ -191,6 +191,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: this.props.mxEvent, event: this.props.mxEvent,
context: this.context.timelineRenderingType,
}); });
}; };
@ -289,7 +290,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
// Like the resend button, the react and reply buttons need to appear before the edit. // Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react // The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`. // button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply && this.context.timelineRenderingType !== TimelineRenderingType.Thread) { if (this.context.canReply) {
toolbarOpts.splice(0, 0, <> toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
@ -297,7 +298,8 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
onClick={this.onReplyClick} onClick={this.onReplyClick}
key="reply" key="reply"
/> />
{ SettingsStore.getValue("feature_thread") && ( { (SettingsStore.getValue("feature_thread")
&& this.context.timelineRenderingType !== TimelineRenderingType.Thread) && (
<RovingAccessibleTooltipButton <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")} title={_t("Thread")}

View file

@ -339,7 +339,7 @@ export default class EventTile extends React.Component<IProps, IState> {
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
// TODO: Types // TODO: Types
private tile = React.createRef<unknown>(); private tile = React.createRef<unknown>();
private replyThread = React.createRef<ReplyChain>(); private replyChain = React.createRef<ReplyChain>();
public readonly ref = createRef<HTMLElement>(); public readonly ref = createRef<HTMLElement>();
@ -933,7 +933,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// TODO: Types // TODO: Types
getTile: () => any | null = () => this.tile.current; getTile: () => any | null = () => this.tile.current;
getReplyChain = () => this.replyThread.current; getReplyChain = () => this.replyChain.current;
getReactions = () => { getReactions = () => {
if ( if (
@ -1214,12 +1214,26 @@ export default class EventTile extends React.Component<IProps, IState> {
]); ]);
} }
case TileShape.Thread: { case TileShape.Thread: {
const thread = 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()); const room = this.context.getRoom(this.props.mxEvent.getRoomId());
return React.createElement(this.props.as || "li", { return React.createElement(this.props.as || "li", {
"className": classes, "className": classes,
"aria-live": ariaLive, "aria-live": ariaLive,
"aria-atomic": true, "aria-atomic": true,
"data-scroll-tokens": scrollToken, "data-scroll-tokens": scrollToken,
"data-has-reply": !!thread,
}, [ }, [
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName"> <div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
<RoomAvatar room={room} width={28} height={28} /> <RoomAvatar room={room} width={28} height={28} />
@ -1235,6 +1249,7 @@ export default class EventTile extends React.Component<IProps, IState> {
</a> </a>
</div>, </div>,
<div className="mx_EventTile_line" key="mx_EventTile_line"> <div className="mx_EventTile_line" key="mx_EventTile_line">
{ thread }
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
@ -1287,7 +1302,7 @@ export default class EventTile extends React.Component<IProps, IState> {
<ReplyChain <ReplyChain
parentEv={this.props.mxEvent} parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
ref={this.replyThread} ref={this.replyChain}
forExport={this.props.forExport} forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout} layout={this.props.layout}

View file

@ -55,6 +55,7 @@ import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import InfoDialog from "../dialogs/InfoDialog"; import InfoDialog from "../dialogs/InfoDialog";
import { RelationType } from 'matrix-js-sdk/src/@types/event'; import { RelationType } from 'matrix-js-sdk/src/@types/event';
import RoomContext from '../../../contexts/RoomContext';
let instanceCount = 0; let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
@ -227,7 +228,6 @@ interface IProps {
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
relation?: IEventRelation; relation?: IEventRelation;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
compact?: boolean; compact?: boolean;
} }
@ -252,8 +252,9 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
public static contextType = RoomContext;
static defaultProps = { static defaultProps = {
showReplyPreview: true,
compact: false, compact: false,
}; };
@ -294,7 +295,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}; };
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') { if (payload.action === 'reply_to_event' && payload.context === this.context.timelineRenderingType) {
// add a timeout for the reply preview to be rendered, so // add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can // that the ScrollPanel listening to the resizeNotifier can
// correctly measure it's new height and scroll down to keep // correctly measure it's new height and scroll down to keep
@ -633,9 +634,9 @@ export default class MessageComposer extends React.Component<IProps, IState> {
<div className={classes} ref={this.ref}> <div className={classes} ref={this.ref}>
{ recordingTooltip } { recordingTooltip }
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
{ this.props.showReplyPreview && ( <ReplyPreview
<ReplyPreview permalinkCreator={this.props.permalinkCreator} /> replyToEvent={this.props.replyToEvent}
) } permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{ controls } { controls }
{ this.renderButtons(menuPosition) } { this.renderButtons(menuPosition) }

View file

@ -17,63 +17,31 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReplyTile from './ReplyTile'; import ReplyTile from './ReplyTile';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventSubscription } from 'fbemitter'; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
function cancelQuoting() { function cancelQuoting(context: TimelineRenderingType) {
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: null, event: null,
context,
}); });
} }
interface IProps { interface IProps {
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
} replyToEvent: MatrixEvent;
interface IState {
event: MatrixEvent;
} }
@replaceableComponent("views.rooms.ReplyPreview") @replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component<IProps, IState> { export default class ReplyPreview extends React.Component<IProps> {
private unmounted = false; public static contextType = RoomContext;
private readonly roomStoreToken: EventSubscription;
constructor(props) { public render(): JSX.Element {
super(props); if (!this.props.replyToEvent) return null;
this.state = {
event: RoomViewStore.getQuotingEvent(),
};
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
componentWillUnmount() {
this.unmounted = true;
// Remove RoomStore listener
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
}
private onRoomViewStoreUpdate = (): void => {
if (this.unmounted) return;
const event = RoomViewStore.getQuotingEvent();
if (this.state.event !== event) {
this.setState({ event });
}
};
render() {
if (!this.state.event) return null;
return <div className="mx_ReplyPreview"> return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section"> <div className="mx_ReplyPreview_section">
@ -86,13 +54,13 @@ export default class ReplyPreview extends React.Component<IProps, IState> {
src={require("../../../../res/img/cancel.svg")} src={require("../../../../res/img/cancel.svg")}
width="18" width="18"
height="18" height="18"
onClick={cancelQuoting} onClick={() => cancelQuoting(this.context.timelineRenderingType)}
/> />
</div> </div>
<div className="mx_ReplyPreview_clear" /> <div className="mx_ReplyPreview_clear" />
<div className="mx_ReplyPreview_tile"> <div className="mx_ReplyPreview_tile">
<ReplyTile <ReplyTile
mxEvent={this.state.event} mxEvent={this.props.replyToEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
/> />
</div> </div>

View file

@ -238,6 +238,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: null, event: null,
context: this.context.timelineRenderingType,
}); });
break; break;
default: default:
@ -269,6 +270,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: replyEventId ? this.props.room.findEventById(replyEventId) : null, event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
context: this.context.timelineRenderingType,
}); });
if (parts) { if (parts) {
this.model.reset(parts); this.model.reset(parts);
@ -479,6 +481,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: null, event: null,
context: this.context.timelineRenderingType,
}); });
} }
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
@ -552,6 +555,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
event: this.props.room.findEventById(replyEventId), event: this.props.room.findEventById(replyEventId),
context: this.context.timelineRenderingType,
}); });
} }
return parts; return parts;
@ -583,7 +587,9 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
switch (payload.action) { switch (payload.action) {
case 'reply_to_event': case 'reply_to_event':
case Action.FocusSendMessageComposer: case Action.FocusSendMessageComposer:
if (payload.context === this.context.timelineRenderingType) {
this.editorRef.current?.focus(); this.editorRef.current?.focus();
}
break; break;
case "send_composer_insert": case "send_composer_insert":
if (payload.userId) { if (payload.userId) {

View file

@ -20,11 +20,11 @@ import { IRoomState } from "../components/structures/RoomView";
import { Layout } from "../settings/Layout"; import { Layout } from "../settings/Layout";
export enum TimelineRenderingType { export enum TimelineRenderingType {
Room, Room = "Room",
Thread, Thread = "Thread",
ThreadsList, ThreadsList = "ThreadsList",
File, File = "File",
Notification, Notification = "Notification",
} }
const RoomContext = createContext<IRoomState>({ const RoomContext = createContext<IRoomState>({

View file

@ -32,6 +32,7 @@ import { retry } from "../utils/promise";
import CountlyAnalytics from "../CountlyAnalytics"; import CountlyAnalytics from "../CountlyAnalytics";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { TimelineRenderingType } from "../contexts/RoomContext";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
@ -153,7 +154,9 @@ class RoomViewStore extends Store<ActionPayload> {
case 'reply_to_event': case 'reply_to_event':
// If currently viewed room does not match the room in which we wish to reply then change rooms // If currently viewed room does not match the room in which we wish to reply then change rooms
// this can happen when performing a search across all rooms // this can happen when performing a search across all rooms
if (payload.event && payload.event.getRoomId() !== this.state.roomId) { if (payload.context === TimelineRenderingType.Room) {
if (payload.event
&& payload.event.getRoomId() !== this.state.roomId) {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: payload.event.getRoomId(), room_id: payload.event.getRoomId(),
@ -164,6 +167,7 @@ class RoomViewStore extends Store<ActionPayload> {
replyingToEvent: payload.event, replyingToEvent: payload.event,
}); });
} }
}
break; break;
case 'open_room_settings': { case 'open_room_settings': {
// FIXME: Using an import will result in test failures // FIXME: Using an import will result in test failures

View file

@ -216,6 +216,7 @@ describe('<SendMessageComposer/>', () => {
expect(spyDispatcher).toHaveBeenCalledWith({ expect(spyDispatcher).toHaveBeenCalledWith({
action: "reply_to_event", action: "reply_to_event",
event: mockEvent, event: mockEvent,
context: TimelineRenderingType.Room,
}); });
// now try with localStorage wiped out // now try with localStorage wiped out
@ -277,6 +278,7 @@ describe('<SendMessageComposer/>', () => {
expect(spyDispatcher).toHaveBeenCalledWith({ expect(spyDispatcher).toHaveBeenCalledWith({
action: "reply_to_event", action: "reply_to_event",
event: null, event: null,
context: TimelineRenderingType.Room,
}); });
expect(wrapper.text()).toBe(""); expect(wrapper.text()).toBe("");