snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
79
react/features/time-timer/actionTypes.ts
Normal file
79
react/features/time-timer/actionTypes.ts
Normal 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';
|
||||
137
react/features/time-timer/actions.ts
Normal file
137
react/features/time-timer/actions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
219
react/features/time-timer/components/native/Disk.tsx
Normal file
219
react/features/time-timer/components/native/Disk.tsx
Normal 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;
|
||||
181
react/features/time-timer/components/web/Disk.tsx
Normal file
181
react/features/time-timer/components/web/Disk.tsx
Normal 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;
|
||||
@@ -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;
|
||||
278
react/features/time-timer/components/web/TimeTimerPill.tsx
Normal file
278
react/features/time-timer/components/web/TimeTimerPill.tsx
Normal 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;
|
||||
19
react/features/time-timer/constants.ts
Normal file
19
react/features/time-timer/constants.ts
Normal 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
|
||||
183
react/features/time-timer/functions.any.ts
Normal file
183
react/features/time-timer/functions.any.ts
Normal 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;
|
||||
}
|
||||
17
react/features/time-timer/functions.native.ts
Normal file
17
react/features/time-timer/functions.native.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
16
react/features/time-timer/functions.web.ts
Normal file
16
react/features/time-timer/functions.web.ts
Normal 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);
|
||||
}
|
||||
3
react/features/time-timer/logger.ts
Normal file
3
react/features/time-timer/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/time-timer');
|
||||
225
react/features/time-timer/middleware.ts
Normal file
225
react/features/time-timer/middleware.ts
Normal 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;
|
||||
});
|
||||
165
react/features/time-timer/reducer.ts
Normal file
165
react/features/time-timer/reducer.ts
Normal 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;
|
||||
});
|
||||
49
react/features/time-timer/types.ts
Normal file
49
react/features/time-timer/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user