226 lines
8.5 KiB
TypeScript
226 lines
8.5 KiB
TypeScript
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;
|
|
});
|