mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 09:08:40 +00:00
added demo modus
This commit is contained in:
parent
602443eea8
commit
a779512198
13 changed files with 310 additions and 43 deletions
|
|
@ -8,3 +8,4 @@ late ApiService apiService;
|
|||
late TwonlyDatabase twonlyDB;
|
||||
|
||||
List<CameraDescription> gCameras = <CameraDescription>[];
|
||||
bool gIsDemoUser = false;
|
||||
|
|
|
|||
|
|
@ -13,19 +13,27 @@ import 'package:twonly/src/providers/settings.provider.dart';
|
|||
import 'package:twonly/src/services/fcm.service.dart';
|
||||
import 'package:twonly/src/services/notification.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'app.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initFCMService();
|
||||
|
||||
initLogger();
|
||||
|
||||
final user = await getUser();
|
||||
if (user != null) {
|
||||
if (user.isDemoUser) {
|
||||
await deleteLocalUserData();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsController = SettingsChangeProvider();
|
||||
// Load the user's preferred theme while the splash screen is displayed.
|
||||
// This prevents a sudden theme change when the app is first displayed.
|
||||
await settingsController.loadSettings();
|
||||
|
||||
initLogger();
|
||||
|
||||
await setupPushNotification();
|
||||
await initMediaStorage();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ part 'userdata.g.dart';
|
|||
|
||||
@JsonSerializable()
|
||||
class UserData {
|
||||
UserData(
|
||||
{required this.userId,
|
||||
required this.username,
|
||||
required this.displayName,
|
||||
required this.subscriptionPlan});
|
||||
UserData({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.displayName,
|
||||
required this.subscriptionPlan,
|
||||
required this.isDemoUser,
|
||||
});
|
||||
|
||||
String username;
|
||||
String displayName;
|
||||
|
|
@ -17,6 +19,9 @@ class UserData {
|
|||
String? avatarJson;
|
||||
int? avatarCounter;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool isDemoUser = false;
|
||||
|
||||
// settings
|
||||
int? defaultShowTime;
|
||||
@JsonKey(defaultValue: "Preview")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
|
|||
username: json['username'] as String,
|
||||
displayName: json['displayName'] as String,
|
||||
subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Preview',
|
||||
isDemoUser: json['isDemoUser'] as bool? ?? false,
|
||||
)
|
||||
..avatarSvg = json['avatarSvg'] as String?
|
||||
..avatarJson = json['avatarJson'] as String?
|
||||
|
|
@ -51,6 +52,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
|||
'avatarSvg': instance.avatarSvg,
|
||||
'avatarJson': instance.avatarJson,
|
||||
'avatarCounter': instance.avatarCounter,
|
||||
'isDemoUser': instance.isDemoUser,
|
||||
'defaultShowTime': instance.defaultShowTime,
|
||||
'subscriptionPlan': instance.subscriptionPlan,
|
||||
'useHighQuality': instance.useHighQuality,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,13 @@ class ApiService {
|
|||
}
|
||||
|
||||
Future<bool> connect() async {
|
||||
final user = await getUser();
|
||||
if (user != null && user.isDemoUser) {
|
||||
print("DEMO user");
|
||||
// the demo user should not be able to connect to the API server...
|
||||
globalCallbackConnectionState(true);
|
||||
return false;
|
||||
}
|
||||
return lockConnecting.protect<bool>(() async {
|
||||
if (_channel != null) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -201,6 +201,9 @@ Future<(String, RetransmitMessage)?> encryptMessage(
|
|||
// encrypts and stores the message and then sends it in the background
|
||||
Future encryptAndSendMessageAsync(int? messageId, int userId, MessageJson msg,
|
||||
{PushKind? pushKind}) async {
|
||||
if (gIsDemoUser) {
|
||||
return;
|
||||
}
|
||||
(String, RetransmitMessage)? stateData =
|
||||
await encryptMessage(messageId, userId, msg, pushKind: pushKind);
|
||||
if (stateData != null) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -8,7 +10,10 @@ import 'package:gal/gal.dart';
|
|||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/database/twonly_database.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/model/protobuf/api/error.pb.dart';
|
||||
import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||
import 'package:twonly/src/providers/settings.provider.dart';
|
||||
|
|
@ -218,3 +223,164 @@ String truncateString(String input, {int maxLength = 20}) {
|
|||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
Future insertDemoContacts() async {
|
||||
List<String> commonUsernames = [
|
||||
'James',
|
||||
'Mary',
|
||||
'John',
|
||||
'Patricia',
|
||||
'Robert',
|
||||
'Jennifer',
|
||||
'Michael',
|
||||
'Linda',
|
||||
'William',
|
||||
'Elizabeth',
|
||||
'David',
|
||||
'Barbara',
|
||||
'Richard',
|
||||
'Susan',
|
||||
'Joseph',
|
||||
'Jessica',
|
||||
'Charles',
|
||||
'Sarah',
|
||||
'Thomas',
|
||||
'Karen',
|
||||
];
|
||||
final List<Map<String, dynamic>> contactConfigs = [
|
||||
{'count': 3, 'requested': true},
|
||||
{'count': 4, 'requested': false, 'accepted': true},
|
||||
{'count': 1, 'accepted': true, 'blocked': true},
|
||||
{'count': 1, 'accepted': true, 'archived': true},
|
||||
{'count': 2, 'accepted': true, 'pinned': true},
|
||||
{'count': 1, 'requested': false},
|
||||
];
|
||||
|
||||
int counter = 0;
|
||||
|
||||
for (var config in contactConfigs) {
|
||||
for (int i = 0; i < config['count']; i++) {
|
||||
if (counter >= commonUsernames.length) {
|
||||
break;
|
||||
}
|
||||
String username = commonUsernames[counter];
|
||||
int userId = Random().nextInt(1000000);
|
||||
await twonlyDB.contactsDao.insertContact(
|
||||
ContactsCompanion(
|
||||
username: Value(username),
|
||||
userId: Value(userId),
|
||||
requested: Value(config['requested'] ?? false),
|
||||
accepted: Value(config['accepted'] ?? false),
|
||||
blocked: Value(config['blocked'] ?? false),
|
||||
archived: Value(config['archived'] ?? false),
|
||||
pinned: Value(config['pinned'] ?? false),
|
||||
),
|
||||
);
|
||||
if (config['accepted'] ?? false) {
|
||||
for (var i = 0; i < 20; i++) {
|
||||
int chatId = Random().nextInt(chatMessages.length);
|
||||
int? messageId = await twonlyDB.messagesDao.insertMessage(
|
||||
MessagesCompanion(
|
||||
contactId: Value(userId),
|
||||
kind: Value(MessageKind.textMessage),
|
||||
sendAt: Value(chatMessages[chatId][1]),
|
||||
acknowledgeByServer: Value(true),
|
||||
acknowledgeByUser: Value(true),
|
||||
messageOtherId:
|
||||
Value(Random().nextBool() ? Random().nextInt(10000) : null),
|
||||
// responseToOtherMessageId: Value(content.responseToMessageId),
|
||||
// responseToMessageId: Value(content.responseToOtherMessageId),
|
||||
downloadState: Value(DownloadState.downloaded),
|
||||
contentJson: Value(
|
||||
jsonEncode(TextMessageContent(text: chatMessages[chatId][0])),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future createFakeDemoData() async {
|
||||
await insertDemoContacts();
|
||||
}
|
||||
|
||||
List<List<dynamic>> chatMessages = [
|
||||
[
|
||||
"Lorem ipsum dolor sit amet.",
|
||||
DateTime.now().subtract(Duration(minutes: 20))
|
||||
],
|
||||
[
|
||||
"Consectetur adipiscing elit.",
|
||||
DateTime.now().subtract(Duration(minutes: 19))
|
||||
],
|
||||
[
|
||||
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
DateTime.now().subtract(Duration(minutes: 18))
|
||||
],
|
||||
["Ut enim ad minim veniam.", DateTime.now().subtract(Duration(minutes: 17))],
|
||||
[
|
||||
"Quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
DateTime.now().subtract(Duration(minutes: 16))
|
||||
],
|
||||
[
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
|
||||
DateTime.now().subtract(Duration(minutes: 15))
|
||||
],
|
||||
[
|
||||
"Excepteur sint occaecat cupidatat non proident.",
|
||||
DateTime.now().subtract(Duration(minutes: 14))
|
||||
],
|
||||
[
|
||||
"Sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
DateTime.now().subtract(Duration(minutes: 13))
|
||||
],
|
||||
[
|
||||
"Curabitur pretium tincidunt lacus.",
|
||||
DateTime.now().subtract(Duration(minutes: 12))
|
||||
],
|
||||
["Nulla facilisi.", DateTime.now().subtract(Duration(minutes: 11))],
|
||||
[
|
||||
"Aenean lacinia bibendum nulla sed consectetur.",
|
||||
DateTime.now().subtract(Duration(minutes: 10))
|
||||
],
|
||||
[
|
||||
"Sed posuere consectetur est at lobortis.",
|
||||
DateTime.now().subtract(Duration(minutes: 9))
|
||||
],
|
||||
[
|
||||
"Vestibulum id ligula porta felis euismod semper.",
|
||||
DateTime.now().subtract(Duration(minutes: 8))
|
||||
],
|
||||
[
|
||||
"Cras justo odio, dapibus ac facilisis in, egestas eget quam.",
|
||||
DateTime.now().subtract(Duration(minutes: 7))
|
||||
],
|
||||
[
|
||||
"Morbi leo risus, porta ac consectetur ac, vestibulum at eros.",
|
||||
DateTime.now().subtract(Duration(minutes: 6))
|
||||
],
|
||||
[
|
||||
"Praesent commodo cursus magna, vel scelerisque nisl consectetur et.",
|
||||
DateTime.now().subtract(Duration(minutes: 5))
|
||||
],
|
||||
[
|
||||
"Donec ullamcorper nulla non metus auctor fringilla.",
|
||||
DateTime.now().subtract(Duration(minutes: 4))
|
||||
],
|
||||
[
|
||||
"Etiam porta sem malesuada magna mollis euismod.",
|
||||
DateTime.now().subtract(Duration(minutes: 3))
|
||||
],
|
||||
[
|
||||
"Aenean lacinia bibendum nulla sed consectetur.",
|
||||
DateTime.now().subtract(Duration(minutes: 2))
|
||||
],
|
||||
[
|
||||
"Nullam quis risus eget urna mollis ornare vel eu leo.",
|
||||
DateTime.now().subtract(Duration(minutes: 1))
|
||||
],
|
||||
["Curabitur blandit tempus porttitor.", DateTime.now()],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:restart_app/restart_app.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/services/api/media_received.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart';
|
||||
import 'package:twonly/src/views/components/flame.dart';
|
||||
import 'package:twonly/src/views/components/initialsavatar.dart';
|
||||
|
|
@ -37,6 +39,7 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
|
||||
GlobalKey firstUserListItemKey = GlobalKey();
|
||||
GlobalKey searchForOtherUsers = GlobalKey();
|
||||
Timer? tutorial;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -54,7 +57,8 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
;
|
||||
});
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () async {
|
||||
tutorial = Timer(Duration(seconds: 1), () async {
|
||||
tutorial = null;
|
||||
if (!mounted) return;
|
||||
await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers);
|
||||
if (!mounted) return;
|
||||
|
|
@ -66,6 +70,7 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
tutorial?.cancel();
|
||||
_contactsSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -183,14 +188,50 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
return 72;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
if (gIsDemoUser && index == 0) {
|
||||
return Container(
|
||||
color: isDarkMode(context)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"This is a Demo-Preview.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: !isDarkMode(context)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await deleteLocalUserData();
|
||||
Restart.restartApp(
|
||||
notificationTitle: 'Demo-Mode exited.',
|
||||
notificationBody:
|
||||
'Click here to open the app again',
|
||||
);
|
||||
},
|
||||
child: Text("Register"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Check if the index is for the pinned users
|
||||
if (index < _pinnedContacts.length) {
|
||||
final contact = _pinnedContacts[index];
|
||||
return UserListItem(
|
||||
key: ValueKey(contact.userId),
|
||||
user: contact,
|
||||
firstUserListItemKey:
|
||||
(index == 0) ? firstUserListItemKey : null,
|
||||
firstUserListItemKey: (index == 0 && !gIsDemoUser ||
|
||||
index == 1 && gIsDemoUser)
|
||||
? firstUserListItemKey
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class ConnectionInfo extends StatefulWidget {
|
||||
|
|
@ -60,7 +61,7 @@ class _ConnectionInfoWidgetState extends State<ConnectionInfo>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!showAnimation) return Container();
|
||||
if (!showAnimation || gIsDemoUser) return Container();
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
return SizedBox(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
|
|||
Message? responseToMessage;
|
||||
GlobalKey verifyShieldKey = GlobalKey();
|
||||
late FocusNode textFieldFocus;
|
||||
Timer? tutorial;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -59,7 +60,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
|
|||
textFieldFocus = FocusNode();
|
||||
initStreams();
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () async {
|
||||
tutorial = Timer(Duration(seconds: 1), () async {
|
||||
tutorial = null;
|
||||
if (!mounted) return;
|
||||
await showVerifyShieldTutorial(context, verifyShieldKey);
|
||||
});
|
||||
|
|
@ -70,6 +72,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
|
|||
super.dispose();
|
||||
userSub.cancel();
|
||||
messageSub.cancel();
|
||||
tutorial?.cancel();
|
||||
textFieldFocus.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ class _RegisterViewState extends State<RegisterView> {
|
|||
|
||||
bool _isTryingToRegister = false;
|
||||
|
||||
Future createNewUser() async {
|
||||
String username = usernameController.text;
|
||||
Future createNewUser({bool isDemoAccount = false}) async {
|
||||
String username = (isDemoAccount) ? "<demo>" : usernameController.text;
|
||||
String inviteCode = inviteCodeController.text;
|
||||
|
||||
setState(() {
|
||||
_isTryingToRegister = true;
|
||||
});
|
||||
|
|
@ -35,30 +36,44 @@ class _RegisterViewState extends State<RegisterView> {
|
|||
|
||||
await createIfNotExistsSignalIdentity();
|
||||
|
||||
final res = await apiService.register(username, inviteCode);
|
||||
int userId = 0;
|
||||
|
||||
if (!isDemoAccount) {
|
||||
final res = await apiService.register(username, inviteCode);
|
||||
if (res.isSuccess) {
|
||||
Log.info("Got user_id ${res.value} from server");
|
||||
userId = res.value.userid.toInt();
|
||||
} else {
|
||||
if (mounted) {
|
||||
showAlertDialog(
|
||||
context,
|
||||
"Oh no!",
|
||||
errorCodeToText(context, res.error),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isTryingToRegister = false;
|
||||
});
|
||||
|
||||
if (res.isSuccess) {
|
||||
Log.info("Got user_id ${res.value} from server");
|
||||
final userData = UserData(
|
||||
userId: res.value.userid.toInt(),
|
||||
username: username,
|
||||
displayName: username,
|
||||
subscriptionPlan: "Preview",
|
||||
);
|
||||
storage.write(key: "userData", value: jsonEncode(userData));
|
||||
apiService.authenticate();
|
||||
widget.callbackOnSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showAlertDialog(context, "Oh no!", errorCodeToText(context, res.error));
|
||||
final userData = UserData(
|
||||
userId: userId,
|
||||
username: username,
|
||||
displayName: username,
|
||||
subscriptionPlan: "Preview",
|
||||
isDemoUser: isDemoAccount,
|
||||
);
|
||||
storage.write(key: "userData", value: jsonEncode(userData));
|
||||
if (!isDemoAccount) {
|
||||
await apiService.authenticate();
|
||||
} else {
|
||||
gIsDemoUser = true;
|
||||
createFakeDemoData();
|
||||
}
|
||||
widget.callbackOnSuccess();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -174,12 +189,23 @@ class _RegisterViewState extends State<RegisterView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showAlertDialog(context, "Coming soon",
|
||||
"This feature is not yet implemented! Just create a new account :/");
|
||||
},
|
||||
label: Text("Restore identity"),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
createNewUser(isDemoAccount: true);
|
||||
},
|
||||
label: Text("Demo"),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
showAlertDialog(context, "Coming soon",
|
||||
"This feature is not yet implemented! Just create a new account :/");
|
||||
},
|
||||
label: Text("Restore identity"),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
// ),
|
||||
|
|
|
|||
|
|
@ -49,8 +49,12 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
|
|||
|
||||
Future initAsync() async {
|
||||
final balance = await loadPlanBalance();
|
||||
balanceInCents =
|
||||
balance!.transactions.map((a) => a.depositCents.toInt()).sum;
|
||||
if (balance == null) {
|
||||
balanceInCents = 0;
|
||||
} else {
|
||||
balanceInCents =
|
||||
balance.transactions.map((a) => a.depositCents.toInt()).sum;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +257,7 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
|
|||
children: [
|
||||
TextButton(
|
||||
onPressed: () => launchUrl(Uri.parse(
|
||||
"https://twonly.eu/legal/de/#revocation-policy")),
|
||||
"https://twonly.eu/de/legal/#revocation-policy")),
|
||||
child: Text(
|
||||
"Widerrufsbelehrung",
|
||||
style: TextStyle(color: Colors.blue),
|
||||
|
|
@ -261,7 +265,7 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse("https://twonly.eu/legal/de/agb.html")),
|
||||
Uri.parse("https://twonly.eu/de/legal/agb.html")),
|
||||
child: Text(
|
||||
"ABG",
|
||||
style: TextStyle(color: Colors.blue),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
|||
# Prevent accidental publishing to pub.dev.
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.0.30+30
|
||||
version: 0.0.31+31
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue