mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature] Add notifications for BS download
This commit is contained in:
@@ -232,7 +232,6 @@
|
||||
"404": "Steam-Server können nicht kontaktiert werden.",
|
||||
"Password": "Passwort ist ungültig.",
|
||||
"InvalidCredentials": "Deine E-Mail oder Passwort ist ungültig 😴",
|
||||
"InvalidPassword": "Dein Passwort ist ungültig 😕",
|
||||
"NoManifest": "Es wurde keine Manifest gefunden",
|
||||
"DirectoryCreate": "Die erforderlichen Ordner können nicht installiert werden.",
|
||||
"NotAvailableApp": "Versuchst du, Beat Saber herunterzuladen, wenn Du es nicht hast? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "Wenn dieser Fehler auftritt, melde den Fehler auf Github mit Protokollen, bitte.",
|
||||
"ConnectionError": "Kann nach 10 Versuchen keine Verbindung zu Steam herstellen 🤯",
|
||||
"LicenceError": "Die Liste der Lizenzen kann nicht abgerufen werden.",
|
||||
"verification-failed": "Verifikation fehlgeschlagen, versuche es später erneut.",
|
||||
"RateLimitExceeded": "Du hast es zu oft versucht, warte eine Weile und versuche es später erneut.",
|
||||
"AlreadyDownloading": "Ein Download ist bereits im Gange",
|
||||
"no-internet": "Keine Internet Verbindung",
|
||||
"TokenRejected": "BeatSaber kann nicht heruntergeladen werden 😥 versuche es später noch einmal 😕",
|
||||
"AccessDenied": "Der Zugang zu Steam wurde verweigert.",
|
||||
"dotnet-required": ".NET 6 Runtime muss installiert sein, um eine Version von BeatSaber herunterzuladen. Lade es herunter, indem Du auf die Schaltfläche unten klickst."
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -232,7 +232,6 @@
|
||||
"404": "Unable to contact Steam servers.",
|
||||
"Password": "Password is invalid.",
|
||||
"InvalidCredentials": "Your e-mail or password is invalid 😴",
|
||||
"InvalidPassword": "Your password is invalid 😕",
|
||||
"NoManifest": "No manifest was found",
|
||||
"DirectoryCreate": "Unable to install the necessary folders.",
|
||||
"NotAvailableApp": "Are you trying to download Beat Saber when you don't have it? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "If you have this error, report the bug on GitHub with the logs pls.",
|
||||
"ConnectionError": "Unable to connect to Steam after 10 tries 🤯",
|
||||
"LicenceError": "Unable to get the list of licenses.",
|
||||
"verification-failed": "Verification failed, try again later.",
|
||||
"RateLimitExceeded": "You've tried too many times, wait a while and try again later.",
|
||||
"AlreadyDownloading": "A download is already in progress",
|
||||
"no-internet": "No internet connection.",
|
||||
"TokenRejected": "Unable to download Beat Saber 😥 try again later 😕",
|
||||
"AccessDenied": "Access to Steam has been denied.",
|
||||
"dotnet-required": ".NET 6 Runtime must be installed in order to download a version of Beat Saber. Download it by clicking the button below."
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
"404": "No se puede contactar con los servidores de Steam",
|
||||
"Password": "La contraseña es inválida.",
|
||||
"InvalidCredentials": "Su correo electrónico o contraseña no son válidos 😴",
|
||||
"InvalidPassword": "Su contraseña no es válida 😕",
|
||||
"NoManifest": "No se ha encontrado el manifiesto",
|
||||
"DirectoryCreate": "No se pueden instalar las carpetas necesarias.",
|
||||
"NotAvailableApp": "¿Intentas descargar BeatSaber cuando no lo tienes? 🤣",
|
||||
@@ -247,10 +246,9 @@
|
||||
"SteamLib": "Si tienes este error, reporta el bug en GitHub con los logs por favor.",
|
||||
"ConnectionError": "No se puede conectar a Steam después de 10 intentos 🤯",
|
||||
"LicenceError": "No se puede obtener la lista de licencias.",
|
||||
"verification-failed": "Verificación fallida, inténtelo más tarde.",
|
||||
"RateLimitExceeded": "Lo has intentado demasiadas veces, espera un poco y vuelve a intentarlo más tarde.",
|
||||
"AlreadyDownloading": "La descarga ya está en curso",
|
||||
"no-internet": "Sin conexión a Internet.",
|
||||
"TokenRejected": "No se puede descargar BeatSaber 😥 inténtalo más tarde 😕",
|
||||
"AccessDenied": "El acceso a Steam ha sido denegado.",
|
||||
"dotnet-required": "Se debe instalar el tiempo de ejecución de .NET 6 para descargar una versión de BeatSaber. Descárguelo haciendo clic en el botón de abajo."
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -215,10 +215,7 @@
|
||||
},
|
||||
"warnings": {
|
||||
"msg": {
|
||||
"ManifestChecksum": "Le manifest téléchargé précédemment ne correspond pas au nouveau 🤔",
|
||||
"ConnectionTimeout": "Ta connexion internet semble instable 🥶",
|
||||
"ConnectionLost": "La connexion a été perdue, nouvelle tentative...",
|
||||
"ConnectionError": "Impossible de se connecter à Steam, nouvelle tentative...",
|
||||
"Unknown": "Quelque chose d'étrange s'est produit 🤔 Votre connexion est surement instable."
|
||||
}
|
||||
},
|
||||
@@ -231,7 +228,6 @@
|
||||
"404": "Impossible de contacter les serveurs de Steam.",
|
||||
"Password": "Le mot de passe est invalide.",
|
||||
"InvalidCredentials": "Ton e-mail ou mot de passe est invalide 😴",
|
||||
"InvalidPassword": "Ton mot de passe est invalide 😕",
|
||||
"NoManifest": "Aucun manifest n'a été trouvé.",
|
||||
"DirectoryCreate": "Impossible d'installer les dossiers nécessaires.",
|
||||
"NotAvailableApp": "Tu essayes de télécharger Beat Saber alors que tu ne l'as pas ? 🤣",
|
||||
@@ -247,10 +243,9 @@
|
||||
"SteamLib": "Si tu as cette erreur, signale le bug sur GitHub avec les logs stp.",
|
||||
"ConnectionError": "Impossible de se connecter à Steam après 10 essais 🤯",
|
||||
"LicenceError": "Impossible d'obtenir la liste des licences.",
|
||||
"verification-failed": "La vérification a échoué, réessaye plus tard.",
|
||||
"RateLimitExceeded": "Tu as essayé trop de fois attends un peu et recommence plus tard.",
|
||||
"AlreadyDownloading": "Un téléchargement est déjà en cours",
|
||||
"no-internet": "Pas de connexion internet.",
|
||||
"TokenRejected": "Impossible de télécharger Beat Saber 😥 réessaye plus tard 😕",
|
||||
"AccessDenied": "L'accès à Steam a été refusé.",
|
||||
"dotnet-required": ".NET 6 Runtime doit être installé pour pouvoir télécharger une version de Beat Saber. Télécharge-le en cliquant sur le bouton ci-dessous."
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -232,7 +232,6 @@
|
||||
"404": "Нет связи с серверами Steam.",
|
||||
"Password": "Неверный пароль.",
|
||||
"InvalidCredentials": "Ваша почта или пароль неверны 😴",
|
||||
"InvalidPassword": "Ваш пароль неверен 😕",
|
||||
"NoManifest": "Файл манифеста не найден",
|
||||
"DirectoryCreate": "Не смогли создать нужные папки.",
|
||||
"NotAvailableApp": "Вы пытаетесь скачать Beat Saber, не купив её? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "Если вы это видите, пожалуйста, сообщите о баге на GitHub с файлом логов.",
|
||||
"ConnectionError": "Не удалось подключиться к Steam за 10 попыток 🤯",
|
||||
"LicenceError": "Не удалось проверить наличие лицензии.",
|
||||
"verification-failed": "Верификация провалена, повторите попытку позже.",
|
||||
"RateLimitExceeded": "Слишком много попыток, вернитесь позже и попробуйте снова.",
|
||||
"AlreadyDownloading": "Скачивание уже в процессе",
|
||||
"no-internet": "Нет интернета.",
|
||||
"TokenRejected": "Не удалось скачать Beat Saber 😥 повторите попытку позже 😕",
|
||||
"AccessDenied": "Доступ к Steam был отклонен.",
|
||||
"dotnet-required": "Среда запуска .NET 6 должна быть установлена, чтобы скачать Beat Saber. Скачайте её с помощью кнопки ниже."
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, spawn } from "child_process";
|
||||
import { Observable, ReplaySubject, Subscriber, filter, map, share } from "rxjs";
|
||||
import { DepotDownloaderArgsOptions, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderEventTypes, DepotDownloaderSubTypeOfEventType } from "../../shared/models/depot-downloader.model";
|
||||
import { DepotDownloaderArgsOptions, DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderEventTypes, DepotDownloaderInfoEvent, DepotDownloaderWarningEvent } from "../../shared/models/depot-downloader.model";
|
||||
import { DownloadEvent } from "main/services/bs-installer.service";
|
||||
|
||||
export class DepotDownloader {
|
||||
|
||||
@@ -22,7 +23,11 @@ export class DepotDownloader {
|
||||
|
||||
subscriber.next(`[Info]|[Start]|${JSON.stringify(options.echoStartData) ?? ""}`);
|
||||
|
||||
this.process.stdout.on("data", data => subscriber.next(data.toString()));
|
||||
this.process.stdout.on("data", data => {
|
||||
const lines: string[] = data.toString().split("\n");
|
||||
lines.forEach(line => subscriber.next(line));
|
||||
});
|
||||
|
||||
this.process.stderr.on("error", error => subscriber.error(error));
|
||||
this.process.on("exit", code => subscriber.complete());
|
||||
|
||||
@@ -36,9 +41,18 @@ export class DepotDownloader {
|
||||
}
|
||||
|
||||
public $events(): Observable<DepotDownloaderEvent<unknown>>{
|
||||
|
||||
const eventTypesArr = Object.values(DepotDownloaderEventType);
|
||||
|
||||
const DepotDownloaderSubTypeOfEventType: {[key in DepotDownloaderEventType]: DepotDownloaderEventTypes[]} = {
|
||||
[DepotDownloaderEventType.Error]: Object.values(DepotDownloaderErrorEvent),
|
||||
[DepotDownloaderEventType.Warning]: Object.values(DepotDownloaderWarningEvent),
|
||||
[DepotDownloaderEventType.Info]: Object.values(DepotDownloaderInfoEvent),
|
||||
}
|
||||
|
||||
return this.processOut$.pipe(map(line => {
|
||||
|
||||
console.log(line.toString());
|
||||
console.log(line);
|
||||
|
||||
const matched = (line.toString() as string).match(/(?:\[(.*?)\])\|(?:\[(.*?)\]\|)?(.*?)(?=$|\[)/gm)?.[0] ?? null;
|
||||
|
||||
@@ -46,7 +60,7 @@ export class DepotDownloader {
|
||||
|
||||
const splitedLine = matched.split("|").map(str => str.trim().replaceAll("[", "").replaceAll("]", "")) as [DepotDownloaderEventType, DepotDownloaderEventTypes, unknown];
|
||||
|
||||
if(!Object.values(DepotDownloaderEventType).includes(splitedLine[0]) || !Object.values(DepotDownloaderSubTypeOfEventType[splitedLine[0]]).includes(splitedLine[1])){
|
||||
if(!eventTypesArr.includes(splitedLine[0] as DepotDownloaderEventType) || !DepotDownloaderSubTypeOfEventType[splitedLine[0]].includes(splitedLine[1])){
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { BsmException } from "shared/models/bsm-exception.model";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { AuthUserService } from "renderer/services/auth-user.service";
|
||||
|
||||
export function SettingsPage() {
|
||||
|
||||
@@ -51,6 +52,7 @@ export function SettingsPage() {
|
||||
const playlistsManager = useService(PlaylistsManagerService);
|
||||
const modelsManager = useService(ModelsManagerService);
|
||||
const versionLinker = useService(VersionFolderLinkerService);
|
||||
const authService = useService(AuthUserService);
|
||||
|
||||
const { firstColor, secondColor } = useThemeColor();
|
||||
|
||||
@@ -67,6 +69,9 @@ export function SettingsPage() {
|
||||
})
|
||||
.sort((a, b) => a.text.localeCompare(b.text));
|
||||
|
||||
const nav = useNavigate();
|
||||
const t = useTranslation();
|
||||
|
||||
const [themeIdSelected, setThemeIdSelected] = useState(themeItem.find(e => e.value === themeService.getTheme()).id);
|
||||
const [languageSelected, setLanguageSelected] = useState(languagesItems.find(e => e.value === i18nService.currentLanguage).id);
|
||||
const [installationFolder, setInstallationFolder] = useState(null);
|
||||
@@ -75,8 +80,7 @@ export function SettingsPage() {
|
||||
const [playlistsDeepLinkEnabled, setPlaylistsDeepLinkEnabled] = useState(false);
|
||||
const [modelsDeepLinkEnabled, setModelsDeepLinkEnabled] = useState(false);
|
||||
const appVersion = useObservable(ipcService.sendV2<string>("current-version"));
|
||||
const nav = useNavigate();
|
||||
const t = useTranslation();
|
||||
const steamSessionExist = useObservable(authService.sessionExist$);
|
||||
|
||||
useEffect(() => {
|
||||
loadInstallationFolder();
|
||||
@@ -203,6 +207,10 @@ export function SettingsPage() {
|
||||
<BsmButton className="inline-block grow-0 bg-transparent sticky h-full w-full top-20 right-20 !m-0 rounded-full p-1" onClick={() => nav(-1)} icon="close" withBar={false} />
|
||||
</div>
|
||||
|
||||
<SettingContainer title="pages.settings.steam.title" description="pages.settings.steam.description">
|
||||
<BsmButton onClick={() => authService.deleteSteamSession()} className="w-fit px-3 py-[2px] text-white rounded-md" withBar={false} text="pages.settings.steam.logout" typeColor="error" disabled={!steamSessionExist}/>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer title="pages.settings.appearance.title" description="pages.settings.appearance.description">
|
||||
<div className="relative w-full h-8 bg-light-main-color-1 dark:bg-main-color-1 flex justify-center rounded-md py-1">
|
||||
<SettingColorChooser color={firstColor} onChange={setFirstColorSetting} />
|
||||
|
||||
@@ -43,7 +43,7 @@ export function VersionViewer() {
|
||||
navigate(`/bs-version/${version.BSVersion}`, { state: version });
|
||||
};
|
||||
const openFolder = () => ipcService.sendLazy("bs-version.open-folder", { args: state });
|
||||
const verifyFiles = () => bsDownloaderService.download(state, true);
|
||||
const verifyFiles = () => bsDownloaderService.verifyBsVersionFiles(state);
|
||||
|
||||
const uninstall = async () => {
|
||||
const modalCompleted = await modalService.openModal(UninstallModal, state);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DownloadEvent, DownloadInfo } from "main/services/bs-installer.service";
|
||||
import { BehaviorSubject, Observable, Subscription, identity, lastValueFrom, of, throwError } from "rxjs";
|
||||
import { distinctUntilChanged, filter, map, take, tap, throttleTime } from "rxjs/operators";
|
||||
import { IpcResponse } from "shared/models/ipc";
|
||||
import { DownloadInfo } from "main/services/bs-installer.service";
|
||||
import { BehaviorSubject, Observable, ReplaySubject, Subscription, lastValueFrom, throwError } from "rxjs";
|
||||
import { distinctUntilChanged, filter, map, share, take, tap, throttleTime } from "rxjs/operators";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { AuthUserService } from "./auth-user.service";
|
||||
import { BSVersionManagerService } from "./bs-version-manager.service";
|
||||
@@ -12,7 +11,7 @@ import { ProgressBarService } from "./progress-bar.service";
|
||||
import { LoginModal } from "renderer/components/modal/modal-types/login-modal.component";
|
||||
import { GuardModal } from "renderer/components/modal/modal-types/guard-modal.component";
|
||||
import { LinkOpenerService } from "./link-opener.service";
|
||||
import { DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderInfoEvent, DepotDownloaderSubTypeOfEventType, DepotDownloaderWarningEvent } from "../../shared/models/depot-downloader.model";
|
||||
import { DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderInfoEvent, DepotDownloaderWarningEvent } from "../../shared/models/depot-downloader.model";
|
||||
import equal from "fast-deep-equal";
|
||||
import { SteamMobileApproveModal } from "renderer/components/modal/modal-types/steam-mobile-approve-modal.component";
|
||||
|
||||
@@ -146,7 +145,10 @@ export class BsDownloaderService {
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.Finished),
|
||||
take(1),
|
||||
).subscribe(() => {
|
||||
console.log("show success notification");
|
||||
if(this.isVerification){
|
||||
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.verification-finished"});
|
||||
}
|
||||
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.download-success"});
|
||||
}));
|
||||
|
||||
return subs;
|
||||
@@ -154,24 +156,37 @@ export class BsDownloaderService {
|
||||
|
||||
private handleWarningEvents(events$: Observable<DepotDownloaderEvent>): Subscription[] {
|
||||
const subs: Subscription[] = [];
|
||||
|
||||
const handledWarnings = Object.values(DepotDownloaderWarningEvent);
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => handledWarnings.includes(event.subType as DepotDownloaderWarningEvent)),
|
||||
).subscribe(event => {
|
||||
this.notificationService.notifyWarning({title: "notifications.types.warning", desc: `notifications.bs-download.warnings.msg.${event.subType}`});
|
||||
}));
|
||||
|
||||
return subs;
|
||||
}
|
||||
|
||||
private hanndleErrorEvents(events$: Observable<DepotDownloaderEvent>): Subscription[] {
|
||||
const subs: Subscription[] = [];
|
||||
return subs;
|
||||
private hanndleErrorEvent(errorEvent: DepotDownloaderEvent) {
|
||||
const handledErrors = Object.values(DepotDownloaderErrorEvent);
|
||||
|
||||
if(handledErrors.includes(errorEvent?.subType as DepotDownloaderErrorEvent)){
|
||||
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.errors.msg.${errorEvent.subType}`});
|
||||
}
|
||||
|
||||
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.errors.msg.${DepotDownloaderErrorEvent.Unknown}`});
|
||||
}
|
||||
|
||||
private wrapDownload(download$: Observable<DepotDownloaderEvent>): Observable<DepotDownloaderEvent> {
|
||||
private wrapDownload(download$: Observable<DepotDownloaderEvent>, silent?: boolean): Observable<DepotDownloaderEvent> {
|
||||
|
||||
return new Observable<DepotDownloaderEvent>(sub => {
|
||||
|
||||
const downloadSub = download$.subscribe(sub);
|
||||
const downloadSub = download$.subscribe({next: n => sub.next(n), error: e => sub.error(e), complete: () => sub.complete()});
|
||||
|
||||
const subs = [
|
||||
...this.handleInfoEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Info))),
|
||||
...this.handleWarningEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Warning))),
|
||||
...this.hanndleErrorEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Error)))
|
||||
...this.handleWarningEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Warning), throttleTime(10_000))),
|
||||
];
|
||||
|
||||
return () => {
|
||||
@@ -180,7 +195,13 @@ export class BsDownloaderService {
|
||||
}
|
||||
|
||||
}).pipe(
|
||||
tap({error: () => this.authService.deleteSteamSession()})
|
||||
tap({
|
||||
error: (e) => {
|
||||
this.authService.deleteSteamSession();
|
||||
!silent && this.hanndleErrorEvent(e)
|
||||
}
|
||||
}),
|
||||
share({connector: () => new ReplaySubject(1)})
|
||||
);
|
||||
|
||||
}
|
||||
@@ -193,7 +214,8 @@ export class BsDownloaderService {
|
||||
|
||||
const infos: DownloadInfo = {...downloadInfo, username: this.authService.getSteamUsername()}
|
||||
return this.wrapDownload(
|
||||
this.ipcService.sendV2<DepotDownloaderEvent>("auto-download-bs-version", { args: infos })
|
||||
this.ipcService.sendV2<DepotDownloaderEvent>("auto-download-bs-version", { args: infos }),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -272,6 +294,10 @@ export class BsDownloaderService {
|
||||
return this.doDownloadBsVersion(version).then(() => version);
|
||||
}
|
||||
|
||||
public verifyBsVersionFiles(version: BSVersion): Promise<BSVersion> {
|
||||
return this.doDownloadBsVersion(version, true).then(() => version);
|
||||
}
|
||||
|
||||
public verifyBsVersion(version: BSVersion): Promise<BSVersion> {
|
||||
return this.doDownloadBsVersion(version, true).then(() => version);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export enum DepotDownloaderErrorEvent {
|
||||
DepotNotFound = "DepotNotFound",
|
||||
NotCompleted = "NotCompleted",
|
||||
InvalidManifest = "InvalidManifest",
|
||||
NoValidKeys = "NoValidKeys",
|
||||
NoValidKeys = "NoValidKey",
|
||||
NoManifestCode = "NoManifestCode",
|
||||
_401 = "401",
|
||||
_404 = "404",
|
||||
@@ -46,17 +46,14 @@ export enum DepotDownloaderErrorEvent {
|
||||
ConnectionError = "ConnectionError",
|
||||
TokenRejected = "TokenRejected",
|
||||
LicenceError = "LicenceError",
|
||||
AccessDenied = "AccessDenied",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export enum DepotDownloaderWarningEvent {
|
||||
ManifestChecksum = "ManifestChecksum",
|
||||
}
|
||||
|
||||
export const DepotDownloaderSubTypeOfEventType = {
|
||||
[DepotDownloaderEventType.Error]: DepotDownloaderErrorEvent,
|
||||
[DepotDownloaderEventType.Warning]: DepotDownloaderWarningEvent,
|
||||
[DepotDownloaderEventType.Info]: DepotDownloaderInfoEvent,
|
||||
ConnectionTimeout = "ConnectionTimeout",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export interface DepotDownloaderArgsOptions {
|
||||
|
||||
Reference in New Issue
Block a user