Add clip player & offline renderer #49
8
source/Component/OfflineRender/OfflineRender.scss
Normal file
8
source/Component/OfflineRender/OfflineRender.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
@import "../Theme/Theme.scss";
|
||||||
|
|
||||||
|
div.offline-render-popup {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
124
source/Component/OfflineRender/OfflineRender.tsx
Normal file
124
source/Component/OfflineRender/OfflineRender.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Component, ReactNode } from "react";
|
||||||
|
import { Popup } from "@Context/Popups";
|
||||||
|
import { useStatusWithEvent, IMixinStatusProps } from "@Context/Status";
|
||||||
|
import { Localization } from "@Component/Localization/Localization";
|
||||||
|
import { AttrInput } from "@Input/AttrInput/AttrInput";
|
||||||
|
import { Message } from "@Input/Message/Message";
|
||||||
|
import { ConfirmContent } from "@Component/ConfirmPopup/ConfirmPopup";
|
||||||
|
import { ProcessPopup } from "@Component/ProcessPopup/ProcessPopup";
|
||||||
|
import { Emitter } from "@Model/Emitter";
|
||||||
|
import "./OfflineRender.scss";
|
||||||
|
|
||||||
|
interface IOfflineRenderProps {
|
||||||
|
close?: () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOfflineRenderState {
|
||||||
|
time: number;
|
||||||
|
fps: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineRender extends Popup<IOfflineRenderProps> {
|
||||||
|
|
||||||
|
public minWidth: number = 250;
|
||||||
|
public minHeight: number = 150;
|
||||||
|
public width: number = 400;
|
||||||
|
public height: number = 300;
|
||||||
|
|
||||||
|
public maskForSelf: boolean = true;
|
||||||
|
|
||||||
|
public onRenderHeader(): ReactNode {
|
||||||
|
return <Localization i18nKey="Popup.Offline.Render.Title"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): ReactNode {
|
||||||
|
return <OfflineRenderComponent {...this.props} close={() => {
|
||||||
|
this.close();
|
||||||
|
}}/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@useStatusWithEvent()
|
||||||
|
class OfflineRenderComponent extends Component<IOfflineRenderProps & IMixinStatusProps, IOfflineRenderState> {
|
||||||
|
|
||||||
|
public constructor(props: IOfflineRenderProps & IMixinStatusProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
name: this.props.status?.getNewClipName() ?? "",
|
||||||
|
time: 10,
|
||||||
|
fps: 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): ReactNode {
|
||||||
|
return <ConfirmContent
|
||||||
|
className="offline-render-popup"
|
||||||
|
actions={[{
|
||||||
|
i18nKey: "Popup.Offline.Render.Input.Start",
|
||||||
|
onClick: () => {
|
||||||
|
|
||||||
|
// 获取新实例
|
||||||
|
let newClip = this.props.status?.newClip();
|
||||||
|
|
||||||
|
if (newClip) {
|
||||||
|
newClip.name = this.state.name;
|
||||||
|
this.props.status?.actuator.offlineRender(newClip, this.state.time, this.state.fps);
|
||||||
|
|
||||||
|
// 开启进度条弹窗
|
||||||
|
this.props.status?.popup.showPopup(ProcessPopup, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭这个弹窗
|
||||||
|
this.props.close && this.props.close();
|
||||||
|
}
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Message i18nKey="Popup.Offline.Render.Message" isTitle first/>
|
||||||
|
|
||||||
|
<AttrInput
|
||||||
|
id={"Render-Name"}
|
||||||
|
value={this.state.name}
|
||||||
|
keyI18n="Popup.Offline.Render.Input.Name"
|
||||||
|
maxLength={15}
|
||||||
|
valueChange={(val) => {
|
||||||
|
this.setState({
|
||||||
|
name: val
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AttrInput
|
||||||
|
isNumber
|
||||||
|
id={"Render-Time"}
|
||||||
|
value={this.state.time}
|
||||||
|
keyI18n="Popup.Offline.Render.Input.Time"
|
||||||
|
max={3600}
|
||||||
|
min={1}
|
||||||
|
valueChange={(val) => {
|
||||||
|
this.setState({
|
||||||
|
time: parseFloat(val)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AttrInput
|
||||||
|
isNumber
|
||||||
|
id={"Render-FPS"}
|
||||||
|
max={1000}
|
||||||
|
min={1}
|
||||||
|
value={this.state.fps}
|
||||||
|
keyI18n="Popup.Offline.Render.Input.Fps"
|
||||||
|
valueChange={(val) => {
|
||||||
|
this.setState({
|
||||||
|
fps: parseFloat(val)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</ConfirmContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OfflineRender };
|
42
source/Component/ProcessPopup/ProcessPopup.scss
Normal file
42
source/Component/ProcessPopup/ProcessPopup.scss
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
@import "../Theme/Theme.scss";
|
||||||
|
|
||||||
|
div.process-popup {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator {
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressTrack {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressBar {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.confirm-root.dark div.ms-ProgressIndicator {
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressTrack {
|
||||||
|
background-color: $lt-bg-color-lvl3-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressBar {
|
||||||
|
background-color: $lt-font-color-normal-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.confirm-root.light div.ms-ProgressIndicator {
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressTrack {
|
||||||
|
background-color: $lt-bg-color-lvl3-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.ms-ProgressIndicator-progressBar {
|
||||||
|
background-color: $lt-font-color-normal-light;
|
||||||
|
}
|
||||||
|
}
|
89
source/Component/ProcessPopup/ProcessPopup.tsx
Normal file
89
source/Component/ProcessPopup/ProcessPopup.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Component, ReactNode } from "react";
|
||||||
|
import { Popup } from "@Context/Popups";
|
||||||
|
import { Localization } from "@Component/Localization/Localization";
|
||||||
|
import { ConfirmContent } from "@Component/ConfirmPopup/ConfirmPopup";
|
||||||
|
import { useStatusWithEvent, IMixinStatusProps } from "@Context/Status";
|
||||||
|
import { ProgressIndicator } from "@fluentui/react";
|
||||||
|
import { ActuatorModel } from "@Model/Actuator";
|
||||||
|
import "./ProcessPopup.scss";
|
||||||
|
|
||||||
|
interface IProcessPopupProps {
|
||||||
|
close?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcessPopup extends Popup<IProcessPopupProps> {
|
||||||
|
|
||||||
|
public minWidth: number = 400;
|
||||||
|
public minHeight: number = 150;
|
||||||
|
public width: number = 400;
|
||||||
|
public height: number = 150;
|
||||||
|
|
||||||
|
public maskForSelf: boolean = true;
|
||||||
|
|
||||||
|
public onClose(): void {}
|
||||||
|
|
||||||
|
public onRenderHeader(): ReactNode {
|
||||||
|
return <Localization i18nKey="Popup.Offline.Render.Process.Title"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): ReactNode {
|
||||||
|
return <ProcessPopupComponent {...this.props} close={() => this.close()}/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@useStatusWithEvent("offlineLoop", "actuatorStartChange", "recordLoop")
|
||||||
|
class ProcessPopupComponent extends Component<IProcessPopupProps & IMixinStatusProps> {
|
||||||
|
|
||||||
|
public render(): ReactNode {
|
||||||
|
|
||||||
|
let current = this.props.status?.actuator.offlineCurrentFrame ?? 0;
|
||||||
|
let all = this.props.status?.actuator.offlineAllFrame ?? 0;
|
||||||
|
|
||||||
|
const isRendering = this.props.status?.actuator.mod === ActuatorModel.Offline;
|
||||||
|
let i18nKey = "";
|
||||||
|
let color: undefined | "red";
|
||||||
|
let onClick = () => {};
|
||||||
|
|
||||||
|
if (isRendering) {
|
||||||
|
i18nKey = "Popup.Offline.Render.Input.End";
|
||||||
|
color = "red";
|
||||||
|
onClick = () => {
|
||||||
|
this.props.status?.actuator.endOfflineRender();
|
||||||
|
this.forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
i18nKey = "Popup.Offline.Render.Input.Finished";
|
||||||
|
onClick = () => {
|
||||||
|
this.props.close && this.props.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ConfirmContent
|
||||||
|
className="process-popup"
|
||||||
|
actions={[{
|
||||||
|
i18nKey: i18nKey,
|
||||||
|
color: color,
|
||||||
|
onClick: onClick
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
|
||||||
|
<ProgressIndicator
|
||||||
|
percentComplete={current / all}
|
||||||
|
barHeight={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Localization
|
||||||
|
i18nKey="Popup.Offline.Render.Process"
|
||||||
|
options={{
|
||||||
|
current: current.toString(),
|
||||||
|
all: all.toString()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</ConfirmContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ProcessPopup };
|
@ -37,6 +37,7 @@ interface IStatusEvent {
|
|||||||
renderLoop: number;
|
renderLoop: number;
|
||||||
physicsLoop: number;
|
physicsLoop: number;
|
||||||
recordLoop: number;
|
recordLoop: number;
|
||||||
|
offlineLoop: number;
|
||||||
mouseModChange: void;
|
mouseModChange: void;
|
||||||
focusObjectChange: void;
|
focusObjectChange: void;
|
||||||
focusLabelChange: void;
|
focusLabelChange: void;
|
||||||
@ -129,6 +130,7 @@ class Status extends Emitter<IStatusEvent> {
|
|||||||
// 循环事件
|
// 循环事件
|
||||||
this.actuator.on("loop", (t) => { this.emit("physicsLoop", t) });
|
this.actuator.on("loop", (t) => { this.emit("physicsLoop", t) });
|
||||||
this.actuator.on("record", (t) => { this.emit("recordLoop", t) });
|
this.actuator.on("record", (t) => { this.emit("recordLoop", t) });
|
||||||
|
this.actuator.on("offline", (t) => { this.emit("offlineLoop", t) });
|
||||||
|
|
||||||
// 对象变化事件
|
// 对象变化事件
|
||||||
this.model.on("objectChange", () => this.emit("objectChange"));
|
this.model.on("objectChange", () => this.emit("objectChange"));
|
||||||
@ -424,7 +426,7 @@ class Status extends Emitter<IStatusEvent> {
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
public newClip() {
|
public getNewClipName() {
|
||||||
let searchKey = I18N(this.setting.language, "Object.List.New.Clip", { id: "" });
|
let searchKey = I18N(this.setting.language, "Object.List.New.Clip", { id: "" });
|
||||||
let nextIndex = 1;
|
let nextIndex = 1;
|
||||||
this.model.clipPool.forEach((obj) => {
|
this.model.clipPool.forEach((obj) => {
|
||||||
@ -432,11 +434,13 @@ class Status extends Emitter<IStatusEvent> {
|
|||||||
obj.name, searchKey
|
obj.name, searchKey
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
const clip = this.model.addClip(
|
return I18N(this.setting.language, "Object.List.New.Clip", {
|
||||||
I18N(this.setting.language, "Object.List.New.Clip", {
|
id: nextIndex.toString()
|
||||||
id: nextIndex.toString()
|
});
|
||||||
})
|
}
|
||||||
);
|
|
||||||
|
public newClip() {
|
||||||
|
const clip = this.model.addClip(this.getNewClipName());
|
||||||
return clip;
|
return clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,6 +72,16 @@ const EN_US = {
|
|||||||
"Popup.Delete.Clip.Confirm": "Are you sure you want to delete this clip? The clip cannot be restored after deletion.",
|
"Popup.Delete.Clip.Confirm": "Are you sure you want to delete this clip? The clip cannot be restored after deletion.",
|
||||||
"Popup.Restore.Behavior.Confirm": "Are you sure you want to reset all parameters of this behavior? This operation cannot be recalled.",
|
"Popup.Restore.Behavior.Confirm": "Are you sure you want to reset all parameters of this behavior? This operation cannot be recalled.",
|
||||||
"Popup.Setting.Title": "Preferences setting",
|
"Popup.Setting.Title": "Preferences setting",
|
||||||
|
"Popup.Offline.Render.Title": "Offline rendering",
|
||||||
|
"Popup.Offline.Render.Process.Title": "Rendering progress",
|
||||||
|
"Popup.Offline.Render.Message": "Rendering Parameters",
|
||||||
|
"Popup.Offline.Render.Input.Name": "Clip name",
|
||||||
|
"Popup.Offline.Render.Input.Time": "Duration (s)",
|
||||||
|
"Popup.Offline.Render.Input.Fps": "FPS (f/s)",
|
||||||
|
"Popup.Offline.Render.Input.Start": "Start rendering",
|
||||||
|
"Popup.Offline.Render.Input.End": "Terminate rendering",
|
||||||
|
"Popup.Offline.Render.Input.Finished": "Finished",
|
||||||
|
"Popup.Offline.Render.Process": "Number of frames completed: {current} / {all}",
|
||||||
"Popup.Load.Save.Title": "Load save",
|
"Popup.Load.Save.Title": "Load save",
|
||||||
"Popup.Load.Save.confirm": "Got it",
|
"Popup.Load.Save.confirm": "Got it",
|
||||||
"Popup.Load.Save.Overwrite": "Overwrite and continue",
|
"Popup.Load.Save.Overwrite": "Overwrite and continue",
|
||||||
|
@ -72,6 +72,16 @@ const ZH_CN = {
|
|||||||
"Popup.Delete.Clip.Confirm": "你确定删除这个剪辑片段,剪辑片段删除后将无法恢复。",
|
"Popup.Delete.Clip.Confirm": "你确定删除这个剪辑片段,剪辑片段删除后将无法恢复。",
|
||||||
"Popup.Restore.Behavior.Confirm": "你确定要重置此行为的全部参数吗?此操作无法撤回。",
|
"Popup.Restore.Behavior.Confirm": "你确定要重置此行为的全部参数吗?此操作无法撤回。",
|
||||||
"Popup.Setting.Title": "首选项设置",
|
"Popup.Setting.Title": "首选项设置",
|
||||||
|
"Popup.Offline.Render.Title": "离线渲染",
|
||||||
|
"Popup.Offline.Render.Process.Title": "渲染进度",
|
||||||
|
"Popup.Offline.Render.Message": "渲染参数",
|
||||||
|
"Popup.Offline.Render.Input.Name": "剪辑名称",
|
||||||
|
"Popup.Offline.Render.Input.Time": "时长 (s)",
|
||||||
|
"Popup.Offline.Render.Input.Fps": "帧率 (f/s)",
|
||||||
|
"Popup.Offline.Render.Input.Start": "开始渲染",
|
||||||
|
"Popup.Offline.Render.Input.End": "终止渲染",
|
||||||
|
"Popup.Offline.Render.Input.Finished": "完成",
|
||||||
|
"Popup.Offline.Render.Process": "完成帧数: {current} / {all}",
|
||||||
"Popup.Load.Save.Title": "加载存档",
|
"Popup.Load.Save.Title": "加载存档",
|
||||||
"Popup.Load.Save.confirm": "我知道了",
|
"Popup.Load.Save.confirm": "我知道了",
|
||||||
"Popup.Load.Save.Overwrite": "覆盖并继续",
|
"Popup.Load.Save.Overwrite": "覆盖并继续",
|
||||||
|
@ -13,6 +13,7 @@ interface IActuatorEvent {
|
|||||||
startChange: boolean;
|
startChange: boolean;
|
||||||
record: number;
|
record: number;
|
||||||
loop: number;
|
loop: number;
|
||||||
|
offline: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -234,6 +235,107 @@ class Actuator extends Emitter<IActuatorEvent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离线渲染参数
|
||||||
|
*/
|
||||||
|
public offlineAllFrame: number = 0;
|
||||||
|
public offlineCurrentFrame: number = 0;
|
||||||
|
private offlineRenderTickTimer?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭离线渲染
|
||||||
|
*/
|
||||||
|
public endOfflineRender() {
|
||||||
|
|
||||||
|
// 清除 timer
|
||||||
|
clearTimeout(this.offlineRenderTickTimer);
|
||||||
|
|
||||||
|
this.recordClip && (this.recordClip.isRecording = false);
|
||||||
|
this.recordClip = undefined;
|
||||||
|
|
||||||
|
// 设置状态
|
||||||
|
this.mod = ActuatorModel.View;
|
||||||
|
|
||||||
|
// 激发结束事件
|
||||||
|
this.start(false);
|
||||||
|
this.emit("record", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离线渲染 tick
|
||||||
|
*/
|
||||||
|
private offlineRenderTick(dt: number) {
|
||||||
|
|
||||||
|
if (this.mod !== ActuatorModel.Offline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.offlineCurrentFrame >= this.offlineAllFrame) {
|
||||||
|
return this.endOfflineRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新模型
|
||||||
|
this.model.update(dt);
|
||||||
|
|
||||||
|
// 录制
|
||||||
|
this.recordClip?.record(dt);
|
||||||
|
|
||||||
|
// 限制更新频率
|
||||||
|
if (this.offlineCurrentFrame % 10 === 0) {
|
||||||
|
this.emit("offline", dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.offlineCurrentFrame++
|
||||||
|
|
||||||
|
if (this.offlineCurrentFrame <= this.offlineAllFrame) {
|
||||||
|
|
||||||
|
// 下一个 tick
|
||||||
|
this.offlineRenderTickTimer = setTimeout(() => this.offlineRenderTick(dt)) as any;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.endOfflineRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离线渲染
|
||||||
|
*/
|
||||||
|
public offlineRender(clip: Clip, time: number, fps: number) {
|
||||||
|
|
||||||
|
// 记录录制片段
|
||||||
|
this.recordClip = clip;
|
||||||
|
clip.isRecording = true;
|
||||||
|
|
||||||
|
// 如果仿真正在进行,停止仿真
|
||||||
|
if (this.start()) this.start(false);
|
||||||
|
|
||||||
|
// 如果正在录制,阻止
|
||||||
|
if (this.mod === ActuatorModel.Record || this.mod === ActuatorModel.Offline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在播放,暂停播放
|
||||||
|
if (this.mod === ActuatorModel.Play) {
|
||||||
|
this.pausePlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置状态
|
||||||
|
this.mod = ActuatorModel.Offline;
|
||||||
|
|
||||||
|
// 计算帧数
|
||||||
|
this.offlineCurrentFrame = 0;
|
||||||
|
this.offlineAllFrame = Math.round(time * fps) - 1;
|
||||||
|
let dt = time / this.offlineAllFrame;
|
||||||
|
|
||||||
|
// 第一帧渲染
|
||||||
|
clip.record(0);
|
||||||
|
|
||||||
|
// 开启时钟
|
||||||
|
this.offlineRenderTick(dt);
|
||||||
|
|
||||||
|
this.emit("record", dt);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放时钟
|
* 播放时钟
|
||||||
*/
|
*/
|
||||||
|
@ -6,6 +6,7 @@ import { Message } from "@Input/Message/Message";
|
|||||||
import { Clip } from "@Model/Clip";
|
import { Clip } from "@Model/Clip";
|
||||||
import { ActuatorModel } from "@Model/Actuator";
|
import { ActuatorModel } from "@Model/Actuator";
|
||||||
import { ConfirmPopup } from "@Component/ConfirmPopup/ConfirmPopup";
|
import { ConfirmPopup } from "@Component/ConfirmPopup/ConfirmPopup";
|
||||||
|
import { OfflineRender } from "@Component/OfflineRender/OfflineRender"
|
||||||
import "./ClipPlayer.scss";
|
import "./ClipPlayer.scss";
|
||||||
|
|
||||||
@useStatusWithEvent("clipChange", "focusClipChange", "actuatorStartChange")
|
@useStatusWithEvent("clipChange", "focusClipChange", "actuatorStartChange")
|
||||||
@ -50,6 +51,7 @@ class ClipPlayer extends Component<IMixinStatusProps> {
|
|||||||
}}
|
}}
|
||||||
add={() => {
|
add={() => {
|
||||||
this.isInnerClick = true;
|
this.isInnerClick = true;
|
||||||
|
this.props.status?.popup.showPopup(OfflineRender, {});
|
||||||
}}
|
}}
|
||||||
click={(clip) => {
|
click={(clip) => {
|
||||||
this.isInnerClick = true;
|
this.isInnerClick = true;
|
||||||
|
Loading…
Reference in New Issue
Block a user