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

279 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;