diff --git a/lib/api_client.dart b/lib/api_client.dart index 74a1bbf..ac128e8 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:contacts_plus_plus/neos_hub.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -124,9 +125,13 @@ class ApiClient { class ClientHolder extends InheritedWidget { final ApiClient client; + late final NeosHub hub; ClientHolder({super.key, required AuthenticationData authenticationData, required super.child}) - : client = ApiClient(authenticationData: authenticationData); + : client = ApiClient(authenticationData: authenticationData) { + hub = NeosHub(apiClient: client); + } + static ClientHolder? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index e87f418..5744745 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -53,3 +53,13 @@ class Aux { return fullUri; } } + + +extension Unique on List { + List unique([Id Function(E element)? id, bool inplace = true]) { + final ids = {}; + var list = inplace ? this : List.from(this); + list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); + return list; + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index af81eff..dbb83f0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'package:contacts_plus_plus/models/message.dart'; -import 'package:contacts_plus_plus/neos_hub.dart'; import 'package:contacts_plus_plus/widgets/home_screen.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:flutter/material.dart'; @@ -20,34 +18,29 @@ class ContactsPlusPlus extends StatefulWidget { class _ContactsPlusPlusState extends State { final Typography _typography = Typography.material2021(platform: TargetPlatform.android); AuthenticationData _authData = AuthenticationData.unauthenticated(); - final Map _messageCache = {}; @override Widget build(BuildContext context) { - return HubHolder( - messageCache: _messageCache, + return ClientHolder( authenticationData: _authData, - child: ClientHolder( - authenticationData: _authData, - child: MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Contacts++', - theme: ThemeData( + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts++', + theme: ThemeData( useMaterial3: true, textTheme: _typography.white, colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) - ), - home: _authData.isAuthenticated ? - const HomeScreen() : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, - ), + ), + home: _authData.isAuthenticated ? + const HomeScreen() : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) async { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, ), ), ); diff --git a/lib/models/message.dart b/lib/models/message.dart index e5a6f4d..fae510b 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:developer'; +import 'package:contacts_plus_plus/api_client.dart'; +import 'package:contacts_plus_plus/apis/message_api.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/config.dart'; import 'package:uuid/uuid.dart'; @@ -95,10 +98,54 @@ class Message extends Comparable { } class MessageCache { - final List _messages; + final List _messages = []; + final ApiClient _apiClient; + final String _userId; + Future? currentOperation; List get messages => _messages; - MessageCache({required List messages}) - : _messages = messages; + MessageCache({required ApiClient apiClient, required String userId}) : _apiClient = apiClient, _userId = userId; + + /// Adds a message to the cache, ensuring integrity of the message cache. + /// Returns true if the message was inserted into the cache and false if an existing message was overwritten. + bool addMessage(Message message) { + final existingIdx = _messages.indexWhere((element) => element.id == message.id); + if (existingIdx == -1) { + _messages.add(message); + _ensureIntegrity(); + } else { + _messages[existingIdx] = message; + _messages.sort(); // Overwriting can't create duplicates, so we just need to sort. + } + return existingIdx == -1; + } + + /// Sets the state of an existing message by id. + /// Returns true if a message with the specified id exists and was modified and false if the message doesn't exist. + bool setMessageState(String messageId, MessageState state) { + final messageIdx = _messages.indexWhere((element) => element.id == messageId); + if (messageIdx == -1) return false; + _messages[messageIdx] = _messages[messageIdx].copyWith(state: state); + return true; + } + + Future loadOlderMessages() async { + final oldest = _messages.first; + final olderMessages = await MessageApi.getUserMessages(_apiClient, userId: _userId, fromTime: oldest.sendTime); + _messages.insertAll(0, olderMessages); + _ensureIntegrity(); + } + + Future loadInitialMessages() async { + final messages = await MessageApi.getUserMessages(_apiClient, userId: _userId); + _messages.clear(); + _messages.addAll(messages); + _ensureIntegrity(); + } + + void _ensureIntegrity() { + _messages.sort(); + _messages.unique((element) => element.id); + } } \ No newline at end of file diff --git a/lib/neos_hub.dart b/lib/neos_hub.dart index db53791..8c0ecc5 100644 --- a/lib/neos_hub.dart +++ b/lib/neos_hub.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:developer'; -import 'package:contacts_plus_plus/models/authentication_data.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; @@ -32,30 +31,34 @@ enum EventTarget { class NeosHub { static const String eofChar = ""; static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar"; - final AuthenticationData _authenticationData; - final Map _messageCache; + final ApiClient _apiClient; + final Map _messageCache = {}; final Map _updateListeners = {}; WebSocketChannel? _wsChannel; - NeosHub({required AuthenticationData authenticationData, required Map messageCache}) - : _authenticationData = authenticationData, _messageCache = messageCache { + NeosHub({required ApiClient apiClient}) + : _apiClient = apiClient { start(); } - MessageCache? getCache(String index) => _messageCache[index]; - - void setCache(String index, List messages) { - _messageCache[index] = MessageCache(messages: messages); + Future getCache(String userId) async { + var cache = _messageCache[userId]; + if (cache == null){ + cache = MessageCache(apiClient: _apiClient, userId: userId); + await cache.loadInitialMessages(); + _messageCache[userId] = cache; + } + return cache; } Future start() async { - if (!_authenticationData.isAuthenticated) { + if (!_apiClient.isAuthenticated) { log("Hub not authenticated."); return; } final response = await http.post( Uri.parse("${Config.neosHubUrl}/negotiate"), - headers: _authenticationData.authorizationHeader, + headers: _apiClient.authorizationHeader, ); ApiClient.checkResponse(response); @@ -94,7 +97,7 @@ class NeosHub { } } - void _handleMessageEvent(body) { + void _handleMessageEvent(body) async { final target = EventTarget.parse(body["target"]); final args = body["arguments"]; switch (target) { @@ -104,49 +107,30 @@ class NeosHub { case EventTarget.messageSent: final msg = args[0]; final message = Message.fromMap(msg, withState: MessageState.sent); - var cache = getCache(message.recipientId); - if (cache == null) { - setCache(message.recipientId, [message]); - } else { - // Possible race condition - final existingIndex = cache.messages.indexWhere((element) => element.id == message.id); - if (existingIndex == -1) { - cache.messages.add(message); - } else { - cache.messages[existingIndex] = message; - } - cache.messages.sort(); - } + final cache = await getCache(message.recipientId); + cache.addMessage(message); notifyListener(message.recipientId); break; case EventTarget.messageReceived: final msg = args[0]; final message = Message.fromMap(msg); - var cache = getCache(message.senderId); - if (cache == null) { - setCache(message.senderId, [message]); - } else { - cache.messages.add(message); - cache.messages.sort(); - } + final cache = await getCache(message.senderId); + cache.addMessage(message); notifyListener(message.senderId); break; case EventTarget.messagesRead: final messageIds = args[0]["ids"] as List; final recipientId = args[0]["recipientId"]; - final cache = getCache(recipientId ?? ""); - if (cache == null) return; + final cache = await getCache(recipientId ?? ""); for (var id in messageIds) { - final idx = cache.messages.indexWhere((element) => element.id == id); - if (idx == -1) continue; - cache.messages[idx] = cache.messages[idx].copyWith(state: MessageState.read); + cache.setMessageState(id, MessageState.read); } notifyListener(recipientId); break; } } - void sendMessage(Message message) { + void sendMessage(Message message) async { if (_wsChannel == null) throw "Neos Hub is not connected"; final msgBody = message.toMap(); final data = { @@ -156,35 +140,9 @@ class NeosHub { msgBody ], }; + final cache = await getCache(message.recipientId); + cache.messages.add(message); _wsChannel!.sink.add(jsonEncode(data)+eofChar); - var cache = _messageCache[message.recipientId]; - if (cache == null) { - setCache(message.recipientId, [message]); - cache = getCache(message.recipientId); - } else { - cache.messages.add(message); - } notifyListener(message.recipientId); } } - -class HubHolder extends InheritedWidget { - HubHolder({super.key, required AuthenticationData authenticationData, required Map messageCache, required super.child}) - : hub = NeosHub(authenticationData: authenticationData, messageCache: messageCache); - - final NeosHub hub; - - static HubHolder? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } - - static HubHolder of(BuildContext context) { - final HubHolder? result = maybeOf(context); - assert(result != null, 'No HubHolder found in context'); - return result!; - } - - @override - bool updateShouldNotify(covariant HubHolder oldWidget) => hub._authenticationData != oldWidget.hub._authenticationData - || hub._messageCache != oldWidget.hub._messageCache; -} \ No newline at end of file diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index 8023546..e7ed197 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -1,14 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus_plus/api_client.dart'; -import 'package:contacts_plus_plus/apis/message_api.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/session.dart'; -import 'package:contacts_plus_plus/neos_hub.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; class Messages extends StatefulWidget { @@ -21,54 +18,34 @@ class Messages extends StatefulWidget { } class _MessagesState extends State { - Future>? _messagesFuture; + Future? _messageCacheFuture; final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); ClientHolder? _clientHolder; - HubHolder? _cacheHolder; bool _isSendable = false; bool _showSessionListChevron = false; double get _shevronOpacity => _showSessionListChevron ? 1.0 : 0.0; - void _loadMessages() { - final cache = _cacheHolder?.hub.getCache(widget.friend.id); - if (cache != null) { - _messagesFuture = Future(() => cache.messages); - } else { - _messagesFuture = MessageApi.getUserMessages( - _clientHolder!.client, userId: widget.friend.id) - ..then((value) { - final list = value.toList(); - list.sort(); - _cacheHolder?.hub.setCache(widget.friend.id, list); - _cacheHolder?.hub.registerListener( - widget.friend.id, () => setState(() {})); - return list; - }); - } - } - @override void didChangeDependencies() { super.didChangeDependencies(); final clientHolder = ClientHolder.of(context); - bool dirty = false; if (_clientHolder != clientHolder) { _clientHolder = clientHolder; - dirty = true; } - final cacheHolder = HubHolder.of(context); - if (_cacheHolder != cacheHolder) { - _cacheHolder = cacheHolder; - dirty = true; - } - if (dirty) _loadMessages(); + _loadMessages(); + } + + void _loadMessages() { + _messageCacheFuture = _clientHolder?.hub.getCache(widget.friend.id); + _clientHolder?.hub.registerListener( + widget.friend.id, () => setState(() {})); } @override void dispose() { - _cacheHolder?.hub.unregisterListener(widget.friend.id); + _clientHolder?.hub.unregisterListener(widget.friend.id); _messageTextController.dispose(); _sessionListScrollController.dispose(); super.dispose(); @@ -107,52 +84,19 @@ class _MessagesState extends State { title: Text(widget.friend.username), scrolledUnderElevation: 0.0, backgroundColor: appBarColor, - /*bottom: sessions.isEmpty ? null : PreferredSize( - preferredSize: Size.fromHeight(_headerHeight), - child: Column( - children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(e.name), - Text("${e.sessionUsers.length} users active"), - ], - ), - ), - const Spacer(), - if (sessions.length > 1) TextButton(onPressed: (){ - setState(() { - _headerExpanded = !_headerExpanded; - }); - }, child: Text("+${sessions.length-1}"),) - ], - )).toList(), - ), - ),*/ ), body: Stack( children: [ FutureBuilder( - future: _messagesFuture, + future: _messageCacheFuture, builder: (context, snapshot) { if (snapshot.hasData) { - final data = _cacheHolder?.hub - .getCache(widget.friend.id) - ?.messages ?? []; + final cache = snapshot.data as MessageCache; return ListView.builder( reverse: true, - itemCount: data.length, + itemCount: cache.messages.length, itemBuilder: (context, index) { - final entry = data.elementAt(index); + final entry = cache.messages[index]; return entry.senderId == apiClient.userId ? MyMessageBubble(message: entry) : OtherMessageBubble(message: entry); @@ -298,7 +242,7 @@ class _MessagesState extends State { padding: const EdgeInsets.only(left: 8, right: 4.0), child: IconButton( splashRadius: 24, - onPressed: _isSendable ? () async { + onPressed: _isSendable && _clientHolder != null ? () async { setState(() { _isSendable = false; }); @@ -311,10 +255,7 @@ class _MessagesState extends State { sendTime: DateTime.now().toUtc(), ); try { - if (_cacheHolder == null) { - throw "Hub not connected."; - } - _cacheHolder!.hub.sendMessage(message); + _clientHolder!.hub.sendMessage(message); _messageTextController.clear(); setState(() {}); } catch (e) {