diff --git a/res/css/_components.pcss b/res/css/_components.pcss index fe50417c00..195fc6cce7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/components/views/dialogs/polls/_PollListItem.pcss b/res/css/components/views/dialogs/polls/_PollListItem.pcss new file mode 100644 index 0000000000..7b19e67594 --- /dev/null +++ b/res/css/components/views/dialogs/polls/_PollListItem.pcss @@ -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; +} diff --git a/res/css/views/dialogs/polls/_PollHistoryDialog.pcss b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss new file mode 100644 index 0000000000..39a53344ed --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss @@ -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; +} diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss new file mode 100644 index 0000000000..6a0a003ce1 --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -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; +} diff --git a/src/DateUtils.ts b/src/DateUtils.ts index c1aa69aacd..c279c1ad1b 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -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); diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index 364f740c6c..4671da9246 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -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 & { roomId: string; + matrixClient: MatrixClient; }; +export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { + const pollStartEvents = getPolls(roomId, matrixClient); -export const PollHistoryDialog: React.FC = ({ onFinished }) => { return ( - {/* @TODO(kerrya) to be implemented in PSG-906 */} +
+ +
); }; diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx new file mode 100644 index 0000000000..ff0ea3a7cf --- /dev/null +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -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 = ({ pollStartEvents }) => { + return ( +
+ {!!pollStartEvents.length ? ( +
    + {pollStartEvents.map((pollStartEvent) => ( + + ))} +
+ ) : ( + {_t("There are no polls in this room")} + )} +
+ ); +}; diff --git a/src/components/views/dialogs/polls/PollListItem.tsx b/src/components/views/dialogs/polls/PollListItem.tsx new file mode 100644 index 0000000000..49df399bd7 --- /dev/null +++ b/src/components/views/dialogs/polls/PollListItem.tsx @@ -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 = ({ event }) => { + const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent; + if (!pollEvent) { + return null; + } + const formattedDate = formatLocalDateShort(event.getTs()); + return ( +
  • + {formattedDate} + + {pollEvent.question.text} +
  • + ); +}; + +export default PollListItem; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts new file mode 100644 index 0000000000..aa730b84ee --- /dev/null +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -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; +}; diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index e221106bb9..37d9a6f97a 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -286,6 +286,7 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { const onRoomPollHistoryClick = (): void => { Modal.createDialog(PollHistoryDialog, { roomId: room.roomId, + matrixClient: cli, }); }; @@ -353,7 +354,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { {_t("Export chat")} )} - ": "If you've forgotten your Security Key you can ", + "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", diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx new file mode 100644 index 0000000000..4557b145e7 --- /dev/null +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -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("", () => { + 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(); + + 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(); + }); +}); diff --git a/test/components/views/dialogs/polls/PollListItem-test.tsx b/test/components/views/dialogs/polls/PollListItem-test.tsx new file mode 100644 index 0000000000..b9e8ffcc74 --- /dev/null +++ b/test/components/views/dialogs/polls/PollListItem-test.tsx @@ -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("", () => { + const event = makePollStartEvent("Question?", "@me:domain.org"); + event.getContent().origin; + const defaultProps = { event }; + const getComponent = (props = {}) => render(); + + 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(); + }); +}); diff --git a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap new file mode 100644 index 0000000000..fd572bc2d1 --- /dev/null +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a list of polls when there are polls in the timeline 1`] = ` +
    +
    +
    diff --git a/test/test-utils/date.ts b/test/test-utils/date.ts index c3010b2ae9..f474f06a9a 100644 --- a/test/test-utils/date.ts +++ b/test/test-utils/date.ts @@ -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(); +}; diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index 5096f8c51a..ffb23ee609 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -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, }); }; diff --git a/test/utils/DateUtils-test.ts b/test/utils/DateUtils-test.ts index 2c72b26177..9b7cd084ac 100644 --- a/test/utils/DateUtils-test.ts +++ b/test/utils/DateUtils-test.ts @@ -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"); + }); +});