snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user