Poll history - read only list of polls in current timeline (#10055)

* add settings while under development

* very basic tests for roomsummarycard

* empty poll history dialog and option in room summary

* pollS history in settings

* render an ugly list of polls in current timeline

* readonly poll history list items

* fix scroll window

* use short year code in date format, tidy

* no results message + tests

* strict fix

* mock intldatetimeformat for stable date formatting

* extract date format fn into date-utils

* jsdoc
This commit is contained in:
Kerry 2023-02-03 10:39:23 +13:00 committed by GitHub
parent 544baa30ed
commit ebb8408f28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 572 additions and 7 deletions

View file

@ -17,6 +17,7 @@
@import "./components/views/beacon/_ShareLatestLocation.pcss";
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/dialogs/polls/_PollListItem.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/elements/_LearnMore.pcss";
@import "./components/views/location/_EnableLiveShare.pcss";
@ -161,6 +162,8 @@
@import "./views/dialogs/_UserSettingsDialog.pcss";
@import "./views/dialogs/_VerifyEMailDialog.pcss";
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
@import "./views/dialogs/polls/_PollHistoryDialog.pcss";
@import "./views/dialogs/polls/_PollHistoryList.pcss";
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss";

View file

@ -0,0 +1,40 @@
/*
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.
*/
.mx_PollListItem {
width: 100%;
display: grid;
justify-content: left;
align-items: center;
grid-gap: $spacing-8;
grid-template-columns: auto auto auto;
grid-template-rows: auto;
color: $primary-content;
}
.mx_PollListItem_icon {
height: 14px;
width: 14px;
color: $quaternary-content;
padding-left: $spacing-8;
}
.mx_PollListItem_question {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,23 @@
/*
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.
*/
.mx_PollHistoryDialog_content {
height: 600px;
width: 100%;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,44 @@
/*
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.
*/
.mx_PollHistoryList {
display: flex;
flex-direction: column;
flex: 1 1 auto;
max-height: 100%;
}
.mx_PollHistoryList_list {
overflow: auto;
list-style: none;
margin-block: 0;
padding-inline: 0;
flex: 1 1 0;
align-content: flex-start;
display: grid;
grid-gap: $spacing-20;
padding-right: $spacing-64;
margin: $spacing-32 0;
}
.mx_PollHistoryList_noResults {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: $secondary-content;
}

View file

@ -269,3 +269,16 @@ export function formatPreciseDuration(durationMs: number): string {
}
return _t("%(value)ss", { value: seconds });
}
/**
* Formats a timestamp to a short date
* (eg 25/12/22 in uk locale)
* localised by system locale
* @param timestamp - epoch timestamp
* @returns {string} formattedDate
*/
export const formatLocalDateShort = (timestamp: number): string =>
new Intl.DateTimeFormat(
undefined, // locales
{ day: "2-digit", month: "2-digit", year: "2-digit" },
).format(timestamp);

View file

@ -15,19 +15,26 @@ limitations under the License.
*/
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import { IDialogProps } from "../IDialogProps";
import { PollHistoryList } from "./PollHistoryList";
import { getPolls } from "./usePollHistory";
type PollHistoryDialogProps = Pick<IDialogProps, "onFinished"> & {
roomId: string;
matrixClient: MatrixClient;
};
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ roomId, matrixClient, onFinished }) => {
const pollStartEvents = getPolls(roomId, matrixClient);
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ onFinished }) => {
return (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
{/* @TODO(kerrya) to be implemented in PSG-906 */}
<div className="mx_PollHistoryDialog_content">
<PollHistoryList pollStartEvents={pollStartEvents} />
</div>
</BaseDialog>
);
};

View file

@ -0,0 +1,40 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
import PollListItem from "./PollListItem";
import { _t } from "../../../../languageHandler";
type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
};
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents }) => {
return (
<div className="mx_PollHistoryList">
{!!pollStartEvents.length ? (
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
))}
</ol>
) : (
<span className="mx_PollHistoryList_noResults">{_t("There are no polls in this room")}</span>
)}
</div>
);
};

View file

@ -0,0 +1,43 @@
/*
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 { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
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";
interface Props {
event: MatrixEvent;
}
const PollListItem: React.FC<Props> = ({ event }) => {
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>
);
};
export default PollListItem;

View file

@ -0,0 +1,40 @@
/*
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 { M_POLL_START } from "matrix-js-sdk/src/@types/polls";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
/**
* Get poll start events in a rooms live timeline
* @param roomId - id of room to retrieve polls for
* @param matrixClient - client
* @returns {MatrixEvent[]} - array fo poll start events
*/
export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => {
const room = matrixClient.getRoom(roomId);
if (!room) {
throw new Error("Cannot find room");
}
// @TODO(kerrya) poll history will be actively fetched in PSG-1043
// for now, just display polls that are in the current timeline
const timelineEvents = room.getLiveTimeline().getEvents();
const pollStartEvents = timelineEvents.filter((event) => M_POLL_START.matches(event.getType()));
return pollStartEvents;
};

View file

@ -286,6 +286,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
const onRoomPollHistoryClick = (): void => {
Modal.createDialog(PollHistoryDialog, {
roomId: room.roomId,
matrixClient: cli,
});
};
@ -353,7 +354,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
{_t("Export chat")}
</Button>
)}
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
<Button
data-testid="shareRoomButton"
className="mx_RoomSummaryCard_icon_share"
onClick={onShareRoomClick}
>
{_t("Share room")}
</Button>
<Button className="mx_RoomSummaryCard_icon_settings" onClick={onRoomSettingsClick}>

View file

@ -3141,6 +3141,7 @@
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Warning</b>: You should only set up key backup from a trusted computer.",
"Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.",
"If you've forgotten your Security Key you can <button>set up new recovery options</button>": "If you've forgotten your Security Key you can <button>set up new recovery options</button>",
"There are no polls in this room": "There are no polls in this room",
"Send custom account data event": "Send custom account data event",
"Send custom room account data event": "Send custom room account data event",
"Event Type": "Event Type",

View file

@ -0,0 +1,86 @@
/*
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 { render } from "@testing-library/react";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog";
import {
getMockClientWithEventEmitter,
makePollStartEvent,
mockClientMethodsUser,
mockIntlDateTimeFormat,
unmockIntlDateTimeFormat,
} from "../../../../test-utils";
describe("<PollHistoryDialog />", () => {
const userId = "@alice:domain.org";
const roomId = "!room:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
getRoom: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
const defaultProps = {
roomId,
matrixClient: mockClient,
onFinished: jest.fn(),
};
const getComponent = () => render(<PollHistoryDialog {...defaultProps} />);
beforeAll(() => {
mockIntlDateTimeFormat();
});
beforeEach(() => {
mockClient.getRoom.mockReturnValue(room);
const timeline = room.getLiveTimeline();
jest.spyOn(timeline, "getEvents").mockReturnValue([]);
});
afterAll(() => {
unmockIntlDateTimeFormat();
});
it("throws when room is not found", () => {
mockClient.getRoom.mockReturnValue(null);
expect(() => getComponent()).toThrow("Cannot find room");
});
it("renders a no polls message when there are no polls in the timeline", () => {
const { getByText } = getComponent();
expect(getByText("There are no polls in this room")).toBeTruthy();
});
it("renders a list of polls when there are polls in the timeline", () => {
const pollStart1 = makePollStartEvent("Question?", userId, undefined, 1675300825090, "$1");
const pollStart2 = makePollStartEvent("Where?", userId, undefined, 1675300725090, "$2");
const pollStart3 = makePollStartEvent("What?", userId, undefined, 1675200725090, "$3");
const message = new MatrixEvent({
type: "m.room.message",
content: {},
});
const timeline = room.getLiveTimeline();
jest.spyOn(timeline, "getEvents").mockReturnValue([pollStart1, pollStart2, pollStart3, message]);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,53 @@
/*
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 { render } from "@testing-library/react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import PollListItem from "../../../../../src/components/views/dialogs/polls/PollListItem";
import { makePollStartEvent, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../../../test-utils";
describe("<PollListItem />", () => {
const event = makePollStartEvent("Question?", "@me:domain.org");
event.getContent().origin;
const defaultProps = { event };
const getComponent = (props = {}) => render(<PollListItem {...defaultProps} {...props} />);
beforeAll(() => {
// mock default locale to en-GB and set timezone
// so these tests run the same everywhere
mockIntlDateTimeFormat();
});
afterAll(() => {
unmockIntlDateTimeFormat();
});
it("renders a poll", () => {
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("renders null when event does not have an extensible poll start event", () => {
const event = new MatrixEvent({
type: "m.room.message",
content: {},
});
const { container } = getComponent({ event });
expect(container.firstElementChild).toBeFalsy();
});
});

View file

@ -0,0 +1,99 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PollHistoryDialog /> renders a list of polls when there are polls in the timeline 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="undefined mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Polls history
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_PollHistoryDialog_content"
>
<div
class="mx_PollHistoryList"
>
<ol
class="mx_PollHistoryList_list"
>
<li
class="mx_PollListItem"
data-testid="pollListItem-$1"
>
<span>
02/02/23
</span>
<div
class="mx_PollListItem_icon"
/>
<span
class="mx_PollListItem_question"
>
Question?
</span>
</li>
<li
class="mx_PollListItem"
data-testid="pollListItem-$2"
>
<span>
02/02/23
</span>
<div
class="mx_PollListItem_icon"
/>
<span
class="mx_PollListItem_question"
>
Where?
</span>
</li>
<li
class="mx_PollListItem"
data-testid="pollListItem-$3"
>
<span>
31/01/23
</span>
<div
class="mx_PollListItem_icon"
/>
<span
class="mx_PollListItem_question"
>
What?
</span>
</li>
</ol>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PollListItem /> renders a poll 1`] = `
<div>
<li
class="mx_PollListItem"
data-testid="pollListItem-$mypoll"
>
<span>
01/01/70
</span>
<div
class="mx_PollListItem_icon"
/>
<span
class="mx_PollListItem_question"
>
Question?
</span>
</li>
</div>
`;

View file

@ -145,7 +145,7 @@ describe("<RoomSummaryCard />", () => {
fireEvent.click(getByText("Polls history"));
expect(modalSpy).toHaveBeenCalledWith(PollHistoryDialog, { roomId });
expect(modalSpy).toHaveBeenCalledWith(PollHistoryDialog, { roomId, matrixClient: mockClient });
});
});

View file

@ -92,6 +92,7 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
</div>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_share"
data-testid="shareRoomButton"
role="button"
tabindex="0"
>

View file

@ -15,3 +15,21 @@ limitations under the License.
*/
export const REPEATABLE_DATE = new Date(2022, 10, 17, 16, 58, 32, 517);
// allow setting default locale and set timezone
// defaults to en-GB / Europe/London
// so tests run the same everywhere
export const mockIntlDateTimeFormat = (defaultLocale = "en-GB", defaultTimezone = "Europe/London"): void => {
// unmock so we can use real DateTimeFormat in mockImplementation
if (jest.isMockFunction(global.Intl.DateTimeFormat)) {
unmockIntlDateTimeFormat();
}
const DateTimeFormat = Intl.DateTimeFormat;
jest.spyOn(global.Intl, "DateTimeFormat").mockImplementation(
(locale, options) => new DateTimeFormat(locale || defaultLocale, { ...options, timeZone: defaultTimezone }),
);
};
export const unmockIntlDateTimeFormat = (): void => {
jest.spyOn(global.Intl, "DateTimeFormat").mockRestore();
};

View file

@ -18,7 +18,13 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { M_POLL_START, PollAnswer, M_POLL_KIND_DISCLOSED } from "matrix-js-sdk/src/@types/polls";
import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events";
export const makePollStartEvent = (question: string, sender: string, answers?: PollAnswer[]): MatrixEvent => {
export const makePollStartEvent = (
question: string,
sender: string,
answers?: PollAnswer[],
ts?: number,
id?: string,
): MatrixEvent => {
if (!answers) {
answers = [
{ id: "socks", [M_TEXT.name]: "Socks" },
@ -27,7 +33,7 @@ export const makePollStartEvent = (question: string, sender: string, answers?: P
}
return new MatrixEvent({
event_id: "$mypoll",
event_id: id || "$mypoll",
room_id: "#myroom:example.com",
sender: sender,
type: M_POLL_START.name,
@ -41,5 +47,6 @@ export const makePollStartEvent = (question: string, sender: string, answers?: P
},
[M_TEXT.name]: `${question}: answers`,
},
origin_server_ts: ts || 0,
});
};

View file

@ -21,8 +21,9 @@ import {
formatFullDateNoDayISO,
formatTimeLeft,
formatPreciseDuration,
formatLocalDateShort,
} from "../../src/DateUtils";
import { REPEATABLE_DATE } from "../test-utils";
import { REPEATABLE_DATE, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../test-utils";
describe("formatSeconds", () => {
it("correctly formats time with hours", () => {
@ -137,3 +138,22 @@ describe("formatTimeLeft", () => {
expect(formatTimeLeft(seconds)).toBe(expected);
});
});
describe("formatLocalDateShort()", () => {
afterAll(() => {
unmockIntlDateTimeFormat();
});
const timestamp = new Date("Fri Dec 17 2021 09:09:00 GMT+0100 (Central European Standard Time)").getTime();
it("formats date correctly by locale", () => {
// format is DD/MM/YY
mockIntlDateTimeFormat("en-UK");
expect(formatLocalDateShort(timestamp)).toEqual("17/12/21");
// US date format is MM/DD/YY
mockIntlDateTimeFormat("en-US");
expect(formatLocalDateShort(timestamp)).toEqual("12/17/21");
mockIntlDateTimeFormat("de-DE");
expect(formatLocalDateShort(timestamp)).toEqual("17.12.21");
});
});