mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2024-12-25 23:59:32 -05:00
checkpoint
This commit is contained in:
parent
ee80dbf3a5
commit
d965f674fc
38
lib/app.dart
38
lib/app.dart
@ -7,6 +7,7 @@ import 'package:flutter_translate/flutter_translate.dart';
|
|||||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||||
|
|
||||||
import 'router/router.dart';
|
import 'router/router.dart';
|
||||||
|
import 'tick.dart';
|
||||||
|
|
||||||
class VeilidChatApp extends ConsumerWidget {
|
class VeilidChatApp extends ConsumerWidget {
|
||||||
const VeilidChatApp({
|
const VeilidChatApp({
|
||||||
@ -23,24 +24,25 @@ class VeilidChatApp extends ConsumerWidget {
|
|||||||
final localizationDelegate = LocalizedApp.of(context).delegate;
|
final localizationDelegate = LocalizedApp.of(context).delegate;
|
||||||
|
|
||||||
return ThemeProvider(
|
return ThemeProvider(
|
||||||
initTheme: theme,
|
initTheme: theme,
|
||||||
builder: (_, theme) => LocalizationProvider(
|
builder: (_, theme) => LocalizationProvider(
|
||||||
state: LocalizationProvider.of(context).state,
|
state: LocalizationProvider.of(context).state,
|
||||||
child: MaterialApp.router(
|
child: BackgroundTicker(
|
||||||
debugShowCheckedModeBanner: false,
|
builder: (context) => MaterialApp.router(
|
||||||
routerConfig: router,
|
debugShowCheckedModeBanner: false,
|
||||||
title: translate('app.title'),
|
routerConfig: router,
|
||||||
theme: theme,
|
title: translate('app.title'),
|
||||||
localizationsDelegates: [
|
theme: theme,
|
||||||
GlobalMaterialLocalizations.delegate,
|
localizationsDelegates: [
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
FormBuilderLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
localizationDelegate
|
FormBuilderLocalizations.delegate,
|
||||||
],
|
localizationDelegate
|
||||||
supportedLocales: localizationDelegate.supportedLocales,
|
],
|
||||||
locale: localizationDelegate.currentLocale,
|
supportedLocales: localizationDelegate.supportedLocales,
|
||||||
)),
|
locale: localizationDelegate.currentLocale,
|
||||||
);
|
)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:awesome_extensions/awesome_extensions.dart';
|
||||||
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||||
@ -9,6 +11,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../../entities/proto.dart' as proto;
|
import '../../entities/proto.dart' as proto;
|
||||||
import '../entities/identity.dart';
|
import '../entities/identity.dart';
|
||||||
import '../providers/account.dart';
|
import '../providers/account.dart';
|
||||||
|
import '../providers/chat.dart';
|
||||||
import '../providers/conversation.dart';
|
import '../providers/conversation.dart';
|
||||||
import '../tools/theme_service.dart';
|
import '../tools/theme_service.dart';
|
||||||
import '../veilid_support/veilid_support.dart';
|
import '../veilid_support/veilid_support.dart';
|
||||||
@ -59,6 +62,8 @@ class ChatComponentState extends ConsumerState<ChatComponent> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
externalize messages so they auto refresh and fix speed.
|
||||||
|
|
||||||
Future<void> _loadMessages() async {
|
Future<void> _loadMessages() async {
|
||||||
final localConversationRecordKey = proto.TypedKeyProto.fromProto(
|
final localConversationRecordKey = proto.TypedKeyProto.fromProto(
|
||||||
widget.activeChatContact.localConversationRecordKey);
|
widget.activeChatContact.localConversationRecordKey);
|
||||||
@ -88,8 +93,8 @@ class ChatComponentState extends ConsumerState<ChatComponent> {
|
|||||||
|
|
||||||
final textMessage = types.TextMessage(
|
final textMessage = types.TextMessage(
|
||||||
author: isLocal ? _localUser : _remoteUser,
|
author: isLocal ? _localUser : _remoteUser,
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
createdAt: (message.timestamp ~/ 1000).toInt(),
|
||||||
id: const Uuid().v4(),
|
id: message.timestamp.toString(),
|
||||||
text: message.text,
|
text: message.text,
|
||||||
);
|
);
|
||||||
return textMessage;
|
return textMessage;
|
||||||
@ -157,16 +162,23 @@ class ChatComponentState extends ConsumerState<ChatComponent> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: scale.primaryScale.subtleBackground,
|
color: scale.primaryScale.subtleBackground,
|
||||||
),
|
),
|
||||||
child: Align(
|
child: Row(children: [
|
||||||
alignment: AlignmentDirectional.centerStart,
|
Align(
|
||||||
child: Padding(
|
alignment: AlignmentDirectional.centerStart,
|
||||||
padding:
|
child: Padding(
|
||||||
const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
|
padding: const EdgeInsetsDirectional.fromSTEB(
|
||||||
child: Text(contactName,
|
16, 0, 16, 0),
|
||||||
textAlign: TextAlign.start,
|
child: Text(contactName,
|
||||||
style: textTheme.titleMedium),
|
textAlign: TextAlign.start,
|
||||||
),
|
style: textTheme.titleMedium),
|
||||||
),
|
)),
|
||||||
|
Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close),
|
||||||
|
onPressed: () async {
|
||||||
|
activeChatState.add(null);
|
||||||
|
}).paddingLTRB(16, 0, 16, 0)
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
|
@ -42,6 +42,7 @@ class ChatSingleContactListWidget extends ConsumerWidget {
|
|||||||
child: (chatList.isEmpty)
|
child: (chatList.isEmpty)
|
||||||
? const EmptyChatListWidget()
|
? const EmptyChatListWidget()
|
||||||
: SearchableList<proto.Chat>(
|
: SearchableList<proto.Chat>(
|
||||||
|
autoFocusOnSearch: false,
|
||||||
initialList: chatList.toList(),
|
initialList: chatList.toList(),
|
||||||
builder: (c) {
|
builder: (c) {
|
||||||
final contact = contactMap[c.remoteConversationKey];
|
final contact = contactMap[c.remoteConversationKey];
|
||||||
|
@ -48,6 +48,7 @@ class ContactListWidget extends ConsumerWidget {
|
|||||||
child: (contactList.isEmpty)
|
child: (contactList.isEmpty)
|
||||||
? const EmptyContactListWidget().toCenter()
|
? const EmptyContactListWidget().toCenter()
|
||||||
: SearchableList<proto.Contact>(
|
: SearchableList<proto.Contact>(
|
||||||
|
autoFocusOnSearch: false,
|
||||||
initialList: contactList.toList(),
|
initialList: contactList.toList(),
|
||||||
builder: (contact) => ContactItemWidget(contact: contact),
|
builder: (contact) => ContactItemWidget(contact: contact),
|
||||||
filter: (value) {
|
filter: (value) {
|
||||||
|
@ -137,17 +137,17 @@ extension IdentityMasterExtension on IdentityMaster {
|
|||||||
// Make empty contact list
|
// Make empty contact list
|
||||||
final contactList =
|
final contactList =
|
||||||
await (await DHTShortArray.create(parent: accountRec.key))
|
await (await DHTShortArray.create(parent: accountRec.key))
|
||||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
.scope((r) async => r.record.ownedDHTRecordPointer);
|
||||||
|
|
||||||
// Make empty contact invitation record list
|
// Make empty contact invitation record list
|
||||||
final contactInvitationRecords =
|
final contactInvitationRecords =
|
||||||
await (await DHTShortArray.create(parent: accountRec.key))
|
await (await DHTShortArray.create(parent: accountRec.key))
|
||||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
.scope((r) async => r.record.ownedDHTRecordPointer);
|
||||||
|
|
||||||
// Make empty chat record list
|
// Make empty chat record list
|
||||||
final chatRecords =
|
final chatRecords =
|
||||||
await (await DHTShortArray.create(parent: accountRec.key))
|
await (await DHTShortArray.create(parent: accountRec.key))
|
||||||
.scope((r) => r.record.ownedDHTRecordPointer);
|
.scope((r) async => r.record.ownedDHTRecordPointer);
|
||||||
|
|
||||||
// Make account object
|
// Make account object
|
||||||
final account = proto.Account()
|
final account = proto.Account()
|
||||||
|
47
lib/pages/chat_only_page.dart
Normal file
47
lib/pages/chat_only_page.dart
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../providers/window_control.dart';
|
||||||
|
import '../tools/tools.dart';
|
||||||
|
import 'home.dart';
|
||||||
|
|
||||||
|
class ChatOnlyPage extends ConsumerStatefulWidget {
|
||||||
|
const ChatOnlyPage({super.key});
|
||||||
|
static const path = '/chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
ChatOnlyPageState createState() => ChatOnlyPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatOnlyPageState extends ConsumerState<ChatOnlyPage>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
final _unfocusNode = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
setState(() {});
|
||||||
|
await ref.read(windowControlProvider.notifier).changeWindowSetup(
|
||||||
|
TitleBarStyle.normal, OrientationCapability.normal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unfocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
ref.watch(windowControlProvider);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => FocusScope.of(context).requestFocus(_unfocusNode),
|
||||||
|
child: HomePage.buildChatComponent(context, ref),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
@ -25,169 +25,8 @@ class HomePage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
HomePageState createState() => HomePageState();
|
HomePageState createState() => HomePageState();
|
||||||
}
|
|
||||||
|
|
||||||
// XXX Eliminate this when we have ValueChanged
|
static Widget buildChatComponent(BuildContext context, WidgetRef ref) {
|
||||||
const int ticksPerContactInvitationCheck = 5;
|
|
||||||
const int ticksPerNewMessageCheck = 5;
|
|
||||||
|
|
||||||
class HomePageState extends ConsumerState<HomePage>
|
|
||||||
with TickerProviderStateMixin {
|
|
||||||
final _unfocusNode = FocusNode();
|
|
||||||
|
|
||||||
Timer? _homeTickTimer;
|
|
||||||
bool _inHomeTick = false;
|
|
||||||
int _contactInvitationCheckTick = 0;
|
|
||||||
int _newMessageCheckTick = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
setState(() {});
|
|
||||||
await ref.read(windowControlProvider.notifier).changeWindowSetup(
|
|
||||||
TitleBarStyle.normal, OrientationCapability.normal);
|
|
||||||
|
|
||||||
_homeTickTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
||||||
if (!_inHomeTick) {
|
|
||||||
unawaited(_onHomeTick());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
final homeTickTimer = _homeTickTimer;
|
|
||||||
if (homeTickTimer != null) {
|
|
||||||
homeTickTimer.cancel();
|
|
||||||
}
|
|
||||||
_unfocusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onHomeTick() async {
|
|
||||||
_inHomeTick = true;
|
|
||||||
try {
|
|
||||||
final unord = <Future<void>>[];
|
|
||||||
// Check extant contact invitations once every N seconds
|
|
||||||
_contactInvitationCheckTick += 1;
|
|
||||||
if (_contactInvitationCheckTick >= ticksPerContactInvitationCheck) {
|
|
||||||
_contactInvitationCheckTick = 0;
|
|
||||||
unord.add(_doContactInvitationCheck());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check new messages once every N seconds
|
|
||||||
_newMessageCheckTick += 1;
|
|
||||||
if (_newMessageCheckTick >= ticksPerNewMessageCheck) {
|
|
||||||
_newMessageCheckTick = 0;
|
|
||||||
unord.add(_doNewMessageCheck());
|
|
||||||
}
|
|
||||||
if (unord.isNotEmpty) {
|
|
||||||
await Future.wait(unord);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_inHomeTick = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _doContactInvitationCheck() async {
|
|
||||||
final contactInvitationRecords =
|
|
||||||
await ref.read(fetchContactInvitationRecordsProvider.future);
|
|
||||||
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
|
||||||
if (contactInvitationRecords == null || activeAccountInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final allChecks = <Future<void>>[];
|
|
||||||
for (final contactInvitationRecord in contactInvitationRecords) {
|
|
||||||
allChecks.add(() async {
|
|
||||||
final acceptReject = await checkAcceptRejectContact(
|
|
||||||
activeAccountInfo: activeAccountInfo,
|
|
||||||
contactInvitationRecord: contactInvitationRecord);
|
|
||||||
if (acceptReject != null) {
|
|
||||||
final acceptedContact = acceptReject.acceptedContact;
|
|
||||||
if (acceptedContact != null) {
|
|
||||||
// Accept
|
|
||||||
await createContact(
|
|
||||||
activeAccountInfo: activeAccountInfo,
|
|
||||||
profile: acceptedContact.profile,
|
|
||||||
remoteIdentity: acceptedContact.remoteIdentity,
|
|
||||||
remoteConversationRecordKey:
|
|
||||||
acceptedContact.remoteConversationRecordKey,
|
|
||||||
localConversationRecordKey:
|
|
||||||
acceptedContact.localConversationRecordKey,
|
|
||||||
);
|
|
||||||
ref
|
|
||||||
..invalidate(fetchContactInvitationRecordsProvider)
|
|
||||||
..invalidate(fetchContactListProvider);
|
|
||||||
} else {
|
|
||||||
// Reject
|
|
||||||
ref.invalidate(fetchContactInvitationRecordsProvider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
}
|
|
||||||
await Future.wait(allChecks);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _doNewMessageCheck() async {
|
|
||||||
final activeChat = activeChatState.currentState;
|
|
||||||
if (activeChat == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
|
||||||
if (activeAccountInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final contactList = ref.read(fetchContactListProvider).asData?.value ??
|
|
||||||
const IListConst([]);
|
|
||||||
|
|
||||||
final activeChatContactIdx = contactList.indexWhere(
|
|
||||||
(c) =>
|
|
||||||
proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) ==
|
|
||||||
activeChat,
|
|
||||||
);
|
|
||||||
if (activeChatContactIdx == -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final activeChatContact = contactList[activeChatContactIdx];
|
|
||||||
final remoteIdentityPublicKey =
|
|
||||||
proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey);
|
|
||||||
final remoteConversationRecordKey = proto.TypedKeyProto.fromProto(
|
|
||||||
activeChatContact.remoteConversationRecordKey);
|
|
||||||
|
|
||||||
await getRemoteConversationMessages(
|
|
||||||
activeAccountInfo: activeAccountInfo,
|
|
||||||
remoteIdentityPublicKey: remoteIdentityPublicKey,
|
|
||||||
remoteConversationRecordKey: remoteConversationRecordKey);
|
|
||||||
|
|
||||||
// xxx add messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore: prefer_expression_function_bodies
|
|
||||||
Widget buildPhone(BuildContext context) {
|
|
||||||
//
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent, elevation: 4, child: MainPager());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore: prefer_expression_function_bodies
|
|
||||||
Widget buildTabletLeftPane(BuildContext context) {
|
|
||||||
//
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent, elevation: 4, child: MainPager());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore: prefer_expression_function_bodies
|
|
||||||
Widget buildTabletRightPane(BuildContext context) {
|
|
||||||
//
|
|
||||||
return buildChatComponent(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildChatComponent(BuildContext context) {
|
|
||||||
final contactList = ref.watch(fetchContactListProvider).asData?.value ??
|
final contactList = ref.watch(fetchContactListProvider).asData?.value ??
|
||||||
const IListConst([]);
|
const IListConst([]);
|
||||||
|
|
||||||
@ -218,6 +57,47 @@ class HomePageState extends ConsumerState<HomePage>
|
|||||||
activeChat: activeChat,
|
activeChat: activeChat,
|
||||||
activeChatContact: activeChatContact);
|
activeChatContact: activeChatContact);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomePageState extends ConsumerState<HomePage>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
final _unfocusNode = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
setState(() {});
|
||||||
|
await ref.read(windowControlProvider.notifier).changeWindowSetup(
|
||||||
|
TitleBarStyle.normal, OrientationCapability.normal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unfocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer_expression_function_bodies
|
||||||
|
Widget buildPhone(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent, elevation: 4, child: MainPager());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer_expression_function_bodies
|
||||||
|
Widget buildTabletLeftPane(BuildContext context) {
|
||||||
|
//
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent, elevation: 4, child: MainPager());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer_expression_function_bodies
|
||||||
|
Widget buildTabletRightPane(BuildContext context) {
|
||||||
|
//
|
||||||
|
return HomePage.buildChatComponent(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
// ignore: prefer_expression_function_bodies
|
// ignore: prefer_expression_function_bodies
|
||||||
Widget buildTablet(BuildContext context) {
|
Widget buildTablet(BuildContext context) {
|
||||||
|
@ -34,8 +34,8 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
|
|
||||||
final _unfocusNode = FocusNode();
|
final _unfocusNode = FocusNode();
|
||||||
|
|
||||||
final pageController = PageController();
|
|
||||||
var _currentPage = 0;
|
var _currentPage = 0;
|
||||||
|
final pageController = PageController();
|
||||||
|
|
||||||
final _selectedIconList = <IconData>[Icons.person, Icons.chat];
|
final _selectedIconList = <IconData>[Icons.person, Icons.chat];
|
||||||
// final _unselectedIconList = <IconData>[
|
// final _unselectedIconList = <IconData>[
|
||||||
@ -235,6 +235,11 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
onNotification: onScrollNotification,
|
onNotification: onScrollNotification,
|
||||||
child: PageView(
|
child: PageView(
|
||||||
controller: pageController,
|
controller: pageController,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentPage = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
//physics: const NeverScrollableScrollPhysics(),
|
//physics: const NeverScrollableScrollPhysics(),
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
_bottomBarPages.length, (index) => _bottomBarPages[index]),
|
_bottomBarPages.length, (index) => _bottomBarPages[index]),
|
||||||
@ -271,9 +276,6 @@ class MainPagerState extends ConsumerState<MainPager>
|
|||||||
onTap: (index) async {
|
onTap: (index) async {
|
||||||
await pageController.animateToPage(index,
|
await pageController.animateToPage(index,
|
||||||
duration: 250.ms, curve: Curves.easeInOut);
|
duration: 250.ms, curve: Curves.easeInOut);
|
||||||
setState(() {
|
|
||||||
_currentPage = index;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import '../entities/identity.dart';
|
|||||||
import '../entities/proto.dart' as proto;
|
import '../entities/proto.dart' as proto;
|
||||||
import '../entities/proto.dart' show Conversation;
|
import '../entities/proto.dart' show Conversation;
|
||||||
|
|
||||||
|
import '../log/loggy.dart';
|
||||||
import '../veilid_support/veilid_support.dart';
|
import '../veilid_support/veilid_support.dart';
|
||||||
import 'account.dart';
|
import 'account.dart';
|
||||||
|
|
||||||
@ -194,6 +195,71 @@ Future<void> addLocalConversationMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> mergeLocalConversationMessages(
|
||||||
|
{required ActiveAccountInfo activeAccountInfo,
|
||||||
|
required TypedKey localConversationRecordKey,
|
||||||
|
required TypedKey remoteIdentityPublicKey,
|
||||||
|
required IList<proto.Message> newMessages}) async {
|
||||||
|
final conversation = await readLocalConversation(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
localConversationRecordKey: localConversationRecordKey,
|
||||||
|
remoteIdentityPublicKey: remoteIdentityPublicKey);
|
||||||
|
if (conversation == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool changed = false;
|
||||||
|
final messagesRecordKey =
|
||||||
|
proto.TypedKeyProto.fromProto(conversation.messages);
|
||||||
|
final crypto = await getConversationCrypto(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
remoteIdentityPublicKey: remoteIdentityPublicKey);
|
||||||
|
final writer = getConversationWriter(activeAccountInfo: activeAccountInfo);
|
||||||
|
|
||||||
|
newMessages = newMessages.sort((a, b) => Timestamp.fromInt64(a.timestamp)
|
||||||
|
.compareTo(Timestamp.fromInt64(b.timestamp)));
|
||||||
|
|
||||||
|
await (await DHTShortArray.openWrite(messagesRecordKey, writer,
|
||||||
|
parent: localConversationRecordKey, crypto: crypto))
|
||||||
|
.scope((messages) async {
|
||||||
|
// Ensure newMessages is sorted by timestamp
|
||||||
|
newMessages =
|
||||||
|
newMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
// Existing messages will always be sorted by timestamp so merging is easy
|
||||||
|
var pos = 0;
|
||||||
|
outer:
|
||||||
|
for (final newMessage in newMessages) {
|
||||||
|
var skip = false;
|
||||||
|
while (pos < messages.length) {
|
||||||
|
final m = await messages.getItemProtobuf(proto.Message.fromBuffer, pos);
|
||||||
|
if (m == null) {
|
||||||
|
log.error('unable to get message #$pos');
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timestamp to insert is less than
|
||||||
|
// the current position, insert it here
|
||||||
|
final newTs = Timestamp.fromInt64(newMessage.timestamp);
|
||||||
|
final curTs = Timestamp.fromInt64(m.timestamp);
|
||||||
|
final cmp = newTs.compareTo(curTs);
|
||||||
|
if (cmp < 0) {
|
||||||
|
break;
|
||||||
|
} else if (cmp == 0) {
|
||||||
|
skip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
// Insert at this position
|
||||||
|
if (!skip) {
|
||||||
|
await messages.tryInsertItem(pos, newMessage.writeToBuffer());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
Future<IList<proto.Message>?> getLocalConversationMessages({
|
Future<IList<proto.Message>?> getLocalConversationMessages({
|
||||||
required ActiveAccountInfo activeAccountInfo,
|
required ActiveAccountInfo activeAccountInfo,
|
||||||
required TypedKey localConversationRecordKey,
|
required TypedKey localConversationRecordKey,
|
||||||
|
@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../pages/chat_only_page.dart';
|
||||||
import '../pages/home.dart';
|
import '../pages/home.dart';
|
||||||
import '../pages/index.dart';
|
import '../pages/index.dart';
|
||||||
import '../pages/new_account.dart';
|
import '../pages/new_account.dart';
|
||||||
|
import '../providers/chat.dart';
|
||||||
import '../providers/local_accounts.dart';
|
import '../providers/local_accounts.dart';
|
||||||
|
import '../tools/responsive.dart';
|
||||||
|
|
||||||
part 'router_notifier.g.dart';
|
part 'router_notifier.g.dart';
|
||||||
|
|
||||||
@ -16,6 +19,7 @@ class RouterNotifier extends _$RouterNotifier implements Listenable {
|
|||||||
|
|
||||||
/// Do we need to make or import an account immediately?
|
/// Do we need to make or import an account immediately?
|
||||||
bool hasAnyAccount = false;
|
bool hasAnyAccount = false;
|
||||||
|
bool hasActiveChat = false;
|
||||||
|
|
||||||
/// AsyncNotifier build
|
/// AsyncNotifier build
|
||||||
@override
|
@override
|
||||||
@ -23,6 +27,7 @@ class RouterNotifier extends _$RouterNotifier implements Listenable {
|
|||||||
hasAnyAccount = await ref.watch(
|
hasAnyAccount = await ref.watch(
|
||||||
localAccountsProvider.selectAsync((data) => data.isNotEmpty),
|
localAccountsProvider.selectAsync((data) => data.isNotEmpty),
|
||||||
);
|
);
|
||||||
|
hasActiveChat = ref.watch(activeChatStateProvider).asData?.value != null;
|
||||||
|
|
||||||
// When this notifier's state changes, inform GoRouter
|
// When this notifier's state changes, inform GoRouter
|
||||||
ref.listenSelf((_, __) {
|
ref.listenSelf((_, __) {
|
||||||
@ -46,6 +51,36 @@ class RouterNotifier extends _$RouterNotifier implements Listenable {
|
|||||||
return hasAnyAccount ? HomePage.path : NewAccountPage.path;
|
return hasAnyAccount ? HomePage.path : NewAccountPage.path;
|
||||||
case NewAccountPage.path:
|
case NewAccountPage.path:
|
||||||
return hasAnyAccount ? HomePage.path : null;
|
return hasAnyAccount ? HomePage.path : null;
|
||||||
|
case HomePage.path:
|
||||||
|
if (!hasAnyAccount) {
|
||||||
|
return NewAccountPage.path;
|
||||||
|
}
|
||||||
|
if (responsiveVisibility(
|
||||||
|
context: context,
|
||||||
|
tablet: false,
|
||||||
|
tabletLandscape: false,
|
||||||
|
desktop: false)) {
|
||||||
|
if (hasActiveChat) {
|
||||||
|
return ChatOnlyPage.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case ChatOnlyPage.path:
|
||||||
|
if (!hasAnyAccount) {
|
||||||
|
return NewAccountPage.path;
|
||||||
|
}
|
||||||
|
if (responsiveVisibility(
|
||||||
|
context: context,
|
||||||
|
tablet: false,
|
||||||
|
tabletLandscape: false,
|
||||||
|
desktop: false)) {
|
||||||
|
if (!hasActiveChat) {
|
||||||
|
return HomePage.path;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return HomePage.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
return hasAnyAccount ? null : NewAccountPage.path;
|
return hasAnyAccount ? null : NewAccountPage.path;
|
||||||
}
|
}
|
||||||
@ -65,6 +100,10 @@ class RouterNotifier extends _$RouterNotifier implements Listenable {
|
|||||||
path: NewAccountPage.path,
|
path: NewAccountPage.path,
|
||||||
builder: (context, state) => const NewAccountPage(),
|
builder: (context, state) => const NewAccountPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ChatOnlyPage.path,
|
||||||
|
builder: (context, state) => const ChatOnlyPage(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
165
lib/tick.dart
Normal file
165
lib/tick.dart
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// XXX Eliminate this when we have ValueChanged
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../entities/proto.dart' as proto;
|
||||||
|
import 'providers/account.dart';
|
||||||
|
import 'providers/chat.dart';
|
||||||
|
import 'providers/contact.dart';
|
||||||
|
import 'providers/contact_invite.dart';
|
||||||
|
import 'providers/conversation.dart';
|
||||||
|
|
||||||
|
const int ticksPerContactInvitationCheck = 5;
|
||||||
|
const int ticksPerNewMessageCheck = 5;
|
||||||
|
|
||||||
|
class BackgroundTicker extends ConsumerStatefulWidget {
|
||||||
|
const BackgroundTicker({required this.builder, super.key});
|
||||||
|
|
||||||
|
final Widget Function(BuildContext) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
BackgroundTickerState createState() => BackgroundTickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundTickerState extends ConsumerState<BackgroundTicker> {
|
||||||
|
Timer? _tickTimer;
|
||||||
|
bool _inTick = false;
|
||||||
|
int _contactInvitationCheckTick = 0;
|
||||||
|
int _newMessageCheckTick = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tickTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (!_inTick) {
|
||||||
|
unawaited(_onTick());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final tickTimer = _tickTimer;
|
||||||
|
if (tickTimer != null) {
|
||||||
|
tickTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: prefer_expression_function_bodies
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.builder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTick() async {
|
||||||
|
_inTick = true;
|
||||||
|
try {
|
||||||
|
final unord = <Future<void>>[];
|
||||||
|
// Check extant contact invitations once every N seconds
|
||||||
|
_contactInvitationCheckTick += 1;
|
||||||
|
if (_contactInvitationCheckTick >= ticksPerContactInvitationCheck) {
|
||||||
|
_contactInvitationCheckTick = 0;
|
||||||
|
unord.add(_doContactInvitationCheck());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check new messages once every N seconds
|
||||||
|
_newMessageCheckTick += 1;
|
||||||
|
if (_newMessageCheckTick >= ticksPerNewMessageCheck) {
|
||||||
|
_newMessageCheckTick = 0;
|
||||||
|
unord.add(_doNewMessageCheck());
|
||||||
|
}
|
||||||
|
if (unord.isNotEmpty) {
|
||||||
|
await Future.wait(unord);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_inTick = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doContactInvitationCheck() async {
|
||||||
|
final contactInvitationRecords =
|
||||||
|
await ref.read(fetchContactInvitationRecordsProvider.future);
|
||||||
|
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
||||||
|
if (contactInvitationRecords == null || activeAccountInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final allChecks = <Future<void>>[];
|
||||||
|
for (final contactInvitationRecord in contactInvitationRecords) {
|
||||||
|
allChecks.add(() async {
|
||||||
|
final acceptReject = await checkAcceptRejectContact(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
contactInvitationRecord: contactInvitationRecord);
|
||||||
|
if (acceptReject != null) {
|
||||||
|
final acceptedContact = acceptReject.acceptedContact;
|
||||||
|
if (acceptedContact != null) {
|
||||||
|
// Accept
|
||||||
|
await createContact(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
profile: acceptedContact.profile,
|
||||||
|
remoteIdentity: acceptedContact.remoteIdentity,
|
||||||
|
remoteConversationRecordKey:
|
||||||
|
acceptedContact.remoteConversationRecordKey,
|
||||||
|
localConversationRecordKey:
|
||||||
|
acceptedContact.localConversationRecordKey,
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
..invalidate(fetchContactInvitationRecordsProvider)
|
||||||
|
..invalidate(fetchContactListProvider);
|
||||||
|
} else {
|
||||||
|
// Reject
|
||||||
|
ref.invalidate(fetchContactInvitationRecordsProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
await Future.wait(allChecks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doNewMessageCheck() async {
|
||||||
|
final activeChat = activeChatState.currentState;
|
||||||
|
if (activeChat == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future);
|
||||||
|
if (activeAccountInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contactList = ref.read(fetchContactListProvider).asData?.value ??
|
||||||
|
const IListConst([]);
|
||||||
|
|
||||||
|
final activeChatContactIdx = contactList.indexWhere(
|
||||||
|
(c) =>
|
||||||
|
proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) ==
|
||||||
|
activeChat,
|
||||||
|
);
|
||||||
|
if (activeChatContactIdx == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final activeChatContact = contactList[activeChatContactIdx];
|
||||||
|
final remoteIdentityPublicKey =
|
||||||
|
proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey);
|
||||||
|
final remoteConversationRecordKey = proto.TypedKeyProto.fromProto(
|
||||||
|
activeChatContact.remoteConversationRecordKey);
|
||||||
|
final localConversationRecordKey = proto.TypedKeyProto.fromProto(
|
||||||
|
activeChatContact.localConversationRecordKey);
|
||||||
|
|
||||||
|
final newMessages = await getRemoteConversationMessages(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
remoteIdentityPublicKey: remoteIdentityPublicKey,
|
||||||
|
remoteConversationRecordKey: remoteConversationRecordKey);
|
||||||
|
if (newMessages != null) {
|
||||||
|
await mergeLocalConversationMessages(
|
||||||
|
activeAccountInfo: activeAccountInfo,
|
||||||
|
localConversationRecordKey: localConversationRecordKey,
|
||||||
|
remoteIdentityPublicKey: remoteIdentityPublicKey,
|
||||||
|
newMessages: newMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -66,7 +66,7 @@ class DHTRecord {
|
|||||||
_valid = false;
|
_valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<T> scope<T>(FutureOr<T> Function(DHTRecord) scopeFunction) async {
|
Future<T> scope<T>(Future<T> Function(DHTRecord) scopeFunction) async {
|
||||||
try {
|
try {
|
||||||
return await scopeFunction(this);
|
return await scopeFunction(this);
|
||||||
} finally {
|
} finally {
|
||||||
@ -76,8 +76,7 @@ class DHTRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<T> deleteScope<T>(
|
Future<T> deleteScope<T>(Future<T> Function(DHTRecord) scopeFunction) async {
|
||||||
FutureOr<T> Function(DHTRecord) scopeFunction) async {
|
|
||||||
try {
|
try {
|
||||||
final out = await scopeFunction(this);
|
final out = await scopeFunction(this);
|
||||||
if (_valid && _open) {
|
if (_valid && _open) {
|
||||||
|
@ -42,10 +42,10 @@ class DHTShortArray {
|
|||||||
}
|
}
|
||||||
stride = oCnt - 1;
|
stride = oCnt - 1;
|
||||||
case DHTSchemaSMPL(oCnt: final oCnt, members: final members):
|
case DHTSchemaSMPL(oCnt: final oCnt, members: final members):
|
||||||
if (oCnt != 0 || members.length != 1 || members[1].mCnt <= 1) {
|
if (oCnt != 0 || members.length != 1 || members[0].mCnt <= 1) {
|
||||||
throw StateError('Invalid SMPL schema in DHTShortArray');
|
throw StateError('Invalid SMPL schema in DHTShortArray');
|
||||||
}
|
}
|
||||||
stride = members[1].mCnt - 1;
|
stride = members[0].mCnt - 1;
|
||||||
}
|
}
|
||||||
assert(stride <= maxElements, 'stride too long');
|
assert(stride <= maxElements, 'stride too long');
|
||||||
_stride = stride;
|
_stride = stride;
|
||||||
@ -117,7 +117,7 @@ class DHTShortArray {
|
|||||||
parent: parent, routingContext: routingContext, crypto: crypto);
|
parent: parent, routingContext: routingContext, crypto: crypto);
|
||||||
try {
|
try {
|
||||||
final dhtShortArray = DHTShortArray._(headRecord: dhtRecord);
|
final dhtShortArray = DHTShortArray._(headRecord: dhtRecord);
|
||||||
await dhtShortArray._refreshHead();
|
await dhtShortArray._refreshHead(forceRefresh: true);
|
||||||
return dhtShortArray;
|
return dhtShortArray;
|
||||||
} on Exception catch (_) {
|
} on Exception catch (_) {
|
||||||
await dhtRecord.close();
|
await dhtRecord.close();
|
||||||
@ -137,7 +137,7 @@ class DHTShortArray {
|
|||||||
parent: parent, routingContext: routingContext, crypto: crypto);
|
parent: parent, routingContext: routingContext, crypto: crypto);
|
||||||
try {
|
try {
|
||||||
final dhtShortArray = DHTShortArray._(headRecord: dhtRecord);
|
final dhtShortArray = DHTShortArray._(headRecord: dhtRecord);
|
||||||
await dhtShortArray._refreshHead();
|
await dhtShortArray._refreshHead(forceRefresh: true);
|
||||||
return dhtShortArray;
|
return dhtShortArray;
|
||||||
} on Exception catch (_) {
|
} on Exception catch (_) {
|
||||||
await dhtRecord.close();
|
await dhtRecord.close();
|
||||||
@ -315,7 +315,7 @@ class DHTShortArray {
|
|||||||
await Future.wait(futures);
|
await Future.wait(futures);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<T> scope<T>(FutureOr<T> Function(DHTShortArray) scopeFunction) async {
|
Future<T> scope<T>(Future<T> Function(DHTShortArray) scopeFunction) async {
|
||||||
try {
|
try {
|
||||||
return await scopeFunction(this);
|
return await scopeFunction(this);
|
||||||
} finally {
|
} finally {
|
||||||
@ -324,7 +324,7 @@ class DHTShortArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<T> deleteScope<T>(
|
Future<T> deleteScope<T>(
|
||||||
FutureOr<T> Function(DHTShortArray) scopeFunction) async {
|
Future<T> Function(DHTShortArray) scopeFunction) async {
|
||||||
try {
|
try {
|
||||||
final out = await scopeFunction(this);
|
final out = await scopeFunction(this);
|
||||||
await close();
|
await close();
|
||||||
|
Loading…
Reference in New Issue
Block a user