mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2024-10-01 06:55:46 -04:00
invitation work
This commit is contained in:
parent
95ed8b28a0
commit
7496a1a2a7
@ -29,6 +29,10 @@
|
||||
"ok": "Ok",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Error",
|
||||
"info": "Info"
|
||||
},
|
||||
"language": {
|
||||
"name": {
|
||||
"en": "English"
|
||||
@ -68,12 +72,20 @@
|
||||
"note_text": "Contact invitations can be used by anyone. Make sure you send the invitation to your contact over a secure medium, and preferably use a password or pin to ensure that they are the only ones who can unlock the invitation and accept it.",
|
||||
"pin_description": "Choose a PIN to protect the contact invite.\n\nThis level of security is appropriate only for casual connections in public environments for 'shoulder surfing' protection.",
|
||||
"password_description": "Choose a strong password to protect the contact invite.\n\nThis level of security is appropriate when you must be sure the contact invitation is only accepted by its intended recipient. Share this password over a different medium than the invite itself.",
|
||||
"pin_does_not_match": "PIN does not match"
|
||||
"pin_does_not_match": "PIN does not match",
|
||||
"contact_invitation": "Contact Invitation",
|
||||
"failed_to_generate": "Failed to generate contact invitation",
|
||||
"copy_invitation": "Copy Invitation",
|
||||
"invitation_copied": "Invitation Copied"
|
||||
},
|
||||
"enter_pin_dialog": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"reenter_pin": "Re-Enter PIN To Confirm"
|
||||
},
|
||||
"contact_list": {
|
||||
"search": "Search contacts",
|
||||
"invitation": "Invitation"
|
||||
},
|
||||
"themes": {
|
||||
"vapor": "Vapor"
|
||||
}
|
||||
|
@ -11,6 +11,8 @@ PODS:
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- smart_auth (0.0.1):
|
||||
- Flutter
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
@ -26,6 +28,7 @@ DEPENDENCIES:
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- smart_auth (from `.symlinks/plugins/smart_auth/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- system_info_plus (from `.symlinks/plugins/system_info_plus/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@ -44,6 +47,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
smart_auth:
|
||||
:path: ".symlinks/plugins/smart_auth/ios"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
system_info_plus:
|
||||
@ -59,6 +64,7 @@ SPEC CHECKSUMS:
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
|
@ -1,14 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:awesome_extensions/awesome_extensions.dart';
|
||||
import 'package:basic_utils/basic_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../tools/tools.dart';
|
||||
import '../veilid_support/veilid_support.dart';
|
||||
|
||||
class ContactInvitationDisplayDialog extends ConsumerStatefulWidget {
|
||||
const ContactInvitationDisplayDialog({
|
||||
@ -40,14 +43,14 @@ class ContactInvitationDisplayDialogState
|
||||
extends ConsumerState<ContactInvitationDisplayDialog> {
|
||||
final focusNode = FocusNode();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
late final FutureProvider<Uint8List?> _generateFutureProvider;
|
||||
late final AutoDisposeFutureProvider<Uint8List?> _generateFutureProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_generateFutureProvider =
|
||||
FutureProvider<Uint8List>((ref) async => widget.generator);
|
||||
AutoDisposeFutureProvider<Uint8List>((ref) async => widget.generator);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -56,11 +59,24 @@ class ContactInvitationDisplayDialogState
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String makeTextInvite(String message, Uint8List data) {
|
||||
final invite = StringUtils.addCharAtPosition(
|
||||
base64UrlNoPadEncode(data), '\n', 40,
|
||||
repeat: true);
|
||||
final msg = message.isNotEmpty ? '$message\n' : '';
|
||||
return '$msg'
|
||||
'--- BEGIN VEILIDCHAT CONTACT INVITE ---\n'
|
||||
'$invite\n'
|
||||
'--- END VEILIDCHAT CONTACT INVITE ---\n';
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: prefer_expression_function_bodies
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scale = theme.extension<ScaleScheme>()!;
|
||||
final textTheme = theme.textTheme;
|
||||
|
||||
final signedContactInvitationBytesV = ref.watch(_generateFutureProvider);
|
||||
final cardsize = MediaQuery.of(context).size.shortestSide - 24;
|
||||
|
||||
@ -71,17 +87,62 @@ class ContactInvitationDisplayDialogState
|
||||
height: cardsize,
|
||||
child: signedContactInvitationBytesV.when(
|
||||
loading: () => waitingPage(context),
|
||||
data: (data) => Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text("Contact Invitation")])),
|
||||
data: (data) {
|
||||
if (data == null) {
|
||||
Navigator.of(context).pop();
|
||||
return const Text('');
|
||||
}
|
||||
final compressedData =
|
||||
Uint8List.fromList(BZip2Encoder().encode(data));
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
translate(
|
||||
'send_invite_dialog.contact_invitation'),
|
||||
style: textTheme.headlineMedium!
|
||||
.copyWith(color: Colors.black))),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 300, minHeight: 300),
|
||||
child: QrImageView.withQr(
|
||||
size: 300,
|
||||
qr: QrCode.fromUint8List(
|
||||
data: compressedData,
|
||||
errorCorrectLevel:
|
||||
QrErrorCorrectLevel.L))),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(widget.message,
|
||||
softWrap: true,
|
||||
style: textTheme.headlineSmall!
|
||||
.copyWith(color: Colors.black))),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: Text(translate(
|
||||
'send_invite_dialog.copy_invitation')),
|
||||
onPressed: () async {
|
||||
showInfoToast(
|
||||
context,
|
||||
translate(
|
||||
'send_invite_dialog.invitation_copied'));
|
||||
await Clipboard.setData(ClipboardData(
|
||||
text: makeTextInvite(
|
||||
widget.message, compressedData)));
|
||||
},
|
||||
),
|
||||
]));
|
||||
},
|
||||
error: (e, s) {
|
||||
Navigator.of(context).pop();
|
||||
showErrorToast(
|
||||
context, "Failed to generate contact invitation: $e");
|
||||
return Text("");
|
||||
showErrorToast(context,
|
||||
translate('send_invite_dialog.failed_to_generate'));
|
||||
return const Text('');
|
||||
})));
|
||||
}
|
||||
|
||||
|
79
lib/components/contact_invitation_item_widget.dart
Normal file
79
lib/components/contact_invitation_item_widget.dart
Normal file
@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:veilid/veilid.dart';
|
||||
import '../../entities/proto.dart' as proto;
|
||||
|
||||
class ContactInvitationItemWidget extends ConsumerWidget {
|
||||
const ContactInvitationItemWidget(
|
||||
{required this.contactInvitationRecord, super.key});
|
||||
|
||||
final proto.ContactInvitationRecord contactInvitationRecord;
|
||||
|
||||
@override
|
||||
// ignore: prefer_expression_function_bodies
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Slidable(
|
||||
// Specify a key if the Slidable is dismissible.
|
||||
key: ObjectKey(contactInvitationRecord),
|
||||
// The start action pane is the one at the left or the top side.
|
||||
startActionPane: ActionPane(
|
||||
// A motion is a widget used to control how the pane animates.
|
||||
motion: const DrawerMotion(),
|
||||
|
||||
// A pane can dismiss the Slidable.
|
||||
//dismissible: DismissiblePane(onDismissed: () {}),
|
||||
|
||||
// All actions are defined in the children parameter.
|
||||
children: [
|
||||
// A SlidableAction can have an icon and/or a label.
|
||||
SlidableAction(
|
||||
onPressed: (context) => (),
|
||||
backgroundColor: Color(0xFFFE4A49),
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (context) => (),
|
||||
backgroundColor: Color(0xFF21B7CA),
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.edit,
|
||||
label: 'Edit',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// The end action pane is the one at the right or the bottom side.
|
||||
// endActionPane: ActionPane(
|
||||
// motion: const DrawerMotion(),
|
||||
// children: [
|
||||
// SlidableAction(
|
||||
// // An action can be bigger than the others.
|
||||
// flex: 2,
|
||||
// onPressed: (context) => (),
|
||||
// backgroundColor: Color(0xFF7BC043),
|
||||
// foregroundColor: Colors.white,
|
||||
// icon: Icons.archive,
|
||||
// label: 'Archive',
|
||||
// ),
|
||||
// SlidableAction(
|
||||
// onPressed: (context) => (),
|
||||
// backgroundColor: Color(0xFF0392CF),
|
||||
// foregroundColor: Colors.white,
|
||||
// icon: Icons.save,
|
||||
// label: 'Save',
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
|
||||
// The child of the Slidable is what the user sees when the
|
||||
// component is not dragged.
|
||||
child: ListTile(
|
||||
title: Text(translate('contact_list.invitation')),
|
||||
subtitle: Text(contactInvitationRecord.message),
|
||||
//Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ),
|
||||
leading: Icon(Icons.person_add)));
|
||||
}
|
||||
}
|
56
lib/components/contact_invitation_list_widget.dart
Normal file
56
lib/components/contact_invitation_list_widget.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:searchable_listview/searchable_listview.dart';
|
||||
|
||||
import '../../entities/proto.dart' as proto;
|
||||
import '../tools/tools.dart';
|
||||
import 'contact_invitation_item_widget.dart';
|
||||
import 'contact_item_widget.dart';
|
||||
import 'empty_contact_list_widget.dart';
|
||||
|
||||
class ContactInvitationListWidget extends ConsumerWidget {
|
||||
const ContactInvitationListWidget(
|
||||
{required this.contactInvitationRecordList, super.key});
|
||||
final IList<proto.ContactInvitationRecord> contactInvitationRecordList;
|
||||
|
||||
@override
|
||||
// ignore: prefer_expression_function_bodies
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
//final scale = theme.extension<ScaleScheme>()!;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Contacts',
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
ListView.builder(itemBuilder: (context, index) {
|
||||
if (index < 0 || index >= contactInvitationRecordList.length) {
|
||||
return null;
|
||||
}
|
||||
return ContactInvitationItemWidget(
|
||||
contactInvitationRecord: contactInvitationRecordList[index],
|
||||
key: ObjectKey(contactInvitationRecordList[index]));
|
||||
}, findChildIndexCallback: (key) {
|
||||
final index = contactInvitationRecordList.indexOf(
|
||||
(key as ObjectKey).value! as proto.ContactInvitationRecord);
|
||||
if (index == -1) {
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
75
lib/components/contact_item_widget.dart
Normal file
75
lib/components/contact_item_widget.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import '../../entities/proto.dart' as proto;
|
||||
|
||||
class ContactItemWidget extends ConsumerWidget {
|
||||
const ContactItemWidget({required this.contact, super.key});
|
||||
|
||||
final proto.Contact contact;
|
||||
|
||||
@override
|
||||
// ignore: prefer_expression_function_bodies
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Slidable(
|
||||
// Specify a key if the Slidable is dismissible.
|
||||
key: ObjectKey(contact),
|
||||
// The start action pane is the one at the left or the top side.
|
||||
startActionPane: ActionPane(
|
||||
// A motion is a widget used to control how the pane animates.
|
||||
motion: const DrawerMotion(),
|
||||
|
||||
// A pane can dismiss the Slidable.
|
||||
//dismissible: DismissiblePane(onDismissed: () {}),
|
||||
|
||||
// All actions are defined in the children parameter.
|
||||
children: [
|
||||
// A SlidableAction can have an icon and/or a label.
|
||||
SlidableAction(
|
||||
onPressed: (context) => (),
|
||||
backgroundColor: Color(0xFFFE4A49),
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (context) => (),
|
||||
backgroundColor: Color(0xFF21B7CA),
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.edit,
|
||||
label: 'Edit',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// The end action pane is the one at the right or the bottom side.
|
||||
// endActionPane: ActionPane(
|
||||
// motion: const DrawerMotion(),
|
||||
// children: [
|
||||
// SlidableAction(
|
||||
// // An action can be bigger than the others.
|
||||
// flex: 2,
|
||||
// onPressed: (context) => (),
|
||||
// backgroundColor: Color(0xFF7BC043),
|
||||
// foregroundColor: Colors.white,
|
||||
// icon: Icons.archive,
|
||||
// label: 'Archive',
|
||||
// ),
|
||||
// SlidableAction(
|
||||
// onPressed: (context) => (),
|
||||
// backgroundColor: Color(0xFF0392CF),
|
||||
// foregroundColor: Colors.white,
|
||||
// icon: Icons.save,
|
||||
// label: 'Save',
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
|
||||
// The child of the Slidable is what the user sees when the
|
||||
// component is not dragged.
|
||||
child: ListTile(
|
||||
title: Text(contact.editedProfile.name),
|
||||
subtitle: Text(contact.editedProfile.title),
|
||||
leading: Icon(Icons.person)));
|
||||
}
|
||||
}
|
@ -1,20 +1,25 @@
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:searchable_listview/searchable_listview.dart';
|
||||
|
||||
import '../../entities/proto.dart' as proto;
|
||||
import '../tools/tools.dart';
|
||||
import 'contact_item_widget.dart';
|
||||
import 'empty_contact_list_widget.dart';
|
||||
|
||||
class ContactListWidget extends ConsumerWidget {
|
||||
const ContactListWidget({required this.contactList, super.key});
|
||||
final List<proto.Contact> contactList;
|
||||
final IList<proto.Contact> contactList;
|
||||
|
||||
@override
|
||||
// ignore: prefer_expression_function_bodies
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
//
|
||||
if (contactList.isEmpty) {
|
||||
return const EmptyContactListWidget();
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
//final scale = theme.extension<ScaleScheme>()!;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@ -23,16 +28,36 @@ class ContactListWidget extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.group_add,
|
||||
color: Theme.of(context).disabledColor,
|
||||
size: 48,
|
||||
),
|
||||
Text(
|
||||
'Contacts',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
SearchableList<proto.Contact>(
|
||||
initialList: contactList.toList(),
|
||||
builder: (contact) => ContactItemWidget(contact: contact),
|
||||
filter: (value) {
|
||||
final lowerValue = value.toLowerCase();
|
||||
return contactList
|
||||
.where((element) =>
|
||||
element.editedProfile.name
|
||||
.toLowerCase()
|
||||
.contains(lowerValue) ||
|
||||
element.editedProfile.title
|
||||
.toLowerCase()
|
||||
.contains(lowerValue))
|
||||
.toList();
|
||||
},
|
||||
emptyWidget: const EmptyContactListWidget(),
|
||||
inputDecoration: InputDecoration(
|
||||
labelText: translate('contact_list.search'),
|
||||
fillColor: Colors.white,
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.blue,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -104,6 +104,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
||||
activeAccountInfo: activeAccountInfo,
|
||||
encryptionKeyType: _encryptionKeyType,
|
||||
encryptionKey: _encryptionKey,
|
||||
message: _messageTextController.text,
|
||||
expiration: _expiration);
|
||||
// ignore: use_build_context_synchronously
|
||||
if (!context.mounted) {
|
||||
@ -119,6 +120,7 @@ class SendInviteDialogState extends ConsumerState<SendInviteDialog> {
|
||||
// if (ret == null) {
|
||||
// return;
|
||||
// }
|
||||
ref.invalidate(fetchContactInvitationRecordsProvider);
|
||||
navigator.pop();
|
||||
}
|
||||
|
||||
|
@ -131,15 +131,22 @@ extension IdentityMasterExtension on IdentityMaster {
|
||||
// Create new account to insert into identity
|
||||
await (await pool.create(parent: identityRec.key))
|
||||
.deleteScope((accountRec) async {
|
||||
// Make empty contact list
|
||||
final contactList =
|
||||
await (await DHTShortArray.create(parent: accountRec.key))
|
||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
||||
|
||||
// Make empty contact invitation record list
|
||||
final contactInvitationRecords = await (await DHTShortArray.create())
|
||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
||||
final contactInvitationRecords =
|
||||
await (await DHTShortArray.create(parent: accountRec.key))
|
||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
||||
|
||||
// Make account object
|
||||
final account = proto.Account()
|
||||
..profile = (proto.Profile()
|
||||
..name = name
|
||||
..title = title)
|
||||
..contactList = contactList.toProto()
|
||||
..contactInvitationRecords = contactInvitationRecords.toProto();
|
||||
|
||||
// Write account key
|
||||
|
@ -1700,6 +1700,7 @@ class ContactInvitationRecord extends $pb.GeneratedMessage {
|
||||
..aOM<TypedKey>(4, _omitFieldNames ? '' : 'chatRecordKey', subBuilder: TypedKey.create)
|
||||
..a<$fixnum.Int64>(5, _omitFieldNames ? '' : 'expiration', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO)
|
||||
..a<$core.List<$core.int>>(6, _omitFieldNames ? '' : 'invitation', $pb.PbFieldType.OY)
|
||||
..aOS(7, _omitFieldNames ? '' : 'message')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@ -1785,6 +1786,15 @@ class ContactInvitationRecord extends $pb.GeneratedMessage {
|
||||
$core.bool hasInvitation() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearInvitation() => clearField(6);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
$core.String get message => $_getSZ(6);
|
||||
@$pb.TagNumber(7)
|
||||
set message($core.String v) { $_setString(6, v); }
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasMessage() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearMessage() => clearField(7);
|
||||
}
|
||||
|
||||
|
||||
|
@ -463,6 +463,7 @@ const ContactInvitationRecord$json = {
|
||||
{'1': 'chat_record_key', '3': 4, '4': 1, '5': 11, '6': '.TypedKey', '10': 'chatRecordKey'},
|
||||
{'1': 'expiration', '3': 5, '4': 1, '5': 4, '10': 'expiration'},
|
||||
{'1': 'invitation', '3': 6, '4': 1, '5': 12, '10': 'invitation'},
|
||||
{'1': 'message', '3': 7, '4': 1, '5': 9, '10': 'message'},
|
||||
],
|
||||
};
|
||||
|
||||
@ -473,5 +474,5 @@ final $typed_data.Uint8List contactInvitationRecordDescriptor = $convert.base64D
|
||||
'GAIgASgLMgouQ3J5cHRvS2V5Ugl3cml0ZXJLZXkSLwoNd3JpdGVyX3NlY3JldBgDIAEoCzIKLk'
|
||||
'NyeXB0b0tleVIMd3JpdGVyU2VjcmV0EjEKD2NoYXRfcmVjb3JkX2tleRgEIAEoCzIJLlR5cGVk'
|
||||
'S2V5Ug1jaGF0UmVjb3JkS2V5Eh4KCmV4cGlyYXRpb24YBSABKARSCmV4cGlyYXRpb24SHgoKaW'
|
||||
'52aXRhdGlvbhgGIAEoDFIKaW52aXRhdGlvbg==');
|
||||
'52aXRhdGlvbhgGIAEoDFIKaW52aXRhdGlvbhIYCgdtZXNzYWdlGAcgASgJUgdtZXNzYWdl');
|
||||
|
||||
|
@ -344,6 +344,8 @@ message ContactInvitationRecord {
|
||||
TypedKey chat_record_key = 4;
|
||||
// Expiration timestamp
|
||||
uint64 expiration = 5;
|
||||
// A copy of the raw SignedContactResponse invitation bytes post-encryption and signing
|
||||
// A copy of the raw SignedContactInvitation invitation bytes post-encryption and signing
|
||||
bytes invitation = 6;
|
||||
// The message sent along with the invitation
|
||||
string message = 7;
|
||||
}
|
@ -4,11 +4,13 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
|
||||
import '../../components/contact_invitation_list_widget.dart';
|
||||
import '../../components/contact_list_widget.dart';
|
||||
import '../../components/profile.dart';
|
||||
import '../../entities/local_account.dart';
|
||||
import '../../entities/proto.dart' as proto;
|
||||
import '../../providers/account.dart';
|
||||
import '../../providers/contact.dart';
|
||||
import '../../providers/local_accounts.dart';
|
||||
import '../../providers/logins.dart';
|
||||
import '../../tools/tools.dart';
|
||||
@ -24,7 +26,6 @@ class AccountPage extends ConsumerStatefulWidget {
|
||||
class AccountPageState extends ConsumerState<AccountPage> {
|
||||
final _unfocusNode = FocusNode();
|
||||
TypedKey? _selectedAccount;
|
||||
List<proto.Contact> _contactList = List<proto.Contact>.empty(growable: true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -62,9 +63,18 @@ class AccountPageState extends ConsumerState<AccountPage> {
|
||||
proto.Account account,
|
||||
// ignore: prefer_expression_function_bodies
|
||||
) {
|
||||
final contactInvitationRecordList =
|
||||
ref.watch(fetchContactInvitationRecordsProvider).asData?.value ??
|
||||
const IListConst([]);
|
||||
final contactList = ref.watch(fetchContactListProvider).asData?.value ??
|
||||
const IListConst([]);
|
||||
|
||||
return Column(children: <Widget>[
|
||||
ProfileWidget(name: account.profile.name, title: account.profile.title),
|
||||
ContactListWidget(contactList: _contactList)
|
||||
if (contactInvitationRecordList.isNotEmpty)
|
||||
ContactInvitationListWidget(
|
||||
contactInvitationRecordList: contactInvitationRecordList),
|
||||
ContactListWidget(contactList: contactList),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -110,9 +110,11 @@ class NewAccountPageState extends ConsumerState<NewAccountPage> {
|
||||
try {
|
||||
await onSubmit(_formKey);
|
||||
} finally {
|
||||
setState(() {
|
||||
isInAsyncCall = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isInAsyncCall = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1,17 +1,30 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../entities/local_account.dart';
|
||||
import '../entities/proto.dart' as proto;
|
||||
import '../entities/proto.dart'
|
||||
show
|
||||
Contact,
|
||||
ContactInvitation,
|
||||
ContactInvitationRecord,
|
||||
ContactRequest,
|
||||
ContactRequestPrivate,
|
||||
SignedContactInvitation;
|
||||
import '../tools/tools.dart';
|
||||
import '../veilid_support/veilid_support.dart';
|
||||
import 'account.dart';
|
||||
|
||||
part 'contact.g.dart';
|
||||
|
||||
Future<Uint8List> createContactInvitation(
|
||||
{required ActiveAccountInfo activeAccountInfo,
|
||||
required EncryptionKeyType encryptionKeyType,
|
||||
required String encryptionKey,
|
||||
required String message,
|
||||
required Timestamp? expiration}) async {
|
||||
final pool = await DHTRecordPool.instance();
|
||||
final accountRecordKey =
|
||||
@ -39,7 +52,7 @@ Future<Uint8List> createContactInvitation(
|
||||
await (await pool.create(parent: accountRecordKey))
|
||||
.deleteScope((localChatRecord) async {
|
||||
// Make ContactRequestPrivate and encrypt with the writer secret
|
||||
final crpriv = proto.ContactRequestPrivate()
|
||||
final crpriv = ContactRequestPrivate()
|
||||
..writerKey = writer.key.toProto()
|
||||
..profile = activeAccountInfo.account.profile
|
||||
..accountMasterRecordKey =
|
||||
@ -51,7 +64,7 @@ Future<Uint8List> createContactInvitation(
|
||||
await cs.encryptNoAuthWithNonce(crprivbytes, writer.secret);
|
||||
|
||||
// Create ContactRequest and embed contactrequestprivate
|
||||
final creq = proto.ContactRequest()
|
||||
final creq = ContactRequest()
|
||||
..encryptionKeyType = encryptionKeyType.toProto()
|
||||
..private = encryptedContactRequestPrivate;
|
||||
|
||||
@ -66,24 +79,25 @@ Future<Uint8List> createContactInvitation(
|
||||
await inboxRecord.eventualWriteProtobuf(creq);
|
||||
|
||||
// Create ContactInvitation and SignedContactInvitation
|
||||
final cinv = proto.ContactInvitation()
|
||||
final cinv = ContactInvitation()
|
||||
..contactRequestRecordKey = inboxRecord.key.toProto()
|
||||
..writerSecret = encryptedSecret;
|
||||
final cinvbytes = cinv.writeToBuffer();
|
||||
final scinv = proto.SignedContactInvitation()
|
||||
final scinv = SignedContactInvitation()
|
||||
..contactInvitation = cinvbytes
|
||||
..identitySignature =
|
||||
(await cs.sign(identityKey, identitySecret, cinvbytes)).toProto();
|
||||
signedContactInvitationBytes = scinv.writeToBuffer();
|
||||
|
||||
// Create ContactInvitationRecord
|
||||
final cinvrec = proto.ContactInvitationRecord()
|
||||
final cinvrec = ContactInvitationRecord()
|
||||
..contactRequestRecordKey = inboxRecord.key.toProto()
|
||||
..writerKey = writer.key.toProto()
|
||||
..writerSecret = writer.secret.toProto()
|
||||
..chatRecordKey = localChatRecord.key.toProto()
|
||||
..expiration = expiration?.toInt64() ?? Int64.ZERO
|
||||
..invitation = signedContactInvitationBytes;
|
||||
..invitation = signedContactInvitationBytes
|
||||
..message = message;
|
||||
|
||||
// Add ContactInvitationRecord to local table if possible
|
||||
// if this fails, don't keep retrying, user can try again later
|
||||
@ -101,3 +115,64 @@ Future<Uint8List> createContactInvitation(
|
||||
|
||||
return signedContactInvitationBytes;
|
||||
}
|
||||
|
||||
/// Get the active account contact invitation list
|
||||
@riverpod
|
||||
Future<IList<ContactInvitationRecord>?> fetchContactInvitationRecords(
|
||||
FetchContactInvitationRecordsRef ref) async {
|
||||
// See if we've logged into this account or if it is locked
|
||||
final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future);
|
||||
if (activeAccountInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final accountRecordKey =
|
||||
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
|
||||
// Decode the contact invitation list from the DHT
|
||||
IList<ContactInvitationRecord> out = const IListConst([]);
|
||||
await (await DHTShortArray.openOwned(
|
||||
proto.OwnedDHTRecordPointerProto.fromProto(
|
||||
activeAccountInfo.account.contactInvitationRecords),
|
||||
parent: accountRecordKey))
|
||||
.scope((cirList) async {
|
||||
for (var i = 0; i < cirList.length; i++) {
|
||||
final cir = await cirList.getItem(i);
|
||||
if (cir == null) {
|
||||
throw StateError('Failed to get contact invitation record');
|
||||
}
|
||||
out = out.add(ContactInvitationRecord.fromBuffer(cir));
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Get the active account contact list
|
||||
@riverpod
|
||||
Future<IList<Contact>?> fetchContactList(FetchContactListRef ref) async {
|
||||
// See if we've logged into this account or if it is locked
|
||||
final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future);
|
||||
if (activeAccountInfo == null) {
|
||||
return null;
|
||||
}
|
||||
final accountRecordKey =
|
||||
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
|
||||
// Decode the contact list from the DHT
|
||||
IList<Contact> out = const IListConst([]);
|
||||
await (await DHTShortArray.openOwned(
|
||||
proto.OwnedDHTRecordPointerProto.fromProto(
|
||||
activeAccountInfo.account.contactList),
|
||||
parent: accountRecordKey))
|
||||
.scope((cList) async {
|
||||
for (var i = 0; i < cList.length; i++) {
|
||||
final cir = await cList.getItem(i);
|
||||
if (cir == null) {
|
||||
throw StateError('Failed to get contact');
|
||||
}
|
||||
out = out.add(Contact.fromBuffer(cir));
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
47
lib/providers/contact.g.dart
Normal file
47
lib/providers/contact.g.dart
Normal file
@ -0,0 +1,47 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'contact.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$fetchContactInvitationRecordsHash() =>
|
||||
r'fcedc1807c6cb25ac6c2c42b372ec04abd4b911f';
|
||||
|
||||
/// Get the active account contact invitation list
|
||||
///
|
||||
/// Copied from [fetchContactInvitationRecords].
|
||||
@ProviderFor(fetchContactInvitationRecords)
|
||||
final fetchContactInvitationRecordsProvider =
|
||||
AutoDisposeFutureProvider<IList<ContactInvitationRecord>?>.internal(
|
||||
fetchContactInvitationRecords,
|
||||
name: r'fetchContactInvitationRecordsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$fetchContactInvitationRecordsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef FetchContactInvitationRecordsRef
|
||||
= AutoDisposeFutureProviderRef<IList<ContactInvitationRecord>?>;
|
||||
String _$fetchContactListHash() => r'60ae4f117fc51c0870449563aedca7baf51cc254';
|
||||
|
||||
/// Get the active account contact list
|
||||
///
|
||||
/// Copied from [fetchContactList].
|
||||
@ProviderFor(fetchContactList)
|
||||
final fetchContactListProvider =
|
||||
AutoDisposeFutureProvider<IList<Contact>?>.internal(
|
||||
fetchContactList,
|
||||
name: r'fetchContactListProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$fetchContactListHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef FetchContactListRef = AutoDisposeFutureProviderRef<IList<Contact>?>;
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
@ -33,7 +33,7 @@ class WindowControl extends _$WindowControl {
|
||||
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(768, 1024),
|
||||
//minimumSize: Size(480, 640),
|
||||
//minimumSize: Size(480, 480),
|
||||
center: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
skipTaskbar: false,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:flutter_translate/flutter_translate.dart';
|
||||
import 'package:motion_toast/motion_toast.dart';
|
||||
import 'package:quickalert/quickalert.dart';
|
||||
|
||||
@ -54,7 +55,14 @@ Future<void> showErrorModal(
|
||||
|
||||
void showErrorToast(BuildContext context, String message) {
|
||||
MotionToast.error(
|
||||
title: Text("Error"),
|
||||
title: Text(translate('toast.error')),
|
||||
description: Text(message),
|
||||
).show(context);
|
||||
}
|
||||
|
||||
void showInfoToast(BuildContext context, String message) {
|
||||
MotionToast.info(
|
||||
title: Text(translate('toast.info')),
|
||||
description: Text(message),
|
||||
).show(context);
|
||||
}
|
||||
|
@ -98,7 +98,7 @@ class DHTRecord {
|
||||
return null;
|
||||
}
|
||||
final lastSeq = _subkeySeqCache[subkey];
|
||||
if (lastSeq != null && valueData.seq <= lastSeq) {
|
||||
if (onlyUpdates && lastSeq != null && valueData.seq <= lastSeq) {
|
||||
return null;
|
||||
}
|
||||
final out = _crypto.decrypt(valueData.data, subkey);
|
||||
@ -137,11 +137,17 @@ class DHTRecord {
|
||||
newValue = await _crypto.encrypt(newValue, subkey);
|
||||
|
||||
// Set the new data if possible
|
||||
final valueData = await _routingContext.setDHTValue(
|
||||
var valueData = await _routingContext.setDHTValue(
|
||||
_recordDescriptor.key, subkey, newValue);
|
||||
if (valueData == null) {
|
||||
// Get the data to check its sequence number
|
||||
valueData = await _routingContext.getDHTValue(
|
||||
_recordDescriptor.key, subkey, false);
|
||||
assert(valueData != null, "can't get value that was just set");
|
||||
_subkeySeqCache[subkey] = valueData!.seq;
|
||||
return null;
|
||||
}
|
||||
_subkeySeqCache[subkey] = valueData.seq;
|
||||
return valueData.data;
|
||||
}
|
||||
|
||||
@ -157,6 +163,12 @@ class DHTRecord {
|
||||
|
||||
// Repeat if newer data on the network was found
|
||||
} while (valueData != null);
|
||||
|
||||
// Get the data to check its sequence number
|
||||
valueData =
|
||||
await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false);
|
||||
assert(valueData != null, "can't get value that was just set");
|
||||
_subkeySeqCache[subkey] = valueData!.seq;
|
||||
}
|
||||
|
||||
Future<void> eventualUpdateBytes(
|
||||
@ -186,6 +198,12 @@ class DHTRecord {
|
||||
|
||||
// Repeat if newer data on the network was found
|
||||
} while (valueData != null);
|
||||
|
||||
// Get the data to check its sequence number
|
||||
valueData =
|
||||
await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false);
|
||||
assert(valueData != null, "can't get value that was just set");
|
||||
_subkeySeqCache[subkey] = valueData!.seq;
|
||||
}
|
||||
|
||||
Future<T?> tryWriteJson<T>(T Function(dynamic) fromJson, T newValue,
|
||||
|
@ -64,6 +64,9 @@ class DHTShortArray {
|
||||
crypto: crypto);
|
||||
try {
|
||||
final dhtShortArray = DHTShortArray._(headRecord: dhtRecord);
|
||||
if (!await dhtShortArray._tryWriteHead()) {
|
||||
throw StateError('Failed to write head at this time');
|
||||
}
|
||||
return dhtShortArray;
|
||||
} on Exception catch (_) {
|
||||
await dhtRecord.delete();
|
||||
@ -327,7 +330,7 @@ class DHTShortArray {
|
||||
// xxx: free list optimization here?
|
||||
}
|
||||
|
||||
int length() => _head.index.length;
|
||||
int get length => _head.index.length;
|
||||
|
||||
Future<Uint8List?> getItem(int pos, {bool forceRefresh = false}) async {
|
||||
await _refreshHead(forceRefresh: forceRefresh, onlyUpdates: true);
|
||||
|
42
pubspec.lock
42
pubspec.lock
@ -42,7 +42,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
|
||||
@ -81,6 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
basic_utils:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: basic_utils
|
||||
sha256: "1fb8c5493fc1b9500512b2e153c0b9bcc9e281621cde7f810420f4761be9e38d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.1"
|
||||
blurry_modal_progress_hud:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -499,6 +507,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.6"
|
||||
flutter_slidable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_slidable
|
||||
sha256: cc4231579e3eae41ae166660df717f4bad1359c87f4a4322ad8ba1befeb3d2be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_spinkit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -925,6 +941,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
quickalert:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1005,6 +1037,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
searchable_listview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: searchable_listview
|
||||
sha256: "0a158665571e03890408e2d5569f0673a36f79fac5a671a54495b52f67b93b63"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -10,8 +10,10 @@ environment:
|
||||
dependencies:
|
||||
animated_theme_switcher: ^2.0.7
|
||||
ansicolor: ^2.0.1
|
||||
archive: ^3.3.7
|
||||
awesome_extensions: ^2.0.9
|
||||
badges: ^3.1.1
|
||||
basic_utils: ^5.6.1
|
||||
blurry_modal_progress_hud: ^1.1.0
|
||||
change_case: ^1.1.0
|
||||
charcode: ^1.3.1
|
||||
@ -30,6 +32,7 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.1.3
|
||||
flutter_slidable: ^3.0.0
|
||||
flutter_spinkit: ^5.2.0
|
||||
flutter_svg: ^2.0.7
|
||||
flutter_translate: ^4.0.4
|
||||
@ -45,10 +48,12 @@ dependencies:
|
||||
path_provider: ^2.0.11
|
||||
pinput: ^2.3.0
|
||||
protobuf: ^3.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
quickalert: ^1.0.1
|
||||
radix_colors: ^1.0.4
|
||||
reorderable_grid: ^1.0.7
|
||||
riverpod_annotation: ^2.1.1
|
||||
searchable_listview: ^2.4.0
|
||||
share_plus: ^7.0.2
|
||||
shared_preferences: ^2.0.15
|
||||
signal_strength_indicator: ^0.4.1
|
||||
|
Loading…
Reference in New Issue
Block a user