mirror of
https://framagit.org/tykayn/mastodon.git
synced 2023-08-25 08:33:12 +02:00
Merge pull request #1566 from ClearlyClaire/glitch-soc/feature/modal-stack
Fix boost/fav confirmation modals closing media modal
This commit is contained in:
commit
82bc8e7647
app/javascript/flavours/glitch
components
containers
features
account_gallery
directory
notifications
status
ui
reducers
@ -76,10 +76,13 @@ export default class ModalRoot extends React.PureComponent {
|
|||||||
this.activeElement = null;
|
this.activeElement = null;
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
this.handleModalClose();
|
this._handleModalClose();
|
||||||
}
|
}
|
||||||
if (this.props.children && !prevProps.children) {
|
if (this.props.children && !prevProps.children) {
|
||||||
this.handleModalOpen();
|
this._handleModalOpen();
|
||||||
|
}
|
||||||
|
if (this.props.children) {
|
||||||
|
this._ensureHistoryBuffer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,22 +91,29 @@ export default class ModalRoot extends React.PureComponent {
|
|||||||
window.removeEventListener('keydown', this.handleKeyDown);
|
window.removeEventListener('keydown', this.handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleModalClose () {
|
_handleModalOpen () {
|
||||||
|
this._modalHistoryKey = Date.now();
|
||||||
|
this.unlistenHistory = this.history.listen((_, action) => {
|
||||||
|
if (action === 'POP') {
|
||||||
|
this.props.onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleModalClose () {
|
||||||
this.unlistenHistory();
|
this.unlistenHistory();
|
||||||
|
|
||||||
const state = this.history.location.state;
|
const { state } = this.history.location;
|
||||||
if (state && state.mastodonModalOpen) {
|
if (state && state.mastodonModalKey === this._modalHistoryKey) {
|
||||||
this.history.goBack();
|
this.history.goBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleModalOpen () {
|
_ensureHistoryBuffer () {
|
||||||
const history = this.history;
|
const { pathname, state } = this.history.location;
|
||||||
const state = {...history.location.state, mastodonModalOpen: true};
|
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
|
||||||
history.push(history.location.pathname, state);
|
this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
|
||||||
this.unlistenHistory = history.listen(() => {
|
}
|
||||||
this.props.onClose();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSiblings = () => {
|
getSiblings = () => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
|
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
|
||||||
import LoadMore from './load_more';
|
import LoadMore from './load_more';
|
||||||
@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
|
|||||||
onScrollToTop: PropTypes.func,
|
onScrollToTop: PropTypes.func,
|
||||||
onScroll: PropTypes.func,
|
onScroll: PropTypes.func,
|
||||||
trackScroll: PropTypes.bool,
|
trackScroll: PropTypes.bool,
|
||||||
shouldUpdateScroll: PropTypes.func,
|
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
showLoading: PropTypes.bool,
|
showLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
@ -264,11 +263,6 @@ class ScrollableList extends PureComponent {
|
|||||||
this.props.onLoadMore();
|
this.props.onLoadMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
|
|
||||||
return !(location.state && location.state.mastodonModalOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadPending = e => {
|
handleLoadPending = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onLoadPending();
|
this.props.onLoadPending();
|
||||||
@ -282,7 +276,7 @@ class ScrollableList extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
|
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
const childrenCount = React.Children.count(children);
|
const childrenCount = React.Children.count(children);
|
||||||
|
|
||||||
@ -348,7 +342,7 @@ class ScrollableList extends PureComponent {
|
|||||||
|
|
||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}>
|
<ScrollContainer scrollKey={scrollKey}>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
);
|
);
|
||||||
|
@ -147,7 +147,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
|
|
||||||
handleOpen = () => {
|
handleOpen = () => {
|
||||||
let state = {...this.context.router.history.location.state};
|
let state = {...this.context.router.history.location.state};
|
||||||
if (state.mastodonModalOpen) {
|
if (state.mastodonModalKey) {
|
||||||
this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
|
this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
|
||||||
} else {
|
} else {
|
||||||
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
|
||||||
|
@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
onScrollToTop: PropTypes.func,
|
onScrollToTop: PropTypes.func,
|
||||||
onScroll: PropTypes.func,
|
onScroll: PropTypes.func,
|
||||||
trackScroll: PropTypes.bool,
|
trackScroll: PropTypes.bool,
|
||||||
shouldUpdateScroll: PropTypes.func,
|
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
isPartial: PropTypes.bool,
|
isPartial: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
|
@ -41,7 +41,7 @@ export default class Mastodon extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll (_, { location }) {
|
shouldUpdateScroll (_, { location }) {
|
||||||
return !(location.state && location.state.mastodonModalOpen);
|
return !(location.state?.mastodonModalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
|
||||||
|
|
||||||
|
// ScrollContainer is used to automatically scroll to the top when pushing a
|
||||||
|
// new history state and remembering the scroll position when going back.
|
||||||
|
// There are a few things we need to do differently, though.
|
||||||
|
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
||||||
|
// If the change is caused by opening a modal, do not scroll to top
|
||||||
|
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default
|
||||||
|
class ScrollContainer extends OriginalScrollContainer {
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
shouldUpdateScroll: defaultShouldUpdateScroll,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
@ -11,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import { getAccountGallery } from 'flavours/glitch/selectors';
|
import { getAccountGallery } from 'flavours/glitch/selectors';
|
||||||
import MediaItem from './components/media_item';
|
import MediaItem from './components/media_item';
|
||||||
import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
|
import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import LoadMore from 'flavours/glitch/components/load_more';
|
import LoadMore from 'flavours/glitch/components/load_more';
|
||||||
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
|
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
@ -104,11 +104,6 @@ class AccountGallery extends ImmutablePureComponent {
|
|||||||
this.handleScrollToBottom();
|
this.handleScrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
|
|
||||||
return !(location.state && location.state.mastodonModalOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
setColumnRef = c => {
|
setColumnRef = c => {
|
||||||
this.column = c;
|
this.column = c;
|
||||||
}
|
}
|
||||||
@ -165,7 +160,7 @@ class AccountGallery extends ImmutablePureComponent {
|
|||||||
<Column ref={this.setColumnRef}>
|
<Column ref={this.setColumnRef}>
|
||||||
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
||||||
|
|
||||||
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='account_gallery'>
|
||||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||||
<HeaderContainer accountId={this.props.params.accountId} />
|
<HeaderContainer accountId={this.props.params.accountId} />
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import AccountCard from './components/account_card';
|
|||||||
import RadioButton from 'flavours/glitch/components/radio_button';
|
import RadioButton from 'flavours/glitch/components/radio_button';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import LoadMore from 'flavours/glitch/components/load_more';
|
import LoadMore from 'flavours/glitch/components/load_more';
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||||
@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
|
|||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
accountIds: ImmutablePropTypes.list.isRequired,
|
accountIds: ImmutablePropTypes.list.isRequired,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
shouldUpdateScroll: PropTypes.func,
|
|
||||||
columnId: PropTypes.string,
|
columnId: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
|
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
|
||||||
const { order, local } = this.getParams(this.props, this.state);
|
const { order, local } = this.getParams(this.props, this.state);
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
|
||||||
@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
|
|||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
|
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -99,7 +99,6 @@ class Notifications extends React.PureComponent {
|
|||||||
notifications: ImmutablePropTypes.list.isRequired,
|
notifications: ImmutablePropTypes.list.isRequired,
|
||||||
showFilterBar: PropTypes.bool.isRequired,
|
showFilterBar: PropTypes.bool.isRequired,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
shouldUpdateScroll: PropTypes.func,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
isUnread: PropTypes.bool,
|
isUnread: PropTypes.bool,
|
||||||
@ -220,7 +219,7 @@ class Notifications extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
||||||
const { notifCleaning, notifCleaningActive } = this.props;
|
const { notifCleaning, notifCleaningActive } = this.props;
|
||||||
const { animatingNCD } = this.state;
|
const { animatingNCD } = this.state;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
@ -273,7 +272,6 @@ class Notifications extends React.PureComponent {
|
|||||||
onLoadPending={this.handleLoadPending}
|
onLoadPending={this.handleLoadPending}
|
||||||
onScrollToTop={this.handleScrollToTop}
|
onScrollToTop={this.handleScrollToTop}
|
||||||
onScroll={this.handleScroll}
|
onScroll={this.handleScroll}
|
||||||
shouldUpdateScroll={shouldUpdateScroll}
|
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
>
|
>
|
||||||
{scrollableContent}
|
{scrollableContent}
|
||||||
|
@ -32,7 +32,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
|||||||
import { initReport } from 'flavours/glitch/actions/reports';
|
import { initReport } from 'flavours/glitch/actions/reports';
|
||||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
|
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||||
@ -507,11 +507,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
|
|
||||||
return !(location.state && location.state.mastodonModalOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants;
|
||||||
const { setExpansion } = this;
|
const { setExpansion } = this;
|
||||||
@ -562,7 +557,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='thread'>
|
||||||
<div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
|
<div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
|
@ -59,12 +59,8 @@ export default class ModalRoot extends React.PureComponent {
|
|||||||
backgroundColor: null,
|
backgroundColor: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
getSnapshotBeforeUpdate () {
|
componentDidUpdate () {
|
||||||
return { visible: !!this.props.type };
|
if (!!this.props.type) {
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState, { visible }) {
|
|
||||||
if (visible) {
|
|
||||||
document.body.classList.add('with-modals--active');
|
document.body.classList.add('with-modals--active');
|
||||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||||
} else {
|
} else {
|
||||||
|
@ -3,8 +3,8 @@ import { closeModal } from 'flavours/glitch/actions/modal';
|
|||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
type: state.get('modal').modalType,
|
type: state.getIn(['modal', 0, 'modalType'], null),
|
||||||
props: state.get('modal').modalProps,
|
props: state.getIn(['modal', 0, 'modalProps'], {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
@ -212,7 +212,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||||||
|
|
||||||
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
|
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
|
||||||
<WrappedRoute path='/search' component={Search} content={children} />
|
<WrappedRoute path='/search' component={Search} content={children} />
|
||||||
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
|
import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
|
||||||
import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
|
import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
|
||||||
|
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
const initialState = {
|
export default function modal(state = ImmutableStack(), action) {
|
||||||
modalType: null,
|
|
||||||
modalProps: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function modal(state = initialState, action) {
|
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MODAL_OPEN:
|
case MODAL_OPEN:
|
||||||
return { modalType: action.modalType, modalProps: action.modalProps };
|
return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
|
return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return (state.modalProps.statusId === action.id) ? initialState : state;
|
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user