mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-03-30 05:35:16 +00:00
### Fixed
- P2P Replication got more robust and stable. ### Breaking changes - Send configuration via Peer-to-Peer connection is not compatible with older versions.
This commit is contained in:
@@ -1,18 +1,10 @@
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../lib/src/common/types.ts";
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE } from "../../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../../common/types.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { fireAndForget, generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
|
||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
||||
import {
|
||||
EVENT_REQUEST_COPY_SETUP_URI,
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
||||
eventHub,
|
||||
@@ -21,268 +13,13 @@ import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { SvelteDialogManager } from "./SetupWizard/ObsidianSvelteDialog.ts";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||
import ScanQRCode from "./SetupWizard/dialogs/ScanQRCode.svelte";
|
||||
import UseSetupURI from "./SetupWizard/dialogs/UseSetupURI.svelte";
|
||||
import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
|
||||
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
||||
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
||||
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import {
|
||||
decodeSettingsFromQRCodeData,
|
||||
encodeQR,
|
||||
encodeSettingsToQRCodeData,
|
||||
encodeSettingsToSetupURI,
|
||||
OutputFormat,
|
||||
} from "../../lib/src/API/processSetting.ts";
|
||||
// import type ObsidianLiveSyncPlugin from "../../main.ts";
|
||||
export const enum UserMode {
|
||||
NewUser = "new-user",
|
||||
ExistingUser = "existing-user",
|
||||
Unknown = "unknown",
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
Update = "unknown", // Alias for Unknown for better readability
|
||||
}
|
||||
|
||||
export class SetupManager extends AbstractObsidianModule {
|
||||
private dialogManager: SvelteDialogManager = new SvelteDialogManager(this.plugin);
|
||||
|
||||
async startOnBoarding(): Promise<boolean> {
|
||||
const isUserNewOrExisting = await this.dialogManager.openWithExplicitCancel(Intro);
|
||||
if (isUserNewOrExisting === "new-user") {
|
||||
await this.onBoard(UserMode.NewUser);
|
||||
} else if (isUserNewOrExisting === "existing-user") {
|
||||
await this.onBoard(UserMode.ExistingUser);
|
||||
} else if (isUserNewOrExisting === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async onBoard(userMode: UserMode): Promise<boolean> {
|
||||
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
|
||||
if (userMode === UserMode.NewUser) {
|
||||
//Ask how to apply initial setup
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
|
||||
if (method === "use-setup-uri") {
|
||||
await this.onUseSetupURI(userMode);
|
||||
} else if (method === "configure-manually") {
|
||||
await this.onConfigureManually(originalSetting, userMode);
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else if (userMode === UserMode.ExistingUser) {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodExisting);
|
||||
if (method === "use-setup-uri") {
|
||||
await this.onUseSetupURI(userMode);
|
||||
} else if (method === "configure-manually") {
|
||||
await this.onConfigureManually(originalSetting, userMode);
|
||||
} else if (method === "scan-qr-code") {
|
||||
await this.onPromptQRCodeInstruction();
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async onUseSetupURI(userMode: UserMode, setupURI: string = ""): Promise<boolean> {
|
||||
const newSetting = await this.dialogManager.openWithExplicitCancel(UseSetupURI, setupURI);
|
||||
if (newSetting === "cancelled") {
|
||||
this._log("Setup URI dialog cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
return await this.confirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
async onCouchDBManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
|
||||
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, originalSetting);
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onBoard(userMode);
|
||||
}
|
||||
const newSetting = { ...baseSetting, ...couchConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
return await this.confirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
async onBucketManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteBucket, currentSetting);
|
||||
if (bucketConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onBoard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...bucketConf } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
return await this.confirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
async onP2PManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSetting);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onBoard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...p2pConf.info } as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_P2P;
|
||||
}
|
||||
return await this.confirmApplySettingsFromWizard(newSetting, userMode, activate, () => {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, p2pConf.devicePeerId);
|
||||
});
|
||||
}
|
||||
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, currentSetting);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await false;
|
||||
}
|
||||
const newSetting = {
|
||||
...currentSetting,
|
||||
...e2eeConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
return await this.confirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, originalSetting);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onBoard(userMode);
|
||||
}
|
||||
const currentSetting = {
|
||||
...originalSetting,
|
||||
...e2eeConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
return await this.selectServer(currentSetting, userMode);
|
||||
}
|
||||
|
||||
async selectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SetupRemote);
|
||||
if (method === "couchdb") {
|
||||
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "bucket") {
|
||||
return await this.onBucketManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "p2p") {
|
||||
return await this.onP2PManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
if (userMode !== UserMode.Unknown) {
|
||||
return await this.onBoard(userMode);
|
||||
}
|
||||
}
|
||||
// Should not reach here.
|
||||
return false;
|
||||
}
|
||||
async confirmApplySettingsFromWizard(
|
||||
newConf: ObsidianLiveSyncSettings,
|
||||
_userMode: UserMode,
|
||||
activate: boolean = true,
|
||||
extra: () => void = () => {}
|
||||
): Promise<boolean> {
|
||||
let userMode = _userMode;
|
||||
// let rebuildRequired = true;
|
||||
if (userMode === UserMode.Unknown) {
|
||||
if (isObjectDifferent(this.settings, newConf, true) === false) {
|
||||
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const patch = generatePatchObj(this.settings, newConf);
|
||||
console.log(`Changes:`);
|
||||
console.dir(patch);
|
||||
if (!activate) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Setting Applied", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
|
||||
if (userModeResult === "new-user") {
|
||||
userMode = UserMode.NewUser;
|
||||
} else if (userModeResult === "existing-user") {
|
||||
userMode = UserMode.ExistingUser;
|
||||
} else if (userModeResult === "compatible-existing-user") {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
} else if (userModeResult === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
|
||||
const confirm = await this.dialogManager.openWithExplicitCancel(component);
|
||||
if (confirm === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (confirm) {
|
||||
extra();
|
||||
await this.applySetting(newConf, userMode);
|
||||
if (userMode === UserMode.NewUser) {
|
||||
// For new users, schedule a rebuild everything.
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
} else {
|
||||
// For existing users, schedule a fetch.
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
}
|
||||
}
|
||||
// Settings applied, but may require rebuild to take effect.
|
||||
return false;
|
||||
}
|
||||
|
||||
async onPromptQRCodeInstruction(): Promise<boolean> {
|
||||
const qrResult = await this.dialogManager.open(ScanQRCode);
|
||||
this._log("QR Code dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
// Result is not used, but log it for debugging.
|
||||
this._log(`QR Code result: ${qrResult}`, LOG_LEVEL_VERBOSE);
|
||||
// QR Code instruction dialog never yields settings directly.
|
||||
return false;
|
||||
}
|
||||
|
||||
async decodeQR(qr: string) {
|
||||
const newSettings = decodeSettingsFromQRCodeData(qr);
|
||||
return await this.confirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
|
||||
}
|
||||
|
||||
async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) {
|
||||
const newSetting = {
|
||||
...this.core.settings,
|
||||
...newConf,
|
||||
};
|
||||
this.core.settings = newSetting;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.services.setting.saveSettingData();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
import { SetupManager, UserMode } from "./SetupManager.ts";
|
||||
|
||||
export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
private _setupManager!: SetupManager;
|
||||
@@ -330,6 +67,11 @@ export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
|
||||
fireAndForget(() => {
|
||||
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
|
||||
})
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async encodeQR() {
|
||||
@@ -380,6 +122,8 @@ export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
await this._setupManager.onUseSetupURI(UserMode.Unknown);
|
||||
}
|
||||
|
||||
// TODO: Where to implement these?
|
||||
|
||||
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
||||
// const buttons = {
|
||||
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
||||
@@ -447,125 +191,6 @@ export class ModuleSetupObsidian extends AbstractObsidianModule {
|
||||
// }
|
||||
// }
|
||||
|
||||
// async applySettingWizard(
|
||||
// oldConf: ObsidianLiveSyncSettings,
|
||||
// newConf: ObsidianLiveSyncSettings,
|
||||
// method = "Setup URI"
|
||||
// ) {
|
||||
// const result = await this.core.confirm.askYesNoDialog(
|
||||
// "Importing Configuration from the " + method + ". Are you sure to proceed ? ",
|
||||
// {}
|
||||
// );
|
||||
// if (result == "yes") {
|
||||
// let newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings;
|
||||
// this.core.replicator.closeReplication();
|
||||
// this.settings.suspendFileWatching = true;
|
||||
// newSettingW = await this.askSyncWithRemoteConfig(newSettingW);
|
||||
// const { settings, shouldRebuild, isModified } = await this.askPerformDoctor(newSettingW);
|
||||
// if (isModified) {
|
||||
// newSettingW = settings;
|
||||
// }
|
||||
// // Back into the default method once.
|
||||
// newSettingW.configPassphraseStore = "";
|
||||
// newSettingW.encryptedPassphrase = "";
|
||||
// newSettingW.encryptedCouchDBConnection = "";
|
||||
// newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""} `;
|
||||
// const setupJustImport = $msg("Setup.Apply.Buttons.OnlyApply");
|
||||
// const setupAsNew = $msg("Setup.Apply.Buttons.ApplyAndFetch");
|
||||
// const setupAsMerge = $msg("Setup.Apply.Buttons.ApplyAndMerge");
|
||||
// const setupAgain = $msg("Setup.Apply.Buttons.ApplyAndRebuild");
|
||||
// const setupCancel = $msg("Setup.Apply.Buttons.Cancel");
|
||||
// newSettingW.syncInternalFiles = false;
|
||||
// newSettingW.usePluginSync = false;
|
||||
// newSettingW.isConfigured = true;
|
||||
// // Migrate completely obsoleted configuration.
|
||||
// if (!newSettingW.useIndexedDBAdapter) {
|
||||
// newSettingW.useIndexedDBAdapter = true;
|
||||
// }
|
||||
// const warn = shouldRebuild ? $msg("Setup.Apply.WarningRebuildRecommended") : "";
|
||||
// const message = $msg("Setup.Apply.Message", {
|
||||
// method,
|
||||
// warn,
|
||||
// });
|
||||
|
||||
// const setupType = await this.core.confirm.askSelectStringDialogue(
|
||||
// message,
|
||||
// [setupAsNew, setupAsMerge, setupAgain, setupJustImport, setupCancel],
|
||||
// { defaultAction: setupAsNew, title: $msg("Setup.Apply.Title", { method }), timeout: 0 }
|
||||
// );
|
||||
// if (setupType == setupJustImport) {
|
||||
// this.core.settings = newSettingW;
|
||||
// this.services.setting.clearUsedPassphrase();
|
||||
// await this.core.saveSettings();
|
||||
// } else if (setupType == setupAsNew) {
|
||||
// this.core.settings = newSettingW;
|
||||
// this.services.setting.clearUsedPassphrase();
|
||||
// await this.core.saveSettings();
|
||||
// await this.core.rebuilder.$fetchLocal();
|
||||
// } else if (setupType == setupAsMerge) {
|
||||
// this.core.settings = newSettingW;
|
||||
// this.services.setting.clearUsedPassphrase();
|
||||
// await this.core.saveSettings();
|
||||
// await this.core.rebuilder.$fetchLocal(true);
|
||||
// } else if (setupType == setupAgain) {
|
||||
// const confirm =
|
||||
// "This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost.";
|
||||
// if (
|
||||
// (await this.core.confirm.askSelectStringDialogue(
|
||||
// "Are you sure you want to do this?",
|
||||
// ["Cancel", confirm],
|
||||
// { defaultAction: "Cancel" }
|
||||
// )) != confirm
|
||||
// ) {
|
||||
// return;
|
||||
// }
|
||||
// this.core.settings = newSettingW;
|
||||
// await this.core.saveSettings();
|
||||
// this.services.setting.clearUsedPassphrase();
|
||||
// await this.core.rebuilder.$rebuildEverything();
|
||||
// } else {
|
||||
// // Explicitly cancel the operation or the dialog was closed.
|
||||
// this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
// this.core.settings = oldConf;
|
||||
// return;
|
||||
// }
|
||||
// this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
// } else {
|
||||
// this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
// this.core.settings = oldConf;
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// async setupWizard(confString: string) {
|
||||
// try {
|
||||
// const oldConf = JSON.parse(JSON.stringify(this.settings));
|
||||
// const encryptingPassphrase = await this.core.confirm.askString(
|
||||
// "Passphrase",
|
||||
// "The passphrase to decrypt your setup URI",
|
||||
// "",
|
||||
// true
|
||||
// );
|
||||
// if (encryptingPassphrase === false) return;
|
||||
// const newConf = await JSON.parse(await decryptString(confString, encryptingPassphrase));
|
||||
// if (newConf) {
|
||||
// await this.applySettingWizard(oldConf, newConf);
|
||||
// this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
// } else {
|
||||
// this._log("Cancelled.", LOG_LEVEL_NOTICE);
|
||||
// }
|
||||
// } catch (ex) {
|
||||
// this._log("Couldn't parse or decrypt configuration uri.", LOG_LEVEL_NOTICE);
|
||||
// this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
// }
|
||||
// }
|
||||
|
||||
// async askHowToApplySetupURI() {
|
||||
// const method = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
|
||||
// if( method === "new-user") {
|
||||
// return UserMode.NewUser;
|
||||
// }
|
||||
// }
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.handleOnLoaded(this._everyOnload.bind(this));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user