import { IReduxState } from '../app/types'; import BaseTheme from '../base/ui/components/BaseTheme'; import { WARNING_THRESHOLD_SECONDS } from './constants'; import { ITimeTimerState } from './reducer'; import { ICalendarTimerEvent, ITimerVisualState } from './types'; // Minimum visible red disk fraction at expiry, so the wedge isn't invisible. const EXPIRED_WEDGE_FLOOR = 0.02; /** * Derives the disk's visual state (colour, fill fraction, elapsed seconds) * from the timer reducer state. * * @param {ITimeTimerState} state - The current time-timer reducer state. * @returns {ITimerVisualState} */ export function getTimerVisualState(state: ITimeTimerState): ITimerVisualState { const { durationSeconds, remainingSeconds, overSeconds, expired } = state; // Time elapsed since the calendar event's scheduled start. Before the // scheduled end this is `duration - remaining` (which already accounts // for late joiners via `defaultRemaining`). After the scheduled end the // timer keeps ticking via `overSeconds`, so total elapsed = // duration + overrun. const elapsedSeconds = Math.max( 0, (durationSeconds - remainingSeconds) + overSeconds ); // Warning state: within the last `WARNING_THRESHOLD_SECONDS` of the // scheduled duration but not yet expired. Used both for the amber // colouring and (in middleware) to trigger the one-time bar-expand. const warning = !expired && remainingSeconds > 0 && remainingSeconds <= WARNING_THRESHOLD_SECONDS; let fillColor = BaseTheme.palette.timeTimerDisk; let fraction = durationSeconds > 0 ? (durationSeconds - remainingSeconds) / durationSeconds : 0; if (warning) { fillColor = BaseTheme.palette.timeTimerWarning; } let overrunArcEndDeg: number | undefined; if (expired) { fillColor = BaseTheme.palette.timeTimerExpiredDisk; if (durationSeconds > 0) { // Treat overrun as a series of laps, each one // `durationSeconds` long — the disk fills exactly once over // the first lap (matching the original scheduled duration), // then stays solid red while a glowing arc sweeps subsequent // laps to convey ongoing motion. const lapIndex = Math.floor(overSeconds / durationSeconds); const lapFraction = (overSeconds % durationSeconds) / durationSeconds; if (lapIndex === 0) { // Lap 1: standard growing wedge, with a small visible // floor right at expiry so the red slice is never invisible. fraction = Math.max(EXPIRED_WEDGE_FLOOR, lapFraction); } else { // Lap 2+: disk fully filled. The leading edge of the // current lap is rendered separately as a glowing // comet-tail arc (see Disk component). fraction = 1; overrunArcEndDeg = lapFraction * 360; } } else { fraction = EXPIRED_WEDGE_FLOOR; } } return { elapsedSeconds, fillColor, fraction, overrunArcEndDeg, warning }; } /** * Computes the {@code setCalendarTimerDuration} arguments for a calendar * event the user is about to join, or {@code undefined} when the event has * no parseable start/end (so the caller should clear any stale duration * instead). Shared by web's and native's calendar list `_onPress`, so both * platforms record the same duration/start-time the same way. * * @param {ICalendarTimerEvent} [event] - The calendar event being joined. * @returns {{ durationSeconds: number, startTimeUnix: number }|undefined} */ export function computeCalendarTimerDuration(event?: ICalendarTimerEvent) { const startUnix = typeof event?.startDate === 'number' ? event.startDate : Date.parse(event?.startDate ?? ''); const endUnix = typeof event?.endDate === 'number' ? event.endDate : Date.parse(event?.endDate ?? ''); if (!isNaN(startUnix) && !isNaN(endUnix) && endUnix > startUnix) { return { durationSeconds: Math.round((endUnix - startUnix) / 1000), startTimeUnix: startUnix }; } return undefined; } /** * Whether the time-timer is enabled. It is enabled by default — only an * explicit {@code timeTimer.enabled === false} turns it off. The timer * renders nothing until a duration is known, so being on by default is * harmless and lets calendar / iframe-API deployments see it with no extra * config. This is the single source of truth for the enabled rule; use it * everywhere (middleware, components) rather than reading the config inline. * * @param {IReduxState} state - The redux state. * @returns {boolean} */ export function isTimeTimerEnabled(state: IReduxState): boolean { return state['features/base/config']?.timeTimer?.enabled !== false; } /** * How long, in seconds of meeting time, the visible countdown is held back * for. Off by default (0) — the timer shows as soon as a duration is known. * * The server (`mod_time_restricted`) hands every client the meeting's limit * the moment they join, so a deployment that does not want the countdown on * screen for the whole meeting sets `timeTimer.suppressForSeconds` and gets * it revealed part-way through instead. The threshold is measured against * time elapsed since the meeting's scheduled start — not since this * participant joined — so the timer appears at the same moment for everyone * and a late joiner past the threshold sees it immediately. * * @param {IReduxState} state - The redux state. * @returns {number} */ export function getTimeTimerSuppressSeconds(state: IReduxState): number { const suppressForSeconds = state['features/base/config']?.timeTimer?.suppressForSeconds; return typeof suppressForSeconds === 'number' && suppressForSeconds > 0 ? suppressForSeconds : 0; } /** * Whether the time-timer should currently be on screen: the feature is * enabled, a timer is running for this meeting, and we are past any * configured suppression window. * * This is the single source of truth for the pill's visibility — the info bar * uses it too, because {@code TimeTimerPill} supersedes the subject and * conference-timer labels and hiding all three would leave the bar empty. * * Note this gates the countdown display only. A meeting that runs over still * raises the timer-ended notification and the overrun treatment on schedule, * whatever the suppression window is. * * @param {IReduxState} state - The redux state. * @returns {boolean} */ export function isTimeTimerVisible(state: IReduxState): boolean { const timerState = state['features/time-timer']; if (!isTimeTimerEnabled(state) || !timerState.running) { return false; } const suppressForSeconds = getTimeTimerSuppressSeconds(state); return suppressForSeconds <= 0 || getTimerVisualState(timerState).elapsedSeconds >= suppressForSeconds; } /** * Whether the meeting has run past its scheduled end and the user has not yet * dismissed the timer-ended notification. Drives the red border around the * conference grid. * * @param {IReduxState} state - The redux state. * @returns {boolean} */ export function isTimeTimerExpiredUnacknowledged(state: IReduxState): boolean { const { acknowledged, expired } = state['features/time-timer']; return expired && !acknowledged; }