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 requirementspull/21833/head
parent
6bb47ec710
commit
562a880c7d
|
@ -203,6 +203,7 @@
|
|||
@import "./views/right_panel/_UserInfo.scss";
|
||||
@import "./views/right_panel/_VerificationPanel.scss";
|
||||
@import "./views/right_panel/_WidgetCard.scss";
|
||||
@import "./views/right_panel/_ThreadPanel.scss";
|
||||
@import "./views/room_settings/_AliasSettings.scss";
|
||||
@import "./views/rooms/_AppsDrawer.scss";
|
||||
@import "./views/rooms/_Autocomplete.scss";
|
||||
|
|
|
@ -79,6 +79,10 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_RightPanel_threadsButton::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/thread.svg');
|
||||
}
|
||||
|
||||
.mx_RightPanel_notifsButton::before {
|
||||
mask-image: url('$(res)/img/element-icons/notifications.svg');
|
||||
mask-position: center;
|
||||
|
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
|
||||
.mx_ThreadPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.mx_BaseCard_header {
|
||||
padding: 6px 0;
|
||||
|
||||
.mx_BaseCard_close {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton.mx_BaseCard_back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
width: calc(100% - 40px);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
|
||||
span:first-of-type {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
font-size: 12px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
|
||||
.mx_ContextualMenu_wrapper {
|
||||
// It's added here due to some weird error if I pass it directly in the style, even though it's a numeric value, so it's being passed 0 instead.
|
||||
// The error: react_devtools_backend.js:2526 Warning: `NaN` is an invalid value for the `top` css style property.
|
||||
top: 43px;
|
||||
}
|
||||
|
||||
.mx_ContextualMenu {
|
||||
position: initial;
|
||||
span:first-of-type {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: $primary-content;
|
||||
}
|
||||
|
||||
font-size: 12px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
|
||||
.mx_ThreadPanel_Header_FilterOptionItem {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
padding-left: 30px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: $event-selected-color;
|
||||
}
|
||||
&[aria-selected="true"] {
|
||||
&::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
mask-image: url("$(res)/img/feather-customised/check.svg");
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 10px;
|
||||
background-color: $primary-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomView_messageListWrapper {
|
||||
background-color: $background;
|
||||
border-radius: 8px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.mx_ScrollPanel {
|
||||
.mx_RoomView_MessageList {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile, .mx_EventListSummary {
|
||||
// Account for scrollbar when hovering
|
||||
width: calc(100% - 3px);
|
||||
margin: 0 2px;
|
||||
|
||||
.mx_MessageTimestamp {
|
||||
// We need to add !important here due to some enormous selectors overriding it anyways
|
||||
// See: _EventTile.scss:241
|
||||
left: unset !important;
|
||||
right: 0 !important;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.mx_EventTile_line.mx_EventTile_line {
|
||||
position: unset;
|
||||
}
|
||||
|
||||
.mx_ThreadInfo {
|
||||
position: relative;
|
||||
padding-right: 11px;
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -16px;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid $message-action-bar-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_DateSeparator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#17191C" fill-rule="evenodd" d="M2 5a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7.667a1 1 0 0 0-.6.2L3.6 22.8A1 1 0 0 1 2 22V5Zm3 4a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Zm1 3a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H6Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 357 B |
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 you’ve 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;
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
) }
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -21,9 +21,10 @@ import { Layout } from "../settings/Layout";
|
|||
|
||||
export enum TimelineRenderingType {
|
||||
Room,
|
||||
Thread,
|
||||
ThreadsList,
|
||||
File,
|
||||
Notification,
|
||||
Thread
|
||||
}
|
||||
|
||||
const RoomContext = createContext<IRoomState>({
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { Action } from "../actions";
|
||||
import dis from '../dispatcher';
|
||||
import { SetRightPanelPhasePayload } from "../payloads/SetRightPanelPhasePayload";
|
||||
|
||||
export const dispatchShowThreadEvent = (event: MatrixEvent) => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.ThreadView,
|
||||
refireParams: {
|
||||
event,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const dispatchShowThreadsPanelEvent = () => {
|
||||
dis.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.ThreadPanel,
|
||||
});
|
||||
};
|
||||
|
|
@ -1831,6 +1831,7 @@
|
|||
"Nothing pinned, yet": "Nothing pinned, yet",
|
||||
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
|
||||
"Pinned messages": "Pinned messages",
|
||||
"Threads": "Threads",
|
||||
"Room Info": "Room Info",
|
||||
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
|
||||
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
|
||||
|
@ -2974,6 +2975,11 @@
|
|||
"You can add more later too, including already existing ones.": "You can add more later too, including already existing ones.",
|
||||
"What projects are you working on?": "What projects are you working on?",
|
||||
"We'll create rooms for each of them. You can add more later too, including already existing ones.": "We'll create rooms for each of them. You can add more later too, including already existing ones.",
|
||||
"My threads": "My threads",
|
||||
"Shows all threads you’ve participated in": "Shows all threads you’ve participated in",
|
||||
"All threads": "All threads",
|
||||
"Shows all threads from current room": "Shows all threads from current room",
|
||||
"Show:": "Show:",
|
||||
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
|
||||
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
|
||||
"Failed to load timeline position": "Failed to load timeline position",
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
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 { shallow, mount, configure } from "enzyme";
|
||||
import '../../skinned-sdk';
|
||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
||||
|
||||
import {
|
||||
ThreadFilterType,
|
||||
ThreadPanelHeader,
|
||||
ThreadPanelHeaderFilterOptionItem,
|
||||
} from '../../../src/components/structures/ThreadPanel';
|
||||
import { ContextMenuButton } from '../../../src/accessibility/context_menu/ContextMenuButton';
|
||||
import ContextMenu from '../../../src/components/structures/ContextMenu';
|
||||
import { _t } from '../../../src/languageHandler';
|
||||
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
describe('ThreadPanel', () => {
|
||||
describe('Header', () => {
|
||||
it('expect that All filter for ThreadPanelHeader properly renders Show: All threads', () => {
|
||||
const wrapper = shallow(
|
||||
<ThreadPanelHeader
|
||||
filterOption={ThreadFilterType.All}
|
||||
setFilterOption={() => undefined} />,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('expect that My filter for ThreadPanelHeader properly renders Show: My threads', () => {
|
||||
const wrapper = shallow(
|
||||
<ThreadPanelHeader
|
||||
filterOption={ThreadFilterType.My}
|
||||
setFilterOption={() => undefined} />,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('expect that ThreadPanelHeader properly opens a context menu when clicked on the button', () => {
|
||||
const wrapper = mount(
|
||||
<ThreadPanelHeader
|
||||
filterOption={ThreadFilterType.All}
|
||||
setFilterOption={() => undefined} />,
|
||||
);
|
||||
const found = wrapper.find(ContextMenuButton);
|
||||
expect(found).not.toBe(undefined);
|
||||
expect(found).not.toBe(null);
|
||||
expect(wrapper.exists(ContextMenu)).toEqual(false);
|
||||
found.simulate('click');
|
||||
expect(wrapper.exists(ContextMenu)).toEqual(true);
|
||||
});
|
||||
|
||||
it('expect that ThreadPanelHeader has the correct option selected in the context menu', () => {
|
||||
const wrapper = mount(
|
||||
<ThreadPanelHeader
|
||||
filterOption={ThreadFilterType.All}
|
||||
setFilterOption={() => undefined} />,
|
||||
);
|
||||
wrapper.find(ContextMenuButton).simulate('click');
|
||||
const found = wrapper.find(ThreadPanelHeaderFilterOptionItem);
|
||||
expect(found.length).toEqual(2);
|
||||
const foundButton = found.find('[aria-selected=true]').first();
|
||||
expect(foundButton.text()).toEqual(`${_t("All threads")}${_t('Shows all threads from current room')}`);
|
||||
expect(foundButton).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = `
|
||||
<div
|
||||
className="mx_ThreadPanel__header"
|
||||
>
|
||||
<span>
|
||||
Threads
|
||||
</span>
|
||||
<ContextMenuButton
|
||||
inputRef={
|
||||
Object {
|
||||
"current": null,
|
||||
}
|
||||
}
|
||||
isExpanded={false}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Show: All threads
|
||||
</ContextMenuButton>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly renders Show: My threads 1`] = `
|
||||
<div
|
||||
className="mx_ThreadPanel__header"
|
||||
>
|
||||
<span>
|
||||
Threads
|
||||
</span>
|
||||
<ContextMenuButton
|
||||
inputRef={
|
||||
Object {
|
||||
"current": null,
|
||||
}
|
||||
}
|
||||
isExpanded={false}
|
||||
onClick={[Function]}
|
||||
>
|
||||
Show: My threads
|
||||
</ContextMenuButton>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
|
||||
<AccessibleButton
|
||||
aria-selected={true}
|
||||
className="mx_ThreadPanel_Header_FilterOptionItem"
|
||||
element="div"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
aria-selected={true}
|
||||
className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>
|
||||
All threads
|
||||
</span>
|
||||
<span>
|
||||
Shows all threads from current room
|
||||
</span>
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
`;
|
Loading…
Reference in New Issue