mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2025-12-30 11:22:31 +00:00
ref(video-picker) Styles refactor (#13206)
Move styles from SCSS to JSS Convert a component from class to function
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
.video-preview {
|
||||
background: none;
|
||||
display: inline-block;
|
||||
|
||||
&-container {
|
||||
max-height: 456px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
&-entry {
|
||||
cursor: pointer;
|
||||
height: 138px;
|
||||
width: 244px;
|
||||
position: relative;
|
||||
margin: 0 7px 4px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border: 2px solid #4687ED;
|
||||
}
|
||||
}
|
||||
|
||||
&-video {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-error {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: 100%;
|
||||
padding: 8px;
|
||||
z-index: 2;
|
||||
|
||||
&-text {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
max-width: calc(100% - 16px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&-checkbox-container {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,6 @@ $flagsImagePath: "../images/";
|
||||
@import 'settings-button';
|
||||
@import 'meter';
|
||||
@import 'audio-preview';
|
||||
@import 'video-preview';
|
||||
@import 'premeeting/main';
|
||||
@import 'modals/invite/invite_more';
|
||||
@import 'modals/security/security';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../../app/types';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconImage } from '../../../../base/icons/svg';
|
||||
import { Video } from '../../../../base/media/components/index';
|
||||
import { equals } from '../../../../base/redux/functions';
|
||||
import { updateSettings } from '../../../../base/settings/actions';
|
||||
import { withPixelLineHeight } from '../../../../base/styles/functions.web';
|
||||
import Checkbox from '../../../../base/ui/components/web/Checkbox';
|
||||
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
@@ -16,12 +17,10 @@ import { openSettingsDialog } from '../../../actions';
|
||||
import { SETTINGS_TABS } from '../../../constants';
|
||||
import { createLocalVideoTracks } from '../../../functions.web';
|
||||
|
||||
const videoClassName = 'video-preview-video flipVideoX';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link VideoSettingsContent}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* Callback to change the flip state.
|
||||
@@ -59,86 +58,143 @@ export interface IProps extends WithTranslation {
|
||||
videoDeviceIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link VideoSettingsContent}.
|
||||
*/
|
||||
interface IState {
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
position: 'relative',
|
||||
right: 'auto'
|
||||
},
|
||||
|
||||
/**
|
||||
* An array of all the jitsiTracks and eventual errors.
|
||||
*/
|
||||
trackData: { deviceId: string; error?: string; jitsiTrack: any | null; }[];
|
||||
}
|
||||
previewEntry: {
|
||||
cursor: 'pointer',
|
||||
height: '138px',
|
||||
width: '244px',
|
||||
position: 'relative',
|
||||
margin: '0 7px',
|
||||
marginBottom: theme.spacing(1),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays a list of video
|
||||
* previews to choose from.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class VideoSettingsContent extends Component<IProps, IState> {
|
||||
_componentWasUnmounted: boolean;
|
||||
'&:last-child': {
|
||||
marginBottom: 0
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes a new {@code VideoSettingsContent} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this._onToggleFlip = this._onToggleFlip.bind(this);
|
||||
selectedEntry: {
|
||||
border: `2px solid ${theme.palette.action01Hover}`
|
||||
},
|
||||
|
||||
this.state = {
|
||||
trackData: new Array(props.videoDeviceIds.length).fill({
|
||||
jitsiTrack: null
|
||||
})
|
||||
};
|
||||
}
|
||||
previewVideo: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover'
|
||||
},
|
||||
|
||||
error: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute'
|
||||
},
|
||||
|
||||
labelContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxWidth: '100%',
|
||||
zIndex: 2,
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
|
||||
label: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: '4px',
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||
color: theme.palette.text01,
|
||||
...withPixelLineHeight(theme.typography.labelBold),
|
||||
width: 'fit-content',
|
||||
maxwidth: `calc(100% - ${theme.spacing(2)} - ${theme.spacing(2)})`,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
checkboxContainer: {
|
||||
padding: '10px 14px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const VideoSettingsContent = ({
|
||||
changeFlip,
|
||||
currentCameraDeviceId,
|
||||
localFlipX,
|
||||
selectBackground,
|
||||
setVideoInputDevice,
|
||||
toggleVideoSettings,
|
||||
videoDeviceIds
|
||||
}: IProps) => {
|
||||
const _componentWasUnmounted = useRef(false);
|
||||
const [ trackData, setTrackData ] = useState(new Array(videoDeviceIds.length).fill({
|
||||
jitsiTrack: null
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const videoDevicesRef = useRef(videoDeviceIds);
|
||||
const trackDataRef = useRef(trackData);
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
/**
|
||||
* Toggles local video flip state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onToggleFlip() {
|
||||
const { localFlipX, changeFlip } = this.props;
|
||||
|
||||
const _onToggleFlip = useCallback(() => {
|
||||
changeFlip(!localFlipX);
|
||||
}
|
||||
}, [ localFlipX, changeFlip ]);
|
||||
|
||||
/**
|
||||
* Destroys all the tracks from trackData object.
|
||||
*
|
||||
* @param {Object[]} tracks - An array of tracks that are to be disposed.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const _disposeTracks = (tracks: { jitsiTrack: any; }[]) => {
|
||||
tracks.forEach(({ jitsiTrack }) => {
|
||||
jitsiTrack?.dispose();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and updates the track data.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
async _setTracks() {
|
||||
this._disposeTracks(this.state.trackData);
|
||||
const _setTracks = async () => {
|
||||
_disposeTracks(trackData);
|
||||
|
||||
const trackData = await createLocalVideoTracks(this.props.videoDeviceIds, 5000);
|
||||
const newTrackData = await createLocalVideoTracks(videoDeviceIds, 5000);
|
||||
|
||||
// In case the component gets unmounted before the tracks are created
|
||||
// avoid a leak by not setting the state
|
||||
if (this._componentWasUnmounted) {
|
||||
this._disposeTracks(trackData);
|
||||
if (_componentWasUnmounted.current) {
|
||||
_disposeTracks(newTrackData);
|
||||
} else {
|
||||
this.setState({
|
||||
trackData
|
||||
});
|
||||
setTrackData(newTrackData);
|
||||
trackDataRef.current = newTrackData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all the tracks from trackData object.
|
||||
*
|
||||
* @param {Object[]} trackData - An array of tracks that are to be disposed.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_disposeTracks(trackData: { jitsiTrack: any; }[]) {
|
||||
trackData.forEach(({ jitsiTrack }) => {
|
||||
jitsiTrack?.dispose();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the click handler used when selecting the video preview.
|
||||
@@ -146,12 +202,10 @@ class VideoSettingsContent extends Component<IProps, IState> {
|
||||
* @param {string} deviceId - The id of the camera device.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onEntryClick(deviceId: string) {
|
||||
return () => {
|
||||
this.props.setVideoInputDevice(deviceId);
|
||||
this.props.toggleVideoSettings();
|
||||
};
|
||||
}
|
||||
const _onEntryClick = (deviceId: string) => () => {
|
||||
setVideoInputDevice(deviceId);
|
||||
toggleVideoSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a preview entry.
|
||||
@@ -160,132 +214,107 @@ class VideoSettingsContent extends Component<IProps, IState> {
|
||||
* @param {number} index - The index of the entry.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderPreviewEntry(data: { deviceId: string; error?: string; jitsiTrack: any | null; }, index: number) {
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const _renderPreviewEntry = (data: { deviceId: string; error?: string; jitsiTrack: any | null; },
|
||||
index: number) => {
|
||||
const { error, jitsiTrack, deviceId } = data;
|
||||
const { currentCameraDeviceId, t } = this.props;
|
||||
const isSelected = deviceId === currentCameraDeviceId;
|
||||
const key = `vp-${index}`;
|
||||
const className = 'video-preview-entry';
|
||||
const tabIndex = '0';
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className = { className }
|
||||
className = { classes.previewEntry }
|
||||
key = { key }
|
||||
tabIndex = { -1 } >
|
||||
<div className = 'video-preview-error'>{t(error)}</div>
|
||||
<div className = { classes.error }>{t(error)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const props: any = {
|
||||
className,
|
||||
const previewProps: any = {
|
||||
className: classes.previewEntry,
|
||||
key,
|
||||
tabIndex
|
||||
};
|
||||
const label = jitsiTrack?.getTrackLabel();
|
||||
|
||||
if (isSelected) {
|
||||
props['aria-checked'] = true;
|
||||
props.className = `${className} video-preview-entry--selected`;
|
||||
previewProps['aria-checked'] = true;
|
||||
previewProps.className = cx(classes.previewEntry, classes.selectedEntry);
|
||||
} else {
|
||||
props.onClick = this._onEntryClick(deviceId);
|
||||
props.onKeyPress = (e: React.KeyboardEvent) => {
|
||||
previewProps.onClick = _onEntryClick(deviceId);
|
||||
previewProps.onKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
props.onClick();
|
||||
previewProps.onClick();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{ ...props }
|
||||
{ ...previewProps }
|
||||
role = 'radio'>
|
||||
<div className = 'video-preview-label'>
|
||||
{label && <div className = 'video-preview-label-text'>
|
||||
<div className = { classes.labelContainer }>
|
||||
{label && <div className = { classes.label }>
|
||||
<span>{label}</span>
|
||||
</div>}
|
||||
</div>
|
||||
<Video
|
||||
className = { videoClassName }
|
||||
className = { cx(classes.previewVideo, 'flipVideoX') }
|
||||
playsinline = { true }
|
||||
videoTrack = {{ jitsiTrack }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._setTracks();
|
||||
}
|
||||
useEffect(() => {
|
||||
_setTracks();
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillUnmount}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this._componentWasUnmounted = true;
|
||||
this._disposeTracks(this.state.trackData);
|
||||
}
|
||||
return () => {
|
||||
_componentWasUnmounted.current = true;
|
||||
_disposeTracks(trackDataRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
if (!equals(this.props.videoDeviceIds, prevProps.videoDeviceIds)) {
|
||||
this._setTracks();
|
||||
useEffect(() => {
|
||||
if (!equals(videoDeviceIds, videoDevicesRef.current)) {
|
||||
_setTracks();
|
||||
videoDevicesRef.current = videoDeviceIds;
|
||||
}
|
||||
}
|
||||
}, [ videoDeviceIds ]);
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { trackData } = this.state;
|
||||
const { selectBackground, t, localFlipX } = this.props;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
aria-labelledby = 'video-settings-button'
|
||||
className = 'video-preview-container'
|
||||
hidden = { false }
|
||||
id = 'video-settings-dialog'
|
||||
role = 'radiogroup'
|
||||
tabIndex = { -1 }>
|
||||
<ContextMenuItemGroup>
|
||||
{trackData.map((data, i) => this._renderPreviewEntry(data, i))}
|
||||
</ContextMenuItemGroup>
|
||||
<ContextMenuItemGroup>
|
||||
<ContextMenuItem
|
||||
accessibilityLabel = 'virtualBackground.title'
|
||||
icon = { IconImage }
|
||||
onClick = { selectBackground }
|
||||
text = { t('virtualBackground.title') } />
|
||||
<div
|
||||
className = 'video-preview-checkbox-container'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { e => e.stopPropagation() }>
|
||||
<Checkbox
|
||||
checked = { localFlipX }
|
||||
label = { t('videothumbnail.mirrorVideo') }
|
||||
onChange = { this._onToggleFlip } />
|
||||
</div>
|
||||
</ContextMenuItemGroup>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ContextMenu
|
||||
aria-labelledby = 'video-settings-button'
|
||||
className = { classes.container }
|
||||
hidden = { false }
|
||||
id = 'video-settings-dialog'
|
||||
role = 'radiogroup'
|
||||
tabIndex = { -1 }>
|
||||
<ContextMenuItemGroup>
|
||||
{trackData.map((data, i) => _renderPreviewEntry(data, i))}
|
||||
</ContextMenuItemGroup>
|
||||
<ContextMenuItemGroup>
|
||||
<ContextMenuItem
|
||||
accessibilityLabel = 'virtualBackground.title'
|
||||
icon = { IconImage }
|
||||
onClick = { selectBackground }
|
||||
text = { t('virtualBackground.title') } />
|
||||
<div
|
||||
className = { classes.checkboxContainer }
|
||||
onClick = { stopPropagation }>
|
||||
<Checkbox
|
||||
checked = { localFlipX }
|
||||
label = { t('videothumbnail.mirrorVideo') }
|
||||
onChange = { _onToggleFlip } />
|
||||
</div>
|
||||
</ContextMenuItemGroup>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: IReduxState) => {
|
||||
const { localFlipX } = state['features/base/settings'];
|
||||
@@ -306,4 +335,4 @@ const mapDispatchToProps = (dispatch: IStore['dispatch']) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default translate(connect(mapStateToProps, mapDispatchToProps)(VideoSettingsContent));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(VideoSettingsContent);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import {
|
||||
@@ -55,6 +56,15 @@ interface IProps {
|
||||
videoDeviceIds: string[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
background: 'none',
|
||||
display: 'inline-block'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Popup with a preview of all the video devices.
|
||||
*
|
||||
@@ -69,8 +79,10 @@ function VideoSettingsPopup({
|
||||
setVideoInputDevice,
|
||||
videoDeviceIds
|
||||
}: IProps) {
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = 'video-preview'>
|
||||
<div className = { cx('video-preview', classes.container) }>
|
||||
<Popover
|
||||
allowClick = { true }
|
||||
content = { <VideoSettingsContent
|
||||
|
||||
@@ -17,8 +17,6 @@ import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
|
||||
import { toggleBackgroundEffect } from '../actions';
|
||||
import logger from '../logger';
|
||||
|
||||
const videoClassName = 'video-preview-video';
|
||||
|
||||
/**
|
||||
* The type of the React {@code PureComponent} props of {@link VirtualBackgroundPreview}.
|
||||
*/
|
||||
@@ -88,22 +86,32 @@ const styles = (theme: Theme) => {
|
||||
zIndex: 2,
|
||||
borderRadius: '3px',
|
||||
backgroundColor: theme.palette.uiBackground,
|
||||
position: 'relative' as const,
|
||||
position: 'relative' as const
|
||||
},
|
||||
|
||||
'& .video-preview-loader': {
|
||||
height: '220px',
|
||||
previewLoader: {
|
||||
height: '220px',
|
||||
|
||||
'& svg': {
|
||||
position: 'absolute' as const,
|
||||
top: '40%',
|
||||
left: '45%'
|
||||
}
|
||||
},
|
||||
|
||||
'& .video-preview-error': {
|
||||
height: '220px',
|
||||
position: 'relative'
|
||||
'& svg': {
|
||||
position: 'absolute' as const,
|
||||
top: '40%',
|
||||
left: '45%'
|
||||
}
|
||||
},
|
||||
|
||||
previewVideo: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover' as const
|
||||
},
|
||||
|
||||
error: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '220px',
|
||||
position: 'relative' as const
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -206,7 +214,7 @@ class VirtualBackgroundPreview extends PureComponent<IProps, IState> {
|
||||
*/
|
||||
_loadVideoPreview() {
|
||||
return (
|
||||
<div className = 'video-preview-loader'>
|
||||
<div className = { this.props.classes.previewLoader }>
|
||||
<Spinner size = 'large' />
|
||||
</div>
|
||||
);
|
||||
@@ -219,20 +227,20 @@ class VirtualBackgroundPreview extends PureComponent<IProps, IState> {
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderPreviewEntry(data: Object) {
|
||||
const { t } = this.props;
|
||||
const { classes, t } = this.props;
|
||||
|
||||
if (this.state.loading) {
|
||||
return this._loadVideoPreview();
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className = 'video-preview-error'>{t('deviceSelection.previewUnavailable')}</div>
|
||||
<div className = { classes.error }>{t('deviceSelection.previewUnavailable')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Video
|
||||
className = { videoClassName }
|
||||
className = { classes.previewVideo }
|
||||
playsinline = { true }
|
||||
videoTrack = {{ jitsiTrack: data }} />
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user