mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-05 07:18:29 +08:00
Improved golden layout config persistence. A prompt is now shown when the user tries to leave the page after making changes to the current quest. Set production log level to INFO.
This commit is contained in:
parent
5522e7c6af
commit
33026ce015
@ -1,2 +1,2 @@
|
|||||||
LOG_LEVEL=WARN
|
LOG_LEVEL=INFO
|
||||||
PUBLIC_URL=/assets
|
PUBLIC_URL=/assets
|
||||||
|
@ -33,6 +33,8 @@ export abstract class Persister {
|
|||||||
private server_key(server: Server, key: string): string {
|
private server_key(server: Server, key: string): string {
|
||||||
let k = key + ".";
|
let k = key + ".";
|
||||||
|
|
||||||
|
// Do this manually per server type instead of just appending e.g. `Server[server]` to
|
||||||
|
// ensure the persisted key never changes.
|
||||||
switch (server) {
|
switch (server) {
|
||||||
case Server.Ephinea:
|
case Server.Ephinea:
|
||||||
k += "Ephinea";
|
k += "Ephinea";
|
@ -1,4 +1,4 @@
|
|||||||
import { Persister } from "../../core/persistence";
|
import { Persister } from "../../core/Persister";
|
||||||
import { Server } from "../../core/model";
|
import { Server } from "../../core/model";
|
||||||
import { HuntMethodModel } from "../model/HuntMethodModel";
|
import { HuntMethodModel } from "../model/HuntMethodModel";
|
||||||
import { Duration } from "luxon";
|
import { Duration } from "luxon";
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Server } from "../../core/model";
|
import { Server } from "../../core/model";
|
||||||
import { Persister } from "../../core/persistence";
|
import { Persister } from "../../core/Persister";
|
||||||
import { WantedItemModel } from "../model";
|
import { WantedItemModel } from "../model";
|
||||||
import { ItemTypeStore } from "../../core/stores/ItemTypeStore";
|
import { ItemTypeStore } from "../../core/stores/ItemTypeStore";
|
||||||
import { ServerMap } from "../../core/stores/ServerMap";
|
import { ServerMap } from "../../core/stores/ServerMap";
|
||||||
|
@ -10,7 +10,7 @@ import { WebGLRenderer } from "three";
|
|||||||
import { DisposableThreeRenderer } from "./core/rendering/Renderer";
|
import { DisposableThreeRenderer } from "./core/rendering/Renderer";
|
||||||
|
|
||||||
Logger.useDefaults({
|
Logger.useDefaults({
|
||||||
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] ?? "OFF"],
|
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] ?? "INFO"],
|
||||||
});
|
});
|
||||||
|
|
||||||
function create_three_renderer(): DisposableThreeRenderer {
|
function create_three_renderer(): DisposableThreeRenderer {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { ResizableWidget } from "../../core/gui/ResizableWidget";
|
import { ResizableWidget } from "../../core/gui/ResizableWidget";
|
||||||
import { create_element, disposable_listener, el } from "../../core/gui/dom";
|
import { create_element, el } from "../../core/gui/dom";
|
||||||
import { QuestEditorToolBar } from "./QuestEditorToolBar";
|
import { QuestEditorToolBar } from "./QuestEditorToolBar";
|
||||||
import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout";
|
import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout";
|
||||||
import { quest_editor_ui_persister } from "../persistence/QuestEditorUiPersister";
|
|
||||||
import { QuestInfoView } from "./QuestInfoView";
|
import { QuestInfoView } from "./QuestInfoView";
|
||||||
import "golden-layout/src/css/goldenlayout-base.css";
|
import "golden-layout/src/css/goldenlayout-base.css";
|
||||||
import "../../core/gui/golden_layout_theme.css";
|
import "../../core/gui/golden_layout_theme.css";
|
||||||
@ -24,6 +23,7 @@ import { EntityImageRenderer } from "../rendering/EntityImageRenderer";
|
|||||||
import { AreaAssetLoader } from "../loading/AreaAssetLoader";
|
import { AreaAssetLoader } from "../loading/AreaAssetLoader";
|
||||||
import { EntityAssetLoader } from "../loading/EntityAssetLoader";
|
import { EntityAssetLoader } from "../loading/EntityAssetLoader";
|
||||||
import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
|
import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
|
||||||
|
import { QuestEditorUiPersister } from "../persistence/QuestEditorUiPersister";
|
||||||
import Logger = require("js-logger");
|
import Logger = require("js-logger");
|
||||||
|
|
||||||
const logger = Logger.get("quest_editor/gui/QuestEditorView");
|
const logger = Logger.get("quest_editor/gui/QuestEditorView");
|
||||||
@ -56,8 +56,6 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
{ name: string; create(): ResizableWidget }
|
{ name: string; create(): ResizableWidget }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
private readonly view_white_list: readonly string[];
|
|
||||||
|
|
||||||
private readonly tool_bar: QuestEditorToolBar;
|
private readonly tool_bar: QuestEditorToolBar;
|
||||||
|
|
||||||
private readonly layout_element = create_element("div", { class: "quest_editor_gl_container" });
|
private readonly layout_element = create_element("div", { class: "quest_editor_gl_container" });
|
||||||
@ -74,6 +72,7 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
area_asset_loader: AreaAssetLoader,
|
area_asset_loader: AreaAssetLoader,
|
||||||
entity_asset_loader: EntityAssetLoader,
|
entity_asset_loader: EntityAssetLoader,
|
||||||
entity_image_renderer: EntityImageRenderer,
|
entity_image_renderer: EntityImageRenderer,
|
||||||
|
private readonly quest_editor_ui_persister: QuestEditorUiPersister,
|
||||||
create_three_renderer: () => DisposableThreeRenderer,
|
create_three_renderer: () => DisposableThreeRenderer,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -163,10 +162,6 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.view_white_list = [...this.view_map.values()]
|
|
||||||
.map(({ name }) => name)
|
|
||||||
.filter(name => name !== "quest_runner");
|
|
||||||
|
|
||||||
this.tool_bar = this.disposable(
|
this.tool_bar = this.disposable(
|
||||||
new QuestEditorToolBar(gui_store, area_store, quest_editor_store),
|
new QuestEditorToolBar(gui_store, area_store, quest_editor_store),
|
||||||
);
|
);
|
||||||
@ -213,14 +208,6 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
disposable_listener(window, "beforeunload", e => {
|
|
||||||
if (quest_editor_store.quest_runner.running.val) {
|
|
||||||
quest_editor_store.quest_runner.stop();
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = false;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.finalize_construction();
|
this.finalize_construction();
|
||||||
@ -251,24 +238,31 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
private async init_golden_layout(): Promise<GoldenLayout> {
|
private async init_golden_layout(): Promise<GoldenLayout> {
|
||||||
const default_layout_content = this.get_default_layout_content();
|
const default_layout_content = this.get_default_layout_content();
|
||||||
|
|
||||||
const content = await quest_editor_ui_persister.load_layout_config(
|
|
||||||
this.view_white_list,
|
|
||||||
default_layout_content,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return this.attempt_gl_init({
|
const content = await this.quest_editor_ui_persister.load_layout_config(
|
||||||
...DEFAULT_LAYOUT_CONFIG,
|
default_layout_content,
|
||||||
content,
|
);
|
||||||
});
|
|
||||||
|
if (content) {
|
||||||
|
const gl = this.attempt_gl_init({
|
||||||
|
...DEFAULT_LAYOUT_CONFIG,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Instantiated golden layout with persisted layout.");
|
||||||
|
|
||||||
|
return gl;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("Couldn't instantiate golden layout with persisted layout.", e);
|
logger.warn("Couldn't instantiate golden layout with persisted layout.", e);
|
||||||
|
|
||||||
return this.attempt_gl_init({
|
|
||||||
...DEFAULT_LAYOUT_CONFIG,
|
|
||||||
content: default_layout_content,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Instantiating golden layout with default layout.");
|
||||||
|
|
||||||
|
return this.attempt_gl_init({
|
||||||
|
...DEFAULT_LAYOUT_CONFIG,
|
||||||
|
content: default_layout_content,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout {
|
private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout {
|
||||||
@ -298,7 +292,7 @@ export class QuestEditorView extends ResizableWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
layout.on("stateChanged", () => {
|
layout.on("stateChanged", () => {
|
||||||
quest_editor_ui_persister.persist_layout_config(layout.toConfig().content);
|
this.quest_editor_ui_persister.persist_layout_config(layout.toConfig().content);
|
||||||
});
|
});
|
||||||
|
|
||||||
layout.on("stackCreated", (stack: ContentItem) => {
|
layout.on("stackCreated", (stack: ContentItem) => {
|
||||||
|
@ -8,6 +8,7 @@ import { HttpClient } from "../core/HttpClient";
|
|||||||
import { EntityImageRenderer } from "./rendering/EntityImageRenderer";
|
import { EntityImageRenderer } from "./rendering/EntityImageRenderer";
|
||||||
import { EntityAssetLoader } from "./loading/EntityAssetLoader";
|
import { EntityAssetLoader } from "./loading/EntityAssetLoader";
|
||||||
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
|
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
|
||||||
|
import { QuestEditorUiPersister } from "./persistence/QuestEditorUiPersister";
|
||||||
|
|
||||||
export function initialize_quest_editor(
|
export function initialize_quest_editor(
|
||||||
http_client: HttpClient,
|
http_client: HttpClient,
|
||||||
@ -23,6 +24,9 @@ export function initialize_quest_editor(
|
|||||||
const quest_editor_store = new QuestEditorStore(gui_store, area_store);
|
const quest_editor_store = new QuestEditorStore(gui_store, area_store);
|
||||||
const asm_editor_store = new AsmEditorStore(quest_editor_store);
|
const asm_editor_store = new AsmEditorStore(quest_editor_store);
|
||||||
|
|
||||||
|
// Persisters
|
||||||
|
const quest_editor_ui_persister = new QuestEditorUiPersister();
|
||||||
|
|
||||||
// Entity Image Renderer
|
// Entity Image Renderer
|
||||||
const entity_image_renderer = new EntityImageRenderer(entity_asset_loader);
|
const entity_image_renderer = new EntityImageRenderer(entity_asset_loader);
|
||||||
|
|
||||||
@ -35,6 +39,7 @@ export function initialize_quest_editor(
|
|||||||
area_asset_loader,
|
area_asset_loader,
|
||||||
entity_asset_loader,
|
entity_asset_loader,
|
||||||
entity_image_renderer,
|
entity_image_renderer,
|
||||||
|
quest_editor_ui_persister,
|
||||||
create_three_renderer,
|
create_three_renderer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,74 +1,141 @@
|
|||||||
import { Persister } from "../../core/persistence";
|
import { Persister } from "../../core/Persister";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
import GoldenLayout from "golden-layout";
|
import { ComponentConfig, ItemConfigType } from "golden-layout";
|
||||||
|
|
||||||
const LAYOUT_CONFIG_KEY = "QuestEditorUiPersister.layout_config";
|
const LAYOUT_CONFIG_KEY = "QuestEditorUiPersister.layout_config";
|
||||||
|
|
||||||
export class QuestEditorUiPersister extends Persister {
|
export class QuestEditorUiPersister extends Persister {
|
||||||
persist_layout_config = throttle(
|
persist_layout_config = throttle(
|
||||||
(config: any) => {
|
(config: ItemConfigType[]) => {
|
||||||
this.persist(LAYOUT_CONFIG_KEY, config);
|
this.persist(LAYOUT_CONFIG_KEY, this.to_persisted_item_config(config));
|
||||||
},
|
},
|
||||||
500,
|
500,
|
||||||
{ leading: true, trailing: true },
|
{ leading: false, trailing: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
async load_layout_config(
|
async load_layout_config(
|
||||||
components: readonly string[],
|
default_config: ItemConfigType[],
|
||||||
default_config: GoldenLayout.ItemConfigType[],
|
): Promise<ItemConfigType[] | undefined> {
|
||||||
): Promise<any> {
|
const config = await this.load<ItemConfigType[]>(LAYOUT_CONFIG_KEY);
|
||||||
const config = await this.load<GoldenLayout.ItemConfigType[]>(LAYOUT_CONFIG_KEY);
|
|
||||||
|
|
||||||
if (config && this.verify_layout_config(config, components)) {
|
if (config) {
|
||||||
return config;
|
const components = this.extract_components(default_config);
|
||||||
} else {
|
const verified_config = this.sanitized_layout_config(
|
||||||
return default_config;
|
this.from_persisted_item_config(config),
|
||||||
}
|
components,
|
||||||
}
|
);
|
||||||
|
|
||||||
private verify_layout_config(
|
if (verified_config) {
|
||||||
config: GoldenLayout.ItemConfigType[],
|
return verified_config;
|
||||||
components: readonly string[],
|
|
||||||
): boolean {
|
|
||||||
const set = new Set(components);
|
|
||||||
|
|
||||||
for (const child of config) {
|
|
||||||
if (!this.verify_layout_child(child, set, new Set(), true)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private verify_layout_child(
|
private sanitized_layout_config(
|
||||||
config: GoldenLayout.ItemConfigType,
|
config: ItemConfigType[],
|
||||||
components: Set<string>,
|
components: Map<string, ComponentConfig>,
|
||||||
|
): ItemConfigType[] | undefined {
|
||||||
|
const found = new Set<string>();
|
||||||
|
|
||||||
|
const sanitized_config = config.map(child =>
|
||||||
|
this.sanitize_layout_child(child, components, found),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found.size !== components.size) {
|
||||||
|
// A component was added, use default layout instead of persisted layout.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out removed components.
|
||||||
|
return sanitized_config.filter(item => item) as ItemConfigType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removed old components and adds titles and ids to current components.
|
||||||
|
* Modifies the given ItemConfigType object.
|
||||||
|
*/
|
||||||
|
private sanitize_layout_child(
|
||||||
|
config: ItemConfigType,
|
||||||
|
components: Map<string, ComponentConfig>,
|
||||||
found: Set<string>,
|
found: Set<string>,
|
||||||
first: boolean,
|
): ItemConfigType | undefined {
|
||||||
): boolean {
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return false;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("componentName" in config) {
|
if (config.type === "component" && "componentName" in config) {
|
||||||
if (!components.has(config.componentName)) {
|
const component = components.get(config.componentName);
|
||||||
return false;
|
|
||||||
} else {
|
// Remove deprecated components.
|
||||||
found.add(config.componentName);
|
if (!component) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
found.add(config.componentName);
|
||||||
|
config.id = component.id;
|
||||||
|
config.title = component.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.content) {
|
if (config.content) {
|
||||||
for (const child of config.content) {
|
config.content = config.content
|
||||||
if (!this.verify_layout_child(child, components, found, false)) {
|
.map(child => this.sanitize_layout_child(child, components, found))
|
||||||
return false;
|
.filter(item => item) as ItemConfigType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extract_components(
|
||||||
|
config: ItemConfigType[],
|
||||||
|
map: Map<string, ComponentConfig> = new Map(),
|
||||||
|
): Map<string, ComponentConfig> {
|
||||||
|
for (const child of config) {
|
||||||
|
if ("componentName" in child) {
|
||||||
|
map.set(child.componentName, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.content) {
|
||||||
|
this.extract_components(child.content, map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return first ? components.size === found.size : true;
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private to_persisted_item_config(config: ItemConfigType[]): PersistedItemConfig[] {
|
||||||
|
return config.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
componentName: "componentName" in item ? item.componentName : undefined,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
content: item.content && this.to_persisted_item_config(item.content),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This simply makes a copy to ensure legacy properties are removed.
|
||||||
|
*/
|
||||||
|
private from_persisted_item_config(config: PersistedItemConfig[]): ItemConfigType[] {
|
||||||
|
return config.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
componentName: item.componentName,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
content: item.content && this.from_persisted_item_config(item.content),
|
||||||
|
isClosable: false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const quest_editor_ui_persister = new QuestEditorUiPersister();
|
type PersistedItemConfig = {
|
||||||
|
id?: string | string[];
|
||||||
|
type: string;
|
||||||
|
componentName?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
content?: PersistedItemConfig[];
|
||||||
|
};
|
||||||
|
@ -17,7 +17,7 @@ import { Kind, OP_BB_MAP_DESIGNATE, Opcode, OPCODES_BY_MNEMONIC } from "./opcode
|
|||||||
import { AssemblyLexer, IdentToken, TokenType } from "./AssemblyLexer";
|
import { AssemblyLexer, IdentToken, TokenType } from "./AssemblyLexer";
|
||||||
|
|
||||||
Logger.useDefaults({
|
Logger.useDefaults({
|
||||||
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "OFF"],
|
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "INFO"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx: Worker = self as any;
|
const ctx: Worker = self as any;
|
||||||
|
@ -18,6 +18,7 @@ import LocationLink = languages.LocationLink;
|
|||||||
import IModelContentChange = editor.IModelContentChange;
|
import IModelContentChange = editor.IModelContentChange;
|
||||||
import { Breakpoint } from "../scripting/vm/Debugger";
|
import { Breakpoint } from "../scripting/vm/Debugger";
|
||||||
import { QuestEditorStore } from "./QuestEditorStore";
|
import { QuestEditorStore } from "./QuestEditorStore";
|
||||||
|
import { disposable_listener } from "../../core/gui/dom";
|
||||||
|
|
||||||
const assembly_analyser = new AssemblyAnalyser();
|
const assembly_analyser = new AssemblyAnalyser();
|
||||||
|
|
||||||
@ -119,6 +120,13 @@ export class AsmEditorStore implements Disposable {
|
|||||||
assembly_analyser.issues.observe(({ value }) => this.update_model_markers(value), {
|
assembly_analyser.issues.observe(({ value }) => this.update_model_markers(value), {
|
||||||
call_now: true,
|
call_now: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
disposable_listener(window, "beforeunload", e => {
|
||||||
|
if (this.undo.can_undo.val) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@ import { WritableProperty } from "../../core/observable/property/WritablePropert
|
|||||||
import { QuestRunner } from "../QuestRunner";
|
import { QuestRunner } from "../QuestRunner";
|
||||||
import { AreaStore } from "./AreaStore";
|
import { AreaStore } from "./AreaStore";
|
||||||
import Logger = require("js-logger");
|
import Logger = require("js-logger");
|
||||||
|
import { disposable_listener } from "../../core/gui/dom";
|
||||||
|
|
||||||
const logger = Logger.get("quest_editor/gui/QuestEditorStore");
|
const logger = Logger.get("quest_editor/gui/QuestEditorStore");
|
||||||
|
|
||||||
@ -80,6 +81,15 @@ export class QuestEditorStore implements Disposable {
|
|||||||
this.set_selected_entity(undefined);
|
this.set_selected_entity(undefined);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
disposable_listener(window, "beforeunload", e => {
|
||||||
|
this.quest_runner.stop();
|
||||||
|
|
||||||
|
if (this.undo.can_undo.val) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
const Logger = require("js-logger");
|
const Logger = require("js-logger");
|
||||||
require('dotenv').config({ path: ".env.test" })
|
require('dotenv').config({ path: ".env.test" })
|
||||||
|
|
||||||
const log_level = process.env["LOG_LEVEL"] || "OFF";
|
const log_level = process.env["LOG_LEVEL"] || "WARN";
|
||||||
|
|
||||||
Logger.useDefaults({
|
Logger.useDefaults({
|
||||||
defaultLevel: Logger[log_level],
|
defaultLevel: Logger[log_level],
|
||||||
|
Loading…
Reference in New Issue
Block a user