diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2c068fff33..16574bad79 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,4 @@ -* @matrix-org/element-web +* @matrix-org/element-web +/.github/workflows/** @matrix-org/element-web-app-team +/package.json @matrix-org/element-web-app-team +/yarn.lock @matrix-org/element-web-app-team diff --git a/package.json b/package.json index 7c8a5c46c2..8903d6a54c 100644 --- a/package.json +++ b/package.json @@ -164,9 +164,9 @@ "@types/pako": "^1.0.1", "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "^17.0.49", + "@types/react": "17.0.49", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^17.0.17", + "@types/react-dom": "17.0.17", "@types/react-test-renderer": "^17.0.1", "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "^2.3.1", diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss index bf4118b806..3d463cbc9b 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss @@ -21,6 +21,10 @@ limitations under the License. display: inline-block; font-size: $font-12px; padding: $spacing-12; + + .mx_Clock { + line-height: 1; + } } .mx_VoiceBroadcastBody--pip { @@ -44,9 +48,8 @@ limitations under the License. } .mx_VoiceBroadcastBody_timerow { - align-items: center; display: flex; - gap: $spacing-4; + justify-content: space-between; } .mx_AccessibleButton.mx_VoiceBroadcastBody_blockButton { diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 25495b0542..b6fd8a0bee 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -124,20 +124,10 @@ 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 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'); @@ -146,6 +136,10 @@ export function formatSeconds(inSeconds: number): string { if (hours !== "00") output += `${hours}:`; output += `${minutes}:${seconds}`; + if (isNegative) { + output = "-" + output; + } + return output; } @@ -238,15 +232,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 +254,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/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index 6c16223388..7ba06a1501 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -46,10 +46,9 @@ export const VoiceBroadcastPlaybackBody: React.FC { const { - duration, + times, liveness, playbackState, - position, room, sender, toggle, @@ -94,7 +93,7 @@ export const VoiceBroadcastPlaybackBody: React.FC { - playback.skipTo(Math.max(0, position - SEEK_TIME)); + playback.skipTo(Math.max(0, times.position - SEEK_TIME)); }; seekBackwardButton = ; const onSeekForwardButtonClick = () => { - playback.skipTo(Math.min(duration, position + SEEK_TIME)); + playback.skipTo(Math.min(times.duration, times.position + SEEK_TIME)); }; seekForwardButton = +
- - + +
); diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts index 1828b31d01..0b515c4437 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts @@ -40,18 +40,15 @@ export const useVoiceBroadcastPlayback = (playback: VoiceBroadcastPlayback) => { }, ); - const [duration, setDuration] = useState(playback.durationSeconds); + const [times, setTimes] = useState({ + duration: playback.durationSeconds, + position: playback.timeSeconds, + timeLeft: playback.timeLeftSeconds, + }); useTypedEventEmitter( playback, - VoiceBroadcastPlaybackEvent.LengthChanged, - d => setDuration(d / 1000), - ); - - const [position, setPosition] = useState(playback.timeSeconds); - useTypedEventEmitter( - playback, - VoiceBroadcastPlaybackEvent.PositionChanged, - p => setPosition(p / 1000), + VoiceBroadcastPlaybackEvent.TimesChanged, + t => setTimes(t), ); const [liveness, setLiveness] = useState(playback.getLiveness()); @@ -62,10 +59,9 @@ export const useVoiceBroadcastPlayback = (playback: VoiceBroadcastPlayback) => { ); return { - duration, + times, liveness: liveness, playbackState, - position, room: room, sender: playback.infoEvent.sender, toggle: playbackToggle, diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 2c4054a825..70c7a4d82f 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -43,16 +43,20 @@ export enum VoiceBroadcastPlaybackState { } export enum VoiceBroadcastPlaybackEvent { - PositionChanged = "position_changed", - LengthChanged = "length_changed", + TimesChanged = "times_changed", LivenessChanged = "liveness_changed", StateChanged = "state_changed", InfoStateChanged = "info_state_changed", } +type VoiceBroadcastPlaybackTimes = { + duration: number; + position: number; + timeLeft: number; +}; + interface EventMap { - [VoiceBroadcastPlaybackEvent.PositionChanged]: (position: number) => void; - [VoiceBroadcastPlaybackEvent.LengthChanged]: (length: number) => void; + [VoiceBroadcastPlaybackEvent.TimesChanged]: (times: VoiceBroadcastPlaybackTimes) => void; [VoiceBroadcastPlaybackEvent.LivenessChanged]: (liveness: VoiceBroadcastLiveness) => void; [VoiceBroadcastPlaybackEvent.StateChanged]: ( state: VoiceBroadcastPlaybackState, @@ -229,7 +233,7 @@ export class VoiceBroadcastPlayback if (this.duration === duration) return; this.duration = duration; - this.emit(VoiceBroadcastPlaybackEvent.LengthChanged, this.duration); + this.emitTimesChanged(); this.liveData.update([this.timeSeconds, this.durationSeconds]); } @@ -237,10 +241,21 @@ export class VoiceBroadcastPlayback if (this.position === position) return; this.position = position; - this.emit(VoiceBroadcastPlaybackEvent.PositionChanged, this.position); + this.emitTimesChanged(); this.liveData.update([this.timeSeconds, this.durationSeconds]); } + private emitTimesChanged(): void { + this.emit( + VoiceBroadcastPlaybackEvent.TimesChanged, + { + duration: this.durationSeconds, + position: this.timeSeconds, + timeLeft: this.timeLeftSeconds, + }, + ); + } + private onPlaybackStateChange = async (event: MatrixEvent, newState: PlaybackState): Promise => { if (event !== this.currentlyPlaying) return; if (newState !== PlaybackState.Stopped) return; @@ -337,6 +352,10 @@ export class VoiceBroadcastPlayback return this.duration / 1000; } + public get timeLeftSeconds(): number { + return Math.round(this.durationSeconds) - this.timeSeconds; + } + public async skipTo(timeSeconds: number): Promise { const time = timeSeconds * 1000; const event = this.chunkEvents.findByTime(time); diff --git a/test/utils/DateUtils-test.ts b/test/utils/DateUtils-test.ts index 2815b972d2..9cb020571e 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"; @@ -28,12 +29,14 @@ describe("formatSeconds", () => { expect(formatSeconds((60 * 60 * 3) + (60 * 31) + (55))).toBe("03:31:55"); expect(formatSeconds((60 * 60 * 3) + (60 * 0) + (55))).toBe("03:00:55"); expect(formatSeconds((60 * 60 * 3) + (60 * 31) + (0))).toBe("03:31:00"); + expect(formatSeconds(-((60 * 60 * 3) + (60 * 31) + (0)))).toBe("-03:31:00"); }); it("correctly formats time without hours", () => { expect(formatSeconds((60 * 60 * 0) + (60 * 31) + (55))).toBe("31:55"); expect(formatSeconds((60 * 60 * 0) + (60 * 0) + (55))).toBe("00:55"); expect(formatSeconds((60 * 60 * 0) + (60 * 31) + (0))).toBe("31:00"); + expect(formatSeconds(-((60 * 60 * 0) + (60 * 31) + (0)))).toBe("-31:00"); }); }); @@ -100,6 +103,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 +127,6 @@ describe("formatFullDateNoDayISO", () => { describe("formatTimeLeft", () => { it.each([ - [null, "0s left"], [0, "0s left"], [23, "23s left"], [60 + 23, "1m 23s left"], diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx index a2e95a856e..901a4feb82 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx @@ -42,6 +42,7 @@ jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ describe("VoiceBroadcastPlaybackBody", () => { const userId = "@user:example.com"; const roomId = "!room:example.com"; + const duration = 23 * 60 + 42; // 23:42 let client: MatrixClient; let infoEvent: MatrixEvent; let playback: VoiceBroadcastPlayback; @@ -66,7 +67,7 @@ describe("VoiceBroadcastPlaybackBody", () => { jest.spyOn(playback, "getLiveness"); jest.spyOn(playback, "getState"); jest.spyOn(playback, "skipTo"); - jest.spyOn(playback, "durationSeconds", "get").mockReturnValue(23 * 60 + 42); // 23:42 + jest.spyOn(playback, "durationSeconds", "get").mockReturnValue(duration); }); describe("when rendering a buffering voice broadcast", () => { @@ -95,7 +96,11 @@ describe("VoiceBroadcastPlaybackBody", () => { describe("and being in the middle of the playback", () => { beforeEach(() => { act(() => { - playback.emit(VoiceBroadcastPlaybackEvent.PositionChanged, 10 * 60 * 1000); // 10:00 + playback.emit(VoiceBroadcastPlaybackEvent.TimesChanged, { + duration, + position: 10 * 60, + timeLeft: duration - 10 * 60, + }); }); }); @@ -146,15 +151,20 @@ describe("VoiceBroadcastPlaybackBody", () => { }); }); - describe("and the length updated", () => { + describe("and the times update", () => { beforeEach(() => { act(() => { - playback.emit(VoiceBroadcastPlaybackEvent.LengthChanged, 42000); // 00:42 + playback.emit(VoiceBroadcastPlaybackEvent.TimesChanged, { + duration, + position: 5 * 60 + 13, + timeLeft: 7 * 60 + 5, + }); }); }); - it("should render the new length", async () => { - expect(await screen.findByText("00:42")).toBeInTheDocument(); + it("should render the times", async () => { + expect(await screen.findByText("05:13")).toBeInTheDocument(); + expect(await screen.findByText("-07:05")).toBeInTheDocument(); }); }); }); diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap index c14cf94539..f5d3e90b3c 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap @@ -76,23 +76,28 @@ exports[`VoiceBroadcastPlaybackBody when rendering a 0/not-live broadcast should /> +
- - 23:42 + 00:00 + + + -23:42
@@ -183,23 +188,28 @@ exports[`VoiceBroadcastPlaybackBody when rendering a 1/live broadcast should ren /> +
- - 23:42 + 00:00 + + + -23:42
@@ -291,23 +301,28 @@ exports[`VoiceBroadcastPlaybackBody when rendering a buffering voice broadcast s /> +
- - 23:42 + 00:00 + + + -23:42
@@ -390,23 +405,28 @@ exports[`VoiceBroadcastPlaybackBody when rendering a playing broadcast should re /> +
- - 23:42 + 00:00 + + + -23:42
@@ -469,23 +489,28 @@ exports[`VoiceBroadcastPlaybackBody when rendering a stopped broadcast should re /> +
- - 23:42 + 00:00 + + + -23:42
diff --git a/yarn.lock b/yarn.lock index 0bc5108d8a..33766642e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,7 +2500,7 @@ dependencies: "@types/react" "*" -"@types/react-dom@<18.0.0", "@types/react-dom@^17.0.17": +"@types/react-dom@17.0.17", "@types/react-dom@<18.0.0": version "17.0.17" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1" integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg== @@ -2531,7 +2531,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^17", "@types/react@^17.0.49": +"@types/react@*", "@types/react@17.0.49", "@types/react@^17": version "17.0.49" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.49.tgz#df87ba4ca8b7942209c3dc655846724539dc1049" integrity sha512-CCBPMZaPhcKkYUTqFs/hOWqKjPxhTEmnZWjlHHgIMop67DsXywf9B5Os9Hz8KSacjNOgIdnZVJamwl232uxoPg==