mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
CW-1130-add-app-shortcuts (#2599)
* add app shortcuts * update icons * Improve deep link and quick action navigation * bypass auth when allowedCreate state is active * remove spaces from file names [skip ci] --------- Co-authored-by: Serhii-Borodenko <17529954+Serhii-Borodenko@users.noreply.github.com> Co-authored-by: OmarHatem <omarh.ismail1@gmail.com>
This commit is contained in:
co-authored by
Serhii-Borodenko
OmarHatem
parent
cf795bcc48
commit
2dd6cdce1e
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="251dp"
|
||||
android:height="251dp"
|
||||
android:viewportWidth="251"
|
||||
android:viewportHeight="251">
|
||||
<path
|
||||
android:pathData="M125.5,0.5L125.5,0.5A125,125 0,0 1,250.5 125.5L250.5,125.5A125,125 0,0 1,125.5 250.5L125.5,250.5A125,125 0,0 1,0.5 125.5L0.5,125.5A125,125 0,0 1,125.5 0.5z"
|
||||
android:fillColor="#233461"/>
|
||||
<path
|
||||
android:pathData="M125,154.99L91.2,121.19L99.55,112.61L119.06,132.13V65.63H130.94V132.13L150.45,112.61L158.8,121.19L125,154.99ZM79.94,184.38C75.94,184.38 72.55,182.99 69.78,180.22C67.01,177.45 65.63,174.06 65.63,170.06V148.6H77.5V170.06C77.5,170.67 77.75,171.23 78.26,171.74C78.77,172.25 79.33,172.5 79.94,172.5H170.06C170.67,172.5 171.23,172.25 171.74,171.74C172.25,171.23 172.5,170.67 172.5,170.06V148.6H184.38V170.06C184.38,174.06 182.99,177.45 180.22,180.22C177.45,182.99 174.06,184.38 170.06,184.38H79.94Z"
|
||||
android:fillColor="#91B0FF"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="251dp"
|
||||
android:height="251dp"
|
||||
android:viewportWidth="251"
|
||||
android:viewportHeight="251">
|
||||
<path
|
||||
android:pathData="M125.5,0.5L125.5,0.5A125,125 0,0 1,250.5 125.5L250.5,125.5A125,125 0,0 1,125.5 250.5L125.5,250.5A125,125 0,0 1,0.5 125.5L0.5,125.5A125,125 0,0 1,125.5 0.5z"
|
||||
android:fillColor="#233461"/>
|
||||
<path
|
||||
android:pathData="M63.02,177.86V137.2L113.5,125L63.02,112.8V72.14L188.52,125L63.02,177.86Z"
|
||||
android:fillColor="#91B0FF"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "receive.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "receive-1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "receive-2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 778 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 778 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 778 B |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "send.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "send-1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "send-2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
|
||||
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
+69
-4
@@ -60,6 +60,7 @@ import 'package:flutter_daemon/flutter_daemon.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:cw_core/root_dir.dart';
|
||||
import 'package:quick_actions/quick_actions.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:cw_core/window_size.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -70,6 +71,7 @@ import 'package:trezor_connect/trezor_connect.dart';
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
final rootKey = GlobalKey<RootState>();
|
||||
final RouteObserver<PageRoute<dynamic>> routeObserver = RouteObserver<PageRoute<dynamic>>();
|
||||
final quickActionsStream = StreamController<Uri?>.broadcast();
|
||||
|
||||
Future<void> main({Key? topLevelKey}) async {
|
||||
await runAppWithZone(topLevelKey: topLevelKey);
|
||||
@@ -80,6 +82,43 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
|
||||
|
||||
await runZonedGuarded(() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final Completer<String?> initialShortcutCompleter = Completer<String?>();
|
||||
|
||||
const QuickActions quickActions = QuickActions();
|
||||
|
||||
quickActions.initialize((String shortcutType) {
|
||||
// Complete the completer only once (for cold starts)
|
||||
if (!initialShortcutCompleter.isCompleted) {
|
||||
initialShortcutCompleter.complete(shortcutType);
|
||||
}
|
||||
|
||||
// Convert the shortcut type to a URI and add it to the stream
|
||||
final uri = Uri.parse('cakewallet://quickaction/$shortcutType');
|
||||
quickActionsStream.sink.add(uri);
|
||||
});
|
||||
|
||||
quickActions.setShortcutItems(<ShortcutItem>[
|
||||
const ShortcutItem(
|
||||
type: 'send',
|
||||
icon: 'send',
|
||||
localizedTitle: 'Send',
|
||||
localizedSubtitle: 'Send funds'),
|
||||
const ShortcutItem(
|
||||
type: 'receive',
|
||||
icon: 'receive',
|
||||
localizedTitle: 'Receive',
|
||||
localizedSubtitle: 'Receive funds')
|
||||
]);
|
||||
|
||||
// Fallback in case the initial shortcut is not received (normal cold start)
|
||||
Future<void>.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!initialShortcutCompleter.isCompleted) {
|
||||
initialShortcutCompleter.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
final initialQuickAction = await initialShortcutCompleter.future;
|
||||
FlutterError.onError = ExceptionHandler.onError;
|
||||
|
||||
/// A callback that is invoked when an unhandled error occurs in the root
|
||||
@@ -133,11 +172,11 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
|
||||
runApp(
|
||||
DefaultAssetBundle(
|
||||
bundle: TestAssetBundle(),
|
||||
child: App(key: topLevelKey),
|
||||
child: App(key: topLevelKey, initialQuickAction:initialQuickAction,quickActionsStream: quickActionsStream.stream),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
runApp(App(key: topLevelKey));
|
||||
runApp(App(key: topLevelKey, initialQuickAction: initialQuickAction, quickActionsStream: quickActionsStream.stream));
|
||||
}
|
||||
|
||||
isAppRunning = true;
|
||||
@@ -333,9 +372,11 @@ Future<void> initialSetup({
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
App({this.key});
|
||||
App({this.key, this.initialQuickAction, required this.quickActionsStream});
|
||||
|
||||
final Key? key;
|
||||
final String? initialQuickAction;
|
||||
final Stream<Uri?> quickActionsStream;
|
||||
@override
|
||||
AppState createState() => AppState();
|
||||
}
|
||||
@@ -366,9 +407,13 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
|
||||
statusBarBrightness: statusBarBrightness,
|
||||
statusBarIconBrightness: statusBarIconBrightness));
|
||||
|
||||
final appRouteObserver = AppRouteObserver();
|
||||
|
||||
return Root(
|
||||
key: widget.key ?? rootKey,
|
||||
appStore: appStore,
|
||||
initialQuickAction: widget.initialQuickAction,
|
||||
quickActionsStream: widget.quickActionsStream,
|
||||
authenticationStore: authenticationStore,
|
||||
navigatorKey: navigatorKey,
|
||||
authService: authService,
|
||||
@@ -379,7 +424,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
|
||||
child: ThemeProvider(
|
||||
themeStore: appStore.themeStore,
|
||||
materialAppBuilder: (context, theme, darkTheme, themeMode) => MaterialApp(
|
||||
navigatorObservers: [routeObserver],
|
||||
navigatorObservers: [routeObserver, appRouteObserver],
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: theme,
|
||||
@@ -505,3 +550,23 @@ Future<void> backgroundSync() async {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppRouteObserver extends RouteObserver<PageRoute<dynamic>> {
|
||||
final AppStore appStore = getIt.get<AppStore>();
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
if (route is PageRoute) {
|
||||
appStore.currentRouteName = route.settings.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
if (previousRoute is PageRoute) {
|
||||
appStore.currentRouteName = previousRoute.settings.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -160,12 +160,13 @@ late RouteSettings currentRouteSettings;
|
||||
|
||||
Route<T> handleRouteWithPlatformAwareness<T>(
|
||||
Widget Function(BuildContext) builder, {
|
||||
RouteSettings? settings,
|
||||
bool fullscreenDialog = false,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
|
||||
return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
|
||||
} else {
|
||||
return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
|
||||
return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +424,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
|
||||
param1: initialPaymentRequest,
|
||||
param2: coinTypeToSpendFrom,
|
||||
),
|
||||
settings: settings
|
||||
);
|
||||
|
||||
case Routes.sendTemplate:
|
||||
@@ -430,12 +432,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
|
||||
fullscreenDialog: true, builder: (_) => getIt.get<SendTemplatePage>());
|
||||
|
||||
case Routes.receive:
|
||||
return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>());
|
||||
return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>(), settings: settings);
|
||||
|
||||
case Routes.addressPage:
|
||||
return handleRouteWithPlatformAwareness(
|
||||
(context) => getIt.get<AddressPage>(),
|
||||
);
|
||||
(context) => getIt.get<AddressPage>(), settings: settings);
|
||||
|
||||
case Routes.transactionDetails:
|
||||
return CupertinoPageRoute<void>(
|
||||
|
||||
@@ -36,6 +36,8 @@ class Root extends StatefulWidget {
|
||||
required this.tradeMonitor,
|
||||
required this.nodeSwitchingService,
|
||||
required this.trezorConnect,
|
||||
this.initialQuickAction,
|
||||
required this.quickActionsStream,
|
||||
}) : super(key: key);
|
||||
|
||||
final AuthenticationStore authenticationStore;
|
||||
@@ -47,6 +49,8 @@ class Root extends StatefulWidget {
|
||||
final TradeMonitor tradeMonitor;
|
||||
final NodeSwitchingService nodeSwitchingService;
|
||||
final TrezorConnect trezorConnect;
|
||||
final String? initialQuickAction;
|
||||
final Stream<Uri?> quickActionsStream;
|
||||
|
||||
@override
|
||||
RootState createState() => RootState();
|
||||
@@ -103,7 +107,18 @@ class RootState extends State<Root> with WidgetsBindingObserver {
|
||||
handleDeepLinking(uri);
|
||||
});
|
||||
|
||||
// listen for quick actions
|
||||
widget.quickActionsStream.listen((Uri? uri) {
|
||||
handleDeepLinking(uri);
|
||||
});
|
||||
|
||||
handleDeepLinking(await getInitialUri());
|
||||
|
||||
if (widget.initialQuickAction != null) {
|
||||
final uri = Uri.parse('cakewallet://quickaction/${widget.initialQuickAction}');
|
||||
handleDeepLinking(uri);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
printV(e);
|
||||
}
|
||||
@@ -121,7 +136,14 @@ class RootState extends State<Root> with WidgetsBindingObserver {
|
||||
|
||||
bool requireAuth = await widget.authService.requireAuth();
|
||||
|
||||
if (!requireAuth && widget.authenticationStore.state == AuthenticationState.allowed) {
|
||||
|
||||
if (widget.authenticationStore.state == AuthenticationState.allowedCreate) {
|
||||
requireAuth = false;
|
||||
}
|
||||
|
||||
if (!requireAuth &&
|
||||
(widget.authenticationStore.state == AuthenticationState.allowed ||
|
||||
widget.authenticationStore.state == AuthenticationState.allowedCreate)) {
|
||||
_navigateToDeepLinkScreen();
|
||||
return;
|
||||
}
|
||||
@@ -129,7 +151,7 @@ class RootState extends State<Root> with WidgetsBindingObserver {
|
||||
_deepLinksReactionDisposer = reaction(
|
||||
(_) => widget.authenticationStore.state,
|
||||
(AuthenticationState state) {
|
||||
if (state == AuthenticationState.allowed) {
|
||||
if (state == AuthenticationState.allowed || state == AuthenticationState.allowedCreate) {
|
||||
if (widget.appStore.wallet == null) {
|
||||
waitForWalletInstance(context);
|
||||
} else {
|
||||
|
||||
@@ -34,6 +34,9 @@ abstract class AppStoreBase with Store {
|
||||
@observable
|
||||
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo>? wallet;
|
||||
|
||||
@observable
|
||||
String? currentRouteName;
|
||||
|
||||
WalletListStore walletList;
|
||||
|
||||
SettingsStore settingsStore;
|
||||
|
||||
@@ -28,6 +28,8 @@ class LinkViewModel {
|
||||
bool get _isValidPaymentUri => currentLink?.path.isNotEmpty ?? false;
|
||||
bool get isWalletConnectLink => currentLink?.authority == 'wc';
|
||||
bool get isNanoGptLink => currentLink?.scheme == 'nano-gpt';
|
||||
bool get isQuickActionLink =>
|
||||
currentLink?.scheme == 'cakewallet' && currentLink?.host == 'quickaction';
|
||||
|
||||
String? getRouteToGo() {
|
||||
if (isWalletConnectLink) {
|
||||
@@ -38,6 +40,19 @@ class LinkViewModel {
|
||||
return Routes.walletConnectConnectionsListing;
|
||||
}
|
||||
|
||||
// Check for a quick action first.
|
||||
if (isQuickActionLink) {
|
||||
final action = currentLink!.pathSegments.isNotEmpty ? currentLink!.pathSegments.first : null;
|
||||
switch (action) {
|
||||
case 'send':
|
||||
return Routes.send;
|
||||
case 'receive':
|
||||
return Routes.addressPage;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticationStore.state == AuthenticationState.uninitialized) {
|
||||
return null;
|
||||
}
|
||||
@@ -65,6 +80,10 @@ class LinkViewModel {
|
||||
return currentLink;
|
||||
}
|
||||
|
||||
if (isQuickActionLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNanoGptLink) {
|
||||
switch (currentLink?.authority ?? '') {
|
||||
case "exchange":
|
||||
@@ -113,16 +132,31 @@ class LinkViewModel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent navigating to the same route again.
|
||||
if (appStore.currentRouteName == route) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNanoGptLink) {
|
||||
if (route == Routes.buySellPage || route == Routes.exchange) {
|
||||
await _errorToast(S.current.nano_gpt_thanks_message, fontSize: 14);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick actions must reset navigation to Dashboard → TargetPage
|
||||
if (isQuickActionLink) {
|
||||
currentLink = null;
|
||||
navigatorKey.currentState?.pushNamedAndRemoveUntil(
|
||||
route,
|
||||
ModalRoute.withName(Routes.dashboard),
|
||||
arguments: args,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal navigation flow
|
||||
currentLink = null;
|
||||
navigatorKey.currentState?.pushNamed(
|
||||
route,
|
||||
arguments: args,
|
||||
);
|
||||
navigatorKey.currentState?.pushNamed(route, arguments: args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ dependencies:
|
||||
ref: b48a9defddce036048bae02e2069ebcbd6824afc
|
||||
torch_dart:
|
||||
path: ./scripts/torch_dart
|
||||
quick_actions: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user