mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-24 16:39:13 +00:00
refactor: increase update rate (#430)
* refactor: remove unused components * refactor: increase timer refresh rate
This commit is contained in:
@@ -1,9 +0,0 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export const queryClientMock = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Exported viewer link location
|
|
||||||
const minimalLocation = 'minimal';
|
|
||||||
const speakerLocation = 'speaker';
|
|
||||||
const smLocation = 'sm';
|
|
||||||
const publicLocation = 'public';
|
|
||||||
const studioLocation = 'studio';
|
|
||||||
const cuesheetLocation = 'cuesheet';
|
|
||||||
const countdownLocation = 'countdown';
|
|
||||||
const clockLocation = 'clock';
|
|
||||||
const lowerLocation = 'lower';
|
|
||||||
|
|
||||||
export const viewerLocations = [
|
|
||||||
{ link: speakerLocation, label: 'Stage timer' },
|
|
||||||
{ link: clockLocation, label: 'Clock' },
|
|
||||||
{ link: minimalLocation, label: 'Minimal timer' },
|
|
||||||
{ link: smLocation, label: 'Backstage screen' },
|
|
||||||
{ link: publicLocation, label: 'Public screen' },
|
|
||||||
{ link: lowerLocation, label: 'Lower thirds' },
|
|
||||||
{ link: studioLocation, label: 'Studio clock' },
|
|
||||||
{ link: countdownLocation, label: 'Countdown' },
|
|
||||||
{ link: cuesheetLocation, label: 'Cuesheet' },
|
|
||||||
];
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
|
|
||||||
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
export default function EnableBtn(props) {
|
|
||||||
const { active, text, actionHandler, size = 'xs' } = props;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={size}
|
|
||||||
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
|
|
||||||
colorScheme='blue'
|
|
||||||
variant={active ? 'solid' : 'outline'}
|
|
||||||
onClick={actionHandler}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EnableBtn.propTypes = {
|
|
||||||
active: PropTypes.bool,
|
|
||||||
text: PropTypes.string,
|
|
||||||
actionHandler: PropTypes.func,
|
|
||||||
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
|
||||||
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
interface PauseIconBtnProps {
|
|
||||||
clickhandler: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
||||||
active: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PauseIconBtn(props: PauseIconBtnProps) {
|
|
||||||
const { clickhandler, active, disabled, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label='Pause timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
|
||||||
<IconButton
|
|
||||||
icon={<IoPause size='24px' />}
|
|
||||||
colorScheme='orange'
|
|
||||||
variant={active ? 'solid' : 'outline'}
|
|
||||||
onClick={clickhandler}
|
|
||||||
width={120}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label='Pause playback'
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { FiUsers } from '@react-icons/all-files/fi/FiUsers';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
export default function PublicIconBtn(props) {
|
|
||||||
const { actionHandler, active, size = 'xs', ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label={active ? 'Make event private' : 'Make event public'}>
|
|
||||||
<IconButton
|
|
||||||
size={size}
|
|
||||||
icon={<FiUsers />}
|
|
||||||
colorScheme='blue'
|
|
||||||
variant={active ? 'solid' : 'outline'}
|
|
||||||
onClick={() => actionHandler('update', { field: 'isPublic', value: !active })}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PublicIconBtn.propTypes = {
|
|
||||||
actionHandler: PropTypes.func,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
export default function RollIconBtn(props) {
|
|
||||||
const { clickhandler, active, disabled, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label='Roll mode' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
|
||||||
<IconButton
|
|
||||||
icon={<IoTimeOutline size='24px' />}
|
|
||||||
colorScheme='blue'
|
|
||||||
variant={active ? 'solid' : 'outline'}
|
|
||||||
onClick={clickhandler}
|
|
||||||
width={120}
|
|
||||||
disabled={disabled}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RollIconBtn.propTypes = {
|
|
||||||
clickhandler: PropTypes.func,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
export default function StartIconBtn(props) {
|
|
||||||
const { clickhandler, active, disabled, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label='Start timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
|
||||||
<IconButton
|
|
||||||
icon={<IoPlay size='24px' />}
|
|
||||||
colorScheme='green'
|
|
||||||
variant={active ? 'solid' : 'outline'}
|
|
||||||
onClick={clickhandler}
|
|
||||||
width={120}
|
|
||||||
disabled={disabled}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
StartIconBtn.propTypes = {
|
|
||||||
clickhandler: PropTypes.func,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
disabled: PropTypes.bool
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
export default function TransportIconBtn(props) {
|
|
||||||
const { clickHandler, icon, tooltip, disabled, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label={tooltip} openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
|
||||||
<IconButton
|
|
||||||
icon={icon}
|
|
||||||
colorScheme='white'
|
|
||||||
variant='outline'
|
|
||||||
_hover={!disabled && { bg: '#ebedf0', color: '#333' }}
|
|
||||||
onClick={clickHandler}
|
|
||||||
width={90}
|
|
||||||
disabled={disabled}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TransportIconBtn.propTypes = {
|
|
||||||
clickHandler: PropTypes.func,
|
|
||||||
icon: PropTypes.element,
|
|
||||||
tooltip: PropTypes.string,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
|
||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
export default function UnloadIconBtn(props) {
|
|
||||||
const { clickHandler, disabled, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Tooltip label='Unload event' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
|
||||||
<IconButton
|
|
||||||
icon={<IoStop size='22px' />}
|
|
||||||
colorScheme='red'
|
|
||||||
variant='outline'
|
|
||||||
onClick={clickHandler}
|
|
||||||
width={90}
|
|
||||||
disabled={disabled}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
UnloadIconBtn.propTypes = {
|
|
||||||
clickHandler: PropTypes.func,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Alias } from 'ontime-types';
|
|
||||||
|
|
||||||
export const aliasPlaceholder: Alias = {
|
|
||||||
enabled: false,
|
|
||||||
alias: '',
|
|
||||||
pathAndParams: '',
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export const ontimeVars = [
|
|
||||||
{
|
|
||||||
name: '$timer',
|
|
||||||
description: 'Current running timer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$title',
|
|
||||||
description: 'Current title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$presenter',
|
|
||||||
description: 'Current timer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$subtitle',
|
|
||||||
description: 'Current subtitle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$next-title',
|
|
||||||
description: 'Next title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$next-presenter',
|
|
||||||
description: 'Next timer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '$next-subtitle',
|
|
||||||
description: 'Next subtitle',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -33,7 +33,7 @@ const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string
|
|||||||
},
|
},
|
||||||
onUpdate: {
|
onUpdate: {
|
||||||
title: 'On Every Second',
|
title: 'On Every Second',
|
||||||
subtitle: 'Triggers when timers are updated (at least once a second, can be more)',
|
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
|
||||||
},
|
},
|
||||||
onFinish: {
|
onFinish: {
|
||||||
title: 'On Finish',
|
title: 'On Finish',
|
||||||
|
|||||||
@@ -2,10 +2,3 @@ export const inputProps = {
|
|||||||
size: 'sm',
|
size: 'sm',
|
||||||
autoComplete: 'off',
|
autoComplete: 'off',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const portInputProps = {
|
|
||||||
...inputProps,
|
|
||||||
type: 'number',
|
|
||||||
min: '1024',
|
|
||||||
max: '65535',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { clock } from './Clock.js';
|
|||||||
|
|
||||||
export class TimerService {
|
export class TimerService {
|
||||||
private readonly _interval: NodeJS.Timer;
|
private readonly _interval: NodeJS.Timer;
|
||||||
|
private _updateInterval: number;
|
||||||
|
private _lastUpdate: number | null;
|
||||||
|
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
@@ -23,10 +25,12 @@ export class TimerService {
|
|||||||
* @constructor
|
* @constructor
|
||||||
* @param {object} [timerConfig]
|
* @param {object} [timerConfig]
|
||||||
* @param {number} [timerConfig.refresh]
|
* @param {number} [timerConfig.refresh]
|
||||||
|
* @param {number} [timerConfig.updateInterval]
|
||||||
*/
|
*/
|
||||||
constructor(timerConfig?) {
|
constructor(timerConfig: { refresh?: number; updateInterval?: number } = {}) {
|
||||||
this._clear();
|
this._clear();
|
||||||
this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000);
|
this._interval = setInterval(() => this.update(), timerConfig?.refresh ?? 1000);
|
||||||
|
this._updateInterval = timerConfig?.updateInterval ?? 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +57,8 @@ export class TimerService {
|
|||||||
this.pausedTime = 0;
|
this.pausedTime = 0;
|
||||||
this.pausedAt = null;
|
this.pausedAt = null;
|
||||||
this.secondaryTarget = null;
|
this.secondaryTarget = null;
|
||||||
|
|
||||||
|
this._lastUpdate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +99,7 @@ export class TimerService {
|
|||||||
if (this.timer.startedAt === null) {
|
if (this.timer.startedAt === null) {
|
||||||
this.timer.current = timer.duration;
|
this.timer.current = timer.duration;
|
||||||
}
|
}
|
||||||
this.update();
|
this.update(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,77 +246,95 @@ export class TimerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// force an update
|
// force an update
|
||||||
this.update();
|
this.update(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
private updateRoll() {
|
||||||
|
const tempCurrentTimer = {
|
||||||
|
selectedEventId: this.loadedTimerId,
|
||||||
|
current: this.timer.current,
|
||||||
|
// safeguard on midnight rollover
|
||||||
|
_finishAt:
|
||||||
|
this.timer.expectedFinish >= this.timer.startedAt
|
||||||
|
? this.timer.expectedFinish
|
||||||
|
: this.timer.expectedFinish + DAY_TO_MS,
|
||||||
|
|
||||||
|
clock: this.timer.clock,
|
||||||
|
secondaryTimer: this.timer.secondaryTimer,
|
||||||
|
secondaryTarget: this.secondaryTarget,
|
||||||
|
};
|
||||||
|
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
|
||||||
|
|
||||||
|
this.timer.current = updatedTimer;
|
||||||
|
this.timer.secondaryTimer = updatedSecondaryTimer;
|
||||||
|
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
||||||
|
|
||||||
|
if (isFinished) {
|
||||||
|
this.timer.selectedEventId = null;
|
||||||
|
this.loadedTimerId = null;
|
||||||
|
this._onFinish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// to load the next event we have to escalate to parent service
|
||||||
|
if (doRollLoad) {
|
||||||
|
PlaybackService.roll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePlay() {
|
||||||
|
if (this.playback === Playback.Pause) {
|
||||||
|
this.pausedTime = this.timer.clock - this.pausedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) {
|
||||||
|
this.timer.finishedAt = this.timer.clock;
|
||||||
|
this._onFinish();
|
||||||
|
} else {
|
||||||
|
this.timer.expectedFinish = getExpectedFinish(
|
||||||
|
this.timer.startedAt,
|
||||||
|
this.timer.finishedAt,
|
||||||
|
this.timer.duration,
|
||||||
|
this.pausedTime,
|
||||||
|
this.timer.addedTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.timer.current = getCurrent(
|
||||||
|
this.timer.startedAt,
|
||||||
|
this.timer.duration,
|
||||||
|
this.timer.addedTime,
|
||||||
|
this.pausedTime,
|
||||||
|
this.timer.clock,
|
||||||
|
);
|
||||||
|
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(force = false) {
|
||||||
this.timer.clock = clock.timeNow();
|
this.timer.clock = clock.timeNow();
|
||||||
|
|
||||||
|
// we call integrations if we update timers
|
||||||
|
let shouldNotify = false;
|
||||||
if (this.playback === Playback.Roll) {
|
if (this.playback === Playback.Roll) {
|
||||||
const tempCurrentTimer = {
|
shouldNotify = true;
|
||||||
selectedEventId: this.loadedTimerId,
|
this.updateRoll();
|
||||||
current: this.timer.current,
|
} else if (this.timer.startedAt !== null) {
|
||||||
// safeguard on midnight rollover
|
|
||||||
_finishAt:
|
|
||||||
this.timer.expectedFinish >= this.timer.startedAt
|
|
||||||
? this.timer.expectedFinish
|
|
||||||
: this.timer.expectedFinish + DAY_TO_MS,
|
|
||||||
|
|
||||||
clock: this.timer.clock,
|
|
||||||
secondaryTimer: this.timer.secondaryTimer,
|
|
||||||
secondaryTarget: this.secondaryTarget,
|
|
||||||
};
|
|
||||||
const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer);
|
|
||||||
|
|
||||||
this.timer.current = updatedTimer;
|
|
||||||
this.timer.secondaryTimer = updatedSecondaryTimer;
|
|
||||||
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
|
||||||
|
|
||||||
if (isFinished) {
|
|
||||||
this.timer.selectedEventId = null;
|
|
||||||
this.loadedTimerId = null;
|
|
||||||
this._onFinish();
|
|
||||||
}
|
|
||||||
|
|
||||||
// to load the next event we have to escalate to parent service
|
|
||||||
if (doRollLoad) {
|
|
||||||
PlaybackService.roll();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// we only update timer if a timer has been started
|
// we only update timer if a timer has been started
|
||||||
if (this.timer.startedAt !== null) {
|
shouldNotify = true;
|
||||||
if (this.playback === Playback.Pause) {
|
this.updatePlay();
|
||||||
this.pausedTime = this.timer.clock - this.pausedAt;
|
}
|
||||||
}
|
|
||||||
|
// we only update the store at the updateInterval
|
||||||
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) {
|
// side effects such as onFinish will still be triggered in the update functions
|
||||||
this.timer.finishedAt = this.timer.clock;
|
if (force || this.timer.clock > this._lastUpdate + this._updateInterval) {
|
||||||
this._onFinish();
|
this._lastUpdate = this.timer.clock;
|
||||||
} else {
|
this._onUpdate(shouldNotify);
|
||||||
this.timer.expectedFinish = getExpectedFinish(
|
|
||||||
this.timer.startedAt,
|
|
||||||
this.timer.finishedAt,
|
|
||||||
this.timer.duration,
|
|
||||||
this.pausedTime,
|
|
||||||
this.timer.addedTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.timer.current = getCurrent(
|
|
||||||
this.timer.startedAt,
|
|
||||||
this.timer.duration,
|
|
||||||
this.timer.addedTime,
|
|
||||||
this.pausedTime,
|
|
||||||
this.timer.clock,
|
|
||||||
);
|
|
||||||
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this._onUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onUpdate() {
|
_onUpdate(shouldNotify: boolean) {
|
||||||
eventStore.set('timer', this.timer);
|
eventStore.set('timer', this.timer);
|
||||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
if (shouldNotify) {
|
||||||
|
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onFinish() {
|
_onFinish() {
|
||||||
@@ -358,7 +382,7 @@ export class TimerService {
|
|||||||
}
|
}
|
||||||
this.playback = Playback.Roll;
|
this.playback = Playback.Roll;
|
||||||
this._onRoll();
|
this._onRoll();
|
||||||
this.update();
|
this.update(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onRoll() {
|
_onRoll() {
|
||||||
@@ -370,4 +394,5 @@ export class TimerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventTimer = new TimerService();
|
// calculate at 30fps, refresh at 1fps
|
||||||
|
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 });
|
||||||
|
|||||||
Reference in New Issue
Block a user