snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
/**
* The type of (redux) action which records the duration (and optionally the
* scheduled start + room URL) of the calendar event the participant is about
* to join, so the timer can be started from a native calendar source on join.
*
* {
* type: SET_TIME_TIMER_CALENDAR_DURATION,
* durationSeconds: number|undefined,
* startTimeUnix: number|undefined,
* url: string|undefined
* }
*/
export const SET_TIME_TIMER_CALENDAR_DURATION = 'SET_TIME_TIMER_CALENDAR_DURATION';
/**
* The type of (redux) action which marks the timer-ended notification as
* acknowledged (dismissed) by the user.
*
* {
* type: SET_TIME_TIMER_ACKNOWLEDGED
* }
*/
export const SET_TIME_TIMER_ACKNOWLEDGED = 'SET_TIME_TIMER_ACKNOWLEDGED';
/**
* The type of (redux) action which records that the meeting has reached or
* passed its scheduled end.
*
* {
* type: SET_TIME_TIMER_EXPIRED
* }
*/
export const SET_TIME_TIMER_EXPIRED = 'SET_TIME_TIMER_EXPIRED';
/**
* The type of (redux) action which records that the one-time warning-window
* attention-grab (bar expand) has fired, so it does not repeat.
*
* {
* type: SET_TIME_TIMER_WARNING_TRIGGERED
* }
*/
export const SET_TIME_TIMER_WARNING_TRIGGERED = 'SET_TIME_TIMER_WARNING_TRIGGERED';
/**
* The type of (redux) action which starts (or restarts) the timer for a
* meeting of a known duration.
*
* {
* type: START_TIME_TIMER,
* durationSeconds: number,
* elapsedSeconds: number,
* nowUnix: number
* }
*/
export const START_TIME_TIMER = 'START_TIME_TIMER';
/**
* The type of (redux) action which stops the timer and clears its derived
* state (running / expired / acknowledged / counters).
*
* {
* type: STOP_TIME_TIMER
* }
*/
export const STOP_TIME_TIMER = 'STOP_TIME_TIMER';
/**
* The type of (redux) action dispatched once per second while the timer runs.
* It carries a fresh wall-clock reading so the reducer recomputes the
* remaining / overrun time from the stored scheduled-end timestamp rather than
* counting ticks (which drift when a background tab throttles the interval).
*
* {
* type: TICK_TIME_TIMER,
* nowUnix: number
* }
*/
export const TICK_TIME_TIMER = 'TICK_TIME_TIMER';

View File

@@ -0,0 +1,137 @@
import { IStore } from '../app/types';
import {
SET_TIME_TIMER_ACKNOWLEDGED,
SET_TIME_TIMER_CALENDAR_DURATION,
SET_TIME_TIMER_EXPIRED,
SET_TIME_TIMER_WARNING_TRIGGERED,
START_TIME_TIMER,
STOP_TIME_TIMER,
TICK_TIME_TIMER
} from './actionTypes';
import { isTimeTimerEnabled } from './functions';
/**
* Starts the time-timer.
*
* @param {number} durationSeconds - The meeting's scheduled duration. Always
* supplied by a real source (a calendar event or the iframe API) — the timer
* is never started without one.
* @param {number} elapsedSeconds - Seconds elapsed since the scheduled
* start. MAY exceed durationSeconds (late joiner past the scheduled end) —
* the reducer pins the scheduled end from this value and derives remaining /
* overrun / expired from timestamps thereafter.
* @returns {Object}
*/
export function startTimeTimer(
durationSeconds: number,
elapsedSeconds = 0) {
return {
type: START_TIME_TIMER,
durationSeconds,
elapsedSeconds,
nowUnix: Date.now()
};
}
export function stopTimeTimer() {
return {
type: STOP_TIME_TIMER
};
}
/**
* Advances the timer by recomputing its remaining / overrun counters from the
* stored scheduled-end timestamp against the current wall-clock time. Carrying
* `nowUnix` keeps the reducer pure and makes the timer immune to background-tab
* interval throttling.
*
* @returns {Object}
*/
export function tickTimeTimer() {
return {
type: TICK_TIME_TIMER,
nowUnix: Date.now()
};
}
/**
* Sets (or clears) the meeting timer at runtime from the iframe API
* `setMeetingTimer` command. This is the channel embedded/JaaS deployments
* use — the host app (which owns the meeting schedule) pushes the values in,
* with no calendar or config involved.
*
* A positive `duration` starts/updates the timer; a missing or non-positive
* duration clears it (so the embedder can turn the timer off for a meeting it
* has no schedule for). The command is ignored entirely when the feature is
* disabled (`timeTimer.enabled === false`), so a deployment that opts out is
* never driven into the running / expired state through this path.
*
* @param {Object} options - Timer parameters from the embedder.
* @param {number} options.duration - Scheduled duration in seconds.
* @param {number} [options.elapsed] - Seconds since the scheduled start.
* May be negative (the meeting hasn't started yet — the timer sits at the
* full duration until the start arrives) or exceed the duration (a late
* joiner, landing directly in the overrun state). Defaults to 0.
* @returns {Function}
*/
export function setMeetingTimer({ duration, elapsed = 0 }: { duration?: number; elapsed?: number; } = {}) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!isTimeTimerEnabled(getState())) {
return;
}
if (typeof duration === 'number' && duration > 0) {
dispatch(startTimeTimer(duration, elapsed));
} else {
dispatch(stopTimeTimer());
}
};
}
/**
* Records the duration (and optionally the scheduled start time + room URL) of
* the calendar event the participant is about to join, so the time-timer can
* derive duration + elapsed from a native calendar source — independent of any
* iframe-API embedder. Mirrors the `configOverwrite.timeTimer` channel: the
* timer core stays source-agnostic, this is just one more way to feed it.
*
* @param {number|undefined} durationSeconds - Scheduled duration in seconds.
* @param {Object} [options] - Optional context.
* @param {number} [options.startTimeUnix] - Scheduled start, ms since epoch.
* When provided, the middleware computes `elapsed = now - start` at join so
* late joiners land in the correct (possibly overrun) state.
* @param {string} [options.url] - The room URL this duration belongs to. The
* middleware only applies the duration when the joined conference matches, so
* a duration from a meeting the user opened but never joined cannot leak into
* a different meeting.
* @returns {Object}
*/
export function setCalendarTimerDuration(
durationSeconds: number | undefined,
{ startTimeUnix, url }: { startTimeUnix?: number; url?: string; } = {}) {
return {
type: SET_TIME_TIMER_CALENDAR_DURATION,
durationSeconds,
startTimeUnix,
url
};
}
export function setTimeTimerExpired() {
return {
type: SET_TIME_TIMER_EXPIRED
};
}
export function setTimeTimerAcknowledged() {
return {
type: SET_TIME_TIMER_ACKNOWLEDGED
};
}
export function setTimeTimerWarningTriggered() {
return {
type: SET_TIME_TIMER_WARNING_TRIGGERED
};
}

View File

@@ -0,0 +1,219 @@
import React from 'react';
import Svg, { Circle, Path } from 'react-native-svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
/**
* Native SVG disk for {@code TimeTimerLabel}: a ring with a wedge growing
* clockwise from 12 o'clock. Since react-native-svg has no conic gradient, the
* lap-2+ overrun sweep is drawn as thin angular slices whose colours
* interpolate the same gradient web renders with CSS.
*/
const CX = 50;
const CY = 50;
const RADIUS = 46;
// Number of thin angular slices used to emulate web's conic gradient across
// the swept region. 60 keeps each slice ≤ a few degrees, so the banding is
// imperceptible at the pill's disk size while staying cheap to render.
const OVERRUN_SLICES = 60;
/**
* Converts a polar coordinate (clock angle in degrees, 0 = 12 o'clock) to
* cartesian coordinates on the disk. Identical to web's {@code Disk}.
*
* @param {number} angleDeg - Angle in degrees, clockwise from the top.
* @param {number} r - Radius.
* @returns {{ x: number, y: number }}
*/
function polarToCartesian(angleDeg: number, r: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: CX + (r * Math.cos(rad)),
y: CY + (r * Math.sin(rad))
};
}
/**
* Builds an SVG path for a filled pie wedge covering the given fraction of
* the disk, drawn clockwise from the top. Identical to web's {@code Disk}.
*
* @param {number} fraction - Fraction of the disk to fill (0..1).
* @returns {string}
*/
function wedgePath(fraction: number): string {
const clamped = Math.max(0, Math.min(1, fraction));
if (clamped <= 0) {
return '';
}
if (clamped >= 1) {
const top = polarToCartesian(0, RADIUS);
const bottom = polarToCartesian(180, RADIUS);
return [
`M ${top.x} ${top.y}`,
`A ${RADIUS} ${RADIUS} 0 0 1 ${bottom.x} ${bottom.y}`,
`A ${RADIUS} ${RADIUS} 0 0 1 ${top.x} ${top.y}`,
'Z'
].join(' ');
}
const endAngle = clamped * 360;
const start = polarToCartesian(0, RADIUS);
const end = polarToCartesian(endAngle, RADIUS);
const largeArc = endAngle > 180 ? 1 : 0;
return [
`M ${CX} ${CY}`,
`L ${start.x} ${start.y}`,
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`,
'Z'
].join(' ');
}
/**
* Linearly interpolates between two #rrggbb hex colours.
*
* @param {string} from - Start colour (#rrggbb).
* @param {string} to - End colour (#rrggbb).
* @param {number} t - Interpolation factor, 0..1.
* @returns {string}
*/
function lerpHexColor(from: string, to: string, t: number): string {
const f = parseInt(from.slice(1), 16);
const g = parseInt(to.slice(1), 16);
const fr = (f >> 16) & 0xff, fg = (f >> 8) & 0xff, fb = f & 0xff;
const gr = (g >> 16) & 0xff, gg = (g >> 8) & 0xff, gb = g & 0xff;
const r = Math.round(fr + ((gr - fr) * t));
const gc = Math.round(fg + ((gg - fg) * t));
const b = Math.round(fb + ((gb - fb) * t));
return `#${((1 << 24) + (r << 16) + (gc << 8) + b).toString(16).slice(1)}`;
}
/**
* Builds a filled pie-wedge path spanning [startDeg, endDeg] clockwise from
* the 12 o'clock origin. Used to emulate the conic gradient one thin slice at
* a time.
*
* @param {number} startDeg - Start angle in degrees.
* @param {number} endDeg - End angle in degrees.
* @returns {string}
*/
function slicePath(startDeg: number, endDeg: number): string {
const start = polarToCartesian(startDeg, RADIUS);
const end = polarToCartesian(endDeg, RADIUS);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return [
`M ${CX} ${CY}`,
`L ${start.x} ${start.y}`,
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`,
'Z'
].join(' ');
}
interface IProps {
/**
* Fill colour for the disk (blue / amber / red per timer state).
*/
color: string;
/**
* Fraction of the disk to fill (0..1).
*/
fraction: number;
/**
* Angular position (degrees, clockwise from the top) of the leading edge
* of the overrun sweep during lap 2 and onward. When provided, the
* conic-gradient sweep (base colour → darker edge colour) is drawn from
* the origin to this angle over the fully filled disk.
*/
overrunArcEndDeg?: number;
/**
* Rendered size (width and height) of the disk in points.
*/
size: number;
}
/**
* Renders the time-timer disk as a ring with an optional filled wedge, plus a
* stroked overrun arc during lap 2+.
*
* @param {IProps} props - Component props.
* @returns {ReactElement}
*/
const Disk = ({ color, fraction, overrunArcEndDeg, size }: IProps) => {
const showOverrun = typeof overrunArcEndDeg === 'number';
const wedge = wedgePath(fraction);
// Reproduce web's conic gradient over the swept region [0, deg]: base
// colour at the origin fading to the darker edge colour at the leading
// edge, matched slice-by-slice. The remainder of the disk stays the base
// colour (already painted by the full wedge below).
let overrunSlices: React.ReactElement[] = [];
if (showOverrun) {
const deg = Math.max(0, Math.min(360, overrunArcEndDeg ?? 0));
const step = deg / OVERRUN_SLICES;
overrunSlices = Array.from({ length: OVERRUN_SLICES }, (_, i) => {
const a0 = i * step;
const a1 = (i + 1) * step;
// Colour the gradient would have at the middle of this slice.
const t = deg > 0 ? ((a0 + a1) / 2) / deg : 0;
return (
<Path
d = { slicePath(a0, a1) }
fill = { lerpHexColor(
BaseTheme.palette.timeTimerExpiredDisk,
BaseTheme.palette.timeTimerExpiredOverrunEdge,
t) }
key = { i } />
);
});
}
return (
<Svg
height = { size }
viewBox = '0 0 100 100'
width = { size }>
{/*
* Faint 5% white fill behind the wedge so the unfilled portion
* reads as a subtle lighter shape against the navy chip
* background. Drawn FIRST so the wedge paints over it.
*/}
<Circle
cx = { CX }
cy = { CY }
fill = '#FFFFFF'
fillOpacity = { 0.05 }
r = { RADIUS } />
<Circle
cx = { CX }
cy = { CY }
fill = 'none'
r = { RADIUS }
stroke = { color }
strokeWidth = { 6 } />
{ wedge !== '' && (
<Path
d = { wedge }
fill = { color } />
) }
{ overrunSlices }
</Svg>
);
};
export default Disk;

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
/**
* SVG disk renderer used by {@code TimeTimerPill}. Draws a ring with an
* optional filled wedge. During overrun lap 2+ (once the disk has filled
* once), an additional CSS conic-gradient overlay conveys the ongoing
* sweep — a continuous gradient from the base red at the 12 o'clock origin
* to a darker red at the current leading edge.
*/
const CX = 50;
const CY = 50;
const RADIUS = 46;
/**
* Converts a polar coordinate (clock angle in degrees, 0 = 12 o'clock) to
* cartesian coordinates on the disk.
*
* @param {number} angleDeg - Angle in degrees, clockwise from the top.
* @param {number} r - Radius.
* @returns {{ x: number, y: number }}
*/
function polarToCartesian(angleDeg: number, r: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: CX + (r * Math.cos(rad)),
y: CY + (r * Math.sin(rad))
};
}
/**
* Builds an SVG path for a filled pie wedge covering the given fraction of
* the disk, drawn clockwise from the top.
*
* @param {number} fraction - Fraction of the disk to fill (0..1).
* @returns {string}
*/
function wedgePath(fraction: number): string {
const clamped = Math.max(0, Math.min(1, fraction));
if (clamped <= 0) {
return '';
}
if (clamped >= 1) {
const top = polarToCartesian(0, RADIUS);
const bottom = polarToCartesian(180, RADIUS);
return [
`M ${top.x} ${top.y}`,
`A ${RADIUS} ${RADIUS} 0 0 1 ${bottom.x} ${bottom.y}`,
`A ${RADIUS} ${RADIUS} 0 0 1 ${top.x} ${top.y}`,
'Z'
].join(' ');
}
const endAngle = clamped * 360;
const start = polarToCartesian(0, RADIUS);
const end = polarToCartesian(endAngle, RADIUS);
const largeArc = endAngle > 180 ? 1 : 0;
return [
`M ${CX} ${CY}`,
`L ${start.x} ${start.y}`,
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`,
'Z'
].join(' ');
}
interface IProps {
color: string;
fraction: number;
/**
* Angular position (degrees, clockwise from the top / 12 o'clock) of
* the leading edge of the overrun sweep during lap 2 and onward. When
* provided, the disk renders a conic-gradient overlay from the base
* red at the origin to a darker red at this angle, over the (fully
* filled) base disk.
*/
overrunArcEndDeg?: number;
size: number;
}
const useStyles = makeStyles<{ size: number; }>()((_theme, { size }) => {
return {
root: {
flex: '0 0 auto',
height: size,
position: 'relative',
width: size
},
svg: {
display: 'block',
height: size,
width: size
},
// Circular element overlaid exactly on top of the solid SVG disk. Its
// conic-gradient background is applied inline (see below) rather than
// here: the leading-edge angle changes every second during overrun, so
// baking it into a tss-react style would generate a fresh CSS class on
// every tick. The static positioning stays here.
overrunOverlay: {
borderRadius: '50%',
inset: 0,
position: 'absolute'
}
};
});
/**
* Builds the lap-2+ overrun sweep — a conic gradient running clockwise from
* the 12 o'clock origin: base red → darker red at the current leading edge,
* then back to base red for the remainder (already filled from lap 1).
*
* @param {number} deg - Angular position of the leading edge, in degrees.
* @returns {string}
*/
function overrunGradient(deg: number): string {
const base = BaseTheme.palette.timeTimerExpiredDisk;
const edge = BaseTheme.palette.timeTimerExpiredOverrunEdge;
return `conic-gradient(from 0deg, ${base} 0deg, ${edge} ${deg}deg, ${base} ${deg}deg, ${base} 360deg)`;
}
/**
* Renders the time-timer disk as a ring with an optional filled wedge, plus
* a conic-gradient overrun sweep during lap 2+.
*
* @param {IProps} props - Component props.
* @returns {ReactElement}
*/
const Disk = ({ color, fraction, overrunArcEndDeg, size }: IProps) => {
const { classes } = useStyles({ size });
const showOverrun = typeof overrunArcEndDeg === 'number';
return (
<div className = { classes.root }>
<svg
className = { classes.svg }
viewBox = '0 0 100 100'>
{/*
* Per Figma: a faint 5% white fill behind the wedge so the
* unfilled portion of the disk reads as a subtle lighter
* shape against the navy chip background. Drawn FIRST so the
* wedge paints over it.
*/}
<circle
cx = { CX }
cy = { CY }
fill = '#FFFFFF'
fillOpacity = '0.05'
r = { RADIUS } />
<circle
cx = { CX }
cy = { CY }
fill = 'none'
r = { RADIUS }
stroke = { color }
strokeWidth = '6' />
{fraction > 0 && (
<path
d = { wedgePath(fraction) }
fill = { color } />
)}
</svg>
{showOverrun && (
<div
className = { classes.overrunOverlay }
style = {{ background: overrunGradient(overrunArcEndDeg ?? 0) }} />
)}
</div>
);
};
export default Disk;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getLocalizedDurationFormatter } from '../../../base/i18n/dateUtil';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
/**
* Description for the sticky "Timer ended" notification. Re-renders every
* second as the overrun counter ticks.
*
* @returns {ReactElement}
*/
function TimeTimerEndedDescription() {
const { t } = useTranslation();
const overSeconds = useSelector((state: IReduxState) => state['features/time-timer'].overSeconds);
// Split on a sentinel so the time span can be coloured without a new
// translation key.
const TIME_PLACEHOLDER = '__TIME__';
const raw = t('timeTimer.endedOver', { time: TIME_PLACEHOLDER });
const [ pre, post ] = raw.split(TIME_PLACEHOLDER);
return (
<span>
{pre}
<span style = {{ color: BaseTheme.palette.timeTimerExpiredNotificationText }}>
{getLocalizedDurationFormatter(overSeconds * 1000)}
</span>
{post}
</span>
);
}
export default TimeTimerEndedDescription;

View File

@@ -0,0 +1,278 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { getConferenceName } from '../../../base/conference/functions';
import { getLocalizedDurationFormatter } from '../../../base/i18n/dateUtil';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
import { isToolboxVisible } from '../../../toolbox/functions.web';
import { getTimerVisualState, isTimeTimerVisible } from '../../functions';
import Disk from './Disk';
// Geometry — matches the Figma spec: 28px tall pill, 20px disk, 6px corner
// radius, two side-by-side segments with their own paddings and backgrounds.
const PILL_HEIGHT = 28;
const DISK_SIZE = 20;
const BORDER_RADIUS = 6;
// Per Figma: left segment uses 12px horizontal padding around the meeting
// name + scheduled duration; right segment uses 4px vertical / 8px horizontal
// padding around the remaining-time digits + disk.
const NAME_SEGMENT_PAD_X = '12px';
const TIMER_SEGMENT_PAD_X = '8px';
const TIMER_SEGMENT_PAD_Y = '4px';
const NAME_GAP = '8px';
const TIMER_GAP = '8px';
// Figma palette — deep navy + lighter navy + bright blue, replacing the
// previous translucent-black backgrounds.
const NAME_SEGMENT_BG = '#1F3057';
const TIMER_SEGMENT_BG = '#1A2542';
const REMAINING_TEXT_COLOR = '#82C3FE';
// When collapsed the pill becomes a 36×28 chip — per Figma, slightly wider
// than tall, with 8px horizontal and 4px vertical padding around the 20px
// disk. The same `4px 8px` padding the right-segment uses while expanded
// keeps the disk's position consistent through the collapse animation.
const COLLAPSED_WIDTH = '36px';
const COLLAPSED_DISK_PAD_X = '8px';
const COLLAPSED_DISK_PAD_Y = '4px';
// Collapse / expand timing — matches the surrounding `.subject` info bar's
// `.6s ease-in-out` fade (see _subject.scss) so the morph stays in sync.
const TIMING = '.6s ease-in-out';
const CONTAINER_TRANSITION = `max-width ${TIMING}`;
const SEGMENT_TRANSITION = `padding ${TIMING}`;
const TEXT_TRANSITION = `opacity ${TIMING}`;
const useStyles = makeStyles()(theme => {
return {
// Outer clipping container. A single `max-width` transition shrinks
// this from the natural pill width down to the chip width; everything
// inside is right-anchored (`justify-content: flex-end`) so the LEFT
// side gets clipped off as the box narrows, producing a smooth
// single-property morph from full pill → disk-only chip. The outer
// border-radius means the visible portion always has matching rounded
// corners on whichever side currently shows.
container: {
...theme.typography.labelRegular,
borderRadius: `${BORDER_RADIUS}px`,
boxSizing: 'border-box',
color: theme.palette.text01,
display: 'flex',
height: PILL_HEIGHT,
justifyContent: 'flex-end',
// Bound the expanded width so the max-width transition has an
// immediate visual effect. Names longer than 324px are already
// truncated with ellipsis (see `nameSegment.maxWidth`), so this
// figure is a safe ceiling for the full pill.
maxWidth: '450px',
overflow: 'hidden',
transition: CONTAINER_TRANSITION,
'@media (max-width: 300px)': {
display: 'none'
}
},
containerCollapsed: {
maxWidth: COLLAPSED_WIDTH
},
// Left segment: meeting name + scheduled duration. Lighter navy.
nameSegment: {
alignItems: 'center',
backgroundColor: NAME_SEGMENT_BG,
display: 'flex',
flex: '0 0 auto',
gap: NAME_GAP,
maxWidth: '324px',
paddingLeft: NAME_SEGMENT_PAD_X,
paddingRight: NAME_SEGMENT_PAD_X
},
// Text spans fade to opacity 0 in lock-step with the width collapse
// so the pill's content visually dissolves into the shrinking shape
// rather than getting hard-clipped at the container's edge.
name: {
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: TEXT_TRANSITION,
whiteSpace: 'nowrap'
},
scheduled: {
flex: '0 0 auto',
fontVariantNumeric: 'tabular-nums',
transition: TEXT_TRANSITION,
whiteSpace: 'nowrap'
},
// Right segment: elapsed time + disk. Deeper navy. Keeps its
// natural width while expanded — the outer container's collapsing
// max-width is what clips everything to the left of the disk away.
// When collapsed, the left/right paddings shrink so the disk sits
// symmetrically in a square chip — that subtle right-side pull-in
// (along with the much larger left-side clip via max-width) makes
// the morph feel like the shape closes in from both directions
// rather than just the left.
timerSegment: {
alignItems: 'center',
backgroundColor: TIMER_SEGMENT_BG,
display: 'flex',
flex: '0 0 auto',
gap: TIMER_GAP,
paddingBottom: TIMER_SEGMENT_PAD_Y,
paddingLeft: TIMER_SEGMENT_PAD_X,
paddingRight: TIMER_SEGMENT_PAD_X,
paddingTop: TIMER_SEGMENT_PAD_Y,
transition: SEGMENT_TRANSITION
},
timerSegmentCollapsed: {
paddingBottom: COLLAPSED_DISK_PAD_Y,
paddingLeft: COLLAPSED_DISK_PAD_X,
paddingRight: COLLAPSED_DISK_PAD_X,
paddingTop: COLLAPSED_DISK_PAD_Y
},
// Remaining-time digits — Inter regular at the same size/spacing as
// Figma's "Labels/labelBold" spec, just dropped to weight 400 per
// design feedback. Colour comes from the active state class
// (`elapsedWarning`, `elapsedExpired`) or falls back to the
// bright `#82C3FE` baseline.
elapsed: {
color: REMAINING_TEXT_COLOR,
flex: '0 0 auto',
fontFamily: 'Inter, sans-serif',
fontSize: '12px',
fontStyle: 'normal',
fontVariantNumeric: 'tabular-nums',
fontWeight: 400,
letterSpacing: '0.16px',
lineHeight: '16px',
transition: TEXT_TRANSITION,
whiteSpace: 'nowrap'
},
// Warning state (amber).
elapsedWarning: {
color: BaseTheme.palette.timeTimerWarning
},
nameSegmentWarning: {
backgroundColor: BaseTheme.palette.timeTimerWarningNameSegmentBackground
},
timerSegmentWarning: {
backgroundColor: BaseTheme.palette.timeTimerWarningTimerSegmentBackground
},
// Expired state (wine red); overrides warning.
nameSegmentExpired: {
backgroundColor: BaseTheme.palette.timeTimerExpiredNameSegmentBackground
},
timerSegmentExpired: {
backgroundColor: BaseTheme.palette.timeTimerExpiredTimerSegmentBackground
},
elapsedExpired: {
color: BaseTheme.palette.timeTimerExpiredText
},
textHidden: {
opacity: 0
}
};
});
/**
* Time-timer pill rendered in the always-visible conference info row.
*
* Shows the meeting name + scheduled duration on the left and the elapsed
* time + countdown disk on the right. When the toolbox auto-hides, the pill
* smoothly retracts from left to right via a single `max-width` transition
* on the outer container — content is anchored to the right edge and clipped
* by the container's rounded corners as it narrows, leaving a chip-sized
* dark rectangle around the disk.
*
* Living in `alwaysVisible` (rather than `autoHide`) is what lets us keep
* something rendered while the rest of the bar collapses, so the disk stays
* visible to the user at all times.
*
* Elapsed-time formatting reuses {@code getLocalizedDurationFormatter} — the
* same util used by Jitsi's {@code ConferenceTimer} — for consistency.
*
* @returns {ReactElement | null}
*/
const TimeTimerPill = () => {
const { classes } = useStyles();
const timerState = useSelector((state: IReduxState) => state['features/time-timer']);
const visible = useSelector(isTimeTimerVisible);
const meetingName = useSelector(getConferenceName);
const toolboxVisible = useSelector(isToolboxVisible);
// Not enabled, no timer running, or still inside the configured
// `timeTimer.suppressForSeconds` window — see isTimeTimerVisible. The info
// bar is gated on the same selector, so the subject and conference-timer
// labels this pill supersedes come back while it is hidden.
if (!visible) {
return null;
}
const { elapsedSeconds, fillColor, fraction, overrunArcEndDeg, warning } = getTimerVisualState(timerState);
const { expired } = timerState;
const scheduled = getLocalizedDurationFormatter(timerState.durationSeconds * 1000);
const elapsed = getLocalizedDurationFormatter(elapsedSeconds * 1000);
const collapsed = !toolboxVisible;
// Expired wins over warning if both somehow applied — the red colour is
// more urgent. In practice they're mutually exclusive (warning is gated
// on `!expired` in getTimerVisualState).
let nameSegmentStateClass = '';
let timerSegmentStateClass = '';
let elapsedStateClass = '';
if (expired) {
nameSegmentStateClass = ` ${classes.nameSegmentExpired}`;
timerSegmentStateClass = ` ${classes.timerSegmentExpired}`;
elapsedStateClass = ` ${classes.elapsedExpired}`;
} else if (warning) {
nameSegmentStateClass = ` ${classes.nameSegmentWarning}`;
timerSegmentStateClass = ` ${classes.timerSegmentWarning}`;
elapsedStateClass = ` ${classes.elapsedWarning}`;
}
const containerClass
= `${classes.container}${collapsed ? ` ${classes.containerCollapsed}` : ''}`;
const nameSegmentClass = `${classes.nameSegment}${nameSegmentStateClass}`;
const timerSegmentClass = `${classes.timerSegment}`
+ `${collapsed ? ` ${classes.timerSegmentCollapsed}` : ''}`
+ timerSegmentStateClass;
const hiddenClass = collapsed ? ` ${classes.textHidden}` : '';
const elapsedClass = `${classes.elapsed}${elapsedStateClass}${hiddenClass}`;
return (
<div
className = { containerClass }
data-testid = 'time-timer-pill'>
<div className = { nameSegmentClass }>
<span className = { `${classes.name}${hiddenClass}` }>{ meetingName }</span>
<span className = { `${classes.scheduled}${hiddenClass}` }>({ scheduled })</span>
</div>
<div className = { timerSegmentClass }>
<span className = { elapsedClass }>{ elapsed }</span>
<Disk
color = { fillColor }
fraction = { fraction }
overrunArcEndDeg = { overrunArcEndDeg }
size = { DISK_SIZE } />
</div>
</div>
);
};
export default TimeTimerPill;

View File

@@ -0,0 +1,19 @@
export const TIME_TIMER_NOTIFICATION_ID = 'time-timer-ended';
/**
* `type` of the json-message the `mod_time_restricted` Prosody plugin sends to
* every occupant as they join. It is sent from the room JID, so lib-jitsi-meet
* surfaces it as a NON_PARTICIPANT_MESSAGE_RECEIVED (a server-originated
* message, not a participant's), which the time-timer middleware turns into a
* `startTimeTimer` for the server-enforced limit. When the countdown should
* not be on screen from the very start, `timeTimer.suppressForSeconds` holds
* back the display — the timer itself runs from the moment we are told about it.
*/
export const TIME_RESTRICTED_MESSAGE_TYPE = 'time_restricted';
/**
* Threshold (seconds remaining) at which the timer enters its "warning"
* state: the disk + elapsed digits flip to amber and the pill briefly
* expands to draw the user's attention to the final stretch.
*/
export const WARNING_THRESHOLD_SECONDS = 300; // 5 minutes

View File

@@ -0,0 +1,183 @@
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;
}

View File

@@ -0,0 +1,17 @@
import { getLocalizedDurationFormatter } from '../base/i18n/dateUtil';
import i18next from '../base/i18n/i18next';
export * from './functions.any';
/**
* Description for the "Timer ended" notification on native: a plain string.
* The middleware re-posts it each tick so the overrun counter stays live.
*
* @param {number} overSeconds - Seconds over the scheduled end.
* @returns {string}
*/
export function buildTimerEndedDescription(overSeconds: number) {
return i18next.t('timeTimer.endedOver', {
time: getLocalizedDurationFormatter(overSeconds * 1000)
});
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import TimeTimerEndedDescription from './components/web/TimeTimerEndedDescription';
export * from './functions.any';
/**
* Description for the "Timer ended" notification on web: a component that
* subscribes to `overSeconds` and ticks the overrun counter itself.
*
* @param {number} _overSeconds - Unused on web; kept for signature parity with
* native.
* @returns {React.ReactElement}
*/
export function buildTimerEndedDescription(_overSeconds: number) {
return React.createElement(TimeTimerEndedDescription);
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/time-timer');

View File

@@ -0,0 +1,225 @@
import { IStore } from '../app/types';
import { CONFERENCE_JOINED, CONFERENCE_LEFT, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { getRoomName } from '../base/conference/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { parseURIString } from '../base/util/uri';
import { HIDE_NOTIFICATION } from '../notifications/actionTypes';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_ICON, NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
import { showToolbox } from '../toolbox/actions';
import { START_TIME_TIMER, STOP_TIME_TIMER, TICK_TIME_TIMER } from './actionTypes';
import {
setTimeTimerAcknowledged,
setTimeTimerExpired,
setTimeTimerWarningTriggered,
startTimeTimer,
stopTimeTimer,
tickTimeTimer
} from './actions';
import {
TIME_RESTRICTED_MESSAGE_TYPE,
TIME_TIMER_NOTIFICATION_ID,
WARNING_THRESHOLD_SECONDS
} from './constants';
import { buildTimerEndedDescription, isTimeTimerEnabled } from './functions';
import logger from './logger';
let _tickInterval: ReturnType<typeof setInterval> | undefined;
// True once the "ended" notification has been posted for the current timer,
// so it fires exactly once. Reset when a timer starts or stops.
let _notifiedExpiry = false;
// A string description (native) can't update itself, so the middleware
// re-posts it each tick to keep the overrun counter live. A React element
// (web) ticks itself, so a single post is enough.
const _descriptionSelfTicks = typeof buildTimerEndedDescription(0) !== 'string';
/**
* Clears the per-second tick interval if one is running.
*
* @returns {void}
*/
function _clearTick() {
if (_tickInterval !== undefined) {
clearInterval(_tickInterval);
_tickInterval = undefined;
}
}
/**
* (Re)posts the sticky "Timer ended" notification. The shared uid replaces any
* existing entry in place, so this is safe to call repeatedly.
*
* @param {IStore} store - The redux store.
* @returns {void}
*/
function _postExpiredNotification({ dispatch, getState }: IStore) {
const { overSeconds } = getState()['features/time-timer'];
dispatch(showNotification({
appearance: NOTIFICATION_TYPE.NORMAL,
description: buildTimerEndedDescription(overSeconds),
icon: NOTIFICATION_ICON.ERROR,
titleKey: 'timeTimer.endedTitle',
uid: TIME_TIMER_NOTIFICATION_ID
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
/**
* Posts the "Timer ended" notification the first time the timer expires.
*
* @param {IStore} store - The redux store.
* @returns {void}
*/
function _notifyExpiredOnce(store: IStore) {
if (_notifiedExpiry) {
return;
}
_notifiedExpiry = true;
_postExpiredNotification(store);
}
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: any) => {
const result = next(action);
const { dispatch, getState } = store;
switch (action.type) {
case CONFERENCE_JOINED: {
// Enabled by default; a deployment opts out via timeTimer.enabled=false.
if (isTimeTimerEnabled(getState())) {
const { calendarDurationSeconds, calendarStartTimeUnix, calendarUrl }
= getState()['features/time-timer'];
// Only start from a calendar duration recorded for the room being
// joined — otherwise a duration from a meeting bailed on at prejoin
// could leak in. Match by room name (case-insensitive), not raw URL.
// The iframe-API path drives the timer separately, later.
const calendarRoom = calendarUrl ? parseURIString(calendarUrl)?.room?.toLowerCase() : undefined;
const joinedRoom = getRoomName(getState())?.toLowerCase();
const calendarMatchesRoom = !calendarRoom || calendarRoom === joinedRoom;
if (calendarMatchesRoom
&& typeof calendarDurationSeconds === 'number' && calendarDurationSeconds > 0) {
// Elapsed since the event's scheduled start (not since join),
// so the time matches for everyone. May be negative (joined
// early) or past the duration (joined late); the reducer
// handles both. No start time falls back to 0 (count from now).
const elapsed = typeof calendarStartTimeUnix === 'number'
? Math.round((Date.now() - calendarStartTimeUnix) / 1000)
: 0;
dispatch(startTimeTimer(calendarDurationSeconds, elapsed));
}
}
break;
}
case NON_PARTICIPANT_MESSAGE_RECEIVED: {
// The `mod_time_restricted` Prosody plugin sends this to every
// occupant as they join, so the countdown reflects the server-enforced
// limit. It is a non-participant message because the plugin sends it
// from the room JID rather than from a participant. `elapsedSeconds`
// keeps late joiners in sync with everyone else. Whether the countdown
// is actually on screen is a separate, display-only decision — see
// `timeTimer.suppressForSeconds` / isTimeTimerVisible.
const { id, json } = action;
// Only honour a message that genuinely came from the room JID itself:
// a bare JID has no resource, so the sender id is null. A message
// reflected from any occupant (even a hidden one) carries that
// occupant's nick as the id — reject those so a participant cannot
// forge a timer by sending this json-message into the room.
if (id) {
break;
}
if (json?.type === TIME_RESTRICTED_MESSAGE_TYPE && isTimeTimerEnabled(getState())) {
const duration = Number(json.durationSeconds);
const elapsed = Number(json.elapsedSeconds);
if (duration > 0) {
dispatch(startTimeTimer(duration, Math.max(0, elapsed || 0)));
}
}
break;
}
case CONFERENCE_LEFT: {
// Reset so the next meeting never inherits a stale pill or red border.
_clearTick();
dispatch(stopTimeTimer());
break;
}
case START_TIME_TIMER: {
logger.info(`Timer started: duration=${action.durationSeconds}s elapsed=${action.elapsedSeconds}s`);
_clearTick();
_notifiedExpiry = false;
_tickInterval = setInterval(() => {
dispatch(tickTimeTimer());
}, 1000);
// A timer that starts already expired never hits the live-crossing
// branch below, so post its notification here.
if (getState()['features/time-timer'].expired) {
_notifyExpiredOnce(store);
}
break;
}
case STOP_TIME_TIMER: {
_clearTick();
_notifiedExpiry = false;
// Clear the notification so it doesn't outlive the timer.
dispatch({
type: HIDE_NOTIFICATION,
uid: TIME_TIMER_NOTIFICATION_ID
});
break;
}
case TICK_TIME_TIMER: {
const { acknowledged, expired, remainingSeconds, running, warningTriggered }
= getState()['features/time-timer'];
if (!running) {
break;
}
// Entering the warning window: fire the one-time bar-expand. `<=`
// (not `===`) survives a background tab that jumps several seconds.
if (!warningTriggered && !expired && remainingSeconds <= WARNING_THRESHOLD_SECONDS) {
dispatch(setTimeTimerWarningTriggered());
dispatch(showToolbox());
}
// Crossing the scheduled end: flag expired and expand the bar once.
if (remainingSeconds <= 0 && !expired) {
dispatch(setTimeTimerExpired());
dispatch(showToolbox());
}
// While expired: post once, then (native only) re-post each tick so
// the static-string overrun counter keeps climbing — but not after the
// user dismisses it. Web's description self-ticks, so one post suffices.
if (remainingSeconds <= 0) {
if (_notifiedExpiry) {
if (!_descriptionSelfTicks && !acknowledged) {
_postExpiredNotification(store);
}
} else {
_notifyExpiredOnce(store);
}
}
break;
}
case HIDE_NOTIFICATION: {
// Closing the notification acknowledges it, clearing the red border.
if (action.uid === TIME_TIMER_NOTIFICATION_ID) {
dispatch(setTimeTimerAcknowledged());
}
break;
}
}
return result;
});

View File

@@ -0,0 +1,165 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
SET_TIME_TIMER_ACKNOWLEDGED,
SET_TIME_TIMER_CALENDAR_DURATION,
SET_TIME_TIMER_EXPIRED,
SET_TIME_TIMER_WARNING_TRIGGERED,
START_TIME_TIMER,
STOP_TIME_TIMER,
TICK_TIME_TIMER
} from './actionTypes';
import { WARNING_THRESHOLD_SECONDS } from './constants';
export interface ITimeTimerState {
acknowledged: boolean;
calendarDurationSeconds?: number;
/**
* Scheduled start of the calendar event (ms since epoch) the participant
* is joining, when supplied by a native calendar source. Lets the
* middleware compute elapsed-since-start at join time.
*/
calendarStartTimeUnix?: number;
/**
* Room URL the calendar duration was recorded for. The middleware only
* uses the duration when the joined conference matches this URL, so a
* duration left over from a meeting the user opened but never joined does
* not leak into a different meeting.
*/
calendarUrl?: string;
durationSeconds: number;
expired: boolean;
overSeconds: number;
remainingSeconds: number;
running: boolean;
/**
* Wall-clock time (ms since epoch) of the meeting's scheduled end. This is
* the single source of truth while the timer runs: `remainingSeconds` and
* `overSeconds` are recomputed from it against `Date.now()` on every tick,
* so a throttled background tab can never accumulate drift.
*/
scheduledEndUnix?: number;
warningTriggered: boolean;
}
const DEFAULT_STATE: ITimeTimerState = {
acknowledged: false,
durationSeconds: 0,
expired: false,
overSeconds: 0,
remainingSeconds: 0,
running: false,
warningTriggered: false
};
/**
* Derives the running counters (remaining / over / expired) from the
* scheduled-end timestamp and a wall-clock reading. Keeping this in one place
* means START and TICK stay consistent.
*
* @param {number} durationSeconds - The meeting's scheduled duration.
* @param {number} scheduledEndUnix - Scheduled end, ms since epoch.
* @param {number} nowUnix - Current wall-clock time, ms since epoch.
* @returns {{ expired: boolean, overSeconds: number, remainingSeconds: number }}
*/
function _deriveCounters(durationSeconds: number, scheduledEndUnix: number, nowUnix: number) {
const remainingSeconds = Math.max(0, Math.round((scheduledEndUnix - nowUnix) / 1000));
const overSeconds = Math.max(0, Math.round((nowUnix - scheduledEndUnix) / 1000));
return {
expired: remainingSeconds <= 0,
overSeconds,
remainingSeconds: Math.min(remainingSeconds, durationSeconds)
};
}
ReducerRegistry.register<ITimeTimerState>('features/time-timer', (state = DEFAULT_STATE, action) => {
switch (action.type) {
case SET_TIME_TIMER_CALENDAR_DURATION:
return {
...state,
calendarDurationSeconds: action.durationSeconds,
calendarStartTimeUnix: action.startTimeUnix,
calendarUrl: action.url
};
case START_TIME_TIMER: {
const { durationSeconds, nowUnix } = action;
// `elapsedSeconds` is seconds since the SCHEDULED start. It may be
// negative (joined before the scheduled start) or exceed the duration
// (joined after the scheduled end). We deliberately do NOT clamp it:
// from it and the current wall-clock time we pin the scheduled end as
// an absolute timestamp, and everything else is derived from that
// timestamp here and on every tick. A negative value pushes the end
// out to the true scheduled end, so an early joiner sees 00:00 / full
// duration until the start arrives, then the counter climbs — landing
// on the limit exactly at the scheduled end. A value past the duration
// lands a late joiner directly in the correct overrun / lap state.
const { elapsedSeconds } = action;
const scheduledEndUnix = nowUnix + ((durationSeconds - elapsedSeconds) * 1000);
const counters = _deriveCounters(durationSeconds, scheduledEndUnix, nowUnix);
return {
...state,
acknowledged: false,
durationSeconds,
...counters,
running: true,
scheduledEndUnix,
// Suppress the one-time attention-grab expand if we joined already
// inside the warning window or past the end — the disk just shows
// the correct colour from the start. `warningTriggered` only gates
// the animation, not the colour.
warningTriggered: counters.remainingSeconds <= WARNING_THRESHOLD_SECONDS
};
}
case STOP_TIME_TIMER:
// Full reset of the derived/running state so a stopped or cleared
// timer leaves nothing behind — no frozen pill, no lingering red
// border on the next meeting. Calendar inputs are managed separately
// by SET_TIME_TIMER_CALENDAR_DURATION and intentionally not touched
// here.
return {
...state,
acknowledged: false,
durationSeconds: 0,
expired: false,
overSeconds: 0,
remainingSeconds: 0,
running: false,
scheduledEndUnix: undefined,
warningTriggered: false
};
case SET_TIME_TIMER_EXPIRED:
return {
...state,
expired: true
};
case SET_TIME_TIMER_ACKNOWLEDGED:
return {
...state,
acknowledged: true
};
case SET_TIME_TIMER_WARNING_TRIGGERED:
return {
...state,
warningTriggered: true
};
case TICK_TIME_TIMER: {
if (!state.running || typeof state.scheduledEndUnix !== 'number') {
return state;
}
return {
...state,
..._deriveCounters(state.durationSeconds, state.scheduledEndUnix, action.nowUnix)
};
}
}
return state;
});

View File

@@ -0,0 +1,49 @@
/**
* Visual state of the time-timer disk, derived from the reducer state by
* {@code getTimerVisualState} and consumed by the pill/disk components.
*/
export interface ITimerVisualState {
/**
* Time elapsed since the calendar event's scheduled start, in seconds.
* Derived so every participant sees the same value regardless of when
* they joined.
*/
elapsedSeconds: number;
/**
* Disk fill colour (blue running, amber in the warning window, red once
* over schedule).
*/
fillColor: string;
/**
* Fraction of the disk to fill (0..1).
*/
fraction: number;
/**
* Leading-edge angle (degrees clockwise from 12 o'clock) of the overrun
* sweep during lap 2+, once the disk has filled once. `undefined` while
* running, warning, and the first overrun lap (a plain growing wedge).
*/
overrunArcEndDeg?: number;
/**
* True during the warning window — the final
* {@code WARNING_THRESHOLD_SECONDS} before scheduled end.
*/
warning: boolean;
}
/**
* A calendar event, limited to the fields {@code computeCalendarTimerDuration}
* reads. {@code startDate}/{@code endDate} are already epoch-ms numbers by the
* time an event reaches redux (see {@code _parseCalendarEntry}); a string is
* also accepted and coerced in case a raw ISO event is ever passed in directly.
*/
export interface ICalendarTimerEvent {
endDate?: number | string;
startDate?: number | string;
url?: string;
}