diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 25495b0542..e03dace213 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -124,19 +124,6 @@ export function formatTime(date: Date, showTwelveHour = false): string { return pad(date.getHours()) + ':' + pad(date.getMinutes()); } -export function formatCallTime(delta: Date): string { - const hours = delta.getUTCHours(); - const minutes = delta.getUTCMinutes(); - const seconds = delta.getUTCSeconds(); - - let output = ""; - if (hours) output += `${hours}h `; - if (minutes || output) output += `${minutes}m `; - if (seconds || output) output += `${seconds}s`; - - return output; -} - export function formatSeconds(inSeconds: number): string { 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'); @@ -238,15 +225,16 @@ export function formatRelativeTime(date: Date, showTwelveHour = false): string { } } +const MINUTE_MS = 60000; +const HOUR_MS = MINUTE_MS * 60; +const DAY_MS = HOUR_MS * 24; + /** * Formats duration in ms to human readable string * Returns value in biggest possible unit (day, hour, min, second) * Rounds values up until unit threshold * ie. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d */ -const MINUTE_MS = 60000; -const HOUR_MS = MINUTE_MS * 60; -const DAY_MS = HOUR_MS * 24; export function formatDuration(durationMs: number): string { if (durationMs >= DAY_MS) { return _t('%(value)sd', { value: Math.round(durationMs / DAY_MS) }); @@ -259,3 +247,26 @@ export function formatDuration(durationMs: number): string { } return _t('%(value)ss', { value: Math.round(durationMs / 1000) }); } + +/** + * Formats duration in ms to human readable string + * Returns precise value down to the nearest second + * ie. 23:13:57 -> 23h 13m 57s, 44:56:56 -> 1d 20h 56m 56s + */ +export function formatPreciseDuration(durationMs: number): string { + const days = Math.floor(durationMs / DAY_MS); + const hours = Math.floor((durationMs % DAY_MS) / HOUR_MS); + const minutes = Math.floor((durationMs % HOUR_MS) / MINUTE_MS); + const seconds = Math.floor((durationMs % MINUTE_MS) / 1000); + + if (days > 0) { + return _t('%(days)sd %(hours)sh %(minutes)sm %(seconds)ss', { days, hours, minutes, seconds }); + } + if (hours > 0) { + return _t('%(hours)sh %(minutes)sm %(seconds)ss', { hours, minutes, seconds }); + } + if (minutes > 0) { + return _t('%(minutes)sm %(seconds)ss', { minutes, seconds }); + } + return _t('%(value)ss', { value: seconds }); +} diff --git a/src/components/structures/LegacyCallEventGrouper.ts b/src/components/structures/LegacyCallEventGrouper.ts index 117abdd69e..f6defe766f 100644 --- a/src/components/structures/LegacyCallEventGrouper.ts +++ b/src/components/structures/LegacyCallEventGrouper.ts @@ -119,9 +119,9 @@ export default class LegacyCallEventGrouper extends EventEmitter { return Boolean(this.reject); } - public get duration(): Date { - if (!this.hangup || !this.selectAnswer) return; - return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime()); + public get duration(): number | null { + if (!this.hangup || !this.selectAnswer) return null; + return this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime(); } /** diff --git a/src/components/views/messages/LegacyCallEvent.tsx b/src/components/views/messages/LegacyCallEvent.tsx index 4ab3ba00c3..5704895d18 100644 --- a/src/components/views/messages/LegacyCallEvent.tsx +++ b/src/components/views/messages/LegacyCallEvent.tsx @@ -28,7 +28,7 @@ import LegacyCallEventGrouper, { import AccessibleButton from '../elements/AccessibleButton'; import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; -import { formatCallTime } from "../../../DateUtils"; +import { formatPreciseDuration } from "../../../DateUtils"; import Clock from "../audio_messages/Clock"; const MAX_NON_NARROW_WIDTH = 450 / 70 * 100; @@ -172,10 +172,10 @@ export default class LegacyCallEvent extends React.PureComponent // https://github.com/vector-im/riot-android/issues/2623 // Also the correct hangup code as of VoIP v1 (with underscore) // Also, if we don't have a reason - const duration = this.props.callEventGrouper.duration; + const duration = this.props.callEventGrouper.duration!; let text = _t("Call ended"); if (duration) { - text += " • " + formatCallTime(duration); + text += " • " + formatPreciseDuration(duration); } return (
diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx index 2965f6265b..df59ba05d9 100644 --- a/src/components/views/voip/CallDuration.tsx +++ b/src/components/views/voip/CallDuration.tsx @@ -17,7 +17,7 @@ limitations under the License. import React, { FC, useState, useEffect, memo } from "react"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; -import { formatCallTime } from "../../../DateUtils"; +import { formatPreciseDuration } from "../../../DateUtils"; interface CallDurationProps { delta: number; @@ -29,7 +29,7 @@ interface CallDurationProps { export const CallDuration: FC = memo(({ delta }) => { // Clock desync could lead to a negative duration, so just hide it if that happens if (delta <= 0) return null; - return
{ formatCallTime(new Date(delta)) }
; + return
{ formatPreciseDuration(delta) }
; }); interface GroupCallDurationProps { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 376133905d..c2f34afca9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -56,6 +56,9 @@ "%(value)sh": "%(value)sh", "%(value)sm": "%(value)sm", "%(value)ss": "%(value)ss", + "%(days)sd %(hours)sh %(minutes)sm %(seconds)ss": "%(days)sd %(hours)sh %(minutes)sm %(seconds)ss", + "%(hours)sh %(minutes)sm %(seconds)ss": "%(hours)sh %(minutes)sm %(seconds)ss", + "%(minutes)sm %(seconds)ss": "%(minutes)sm %(seconds)ss", "Identity server has no terms of service": "Identity server has no terms of service", "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.", "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", diff --git a/test/utils/DateUtils-test.ts b/test/utils/DateUtils-test.ts index 2815b972d2..55893e48d8 100644 --- a/test/utils/DateUtils-test.ts +++ b/test/utils/DateUtils-test.ts @@ -20,6 +20,7 @@ import { formatDuration, formatFullDateNoDayISO, formatTimeLeft, + formatPreciseDuration, } from "../../src/DateUtils"; import { REPEATABLE_DATE } from "../test-utils"; @@ -100,6 +101,22 @@ describe('formatDuration()', () => { }); }); +describe("formatPreciseDuration", () => { + const MINUTE_MS = 1000 * 60; + const HOUR_MS = MINUTE_MS * 60; + const DAY_MS = HOUR_MS * 24; + + it.each<[string, string, number]>([ + ['3 days, 6 hours, 48 minutes, 59 seconds', '3d 6h 48m 59s', 3 * DAY_MS + 6 * HOUR_MS + 48 * MINUTE_MS + 59000], + ['6 hours, 48 minutes, 59 seconds', '6h 48m 59s', 6 * HOUR_MS + 48 * MINUTE_MS + 59000], + ['48 minutes, 59 seconds', '48m 59s', 48 * MINUTE_MS + 59000], + ['59 seconds', '59s', 59000], + ['0 seconds', '0s', 0], + ])('%s formats to %s', (_description, expectedResult, input) => { + expect(formatPreciseDuration(input)).toEqual(expectedResult); + }); +}); + describe("formatFullDateNoDayISO", () => { it("should return ISO format", () => { expect(formatFullDateNoDayISO(REPEATABLE_DATE)).toEqual("2022-11-17T16:58:32.517Z"); @@ -108,7 +125,6 @@ describe("formatFullDateNoDayISO", () => { describe("formatTimeLeft", () => { it.each([ - [null, "0s left"], [0, "0s left"], [23, "23s left"], [60 + 23, "1m 23s left"],