Create room threads list view (#6904)

Implement https://github.com/vector-im/element-web/issues/18957 following requirements:
* Create a new right panel view to list all the threads in a given room.
* Change ThreadView previous phase to be ThreadPanel rather than RoomSummary
* Implement local filters for My and All threads

In addition: 
* Create a new TileShape for proper rendering requirements (hiding typing indicator)
* Create new timelineRenderingType for proper rendering requirements
This commit is contained in:
Dariusz Niemczyk 2021-10-14 15:27:35 +02:00 committed by GitHub
parent 6bb47ec710
commit 562a880c7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 623 additions and 121 deletions

View file

@ -26,7 +26,7 @@ import shouldHideEvent from '../../shouldHideEvent';
import { wantsDateSeparator } from '../../DateUtils';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import RoomContext from "../../contexts/RoomContext";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/Layout";
import { _t } from "../../languageHandler";
import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
@ -66,7 +66,9 @@ export function shouldFormContinuation(
prevEvent: MatrixEvent,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
timelineRenderingType?: TimelineRenderingType,
): boolean {
if (timelineRenderingType === TimelineRenderingType.ThreadsList) return false;
// sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period
@ -722,7 +724,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// is this a continuation of the previous message?
const continuation = !wantsDateSeparator &&
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents, this.context.timelineRenderingType);
const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId);
@ -794,6 +796,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}
public wantsDateSeparator(prevEvent: MatrixEvent, nextEventDate: Date): boolean {
if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
return false;
}
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.

View file

@ -53,7 +53,7 @@ import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { dispatchShowThreadsPanelEvent } from '../../dispatcher/dispatch-actions/threads';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
@ -199,10 +199,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
const isChangingRoom = payload.action === 'view_room' && payload.room_id !== this.props.room.roomId;
const isViewingThread = this.state.phase === RightPanelPhases.ThreadView;
if (isChangingRoom && isViewingThread) {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
dispatchShowThreadsPanelEvent();
}
if (payload.action === Action.AfterRightPanelPhaseChange) {

View file

@ -14,17 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
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 { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { Room } from 'matrix-js-sdk/src/models/room';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile from '../views/rooms/EventTile';
import EventTile, { TileShape } from '../views/rooms/EventTile';
import MatrixClientContext from '../../contexts/MatrixClientContext';
import { _t } from '../../languageHandler';
import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
import ContextMenu, { useContextMenu } from './ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/Layout';
import { useEventEmitter } from '../../hooks/useEventEmitter';
import AccessibleButton from '../views/elements/AccessibleButton';
interface IProps {
roomId: string;
@ -32,62 +41,199 @@ interface IProps {
resizeNotifier: ResizeNotifier;
}
interface IState {
threads?: Thread[];
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 {
"My",
"All"
}
@replaceableComponent("structures.ThreadView")
export default class ThreadPanel extends React.Component<IProps, IState> {
private room: Room;
type ThreadPanelHeaderOption = {
label: string;
description: string;
key: ThreadFilterType;
};
constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
}
const useFilteredThreadsTimelinePanel = ({
threads,
room,
filterOption,
userId,
updateTimeline,
}: {
threads: Set<Thread>;
room: Room;
userId: string;
filterOption: ThreadFilterType;
updateTimeline: () => void;
}) => {
const timelineSet = useMemo(() => new EventTimelineSet(room, {
unstableClientRelationAggregation: true,
timelineSupport: true,
}), [room]);
public componentDidMount(): void {
this.room.on(ThreadEvent.Update, this.onThreadEventReceived);
this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
}
useEffect(() => {
let filteredThreads = Array.from(threads);
if (filterOption === ThreadFilterType.My) {
filteredThreads = filteredThreads.filter(thread => {
return thread.rootEvent.getSender() === userId;
});
}
// NOTE: Temporarily reverse the list until https://github.com/vector-im/element-web/issues/19393 gets properly resolved
// The proper list order should be top-to-bottom, like in social-media newsfeeds.
filteredThreads.reverse().forEach(thread => {
const event = thread.rootEvent;
if (timelineSet.findEventById(event.getId()) || event.status !== null) return;
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
true,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, timelineSet]);
public componentWillUnmount(): void {
this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived);
}
useEventEmitter(room, ThreadEvent.Update, (thread) => {
const event = thread.rootEvent;
if (
// If that's a reply and not an event
event !== thread.replyToEvent &&
timelineSet.findEventById(event.getId()) ||
event.status !== null
) return;
if (event !== thread.events[thread.events.length - 1]) {
timelineSet.removeEvent(thread.events[thread.events.length - 1]);
timelineSet.removeEvent(event);
}
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
false,
);
updateTimeline();
});
private onThreadEventReceived = () => this.updateThreads();
return timelineSet;
};
private updateThreads = (callback?: () => void): void => {
this.setState({
threads: this.room.getThreads(),
}, callback);
};
export const ThreadPanelHeaderFilterOptionItem = ({
label,
description,
onClick,
isSelected,
}: ThreadPanelHeaderOption & {
onClick: () => void;
isSelected: boolean;
}) => {
return <AccessibleButton
aria-selected={isSelected}
className="mx_ThreadPanel_Header_FilterOptionItem"
onClick={onClick}
>
<span>{ label }</span>
<span>{ description }</span>
</AccessibleButton>;
};
private renderEventTile(event: MatrixEvent): JSX.Element {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
filterOption: ThreadFilterType;
setFilterOption: (filterOption: ThreadFilterType) => void;
}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("My threads"),
description: _t("Shows all threads youve participated in"),
key: ThreadFilterType.My,
},
{
label: _t("All threads"),
description: _t('Shows all threads from current room'),
key: ThreadFilterType.All,
},
];
public render(): JSX.Element {
return (
const value = options.find(option => option.key === filterOption);
const contextMenuOptions = options.map(opt => <ThreadPanelHeaderFilterOptionItem
key={opt.key}
label={opt.label}
description={opt.description}
onClick={() => {
setFilterOption(opt.key);
closeMenu();
}}
isSelected={opt === value}
/>);
const contextMenu = menuDisplayed ? <ContextMenu top={0} right={25} onFinished={closeMenu} managed={false}>
{ contextMenuOptions }
</ContextMenu> : null;
return <div className="mx_ThreadPanel__header">
<span>{ _t("Threads") }</span>
<ContextMenuButton inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
{ `${_t('Show:')} ${value.label}` }
</ContextMenuButton>
{ contextMenu }
</div>;
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
const mxClient = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const room = mxClient.getRoom(roomId);
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
const ref = useRef<TimelinePanel>();
const filteredTimelineSet = useFilteredThreadsTimelinePanel({
threads: room.threads,
room,
filterOption,
userId: mxClient.getUserId(),
updateTimeline: () => ref.current?.refreshTimeline(),
});
return (
<RoomContext.Provider value={{
...roomContext,
timelineRenderingType: TimelineRenderingType.ThreadsList,
liveTimeline: filteredTimelineSet.getLiveTimeline(),
showHiddenEventsInTimeline: true,
}}>
<BaseCard
header={<ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
className="mx_ThreadPanel"
onClose={this.props.onClose}
onClose={onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
<TimelinePanel
ref={ref}
showReadReceipts={false} // No RR support in thread's MVP
manageReadReceipts={false} // No RR support in thread's MVP
manageReadMarkers={false} // No RM support in thread's MVP
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
timelineSet={filteredTimelineSet}
showUrlPreview={true}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout"
membersLoaded={true}
tileShape={TileShape.ThreadPanel}
/>
</BaseCard>
);
}
}
</RoomContext.Provider>
);
};
export default ThreadPanel;

View file

@ -156,7 +156,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
previousPhase={RightPanelPhases.ThreadPanel}
withoutScrollContainer={true}
>
{ this.state.thread && (

View file

@ -31,6 +31,8 @@ import RightPanelStore from "../../../stores/RightPanelStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { useSettingValue } from "../../../hooks/useSettings";
import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
import { dispatchShowThreadsPanelEvent } from "../../../dispatcher/dispatch-actions/threads";
import SettingsStore from "../../../settings/SettingsStore";
const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary,
@ -122,6 +124,13 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
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']}
/> }
<HeaderButton
name="notifsButton"
title={_t('Notifications')}

View file

@ -48,6 +48,7 @@ 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;
@ -221,13 +222,6 @@ const onRoomFilesClick = () => {
});
};
const onRoomThreadsClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
};
const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
@ -291,7 +285,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
{ _t("Export chat") }
</Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
<Button className="mx_RoomSummaryCard_icon_threads" onClick={dispatchShowThreadsPanelEvent}>
{ _t("Show threads") }
</Button>
) }

View file

@ -56,9 +56,9 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
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";
import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/threads';
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -193,6 +193,7 @@ export enum TileShape {
FileGrid = "file_grid",
Pinned = "pinned",
Thread = "thread",
ThreadPanel = "thread_list"
}
interface IProps {
@ -511,6 +512,10 @@ export default class EventTile extends React.Component<IProps, IState> {
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.off(ThreadEvent.Ready, this.updateThread);
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
}
}
componentDidUpdate(prevProps, prevState, snapshot) {
@ -541,13 +546,7 @@ export default class EventTile extends React.Component<IProps, IState> {
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
dispatchShowThreadEvent(this.props.mxEvent);
}}
>
<span className="mx_EventListSummary_avatars">

View file

@ -155,58 +155,55 @@ export default class RoomHeader extends React.Component<IProps> {
/>;
}
let forgetButton;
if (this.props.onForgetClick) {
forgetButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")} />;
}
const buttons: JSX.Element[] = [];
let appsButton;
if (this.props.onAppsClick) {
appsButton =
<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")} />;
}
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")} />;
}
let voiceCallButton;
let videoCallButton;
if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) {
voiceCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
title={_t("Voice call")} />;
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />;
const voiceCallButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
title={_t("Voice call")}
/>;
const videoCallButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")}
/>;
buttons.push(voiceCallButton, videoCallButton);
}
if (this.props.onForgetClick) {
const forgetButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
/>;
buttons.push(forgetButton);
}
if (this.props.onAppsClick) {
const appsButton = <AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
/>;
buttons.push(appsButton);
}
if (this.props.onSearchClick && this.props.inRoom) {
const searchButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
/>;
buttons.push(searchButton);
}
const rightRow =
<div className="mx_RoomHeader_buttons">
{ videoCallButton }
{ voiceCallButton }
{ forgetButton }
{ appsButton }
{ searchButton }
{ buttons }
</div>;
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;