Poll history: detail screen (#10172)
* basic navigation to focused poll * add tooltip * drill permalinkCreator down to poll history * render poll tile and link to timeline * tidy and lint * unit test poll detail * add view poll link to ended pollliste item * strict fix * pr improvements * pass room as prop * permalinkcreator ts assertion
This commit is contained in:
parent
9b2b3ca42e
commit
f57495d3cd
21 changed files with 588 additions and 104 deletions
|
@ -262,7 +262,14 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
card = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
|
||||
card = (
|
||||
<RoomSummaryCard
|
||||
room={this.props.room}
|
||||
onClose={this.onClose}
|
||||
// whenever RightPanel is passed a room it is passed a permalinkcreator
|
||||
permalinkCreator={this.props.permalinkCreator!}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Widget:
|
||||
|
|
89
src/components/views/dialogs/polls/PollDetail.tsx
Normal file
89
src/components/views/dialogs/polls/PollDetail.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
Copyright 2023 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 { Poll } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import dispatcher from "../../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks";
|
||||
import { MediaEventHelper } from "../../../../utils/MediaEventHelper";
|
||||
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
import MPollBody from "../../messages/MPollBody";
|
||||
|
||||
interface Props {
|
||||
poll: Poll;
|
||||
requestModalClose: () => void;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
const NOOP = (): void => {};
|
||||
|
||||
/**
|
||||
* Content of the PollHistoryDialog when a specific poll is selected
|
||||
*/
|
||||
export const PollDetail: React.FC<Props> = ({ poll, permalinkCreator, requestModalClose }) => {
|
||||
// link to end event for ended polls
|
||||
const eventIdToLinkTo = poll.isEnded ? poll.endEventId! : poll.pollId;
|
||||
const linkToTimeline = permalinkCreator.forEvent(eventIdToLinkTo);
|
||||
|
||||
const onLinkClick = (e: ButtonEvent): void => {
|
||||
if ((e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey) {
|
||||
// native behavior for link on ctrl/cmd + click
|
||||
return;
|
||||
}
|
||||
// otherwise handle navigation in the app
|
||||
e.preventDefault();
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: eventIdToLinkTo,
|
||||
highlighted: true,
|
||||
room_id: poll.roomId,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
|
||||
requestModalClose();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<MPollBody
|
||||
mxEvent={poll.rootEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
onHeightChanged={NOOP}
|
||||
onMessageAllowed={NOOP}
|
||||
// MPollBody doesn't use this
|
||||
// and MessageEvent only defines it for eligible events
|
||||
// should be fixed on IBodyProps types
|
||||
// cheat to fulfil the type here
|
||||
mediaEventHelper={{} as unknown as MediaEventHelper}
|
||||
/>
|
||||
<br />
|
||||
<div>
|
||||
<AccessibleButton
|
||||
kind="link_inline"
|
||||
element="a"
|
||||
href={linkToTimeline}
|
||||
onClick={onLinkClick}
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{_t("View poll in timeline")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
36
src/components/views/dialogs/polls/PollDetailHeader.tsx
Normal file
36
src/components/views/dialogs/polls/PollDetailHeader.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
Copyright 2023 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 { Icon as LeftCaretIcon } from "../../../../../res/img/element-icons/caret-left.svg";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import { PollHistoryFilter } from "./types";
|
||||
|
||||
interface Props {
|
||||
filter: PollHistoryFilter;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export const PollDetailHeader: React.FC<Props> = ({ filter, onNavigateBack }) => {
|
||||
return (
|
||||
<AccessibleButton kind="content_inline" onClick={onNavigateBack} className="mx_PollDetailHeader">
|
||||
<LeftCaretIcon className="mx_PollDetailHeader_icon" />
|
||||
{filter === "ACTIVE" ? _t("Active polls") : _t("Past polls")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
|
@ -16,19 +16,23 @@ limitations under the License.
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixEvent, Poll, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import BaseDialog from "../BaseDialog";
|
||||
import { IDialogProps } from "../IDialogProps";
|
||||
import { PollHistoryList } from "./PollHistoryList";
|
||||
import { PollHistoryFilter } from "./types";
|
||||
import { PollDetailHeader } from "./PollDetailHeader";
|
||||
import { PollDetail } from "./PollDetail";
|
||||
import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks";
|
||||
import { usePollsWithRelations } from "./usePollHistory";
|
||||
import { useFetchPastPolls } from "./fetchPastPolls";
|
||||
|
||||
type PollHistoryDialogProps = Pick<IDialogProps, "onFinished"> & {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
matrixClient: MatrixClient;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
};
|
||||
|
||||
const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => right.getTs() - left.getTs();
|
||||
|
@ -46,25 +50,42 @@ const filterAndSortPolls = (polls: Map<string, Poll>, filter: PollHistoryFilter)
|
|||
.sort(sortEventsByLatest);
|
||||
};
|
||||
|
||||
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ roomId, matrixClient, onFinished }) => {
|
||||
const room = matrixClient.getRoom(roomId)!;
|
||||
const { isLoading } = useFetchPastPolls(room, matrixClient);
|
||||
const { polls } = usePollsWithRelations(roomId, matrixClient);
|
||||
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({
|
||||
room,
|
||||
matrixClient,
|
||||
permalinkCreator,
|
||||
onFinished,
|
||||
}) => {
|
||||
const { polls } = usePollsWithRelations(room.roomId, matrixClient);
|
||||
const [filter, setFilter] = useState<PollHistoryFilter>("ACTIVE");
|
||||
const [focusedPollId, setFocusedPollId] = useState<string | null>(null);
|
||||
const { isLoading } = useFetchPastPolls(room, matrixClient);
|
||||
|
||||
const pollStartEvents = filterAndSortPolls(polls, filter);
|
||||
const isLoadingPollResponses = [...polls.values()].some((poll) => poll.isFetchingResponses);
|
||||
|
||||
const focusedPoll = focusedPollId ? polls.get(focusedPollId) : undefined;
|
||||
const title = focusedPoll ? (
|
||||
<PollDetailHeader filter={filter} onNavigateBack={() => setFocusedPollId(null)} />
|
||||
) : (
|
||||
_t("Polls history")
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
|
||||
<BaseDialog title={title} onFinished={onFinished}>
|
||||
<div className="mx_PollHistoryDialog_content">
|
||||
<PollHistoryList
|
||||
pollStartEvents={pollStartEvents}
|
||||
isLoading={isLoading || isLoadingPollResponses}
|
||||
polls={polls}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
{focusedPoll ? (
|
||||
<PollDetail poll={focusedPoll} permalinkCreator={permalinkCreator} requestModalClose={onFinished} />
|
||||
) : (
|
||||
<PollHistoryList
|
||||
pollStartEvents={pollStartEvents}
|
||||
isLoading={isLoading || isLoadingPollResponses}
|
||||
polls={polls}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
onItemClick={setFocusedPollId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -40,8 +40,9 @@ type PollHistoryListProps = {
|
|||
pollStartEvents: MatrixEvent[];
|
||||
polls: Map<string, Poll>;
|
||||
filter: PollHistoryFilter;
|
||||
onFilterChange: (filter: PollHistoryFilter) => void;
|
||||
isLoading?: boolean;
|
||||
onFilterChange: (filter: PollHistoryFilter) => void;
|
||||
onItemClick: (pollId: string) => void;
|
||||
};
|
||||
export const PollHistoryList: React.FC<PollHistoryListProps> = ({
|
||||
pollStartEvents,
|
||||
|
@ -49,6 +50,7 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({
|
|||
filter,
|
||||
isLoading,
|
||||
onFilterChange,
|
||||
onItemClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mx_PollHistoryList">
|
||||
|
@ -65,12 +67,17 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({
|
|||
<ol className={classNames("mx_PollHistoryList_list", `mx_PollHistoryList_list_${filter}`)}>
|
||||
{pollStartEvents.map((pollStartEvent) =>
|
||||
filter === "ACTIVE" ? (
|
||||
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
|
||||
<PollListItem
|
||||
key={pollStartEvent.getId()!}
|
||||
event={pollStartEvent}
|
||||
onClick={() => onItemClick(pollStartEvent.getId()!)}
|
||||
/>
|
||||
) : (
|
||||
<PollListItemEnded
|
||||
key={pollStartEvent.getId()!}
|
||||
event={pollStartEvent}
|
||||
poll={polls.get(pollStartEvent.getId()!)!}
|
||||
onClick={() => onItemClick(pollStartEvent.getId()!)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
|
|
@ -20,22 +20,30 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
|||
|
||||
import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg";
|
||||
import { formatLocalDateShort } from "../../../../DateUtils";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import TooltipTarget from "../../elements/TooltipTarget";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
|
||||
interface Props {
|
||||
event: MatrixEvent;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const PollListItem: React.FC<Props> = ({ event }) => {
|
||||
export const PollListItem: React.FC<Props> = ({ event, onClick }) => {
|
||||
const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent;
|
||||
if (!pollEvent) {
|
||||
return null;
|
||||
}
|
||||
const formattedDate = formatLocalDateShort(event.getTs());
|
||||
return (
|
||||
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItem">
|
||||
<span>{formattedDate}</span>
|
||||
<PollIcon className="mx_PollListItem_icon" />
|
||||
<span className="mx_PollListItem_question">{pollEvent.question.text}</span>
|
||||
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItem" onClick={onClick}>
|
||||
<TooltipTarget label={_t("View poll")} alignment={Alignment.Top}>
|
||||
<div className="mx_PollListItem_content">
|
||||
<span>{formattedDate}</span>
|
||||
<PollIcon className="mx_PollListItem_icon" />
|
||||
<span className="mx_PollListItem_question">{pollEvent.question.text}</span>
|
||||
</div>
|
||||
</TooltipTarget>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -25,10 +25,13 @@ import { formatLocalDateShort } from "../../../../DateUtils";
|
|||
import { allVotes, collectUserVotes, countVotes } from "../../messages/MPollBody";
|
||||
import { PollOption } from "../../polls/PollOption";
|
||||
import { Caption } from "../../typography/Caption";
|
||||
import TooltipTarget from "../../elements/TooltipTarget";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
|
||||
interface Props {
|
||||
event: MatrixEvent;
|
||||
poll: Poll;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
type EndedPollState = {
|
||||
|
@ -88,7 +91,7 @@ const usePollVotes = (poll: Poll): Partial<EndedPollState> => {
|
|||
* @param event - the poll start MatrixEvent
|
||||
* @param poll - Poll instance
|
||||
*/
|
||||
export const PollListItemEnded: React.FC<Props> = ({ event, poll }) => {
|
||||
export const PollListItemEnded: React.FC<Props> = ({ event, poll, onClick }) => {
|
||||
const pollEvent = poll.pollEvent;
|
||||
const { winningAnswers, totalVoteCount } = usePollVotes(poll);
|
||||
if (!pollEvent) {
|
||||
|
@ -97,31 +100,35 @@ export const PollListItemEnded: React.FC<Props> = ({ event, poll }) => {
|
|||
const formattedDate = formatLocalDateShort(event.getTs());
|
||||
|
||||
return (
|
||||
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItemEnded">
|
||||
<div className="mx_PollListItemEnded_title">
|
||||
<PollIcon className="mx_PollListItemEnded_icon" />
|
||||
<span className="mx_PollListItemEnded_question">{pollEvent.question.text}</span>
|
||||
<Caption>{formattedDate}</Caption>
|
||||
</div>
|
||||
{!!winningAnswers?.length && (
|
||||
<div className="mx_PollListItemEnded_answers">
|
||||
{winningAnswers?.map(({ answer, voteCount }) => (
|
||||
<PollOption
|
||||
key={answer.id}
|
||||
answer={answer}
|
||||
voteCount={voteCount}
|
||||
totalVoteCount={totalVoteCount!}
|
||||
pollId={poll.pollId}
|
||||
displayVoteCount
|
||||
isChecked
|
||||
isEnded
|
||||
/>
|
||||
))}
|
||||
<li data-testid={`pollListItem-${event.getId()!}`} className="mx_PollListItemEnded" onClick={onClick}>
|
||||
<TooltipTarget label={_t("View poll")} alignment={Alignment.Top}>
|
||||
<div className="mx_PollListItemEnded_content">
|
||||
<div className="mx_PollListItemEnded_title">
|
||||
<PollIcon className="mx_PollListItemEnded_icon" />
|
||||
<span className="mx_PollListItemEnded_question">{pollEvent.question.text}</span>
|
||||
<Caption>{formattedDate}</Caption>
|
||||
</div>
|
||||
{!!winningAnswers?.length && (
|
||||
<div className="mx_PollListItemEnded_answers">
|
||||
{winningAnswers?.map(({ answer, voteCount }) => (
|
||||
<PollOption
|
||||
key={answer.id}
|
||||
answer={answer}
|
||||
voteCount={voteCount}
|
||||
totalVoteCount={totalVoteCount!}
|
||||
pollId={poll.pollId}
|
||||
displayVoteCount
|
||||
isChecked
|
||||
isEnded
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mx_PollListItemEnded_voteCount">
|
||||
<Caption>{_t("Final result based on %(count)s votes", { count: totalVoteCount })}</Caption>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mx_PollListItemEnded_voteCount">
|
||||
<Caption>{_t("Final result based on %(count)s votes", { count: totalVoteCount })}</Caption>
|
||||
</div>
|
||||
</TooltipTarget>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -37,6 +37,7 @@ import WidgetAvatar from "../avatars/WidgetAvatar";
|
|||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
|
||||
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
|
@ -55,6 +56,7 @@ import { PollHistoryDialog } from "../dialogs/polls/PollHistoryDialog";
|
|||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
|
@ -268,7 +270,7 @@ const onRoomSettingsClick = (ev: ButtonEvent): void => {
|
|||
PosthogTrackers.trackInteraction("WebRightPanelRoomInfoSettingsButton", ev);
|
||||
};
|
||||
|
||||
const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
||||
const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onShareRoomClick = (): void => {
|
||||
|
@ -285,8 +287,9 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
|||
|
||||
const onRoomPollHistoryClick = (): void => {
|
||||
Modal.createDialog(PollHistoryDialog, {
|
||||
roomId: room.roomId,
|
||||
room,
|
||||
matrixClient: cli,
|
||||
permalinkCreator,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue