Make shared components aware of layout and density settings (#33368)
* Add shared event presentation context * Add app-web event presentation mapper * Wire event presentation provider into app timelines * Add Storybook controls for event layout and density * Wire compact density through app/web event presentation provider * Use event presentation density for URL previews * Move TileErrorView layout to event presentation context * Minor fix and updated snapshot * Updated snapshots for url preview group * Prettier fix * Restore removed story to fix missing playwright test * Updates after review comments * Fix prettier issue
This commit is contained in:
@@ -34,6 +34,7 @@ import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import Measured from "../views/elements/Measured";
|
||||
import EmptyState from "../views/right_panel/EmptyState";
|
||||
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
@@ -286,15 +287,17 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview={false}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
empty={emptyState}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
<EventPresentationContextProvider layout={Layout.Group}>
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview={false}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
empty={emptyState}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</BaseCard>
|
||||
</ScopedRoomContextProvider>
|
||||
);
|
||||
|
||||
@@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
const DEBUG = false;
|
||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||
@@ -2583,32 +2584,34 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
let messagePanel: JSX.Element | undefined;
|
||||
if (!isRoomEncryptionLoading) {
|
||||
messagePanel = (
|
||||
<TimelinePanel
|
||||
ref={this.gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={
|
||||
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
|
||||
}
|
||||
manageReadMarkers={!this.state.isPeeking}
|
||||
hidden={hideMessagePanel}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventId={this.state.initialEventId}
|
||||
eventScrollIntoView={this.state.initialEventScrollIntoView}
|
||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={this.messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
showReactions={true}
|
||||
layout={this.state.layout}
|
||||
editState={this.state.editState}
|
||||
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity}
|
||||
/>
|
||||
<EventPresentationContextProvider layout={this.state.layout}>
|
||||
<TimelinePanel
|
||||
ref={this.gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={
|
||||
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
|
||||
}
|
||||
manageReadMarkers={!this.state.isPeeking}
|
||||
hidden={hideMessagePanel}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventId={this.state.initialEventId}
|
||||
eventScrollIntoView={this.state.initialEventScrollIntoView}
|
||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={this.messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
showReactions={true}
|
||||
layout={this.state.layout}
|
||||
editState={this.state.editState}
|
||||
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
|
||||
import Heading from "../views/typography/Heading";
|
||||
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
|
||||
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
@@ -388,32 +389,36 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
const layout = this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group;
|
||||
|
||||
timeline = (
|
||||
<>
|
||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
|
||||
<TimelinePanel
|
||||
key={this.state.thread.id}
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.context.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={true}
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.state.thread.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// ThreadView doesn't support IRC layout at this time
|
||||
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.props.initialEvent?.getId()}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
/>
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
key={this.state.thread.id}
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.context.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={true}
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.state.thread.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// ThreadView doesn't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.props.initialEvent?.getId()}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
||||
import Measured from "../elements/Measured";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
@@ -197,6 +198,7 @@ export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
|
||||
const myMembership = this.props.room.getMyMembership();
|
||||
const showComposer = myMembership === KnownMembership.Join;
|
||||
const layout = this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group;
|
||||
|
||||
return (
|
||||
<ScopedRoomContextProvider
|
||||
@@ -215,27 +217,29 @@ export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
/>
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
ReactionsRowButtonView,
|
||||
ReactionsRowView,
|
||||
TileErrorView,
|
||||
type TileErrorViewLayout,
|
||||
useViewModel,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
@@ -1579,24 +1578,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
*/
|
||||
interface EventTileErrorFallbackProps {
|
||||
error: Error;
|
||||
layout: Layout;
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
|
||||
function EventTileErrorFallback({ error, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
|
||||
const developerMode = useSettingValue("developerMode");
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }),
|
||||
);
|
||||
const vm = useCreateAutoDisposedViewModel(() => new TileErrorViewModel({ error, mxEvent, developerMode }));
|
||||
|
||||
useEffect(() => {
|
||||
vm.setError(error);
|
||||
}, [error, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setLayout(layout as TileErrorViewLayout);
|
||||
}, [layout, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setDeveloperMode(developerMode);
|
||||
}, [developerMode, vm]);
|
||||
@@ -1606,7 +1598,6 @@ function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileEr
|
||||
|
||||
interface EventTileErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
layout: Layout;
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -1626,13 +1617,7 @@ class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps
|
||||
|
||||
public render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<EventTileErrorFallback
|
||||
error={this.state.error}
|
||||
layout={this.props.layout}
|
||||
mxEvent={this.props.mxEvent}
|
||||
/>
|
||||
);
|
||||
return <EventTileErrorFallback error={this.state.error} mxEvent={this.props.mxEvent} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
@@ -1642,7 +1627,7 @@ class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps
|
||||
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
|
||||
const SafeEventTile = (props: EventTileProps): JSX.Element => {
|
||||
return (
|
||||
<EventTileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
|
||||
<EventTileErrorBoundary mxEvent={props.mxEvent}>
|
||||
<UnwrappedEventTile {...props} />
|
||||
</EventTileErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type PropsWithChildren, useMemo } from "react";
|
||||
import { EventPresentationProvider, type EventLayout, type EventPresentation } from "@element-hq/web-shared-components";
|
||||
|
||||
import { Layout } from "../settings/enums/Layout";
|
||||
import { useSettingValue } from "../hooks/useSettings";
|
||||
|
||||
const EVENT_LAYOUT_BY_APP_LAYOUT: Record<Layout, EventLayout> = {
|
||||
[Layout.Bubble]: "bubble",
|
||||
[Layout.Group]: "group",
|
||||
[Layout.IRC]: "irc",
|
||||
};
|
||||
|
||||
function getEventDensity(layout: Layout, useCompactLayout: boolean): EventPresentation["density"] {
|
||||
return useCompactLayout && layout === Layout.Group ? "compact" : "default";
|
||||
}
|
||||
|
||||
/** Converts app/web layout settings into shared event presentation settings. */
|
||||
export function getEventPresentation(layout: Layout, useCompactLayout: boolean): EventPresentation {
|
||||
return {
|
||||
layout: EVENT_LAYOUT_BY_APP_LAYOUT[layout],
|
||||
density: getEventDensity(layout, useCompactLayout),
|
||||
};
|
||||
}
|
||||
|
||||
/** Props for the app/web event presentation context provider. */
|
||||
export interface EventPresentationContextProviderProps {
|
||||
/** Layout selected by the app/web surface rendering the timeline. */
|
||||
layout: Layout;
|
||||
}
|
||||
|
||||
/** Provides shared event presentation using app/web-owned layout settings. */
|
||||
export function EventPresentationContextProvider({
|
||||
layout,
|
||||
children,
|
||||
}: Readonly<PropsWithChildren<EventPresentationContextProviderProps>>): JSX.Element {
|
||||
// Compact density is still owned by app/web; this exposes it as shared event presentation.
|
||||
const useCompactLayout = useSettingValue("useCompactLayout");
|
||||
const eventLayout = EVENT_LAYOUT_BY_APP_LAYOUT[layout];
|
||||
const density = getEventDensity(layout, useCompactLayout);
|
||||
const value = useMemo<EventPresentation>(() => ({ layout: eventLayout, density }), [eventLayout, density]);
|
||||
|
||||
return <EventPresentationProvider value={value}>{children}</EventPresentationProvider>;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { type MouseEventHandler } from "react";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BaseViewModel,
|
||||
type TileErrorViewLayout,
|
||||
type TileErrorViewSnapshot as TileErrorViewSnapshotInterface,
|
||||
type TileErrorViewModel as TileErrorViewModelInterface,
|
||||
} from "@element-hq/web-shared-components";
|
||||
@@ -24,10 +23,6 @@ import BugReportDialog from "../../components/views/dialogs/BugReportDialog";
|
||||
const TILE_ERROR_BUG_REPORT_LABEL = "react-tile-soft-crash";
|
||||
|
||||
export interface TileErrorViewModelProps {
|
||||
/**
|
||||
* Layout variant used by the host timeline.
|
||||
*/
|
||||
layout: TileErrorViewLayout;
|
||||
/**
|
||||
* Event whose tile failed to render.
|
||||
*/
|
||||
@@ -64,17 +59,15 @@ function getViewSourceCtaLabel(developerMode: boolean): string | undefined {
|
||||
/**
|
||||
* ViewModel for the tile error fallback, providing the snapshot shown when a tile fails to render.
|
||||
*
|
||||
* The snapshot includes the host timeline layout, the fallback message, the event type,
|
||||
* and optional bug-report and view-source action labels. The view model also exposes
|
||||
* click handlers for those actions, opening the bug-report or view-source dialog when
|
||||
* available.
|
||||
* The snapshot includes the fallback message, event type, and optional bug-report and
|
||||
* view-source action labels. The view model also exposes click handlers for those
|
||||
* actions, opening the bug-report or view-source dialog when available.
|
||||
*/
|
||||
export class TileErrorViewModel
|
||||
extends BaseViewModel<TileErrorViewSnapshotInterface, TileErrorViewModelProps>
|
||||
implements TileErrorViewModelInterface
|
||||
{
|
||||
private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({
|
||||
layout: props.layout,
|
||||
message: _t("timeline|error_rendering_message"),
|
||||
eventType: props.mxEvent.getType(),
|
||||
bugReportCtaLabel: getBugReportCtaLabel(),
|
||||
@@ -85,11 +78,6 @@ export class TileErrorViewModel
|
||||
super(props, TileErrorViewModel.computeSnapshot(props));
|
||||
}
|
||||
|
||||
public setLayout(layout: TileErrorViewLayout): void {
|
||||
this.props.layout = layout;
|
||||
this.snapshot.merge({ layout });
|
||||
}
|
||||
|
||||
public setError(error: Error): void {
|
||||
this.props.error = error;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { thumbHeight } from "../../ImageUtils";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { PosthogAnalytics } from "../../PosthogAnalytics";
|
||||
|
||||
const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
|
||||
@@ -239,7 +238,6 @@ export class UrlPreviewGroupViewModel
|
||||
private readonly client: MatrixClient;
|
||||
private readonly storageKey: string;
|
||||
private readonly eventSendTime: number;
|
||||
private readonly useCompactLayoutSettingWatcher: string;
|
||||
|
||||
/**
|
||||
* Should the URL preview render according to the application.
|
||||
@@ -282,7 +280,6 @@ export class UrlPreviewGroupViewModel
|
||||
totalPreviewCount: 0,
|
||||
previewsLimited: true,
|
||||
overPreviewLimit: false,
|
||||
compactLayout: SettingsStore.getValue("useCompactLayout"),
|
||||
});
|
||||
this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1";
|
||||
this.urlPreviewVisible = props.visible;
|
||||
@@ -291,15 +288,6 @@ export class UrlPreviewGroupViewModel
|
||||
this.client = props.client;
|
||||
this.eventSendTime = props.mxEvent.getTs();
|
||||
this.onImageClick = props.onImageClicked;
|
||||
this.useCompactLayoutSettingWatcher = SettingsStore.watchSetting(
|
||||
"useCompactLayout",
|
||||
null,
|
||||
(_setting, _roomid, _level, compactLayout) => {
|
||||
this.snapshot.merge({
|
||||
compactLayout: !!compactLayout,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,11 +371,6 @@ export class UrlPreviewGroupViewModel
|
||||
return result;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
SettingsStore.unwatchSetting(this.useCompactLayoutSettingWatcher);
|
||||
}
|
||||
|
||||
private get visibility(): PreviewVisibility {
|
||||
if (!this.urlPreviewVisible) {
|
||||
return PreviewVisibility.Hidden;
|
||||
|
||||
Reference in New Issue
Block a user