🐛 Fix several known bugs
This commit is contained in:
		| @@ -205,6 +205,7 @@ class ChatProvider extends ChangeNotifier { | ||||
|   void unFocus() { | ||||
|     currentCall = null; | ||||
|     focusChannel = null; | ||||
|     historyPagingController?.dispose(); | ||||
|     historyPagingController = null; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|   | ||||
| @@ -14,9 +14,8 @@ class AccountScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.account, | ||||
|       noSafeArea: true, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       child: AccountScreenWidget( | ||||
|       body: AccountScreenWidget( | ||||
|         onSelect: (item) { | ||||
|           SolianRouter.router.pushNamed(item); | ||||
|         }, | ||||
|   | ||||
| @@ -18,9 +18,8 @@ class FriendScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.friend, | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       child: const FriendScreenWidget(), | ||||
|       body: const FriendScreenWidget(), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,7 @@ class PersonalizeScreen extends StatelessWidget { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.personalize, | ||||
|       hideDrawer: true, | ||||
|       child: const PersonalizeScreenWidget(), | ||||
|       body: const PersonalizeScreenWidget(), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class SignInScreen extends StatelessWidget { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.signIn, | ||||
|       hideDrawer: true, | ||||
|       child: Center( | ||||
|       body: Center( | ||||
|         child: Container( | ||||
|           width: MediaQuery.of(context).size.width * 0.6, | ||||
|           constraints: const BoxConstraints(maxWidth: 360), | ||||
|   | ||||
| @@ -71,7 +71,7 @@ class SignUpScreen extends StatelessWidget { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.signUp, | ||||
|       hideDrawer: true, | ||||
|       child: Center( | ||||
|       body: Center( | ||||
|         child: Container( | ||||
|           width: MediaQuery.of(context).size.width * 0.6, | ||||
|           constraints: const BoxConstraints(maxWidth: 360), | ||||
|   | ||||
| @@ -132,7 +132,7 @@ class _ChatCallState extends State<ChatCall> { | ||||
|       title: AppLocalizations.of(context)!.chatCall, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       hideDrawer: true, | ||||
|       child: content, | ||||
|       body: content, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -102,6 +102,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> { | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       hideDrawer: true, | ||||
|       showSafeArea: true, | ||||
|       title: AppLocalizations.of(context)!.chatChannelOrganize, | ||||
|       appBarActions: <Widget>[ | ||||
|         TextButton( | ||||
| @@ -109,7 +110,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> { | ||||
|           child: Text(AppLocalizations.of(context)!.apply.toUpperCase()), | ||||
|         ), | ||||
|       ], | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), | ||||
|           widget.editing != null ? editingBanner : Container(), | ||||
|   | ||||
| @@ -38,6 +38,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> { | ||||
|     _selfId = prof['id']; | ||||
|  | ||||
|     var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members'); | ||||
|     print(uri); | ||||
|  | ||||
|     var res = await auth.client!.get(uri); | ||||
|     if (res.statusCode == 200) { | ||||
| @@ -141,7 +142,6 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> { | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.chatMember, | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       appBarActions: [ | ||||
|         IconButton( | ||||
| @@ -149,7 +149,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> { | ||||
|           onPressed: () => promptAddMember(), | ||||
|         ), | ||||
|       ], | ||||
|       child: RefreshIndicator( | ||||
|       body: RefreshIndicator( | ||||
|         onRefresh: () => fetchMemberships(), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|   | ||||
| @@ -32,6 +32,7 @@ class ChatScreen extends StatelessWidget { | ||||
|     return IndentScaffold( | ||||
|       title: chat.focusChannel?.name ?? 'Loading...', | ||||
|       hideDrawer: true, | ||||
|       showSafeArea: true, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       appBarActions: chat.focusChannel != null | ||||
|           ? [ | ||||
| @@ -48,7 +49,7 @@ class ChatScreen extends StatelessWidget { | ||||
|               ), | ||||
|             ] | ||||
|           : [], | ||||
|       child: ChatWidget( | ||||
|       body: ChatWidget( | ||||
|         alias: alias, | ||||
|         realm: realm, | ||||
|       ), | ||||
| @@ -96,7 +97,7 @@ class _ChatWidgetState extends State<ChatWidget> { | ||||
|     if (a?.replyTo != null) return false; | ||||
|     if (a == null || b == null) return false; | ||||
|     if (a.senderId != b.senderId) return false; | ||||
|     return a.createdAt.difference(b.createdAt).inMinutes <= 5; | ||||
|     return a.createdAt.difference(b.createdAt).inMinutes <= 3; | ||||
|   } | ||||
|  | ||||
|   Message? _editingItem; | ||||
| @@ -107,6 +108,7 @@ class _ChatWidgetState extends State<ChatWidget> { | ||||
|       context: context, | ||||
|       builder: (context) => ChatMessageAction( | ||||
|         channel: widget.alias, | ||||
|         realm: widget.realm, | ||||
|         item: item, | ||||
|         onEdit: () => setState(() { | ||||
|           _editingItem = item; | ||||
|   | ||||
| @@ -67,8 +67,7 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.chatDetail, | ||||
|       hideDrawer: true, | ||||
|       noSafeArea: true, | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
| @@ -103,9 +102,12 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> { | ||||
|                   title: Text(AppLocalizations.of(context)!.chatMember), | ||||
|                   onTap: () { | ||||
|                     SolianRouter.router.pushNamed( | ||||
|                       'chat.channel.member', | ||||
|                       widget.realm == 'global' ? 'chat.channel.member' : 'realms.chat.channel.member', | ||||
|                       extra: widget.channel, | ||||
|                       pathParameters: {'channel': widget.channel.alias}, | ||||
|                       pathParameters: { | ||||
|                         'channel': widget.channel.alias, | ||||
|                         ...(widget.realm == 'global' ? {} : {'realm': widget.realm}), | ||||
|                       }, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|   | ||||
| @@ -24,7 +24,7 @@ class ChatListScreen extends StatelessWidget { | ||||
|       title: AppLocalizations.of(context)!.chat, | ||||
|       appBarActions: const [NotificationButton()], | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       child: const ChatListWidget(), | ||||
|       body: const ChatListWidget(), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -23,11 +23,10 @@ class ExplorePostScreen extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentScaffold( | ||||
|       noSafeArea: true, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       appBarActions: const [NotificationButton()], | ||||
|       title: AppLocalizations.of(context)!.explore, | ||||
|       child: const ExplorePostWidget(showRealmShortcuts: true), | ||||
|       body: const ExplorePostWidget(showRealmShortcuts: true), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -73,10 +73,9 @@ class _NotificationScreenState extends State<NotificationScreen> { | ||||
|     final nty = context.watch<NotifyProvider>(); | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       title: AppLocalizations.of(context)!.notification, | ||||
|       child: RefreshIndicator( | ||||
|       body: RefreshIndicator( | ||||
|         onRefresh: () => nty.fetch(auth), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|   | ||||
| @@ -122,6 +122,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> { | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       hideDrawer: true, | ||||
|       showSafeArea: true, | ||||
|       title: AppLocalizations.of(context)!.newComment, | ||||
|       appBarActions: <Widget>[ | ||||
|         TextButton( | ||||
| @@ -129,7 +130,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> { | ||||
|           child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()), | ||||
|         ), | ||||
|       ], | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           _isSubmitting | ||||
|               ? const LinearProgressIndicator().animate().scaleX() | ||||
|   | ||||
| @@ -55,6 +55,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> { | ||||
|     final uri = widget.editing == null | ||||
|         ? getRequestUri('interactive', '/api/p/moments') | ||||
|         : getRequestUri('interactive', '/api/p/moments/${widget.editing!.id}'); | ||||
|     print(uri); | ||||
|  | ||||
|     final req = Request(widget.editing == null ? 'POST' : 'PUT', uri); | ||||
|     req.headers['Content-Type'] = 'application/json'; | ||||
| @@ -113,6 +114,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> { | ||||
|     ); | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       showSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       title: AppLocalizations.of(context)!.newMoment, | ||||
|       appBarActions: <Widget>[ | ||||
| @@ -121,7 +123,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> { | ||||
|           child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()), | ||||
|         ), | ||||
|       ], | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), | ||||
|           FutureBuilder( | ||||
|   | ||||
| @@ -21,9 +21,8 @@ class PostScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.post, | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       child: PostScreenWidget( | ||||
|       body: PostScreenWidget( | ||||
|         dataset: dataset, | ||||
|         alias: alias, | ||||
|       ), | ||||
|   | ||||
| @@ -21,7 +21,6 @@ class RealmScreen extends StatelessWidget { | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       title: realm.focusRealm?.name ?? 'Loading...', | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       appBarActions: realm.focusRealm != null | ||||
| @@ -32,17 +31,15 @@ class RealmScreen extends StatelessWidget { | ||||
|               ), | ||||
|             ] | ||||
|           : [], | ||||
|       appBarLeading: IconButton( | ||||
|         icon: const Icon(Icons.arrow_back), | ||||
|         onPressed: () { | ||||
|           if (SolianTheme.isLargeScreen(context)) { | ||||
|             realm.clearFocus(); | ||||
|           } else if (SolianRouter.router.canPop()) { | ||||
|             SolianRouter.router.pop(); | ||||
|           } | ||||
|         }, | ||||
|       ), | ||||
|       child: RealmWidget( | ||||
|       appBarLeading: SolianTheme.isLargeScreen(context) | ||||
|           ? IconButton( | ||||
|               icon: const Icon(Icons.arrow_back), | ||||
|               onPressed: () { | ||||
|                 realm.clearFocus(); | ||||
|               }, | ||||
|             ) | ||||
|           : null, | ||||
|       body: RealmWidget( | ||||
|         alias: alias, | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -111,6 +111,7 @@ class _RealmEditorScreenState extends State<RealmEditorScreen> { | ||||
|  | ||||
|     return IndentScaffold( | ||||
|       hideDrawer: true, | ||||
|       showSafeArea: true, | ||||
|       title: AppLocalizations.of(context)!.realmEstablish, | ||||
|       appBarActions: <Widget>[ | ||||
|         TextButton( | ||||
| @@ -119,7 +120,7 @@ class _RealmEditorScreenState extends State<RealmEditorScreen> { | ||||
|         ), | ||||
|       ], | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), | ||||
|           widget.editing != null ? editingBanner : Container(), | ||||
|   | ||||
| @@ -23,7 +23,7 @@ class RealmListScreen extends StatelessWidget { | ||||
|             title: AppLocalizations.of(context)!.realm, | ||||
|             appBarActions: const [NotificationButton()], | ||||
|             fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|             child: const RealmListWidget(), | ||||
|             body: const RealmListWidget(), | ||||
|           ) | ||||
|         : RealmScreen(alias: realm.focusRealm!.alias); | ||||
|   } | ||||
|   | ||||
| @@ -65,8 +65,7 @@ class _RealmManageScreenState extends State<RealmManageScreen> { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.realmManage, | ||||
|       hideDrawer: true, | ||||
|       noSafeArea: true, | ||||
|       child: Column( | ||||
|       body: Column( | ||||
|         children: [ | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
|   | ||||
| @@ -142,7 +142,6 @@ class _RealmMemberScreenState extends State<RealmMemberScreen> { | ||||
|     return IndentScaffold( | ||||
|       title: AppLocalizations.of(context)!.realmMember, | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
|       appBarActions: [ | ||||
|         IconButton( | ||||
| @@ -150,7 +149,7 @@ class _RealmMemberScreenState extends State<RealmMemberScreen> { | ||||
|           onPressed: () => promptAddMember(), | ||||
|         ), | ||||
|       ], | ||||
|       child: RefreshIndicator( | ||||
|       body: RefreshIndicator( | ||||
|         onRefresh: () => fetchMemberships(), | ||||
|         child: CustomScrollView( | ||||
|           slivers: [ | ||||
|   | ||||
| @@ -65,8 +65,7 @@ class _UserInfoScreenState extends State<UserInfoScreen> { | ||||
|       title: _userinfo?.nick ?? 'Loading...', | ||||
|       fixedAppBarColor: SolianTheme.isLargeScreen(context), | ||||
|       hideDrawer: true, | ||||
|       noSafeArea: true, | ||||
|       child: FutureBuilder( | ||||
|       body: FutureBuilder( | ||||
|         future: fetchUserinfo(), | ||||
|         builder: (context, snapshot) { | ||||
|           if (!snapshot.hasData || snapshot.data == null) { | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:solian/widgets/chat/message_deletion.dart'; | ||||
|  | ||||
| class ChatMessageAction extends StatelessWidget { | ||||
|   final String channel; | ||||
|   final String realm; | ||||
|   final Message item; | ||||
|   final Function? onEdit; | ||||
|   final Function? onReply; | ||||
| @@ -15,6 +16,7 @@ class ChatMessageAction extends StatelessWidget { | ||||
|     super.key, | ||||
|     required this.channel, | ||||
|     required this.item, | ||||
|     this.realm = 'global', | ||||
|     this.onEdit, | ||||
|     this.onReply, | ||||
|   }); | ||||
| @@ -67,6 +69,7 @@ class ChatMessageAction extends StatelessWidget { | ||||
|                           builder: (context) => ChatMessageDeletionDialog( | ||||
|                             item: item, | ||||
|                             channel: channel, | ||||
|                             realm: realm, | ||||
|                           ), | ||||
|                         ).then((did) { | ||||
|                           if (did == true && Navigator.canPop(context)) { | ||||
|   | ||||
| @@ -10,17 +10,18 @@ import 'package:solian/widgets/exts.dart'; | ||||
|  | ||||
| class ChatMessageDeletionDialog extends StatefulWidget { | ||||
|   final String channel; | ||||
|   final String realm; | ||||
|   final Message item; | ||||
|  | ||||
|   const ChatMessageDeletionDialog({ | ||||
|     super.key, | ||||
|     required this.item, | ||||
|     required this.channel, | ||||
|     this.realm = 'global' | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ChatMessageDeletionDialog> createState() => | ||||
|       _ChatMessageDeletionDialogState(); | ||||
|   State<ChatMessageDeletionDialog> createState() => _ChatMessageDeletionDialogState(); | ||||
| } | ||||
|  | ||||
| class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> { | ||||
| @@ -30,8 +31,8 @@ class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> { | ||||
|     final auth = context.read<AuthProvider>(); | ||||
|     if (!await auth.isAuthorized()) return; | ||||
|  | ||||
|     final uri = getRequestUri('messaging', | ||||
|         '/api/channels/global/${widget.channel}/messages/${widget.item.id}'); | ||||
|     final uri = | ||||
|         getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.item.id}'); | ||||
|  | ||||
|     setState(() => _isSubmitting = true); | ||||
|     final res = await auth.client!.delete(uri); | ||||
|   | ||||
| @@ -57,11 +57,13 @@ class RealmShortcuts extends StatelessWidget { | ||||
|           onTap: () async { | ||||
|             if (SolianTheme.isLargeScreen(context)) { | ||||
|               await realm.fetchSingle(auth, element.alias); | ||||
|               SolianRouter.router.pushNamed('realms'); | ||||
|             } else { | ||||
|               SolianRouter.router.pushNamed( | ||||
|                 'realms.details', | ||||
|                 pathParameters: {'realm': element.alias}, | ||||
|               ); | ||||
|             } | ||||
|             SolianRouter.router.pushNamed( | ||||
|               'realms.details', | ||||
|               pathParameters: {'realm': element.alias}, | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|   | ||||
| @@ -1,45 +1,67 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:solian/router.dart'; | ||||
| import 'package:solian/utils/theme.dart'; | ||||
| import 'package:solian/widgets/navigation_drawer.dart'; | ||||
|  | ||||
| class IndentScaffold extends StatelessWidget { | ||||
|   final Widget? child; | ||||
|   final Widget? body; | ||||
|   final Widget? floatingActionButton; | ||||
|   final Widget? appBarLeading; | ||||
|   final List<Widget>? appBarActions; | ||||
|   final bool noSafeArea; | ||||
|   final bool hideDrawer; | ||||
|   final bool showSafeArea; | ||||
|   final bool fixedAppBarColor; | ||||
|   final String title; | ||||
|  | ||||
|   const IndentScaffold({ | ||||
|     super.key, | ||||
|     this.child, | ||||
|     this.body, | ||||
|     required this.title, | ||||
|     this.floatingActionButton, | ||||
|     this.appBarLeading, | ||||
|     this.appBarActions, | ||||
|     this.hideDrawer = false, | ||||
|     this.showSafeArea = false, | ||||
|     this.fixedAppBarColor = false, | ||||
|     this.noSafeArea = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final content = child ?? Container(); | ||||
|     final backButton = IconButton( | ||||
|       icon: const Icon(Icons.arrow_back), | ||||
|       tooltip: MaterialLocalizations.of(context).backButtonTooltip, | ||||
|       onPressed: () { | ||||
|         if (SolianRouter.router.canPop()) { | ||||
|           SolianRouter.router.pop(); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     final drawerButton = Builder( | ||||
|       builder: (context) { | ||||
|         return IconButton( | ||||
|           icon: const Icon(Icons.menu), | ||||
|           tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, | ||||
|           onPressed: () { | ||||
|             Scaffold.of(context).openDrawer(); | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|     ); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text(title), | ||||
|         leading: appBarLeading, | ||||
|         leading: appBarLeading ?? (hideDrawer ? backButton : drawerButton), | ||||
|         actions: appBarActions, | ||||
|         centerTitle: false, | ||||
|         elevation: fixedAppBarColor ? 4 : null, | ||||
|         automaticallyImplyLeading: false, | ||||
|       ), | ||||
|       floatingActionButton: floatingActionButton, | ||||
|       drawer: !hideDrawer ? const SolianNavigationDrawer() : null, | ||||
|       drawerScrimColor: SolianTheme.isLargeScreen(context) ? Colors.transparent : null, | ||||
|       body: noSafeArea ? content : SafeArea(child: content), | ||||
|       body: showSafeArea ? SafeArea(child: body ?? Container()) : body, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user