👽 Update API to microservices
♻️ Refactor router pushes
This commit is contained in:
@@ -105,7 +105,7 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/account/${data.name}');
|
||||
context.pushNamed('accountProfile', pathParameters: {'name': data.name});
|
||||
},
|
||||
icon: const Icon(Symbols.launch),
|
||||
label: Text('accountProfileView').tr(),
|
||||
|
@@ -18,7 +18,7 @@ Future<List<SnAccount>> searchAccounts(Ref ref, {required String query}) async {
|
||||
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final response = await apiClient.get(
|
||||
'/accounts/search',
|
||||
'/id/accounts/search',
|
||||
queryParameters: {'query': query},
|
||||
);
|
||||
|
||||
|
@@ -16,7 +16,9 @@ part 'account_session_sheet.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAuthDevice>> authDevices(Ref ref) async {
|
||||
final resp = await ref.watch(apiClientProvider).get('/accounts/me/devices');
|
||||
final resp = await ref
|
||||
.watch(apiClientProvider)
|
||||
.get('/id/accounts/me/devices');
|
||||
final sessionId = resp.headers.value('x-auth-session');
|
||||
final data =
|
||||
resp.data.map<SnAuthDevice>((e) {
|
||||
@@ -122,7 +124,7 @@ class AccountSessionSheet extends HookConsumerWidget {
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.delete('/accounts/me/sessions/$sessionId');
|
||||
await apiClient.delete('/id/accounts/me/sessions/$sessionId');
|
||||
ref.invalidate(authDevicesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
|
@@ -6,7 +6,7 @@ part of 'account_session_sheet.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authDevicesHash() => r'19807110962206a9637075d03cd372233cae2f49';
|
||||
String _$authDevicesHash() => r'8bc41a1ffc37df8e757c977b4ddae11db8faaeb5';
|
||||
|
||||
/// See also [authDevices].
|
||||
@ProviderFor(authDevices)
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/activity.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/widgets/account/event_details_widget.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
/// A reusable widget for displaying an event calendar with event details
|
||||
@@ -123,57 +122,10 @@ class EventCalendarWidget extends HookConsumerWidget {
|
||||
events.value
|
||||
?.where((e) => isSameDay(e.date, selectedDay.value))
|
||||
.firstOrNull;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(DateFormat.EEEE().format(selectedDay.value))
|
||||
.fontSize(16)
|
||||
.bold()
|
||||
.textColor(
|
||||
Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
Text(DateFormat.yMd().format(selectedDay.value))
|
||||
.fontSize(12)
|
||||
.textColor(
|
||||
Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const Gap(16),
|
||||
if (event?.checkInResult != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'checkInResultLevel${event!.checkInResult!.level}',
|
||||
).tr().fontSize(16).bold(),
|
||||
for (final tip in event.checkInResult!.tips)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
size: 12,
|
||||
fill: 1,
|
||||
).padding(top: 4, right: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tip.title).bold(),
|
||||
Text(tip.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 8),
|
||||
],
|
||||
),
|
||||
if (event?.checkInResult == null &&
|
||||
(event?.statuses.isEmpty ?? true))
|
||||
Text('eventCalanderEmpty').tr(),
|
||||
],
|
||||
).padding(vertical: 24, horizontal: 24);
|
||||
return EventDetailsWidget(
|
||||
selectedDay: selectedDay.value,
|
||||
event: event,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
63
lib/widgets/account/event_details_widget.dart
Normal file
63
lib/widgets/account/event_details_widget.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/activity.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class EventDetailsWidget extends StatelessWidget {
|
||||
final DateTime selectedDay;
|
||||
final SnEventCalendarEntry? event;
|
||||
|
||||
const EventDetailsWidget({
|
||||
super.key,
|
||||
required this.selectedDay,
|
||||
required this.event,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(DateFormat.EEEE().format(selectedDay))
|
||||
.fontSize(16)
|
||||
.bold()
|
||||
.textColor(Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
Text(DateFormat.yMd().format(selectedDay))
|
||||
.fontSize(12)
|
||||
.textColor(Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
const Gap(16),
|
||||
if (event?.checkInResult != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'checkInResultLevel${event!.checkInResult!.level}',
|
||||
).tr().fontSize(16).bold(),
|
||||
for (final tip in event!.checkInResult!.tips)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
size: 12,
|
||||
fill: 1,
|
||||
).padding(top: 4, right: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(tip.title).bold(), Text(tip.content)],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 8),
|
||||
],
|
||||
),
|
||||
if (event?.checkInResult == null && (event?.statuses.isEmpty ?? true))
|
||||
Text('eventCalanderEmpty').tr(),
|
||||
],
|
||||
).padding(vertical: 24, horizontal: 24);
|
||||
}
|
||||
}
|
@@ -17,7 +17,7 @@ part 'status.g.dart';
|
||||
Future<SnAccountStatus?> accountStatus(Ref ref, String uname) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
try {
|
||||
final resp = await apiClient.get('/accounts/$uname/statuses');
|
||||
final resp = await apiClient.get('/id/accounts/$uname/statuses');
|
||||
return SnAccountStatus.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (err is DioException) {
|
||||
|
@@ -6,7 +6,7 @@ part of 'status.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$accountStatusHash() => r'8c3ba5242da1d1e75e3cbf1f2934ff7d5683d0d6';
|
||||
String _$accountStatusHash() => r'c861a0565d6229fd35666bba7cb2f5c6b7298e46';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
@@ -32,7 +32,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
|
||||
submitting.value = true;
|
||||
final user = ref.watch(userInfoProvider);
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.delete('/accounts/me/statuses');
|
||||
await apiClient.delete('/id/accounts/me/statuses');
|
||||
if (!context.mounted) return;
|
||||
ref.invalidate(accountStatusProvider(user.value!.name));
|
||||
Navigator.pop(context);
|
||||
|
@@ -16,7 +16,7 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
|
||||
if (roomId.isEmpty) return null;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/chat/realtime/$roomId');
|
||||
final resp = await apiClient.get('/sphere/chat/realtime/$roomId');
|
||||
return SnRealtimeCall.fromJson(resp.data);
|
||||
} catch (e) {
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
@@ -42,9 +42,9 @@ class AudioCallButton extends HookConsumerWidget {
|
||||
Future<void> handleJoin() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await apiClient.post('/chat/realtime/$roomId');
|
||||
await apiClient.post('/sphere/chat/realtime/$roomId');
|
||||
if (context.mounted) {
|
||||
context.push('/chat/$roomId/call');
|
||||
context.pushNamed('chatCall', pathParameters: {'id': roomId});
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
@@ -56,7 +56,7 @@ class AudioCallButton extends HookConsumerWidget {
|
||||
Future<void> handleEnd() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await apiClient.delete('/chat/realtime/$roomId');
|
||||
await apiClient.delete('/sphere/chat/realtime/$roomId');
|
||||
callNotifier.dispose(); // Clean up call resources
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
@@ -96,7 +96,7 @@ class AudioCallButton extends HookConsumerWidget {
|
||||
tooltip: 'Join Ongoing Call',
|
||||
onPressed: () {
|
||||
if (context.mounted) {
|
||||
context.push('/chat/$roomId/call');
|
||||
context.pushNamed('chatCall', pathParameters: {'id': roomId});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@@ -6,7 +6,7 @@ part of 'call_button.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$ongoingCallHash() => r'ab7337bcd4d766897bd6d6a38f418c6bdd15eb94';
|
||||
String _$ongoingCallHash() => r'48031badb79efa07aefb3a4fc51635be457bd3f9';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
@@ -360,7 +360,7 @@ class CallOverlayBar extends HookConsumerWidget {
|
||||
).padding(all: 16),
|
||||
),
|
||||
onTap: () {
|
||||
context.push('/chat/${callNotifier.roomId!}/call');
|
||||
context.pushNamed('chatCall', pathParameters: {'id': callNotifier.roomId!});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@@ -22,7 +22,7 @@ part 'check_in.g.dart';
|
||||
Future<SnCheckInResult?> checkInResultToday(Ref ref) async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
final resp = await client.get('/accounts/me/check-in');
|
||||
final resp = await client.get('/id/accounts/me/check-in');
|
||||
return SnCheckInResult.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (err is DioException) {
|
||||
@@ -45,7 +45,7 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
final client = ref.read(apiClientProvider);
|
||||
try {
|
||||
await client.post(
|
||||
'/accounts/me/check-in',
|
||||
'/id/accounts/me/check-in',
|
||||
data: captchatTk == null ? null : jsonEncode(captchatTk),
|
||||
);
|
||||
ref.invalidate(checkInResultTodayProvider);
|
||||
@@ -136,7 +136,10 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
if (todayResult.valueOrNull == null) {
|
||||
checkIn();
|
||||
} else {
|
||||
context.push('/account/me/calendar');
|
||||
context.pushNamed(
|
||||
'accountCalendar',
|
||||
pathParameters: {'name': 'me'},
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: AnimatedSwitcher(
|
||||
|
@@ -7,7 +7,7 @@ part of 'check_in.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$checkInResultTodayHash() =>
|
||||
r'0e2af6c1f419b2ee74ee38b6fb5d8071498e75c8';
|
||||
r'402e3a3be0d441ae12b2370d19d09bf81326933f';
|
||||
|
||||
/// See also [checkInResultToday].
|
||||
@ProviderFor(checkInResultToday)
|
||||
|
@@ -80,7 +80,9 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
final url = Uri.tryParse(href);
|
||||
if (url != null) {
|
||||
if (url.scheme == 'solian') {
|
||||
context.push(['', url.host, ...url.pathSegments].join('/'));
|
||||
if (url.host == 'account') {
|
||||
context.pushNamed('accountProfile', pathParameters: {'name': url.pathSegments[0]});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final whitelistDomains = ['solian.app', 'solsynth.dev'];
|
||||
|
@@ -72,7 +72,7 @@ class PostItem extends HookConsumerWidget {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.push('/publishers/${item.publisher.name}');
|
||||
context.pushNamed('publisherProfile', pathParameters: {'name': item.publisher.name});
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -254,7 +254,7 @@ class PostItem extends HookConsumerWidget {
|
||||
GestureDetector(
|
||||
child: ProfilePictureWidget(file: item.publisher.picture),
|
||||
onTap: () {
|
||||
context.push('/publishers/${item.publisher.name}');
|
||||
context.pushNamed('publisherProfile', pathParameters: {'name': item.publisher.name});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
@@ -427,7 +427,7 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
onTap: () {
|
||||
if (isOpenable) {
|
||||
context.push('/posts/${item.id}');
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -496,7 +496,7 @@ class PostItem extends HookConsumerWidget {
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context.push('/posts/${item.id}/edit').then((value) {
|
||||
context.pushNamed('postEdit', pathParameters: {'id': item.id}).then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
@@ -515,7 +515,7 @@ class PostItem extends HookConsumerWidget {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/posts/${item.id}')
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
@@ -541,8 +541,8 @@ class PostItem extends HookConsumerWidget {
|
||||
title: 'reply'.tr(),
|
||||
image: MenuImage.icon(Symbols.reply),
|
||||
callback: () {
|
||||
context.push(
|
||||
'/posts/compose',
|
||||
context.pushNamed(
|
||||
'postCompose',
|
||||
extra: PostComposeInitialState(replyingTo: item),
|
||||
);
|
||||
},
|
||||
@@ -551,8 +551,8 @@ class PostItem extends HookConsumerWidget {
|
||||
title: 'forward'.tr(),
|
||||
image: MenuImage.icon(Symbols.forward),
|
||||
callback: () {
|
||||
context.push(
|
||||
'/posts/compose',
|
||||
context.pushNamed(
|
||||
'postCompose',
|
||||
extra: PostComposeInitialState(forwardingTo: item),
|
||||
);
|
||||
},
|
||||
@@ -732,7 +732,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
|
||||
),
|
||||
],
|
||||
),
|
||||
).gestures(onTap: () => context.push('/posts/${referencePost.id}'));
|
||||
).gestures(onTap: () => context.pushNamed('postDetail', pathParameters: {'id': referencePost.id}));
|
||||
}
|
||||
|
||||
class PostReactionList extends HookConsumerWidget {
|
||||
|
@@ -45,7 +45,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context.push('/posts/${item.id}/edit').then((value) {
|
||||
context.pushNamed('postEdit', pathParameters: {'id': item.id}).then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/posts/${item.id}')
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
@@ -80,7 +80,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
// Copy post link to clipboard
|
||||
context.push('/posts/${item.id}');
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -94,7 +94,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
if (isOpenable) {
|
||||
context.push('/posts/${item.id}');
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
|
@@ -30,7 +30,10 @@ class PostListNotifier extends _$PostListNotifier
|
||||
if (pubName != null) 'pub': pubName,
|
||||
};
|
||||
|
||||
final response = await client.get('/posts', queryParameters: queryParams);
|
||||
final response = await client.get(
|
||||
'/sphere/posts',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
final posts = data.map((json) => SnPost.fromJson(json)).toList();
|
||||
|
@@ -6,7 +6,7 @@ part of 'post_list.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$postListNotifierHash() => r'a2a273cbf96393a84a66bd6ae8e88058704f3195';
|
||||
String _$postListNotifierHash() => r'2e4fb36123d3f97ac1edf9945043251d4eb519a2';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
@@ -43,8 +43,7 @@ class PublisherModal extends HookConsumerWidget {
|
||||
const Gap(12),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.push('/creators/publishers/new')
|
||||
.then((value) {
|
||||
context.pushNamed('creatorNew').then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(
|
||||
publishersManagedProvider,
|
||||
|
@@ -29,7 +29,7 @@ class PublisherCard extends ConsumerWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/publishers/${publisher.name}');
|
||||
context.pushNamed('publisherProfile', pathParameters: {'name': publisher.name});
|
||||
},
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
|
@@ -31,7 +31,7 @@ class RealmCard extends ConsumerWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.push('/realms/${realm.slug}');
|
||||
context.pushNamed('realmDetail', pathParameters: {'slug': realm.slug});
|
||||
},
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
|
@@ -14,7 +14,7 @@ class RealmTile extends HookConsumerWidget {
|
||||
leading: ProfilePictureWidget(file: realm.picture),
|
||||
title: Text(realm.name),
|
||||
subtitle: Text(realm.description),
|
||||
onTap: () => context.push('/realms/${realm.slug}'),
|
||||
onTap: () => context.pushNamed('realmDetail', pathParameters: {'slug': realm.slug}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -178,7 +178,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
|
||||
// Navigate to compose screen
|
||||
if (mounted) {
|
||||
context.push('/posts/compose', extra: initialState);
|
||||
context.pushNamed('postCompose', extra: initialState);
|
||||
Navigator.of(context).pop(); // Close the share sheet
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -323,7 +323,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
|
||||
// Navigate to chat if requested
|
||||
if (shouldNavigate == true && mounted) {
|
||||
context.push('/chat/${chatRoom.id}');
|
||||
context.push('/sphere/chat/${chatRoom.id}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
@@ -17,7 +17,7 @@ class WebArticleCard extends StatelessWidget {
|
||||
});
|
||||
|
||||
void _onTap(BuildContext context) {
|
||||
context.push('/feeds/articles/${article.id}');
|
||||
context.pushNamed('articleDetail', pathParameters: {'id': article.id});
|
||||
}
|
||||
|
||||
@override
|
||||
|
Reference in New Issue
Block a user