fix
This commit is contained in:
@@ -198,14 +198,6 @@ const DesktopWorkspace = ({
|
|||||||
engine.setEnsureDocumentSize(ensureDocumentSize);
|
engine.setEnsureDocumentSize(ensureDocumentSize);
|
||||||
}, [engine, ensureDocumentSize]);
|
}, [engine, ensureDocumentSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
engine.ensureLayoutForItems();
|
|
||||||
}, [engine, docSizeVersion]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
engine.setItemRefs(itemRefs);
|
|
||||||
}, [engine, itemRefs]);
|
|
||||||
|
|
||||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
|
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
|
||||||
const {
|
const {
|
||||||
layout: layoutSnapshot,
|
layout: layoutSnapshot,
|
||||||
@@ -215,8 +207,48 @@ const DesktopWorkspace = ({
|
|||||||
tagDropTargetId,
|
tagDropTargetId,
|
||||||
pendingTagDocId,
|
pendingTagDocId,
|
||||||
pendingRemovalTag,
|
pendingRemovalTag,
|
||||||
|
initialLoadDone,
|
||||||
} = workspaceSnapshot;
|
} = workspaceSnapshot;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isDevEnv = () => {
|
||||||
|
if (typeof globalThis === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const proc = globalThis.process;
|
||||||
|
if (!proc || typeof proc !== 'object') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const env = proc.env;
|
||||||
|
if (!env || typeof env !== 'object') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return env.NODE_ENV !== 'production';
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldWaitForPersisted = allowLayoutPersistence && !initialLoadDone;
|
||||||
|
|
||||||
|
if (isDevEnv()) {
|
||||||
|
console.log('[desk][layout] React effect -> ensureLayoutForItems', {
|
||||||
|
docSizeVersion,
|
||||||
|
initialLoadDone,
|
||||||
|
itemCount: items.length,
|
||||||
|
allowLayoutPersistence,
|
||||||
|
shouldWaitForPersisted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldWaitForPersisted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.ensureLayoutForItems();
|
||||||
|
}, [engine, docSizeVersion, initialLoadDone, items.length, allowLayoutPersistence]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
engine.setItemRefs(itemRefs);
|
||||||
|
}, [engine, itemRefs]);
|
||||||
|
|
||||||
const layoutRef = useRef(layoutSnapshot);
|
const layoutRef = useRef(layoutSnapshot);
|
||||||
layoutRef.current = engine.layout;
|
layoutRef.current = engine.layout;
|
||||||
|
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
setDragTransform(item.docId, payload);
|
setDragTransform(item.docId, payload);
|
||||||
const node = itemRefs.current.get(item.docId);
|
const node = itemRefs.current.get(item.docId);
|
||||||
applyDomTransform(node, payload);
|
applyDomTransform(node, payload);
|
||||||
|
console.log('[desk] drag update', item.docId, payload.centerX, payload.centerY);
|
||||||
});
|
});
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
state.lastClientX = event.clientX;
|
||||||
@@ -668,6 +669,7 @@ const useDocumentDrag = (options = {}) => {
|
|||||||
setDragTransform(state.docKey, transformPayload);
|
setDragTransform(state.docKey, transformPayload);
|
||||||
const primaryNode = itemRefs.current.get(state.docKey);
|
const primaryNode = itemRefs.current.get(state.docKey);
|
||||||
applyDomTransform(primaryNode, transformPayload);
|
applyDomTransform(primaryNode, transformPayload);
|
||||||
|
console.log('[desk] drag update', state.docKey, transformPayload.centerX, transformPayload.centerY);
|
||||||
|
|
||||||
const offsetX = pointerCanvasX - currentCenterX;
|
const offsetX = pointerCanvasX - currentCenterX;
|
||||||
const offsetY = pointerCanvasY - currentCenterY;
|
const offsetY = pointerCanvasY - currentCenterY;
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ export const ANGULAR_DAMPING = 11;
|
|||||||
export const TORQUE_TO_ACCELERATION = 0.006;
|
export const TORQUE_TO_ACCELERATION = 0.006;
|
||||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||||
|
|
||||||
|
const getNodeEnv = () => {
|
||||||
|
if (typeof globalThis === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const proc = globalThis.process;
|
||||||
|
if (!proc || typeof proc !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const env = proc.env;
|
||||||
|
if (!env || typeof env !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return env.NODE_ENV;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDevEnvironment = () => getNodeEnv() !== 'production';
|
||||||
|
|
||||||
export const applyDomTransform = (
|
export const applyDomTransform = (
|
||||||
node,
|
node,
|
||||||
{
|
{
|
||||||
@@ -416,6 +433,17 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
|
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> received', {
|
||||||
|
allowLayoutPersistence,
|
||||||
|
tenantId,
|
||||||
|
viewId,
|
||||||
|
currentAllow: this.allowLayoutPersistence,
|
||||||
|
currentTenant: this.tenantId,
|
||||||
|
currentView: this.viewId,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
const allowChanged =
|
const allowChanged =
|
||||||
typeof allowLayoutPersistence === 'boolean'
|
typeof allowLayoutPersistence === 'boolean'
|
||||||
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
||||||
@@ -423,6 +451,21 @@ export class WorkspaceEngine {
|
|||||||
const viewChanged = viewId !== undefined && viewId !== this.viewId;
|
const viewChanged = viewId !== undefined && viewId !== this.viewId;
|
||||||
|
|
||||||
if (!allowChanged && !tenantChanged && !viewChanged) {
|
if (!allowChanged && !tenantChanged && !viewChanged) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> no changes');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.allowLayoutPersistence
|
||||||
|
&& this.tenantId
|
||||||
|
&& this.viewId
|
||||||
|
&& !this.initialLoadDone
|
||||||
|
&& !this.loadingPersisted
|
||||||
|
) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> triggering load for pending persistence');
|
||||||
|
}
|
||||||
|
this.loadPersistedLayout();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +480,9 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.allowLayoutPersistence) {
|
if (!this.allowLayoutPersistence) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> persistence disabled, clearing state');
|
||||||
|
}
|
||||||
this.persistedLayout = new Map();
|
this.persistedLayout = new Map();
|
||||||
this.layoutDirty = false;
|
this.layoutDirty = false;
|
||||||
this.emit();
|
this.emit();
|
||||||
@@ -444,10 +490,22 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.tenantId || !this.viewId) {
|
if (!this.tenantId || !this.viewId) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> missing tenant/view, waiting', {
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
viewId: this.viewId,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.initialLoadDone) {
|
if (!this.initialLoadDone) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] updateConfig -> fetching persisted layout', {
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
viewId: this.viewId,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.loadPersistedLayout();
|
this.loadPersistedLayout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,8 +513,23 @@ export class WorkspaceEngine {
|
|||||||
setItems(items) {
|
setItems(items) {
|
||||||
const normalized = Array.isArray(items) ? items : [];
|
const normalized = Array.isArray(items) ? items : [];
|
||||||
this.items = normalized;
|
this.items = normalized;
|
||||||
this.ensureLayoutForItems();
|
const canGenerateLayoutImmediately =
|
||||||
|
!this.allowLayoutPersistence
|
||||||
|
|| !this.tenantId
|
||||||
|
|| !this.viewId
|
||||||
|
|| this.initialLoadDone;
|
||||||
|
if (canGenerateLayoutImmediately) {
|
||||||
|
this.ensureLayoutForItems();
|
||||||
|
}
|
||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] setItems', {
|
||||||
|
itemCount: this.items.length,
|
||||||
|
canGenerateLayoutImmediately,
|
||||||
|
allowLayoutPersistence: this.allowLayoutPersistence,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocumentLookup(map) {
|
setDocumentLookup(map) {
|
||||||
@@ -490,6 +563,14 @@ export class WorkspaceEngine {
|
|||||||
this.ensureLayoutForItems();
|
this.ensureLayoutForItems();
|
||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
this.emit();
|
this.emit();
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][config] setCanvasSize', {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
allowLayoutPersistence: this.allowLayoutPersistence,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraggingId(docId) {
|
setDraggingId(docId) {
|
||||||
@@ -828,7 +909,31 @@ export class WorkspaceEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureLayoutForItems() {
|
ensureLayoutForItems() {
|
||||||
if (!this.canvasSize.width || !this.canvasSize.height) {
|
if (this.allowLayoutPersistence && !this.initialLoadDone) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> waiting for persisted layout to load');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
|
||||||
|
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
|
||||||
|
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
|
||||||
|
|
||||||
|
if (!persistenceReady) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> waiting for persisted layout to load', {
|
||||||
|
allowLayoutPersistence: this.allowLayoutPersistence,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canvasReady) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> canvas missing size', this.canvasSize);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.items.length) {
|
if (!this.items.length) {
|
||||||
@@ -839,8 +944,10 @@ export class WorkspaceEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingSizes = this.items.some((doc) => !this.ensureDocumentSize(doc));
|
if (!sizesReady) {
|
||||||
if (missingSizes) {
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> waiting for document sizes');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,8 +956,11 @@ export class WorkspaceEngine {
|
|||||||
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||||
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
||||||
const docsNeedingLayout = [];
|
const docsNeedingLayout = [];
|
||||||
|
let generatedCount = 0;
|
||||||
|
|
||||||
const currentEntries = new Map(this.layout);
|
const currentEntries = new Map(this.layout);
|
||||||
|
let persistedAppliedCount = 0;
|
||||||
|
let reusedCurrentCount = 0;
|
||||||
|
|
||||||
this.items.forEach((doc) => {
|
this.items.forEach((doc) => {
|
||||||
if (!doc?.id) {
|
if (!doc?.id) {
|
||||||
@@ -870,11 +980,11 @@ export class WorkspaceEngine {
|
|||||||
const minCenterY = DESK_CANVAS_PADDING + halfHeight;
|
const minCenterY = DESK_CANVAS_PADDING + halfHeight;
|
||||||
const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight);
|
const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight);
|
||||||
|
|
||||||
const persisted = this.persistedLayout.get(docKey) || null;
|
const persisted = this.persistedLayout.get(docKey);
|
||||||
let existing = currentEntries.get(docKey) || null;
|
const currentEntry = currentEntries.get(docKey) || null;
|
||||||
if (persisted) {
|
const hasPersisted = Boolean(persisted);
|
||||||
existing = existing ? { ...existing, ...persisted } : { ...persisted };
|
const hasCurrent = !hasPersisted && Boolean(currentEntry);
|
||||||
}
|
let existing = persisted || currentEntry;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
||||||
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
||||||
@@ -886,6 +996,11 @@ export class WorkspaceEngine {
|
|||||||
const z = existing.z ?? maxZ;
|
const z = existing.z ?? maxZ;
|
||||||
maxZ = Math.max(maxZ, z);
|
maxZ = Math.max(maxZ, z);
|
||||||
next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight });
|
next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight });
|
||||||
|
if (hasPersisted) {
|
||||||
|
persistedAppliedCount += 1;
|
||||||
|
} else if (hasCurrent) {
|
||||||
|
reusedCurrentCount += 1;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,14 +1027,31 @@ export class WorkspaceEngine {
|
|||||||
);
|
);
|
||||||
generatedLayout.forEach((entry, docId) => {
|
generatedLayout.forEach((entry, docId) => {
|
||||||
next.set(docId, entry);
|
next.set(docId, entry);
|
||||||
|
generatedCount += 1;
|
||||||
});
|
});
|
||||||
maxZ = Math.max(maxZ, updatedMaxZ);
|
maxZ = Math.max(maxZ, updatedMaxZ);
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> generated layout for items', {
|
||||||
|
requested: docsNeedingLayout.length,
|
||||||
|
applied: generatedCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.layout = next;
|
this.layout = next;
|
||||||
this.zCounter = Math.max(this.zCounter, maxZ);
|
this.zCounter = Math.max(this.zCounter, maxZ);
|
||||||
this.syncLayoutSnapshot();
|
this.syncLayoutSnapshot();
|
||||||
this.recalcVisibleDocIds();
|
this.recalcVisibleDocIds();
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][layout] ensureLayoutForItems -> applied layout', {
|
||||||
|
items: this.items.length,
|
||||||
|
layoutSize: this.layout.size,
|
||||||
|
persistedAppliedCount,
|
||||||
|
reusedCurrentCount,
|
||||||
|
generatedCount,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recalcVisibleDocIds() {
|
recalcVisibleDocIds() {
|
||||||
@@ -1073,6 +1205,7 @@ export class WorkspaceEngine {
|
|||||||
tagDropTargetId: this.tagDropTargetId,
|
tagDropTargetId: this.tagDropTargetId,
|
||||||
pendingTagDocId: this.pendingTagDocId,
|
pendingTagDocId: this.pendingTagDocId,
|
||||||
pendingRemovalTag: this.pendingRemovalTag,
|
pendingRemovalTag: this.pendingRemovalTag,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,14 +1222,38 @@ export class WorkspaceEngine {
|
|||||||
|
|
||||||
async loadPersistedLayout() {
|
async loadPersistedLayout() {
|
||||||
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> aborted, missing config', {
|
||||||
|
allowLayoutPersistence: this.allowLayoutPersistence,
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
viewId: this.viewId,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.loadingPersisted || this.initialLoadDone) {
|
if (this.loadingPersisted || this.initialLoadDone) {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> already loading or done', {
|
||||||
|
loadingPersisted: this.loadingPersisted,
|
||||||
|
initialLoadDone: this.initialLoadDone,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingPersisted = true;
|
this.loadingPersisted = true;
|
||||||
try {
|
try {
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> fetching', {
|
||||||
|
tenantId: this.tenantId,
|
||||||
|
viewId: this.viewId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId });
|
const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId });
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> fetched records', {
|
||||||
|
count: records.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
records.forEach((record) => {
|
records.forEach((record) => {
|
||||||
if (!record || !record.documentId) {
|
if (!record || !record.documentId) {
|
||||||
@@ -1119,11 +1276,29 @@ export class WorkspaceEngine {
|
|||||||
this.layoutSnapshot = new Map(this.layout);
|
this.layoutSnapshot = new Map(this.layout);
|
||||||
this.ensureLayoutForItems();
|
this.ensureLayoutForItems();
|
||||||
this.initialLoadDone = true;
|
this.initialLoadDone = true;
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> applied persisted layout', {
|
||||||
|
layoutSize: this.layout.size,
|
||||||
|
persistedSize: this.persistedLayout.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.emit();
|
this.emit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desk] Failed to load persisted layout', error);
|
console.warn('[desk] Failed to load persisted layout', error);
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingPersisted = false;
|
this.loadingPersisted = false;
|
||||||
|
if (!this.initialLoadDone) {
|
||||||
|
this.initialLoadDone = true;
|
||||||
|
if (!this.layout.size) {
|
||||||
|
this.ensureLayoutForItems();
|
||||||
|
}
|
||||||
|
if (isDevEnvironment()) {
|
||||||
|
console.log('[desk][load] loadPersistedLayout -> fallback to generated layout', {
|
||||||
|
layoutSize: this.layout.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user