diff --git a/playwright/shared-component-snapshots/audio-clock--default-linux.png b/playwright/shared-component-snapshots/audio-clock--default-linux.png new file mode 100644 index 0000000000..be66f4b70c Binary files /dev/null and b/playwright/shared-component-snapshots/audio-clock--default-linux.png differ diff --git a/playwright/shared-component-snapshots/audio-clock--lot-of-seconds-linux.png b/playwright/shared-component-snapshots/audio-clock--lot-of-seconds-linux.png new file mode 100644 index 0000000000..b4879e1a0c Binary files /dev/null and b/playwright/shared-component-snapshots/audio-clock--lot-of-seconds-linux.png differ diff --git a/src/DateUtils.ts b/src/DateUtils.ts index e788ca09bf..a5e22c0891 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -13,6 +13,8 @@ import { type Optional } from "matrix-events-sdk"; import { _t, getUserLanguage } from "./languageHandler"; import { getUserTimezone } from "./TimezoneHandler"; +export { formatSeconds } from "./shared-components/utils/DateUtils"; + export const MINUTE_MS = 60000; export const HOUR_MS = MINUTE_MS * 60; export const DAY_MS = HOUR_MS * 24; @@ -180,31 +182,6 @@ export function formatTime(date: Date, showTwelveHour = false, locale?: string): }).format(date); } -export function formatSeconds(inSeconds: number): string { - const isNegative = inSeconds < 0; - inSeconds = Math.abs(inSeconds); - - const hours = Math.floor(inSeconds / (60 * 60)) - .toFixed(0) - .padStart(2, "0"); - const minutes = Math.floor((inSeconds % (60 * 60)) / 60) - .toFixed(0) - .padStart(2, "0"); - const seconds = Math.floor((inSeconds % (60 * 60)) % 60) - .toFixed(0) - .padStart(2, "0"); - - let output = ""; - if (hours !== "00") output += `${hours}:`; - output += `${minutes}:${seconds}`; - - if (isNegative) { - output = "-" + output; - } - - return output; -} - export function formatTimeLeft(inSeconds: number): string { const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0); const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0); diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx index 920baa99be..63d6c0cb23 100644 --- a/src/components/views/audio_messages/DurationClock.tsx +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; -import Clock from "./Clock"; +import { Clock } from "../../../shared-components/audio/Clock"; import { type Playback } from "../../../audio/Playback"; interface IProps { diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index 74415566a6..7807f30b72 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { type IRecordingUpdate } from "../../../audio/VoiceRecording"; -import Clock from "./Clock"; +import { Clock } from "../../../shared-components/audio/Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; diff --git a/src/components/views/audio_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx index 0d69793379..9dc6cdced3 100644 --- a/src/components/views/audio_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; -import Clock from "./Clock"; +import { Clock } from "../../../shared-components/audio/Clock"; import { type Playback, PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; diff --git a/src/components/views/messages/LegacyCallEvent.tsx b/src/components/views/messages/LegacyCallEvent.tsx index da41a56c02..6d144c5641 100644 --- a/src/components/views/messages/LegacyCallEvent.tsx +++ b/src/components/views/messages/LegacyCallEvent.tsx @@ -18,7 +18,7 @@ import { LegacyCallEventGrouperEvent } from "../../structures/LegacyCallEventGro import AccessibleButton from "../elements/AccessibleButton"; import InfoTooltip, { InfoTooltipKind } from "../elements/InfoTooltip"; import { formatPreciseDuration } from "../../../DateUtils"; -import Clock from "../audio_messages/Clock"; +import { Clock } from "../../../shared-components/audio/Clock"; const MAX_NON_NARROW_WIDTH = (450 / 70) * 100; diff --git a/src/shared-components/audio/Clock/Clock.stories.tsx b/src/shared-components/audio/Clock/Clock.stories.tsx new file mode 100644 index 0000000000..e05ee086d9 --- /dev/null +++ b/src/shared-components/audio/Clock/Clock.stories.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { Clock } from "./Clock"; + +export default { + title: "Audio/Clock", + component: Clock, + tags: ["autodocs"], + args: { + seconds: 20, + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const LotOfSeconds = Template.bind({}); +LotOfSeconds.args = { + seconds: 99999999999999, +}; diff --git a/src/shared-components/audio/Clock/Clock.test.tsx b/src/shared-components/audio/Clock/Clock.test.tsx new file mode 100644 index 0000000000..fdbbf49518 --- /dev/null +++ b/src/shared-components/audio/Clock/Clock.test.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render } from "jest-matrix-react"; +import React from "react"; + +import * as stories from "./Clock.stories.tsx"; + +const { Default, LotOfSeconds } = composeStories(stories); + +describe("Clock", () => { + it("renders the clock", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the clock with a lot of seconds", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/views/audio_messages/Clock.tsx b/src/shared-components/audio/Clock/Clock.tsx similarity index 76% rename from src/components/views/audio_messages/Clock.tsx rename to src/shared-components/audio/Clock/Clock.tsx index d9f1a3d6c5..9aa114e415 100644 --- a/src/components/views/audio_messages/Clock.tsx +++ b/src/shared-components/audio/Clock/Clock.tsx @@ -1,7 +1,6 @@ /* Copyright 2024 New Vector Ltd. Copyright 2021-2023 The Matrix.org Foundation C.I.C. - SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ @@ -9,24 +8,18 @@ Please see LICENSE files in the repository root for full details. import React, { type HTMLProps } from "react"; import { Temporal } from "temporal-polyfill"; -import { formatSeconds } from "../../../DateUtils"; +import { formatSeconds } from "../../utils/DateUtils"; -interface Props extends Pick, "aria-live" | "role"> { +export interface Props extends Pick, "aria-live" | "role"> { seconds: number; - formatFn: (seconds: number) => string; } /** * Clock which represents time periods rather than absolute time. - * Simply converts seconds using formatFn. - * Defaulting to formatSeconds(). + * Simply converts seconds using formatSeconds(). * Note that in this case hours will not be displayed, making it possible to see "82:29". */ -export default class Clock extends React.Component { - public static defaultProps = { - formatFn: formatSeconds, - }; - +export class Clock extends React.Component { public shouldComponentUpdate(nextProps: Readonly): boolean { const currentFloor = Math.floor(this.props.seconds); const nextFloor = Math.floor(nextProps.seconds); @@ -47,9 +40,10 @@ export default class Clock extends React.Component { dateTime={this.calculateDuration(seconds)} aria-live={this.props["aria-live"]} role={role} + /* Keep class for backward compatibility with parent component */ className="mx_Clock" > - {this.props.formatFn(seconds)} + {formatSeconds(seconds)} ); } diff --git a/src/shared-components/audio/Clock/__snapshots__/Clock.test.tsx.snap b/src/shared-components/audio/Clock/__snapshots__/Clock.test.tsx.snap new file mode 100644 index 0000000000..2fdcedd7c3 --- /dev/null +++ b/src/shared-components/audio/Clock/__snapshots__/Clock.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Clock renders the clock 1`] = ` +
+ +
+`; + +exports[`Clock renders the clock with a lot of seconds 1`] = ` +
+ +
+`; diff --git a/src/shared-components/audio/Clock/index.tsx b/src/shared-components/audio/Clock/index.tsx new file mode 100644 index 0000000000..bc261bb283 --- /dev/null +++ b/src/shared-components/audio/Clock/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { Clock } from "./Clock"; diff --git a/src/shared-components/utils/DateUtils.ts b/src/shared-components/utils/DateUtils.ts new file mode 100644 index 0000000000..146aeecbd2 --- /dev/null +++ b/src/shared-components/utils/DateUtils.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Formats a number of seconds into a human-readable string. + * @param inSeconds + */ +export function formatSeconds(inSeconds: number): string { + const isNegative = inSeconds < 0; + inSeconds = Math.abs(inSeconds); + + const hours = Math.floor(inSeconds / (60 * 60)) + .toFixed(0) + .padStart(2, "0"); + const minutes = Math.floor((inSeconds % (60 * 60)) / 60) + .toFixed(0) + .padStart(2, "0"); + const seconds = Math.floor((inSeconds % (60 * 60)) % 60) + .toFixed(0) + .padStart(2, "0"); + + let output = ""; + if (hours !== "00") output += `${hours}:`; + output += `${minutes}:${seconds}`; + + if (isNegative) { + output = "-" + output; + } + + return output; +}