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
pull/21833/head
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

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

View File

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

View File

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

View File

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

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;

View File

@ -21,9 +21,10 @@ import { Layout } from "../settings/Layout";
export enum TimelineRenderingType {
Room,
Thread,
ThreadsList,
File,
Notification,
Thread
}
const RoomContext = createContext<IRoomState>({

View File

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

View File

@ -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 youve participated in": "Shows all threads youve 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",

View File

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

View File

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