diff --git a/lib/src/database/daos/contacts.dao.dart b/lib/src/database/daos/contacts.dao.dart index d8a541fc..f649b2f7 100644 --- a/lib/src/database/daos/contacts.dao.dart +++ b/lib/src/database/daos/contacts.dao.dart @@ -36,6 +36,11 @@ class ContactsDao extends DatabaseAccessor with _$ContactsDaoMixin { return select(contacts)..where((t) => t.userId.equals(userId)); } + Stream> watchContactsByIds(Set userIds) { + if (userIds.isEmpty) return Stream.value(const []); + return (select(contacts)..where((t) => t.userId.isIn(userIds))).watch(); + } + Future getContactById(int userId) async { return (select( contacts, diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 8de76450..2be043c7 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -86,9 +86,18 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { await into(groupHistories).insert(insertAction); } - Stream> watchGroupActions(String groupId) { + Stream> watchGroupActions( + String groupId, { + DateTime? since, + }) { return (select(groupHistories) - ..where((t) => t.groupId.equals(groupId)) + ..where( + (t) => + t.groupId.equals(groupId) & + (since == null + ? const Constant(true) + : t.actionAt.isBiggerOrEqualValue(since)), + ) ..orderBy([(t) => OrderingTerm.asc(t.actionAt)])) .watch(); } diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 5697cd6c..c16d4edc 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -166,6 +166,13 @@ class MediaFilesDao extends DatabaseAccessor .watch(); } + Stream> watchMediaFilesByIds(Set mediaIds) { + if (mediaIds.isEmpty) return Stream.value(const []); + return (select( + mediaFiles, + )..where((file) => file.mediaId.isIn(mediaIds))).watch(); + } + Stream> watchMediaFilesForGroup(String groupId) { final query = select(mediaFiles).join([ innerJoin( diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 9e37527b..96d52672 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -142,7 +142,10 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .not() | mediaFiles.downloadState.isNull()), ) - ..orderBy([OrderingTerm.desc(messages.createdAt)]) + ..orderBy([ + OrderingTerm.desc(messages.createdAt), + OrderingTerm.desc(messages.messageId), + ]) ..limit(1); return query.map((row) => row.readTable(messages)).watchSingleOrNull(); @@ -180,7 +183,10 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ); } - Future>> watchByGroupId(String groupId) async { + Future>> watchByGroupId( + String groupId, { + int limit = 100, + }) async { final group = await twonlyDB.groupsDao.getGroup(groupId); final deletionTime = clock.now().subtract( Duration( @@ -211,9 +217,61 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .equals(DownloadState.reuploadRequested.name) .not()))), ) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + ..orderBy([OrderingTerm.desc(messages.createdAt)]) + ..limit(limit); - return query.map((row) => row.readTable(messages)).watch(); + return query + .map((row) => row.readTable(messages)) + .watch() + .map((items) => items.reversed.toList()); + } + + Future> getMessagesBefore( + String groupId, + DateTime before, { + required String beforeMessageId, + int limit = 100, + }) async { + final group = await twonlyDB.groupsDao.getGroup(groupId); + final deletionTime = clock.now().subtract( + Duration(milliseconds: group!.deleteMessagesAfterMilliseconds), + ); + final query = + select(messages).join([ + leftOuterJoin( + mediaFiles, + mediaFiles.mediaId.equalsExp(messages.mediaId), + ), + ]) + ..where( + messages.groupId.equals(groupId) & + (messages.createdAt.isSmallerThanValue(before) | + (messages.createdAt.equals(before) & + messages.messageId.isSmallerThanValue( + beforeMessageId, + ))) & + (messages.openedAt.isBiggerThanValue(deletionTime) | + messages.openedAt.isNull() | + messages.mediaStored.equals(true)) & + (messages.isDeletedFromSender.equals(true) | + (messages.type.equals(MessageType.text.name).not() & + messages.type.equals(MessageType.media.name).not()) | + (messages.type.equals(MessageType.text.name) & + messages.content.isNotNull()) | + (messages.type.equals(MessageType.media.name) & + messages.mediaId.isNotNull() & + (mediaFiles.downloadState.isNull() | + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not()))), + ) + ..orderBy([ + OrderingTerm.desc(messages.createdAt), + OrderingTerm.desc(messages.messageId), + ]) + ..limit(limit); + final items = await query.map((row) => row.readTable(messages)).get(); + return items.reversed.toList(); } Stream> watchMembersByGroupId(String groupId) { @@ -628,9 +686,19 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .watch(); } - Stream> watchMessageActionsForGroup( - String groupId, + Stream> watchAcknowledgementsForMessages( + Set messageIds, ) { + if (messageIds.isEmpty) return Stream.value(const []); + return (select(messageActions)..where( + (action) => + action.messageId.isIn(messageIds) & + action.type.equals(MessageActionType.ackByUserAt.name), + )) + .watch(); + } + + Stream> watchMessageActionsForGroup(String groupId) { final query = select(messageActions).join([ innerJoin( messages, diff --git a/lib/src/database/daos/reactions.dao.dart b/lib/src/database/daos/reactions.dao.dart index da183a34..ea9d6b7f 100644 --- a/lib/src/database/daos/reactions.dao.dart +++ b/lib/src/database/daos/reactions.dao.dart @@ -108,6 +108,14 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { .watch(); } + Stream> watchReactionsForMessages(Set messageIds) { + if (messageIds.isEmpty) return Stream.value(const []); + return (select(reactions) + ..where((reaction) => reaction.messageId.isIn(messageIds)) + ..orderBy([(reaction) => OrderingTerm.desc(reaction.createdAt)])) + .watch(); + } + Stream> watchReactionsForGroup(String groupId) { final query = select(reactions).join([ diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 90707294..27ed7ec8 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -296,11 +296,9 @@ class MediaFileService { bool get imagePreviewAvailable => mediaFile.hasThumbnail || - (thumbnailPath.existsSync() && thumbnailPath.lengthSync() > 0) || mediaFile.type == MediaType.audio || ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && - storedPath.existsSync() && - storedPath.lengthSync() > 0); + mediaFile.stored); Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); diff --git a/lib/src/visual/components/avatar_icon.comp.dart b/lib/src/visual/components/avatar_icon.comp.dart index 9fd79f1e..4ed70896 100644 --- a/lib/src/visual/components/avatar_icon.comp.dart +++ b/lib/src/visual/components/avatar_icon.comp.dart @@ -6,7 +6,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/avatars.dart'; -import 'package:twonly/src/utils/log.dart'; import 'package:vector_graphics/vector_graphics.dart'; class AvatarIcon extends StatefulWidget { @@ -34,6 +33,7 @@ class AvatarIcon extends StatefulWidget { class _AvatarIconState extends State { List _avatarContacts = []; + Set _contactsWithPngAvatar = {}; String? _myAvatarPath; StreamSubscription>? groupStream; @@ -59,9 +59,24 @@ class _AvatarIconState extends State { _avatarContacts = contacts .where((contact) => contact.avatarSvgCompressed != null) .toList(); + unawaited(_refreshAvatarFiles()); if (mounted) setState(() {}); } + Future _refreshAvatarFiles() async { + final available = {}; + for (final contact in _avatarContacts) { + final file = avatarPNGFile(contact.userId); + // Async file access keeps avatar discovery off the UI thread. + // ignore: avoid_slow_async_io + if (await file.exists()) { + available.add(contact.userId); + } + } + if (!mounted) return; + setState(() => _contactsWithPngAvatar = available); + } + @override void dispose() { groupStream?.cancel(); @@ -79,18 +94,13 @@ class _AvatarIconState extends State { Widget getAvatarForContact(Contact contact) { final avatarFile = avatarPNGFile(contact.userId); - if (avatarFile.existsSync()) { + if (_contactsWithPngAvatar.contains(contact.userId)) { return Image.file( avatarFile, errorBuilder: errorBuilder, ); } - Log.warn( - 'PNG avatar file for contact ${contact.userId} does not exist. Generating in background.', - ); - unawaited(createPushAvatars(forceForUserId: contact.userId)); - if (contact.avatarSvgCompressed != null) { return SvgPicture.string( getAvatarSvg(contact.avatarSvgCompressed!), @@ -119,6 +129,7 @@ class _AvatarIconState extends State { } } } + unawaited(_refreshAvatarFiles()); setState(() {}); }); } else if (widget.myAvatar) { @@ -132,6 +143,7 @@ class _AvatarIconState extends State { .listen((contact) { if (contact != null && contact.avatarSvgCompressed != null) { _avatarContacts = [contact]; + unawaited(_refreshAvatarFiles()); setState(() {}); } }); diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 68301e48..cf1ca96d 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -13,7 +13,9 @@ import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/services/api/messages.api.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -36,6 +38,7 @@ class _MessageAnimationState { bool hasReceivedFirstBatch = false; final HashSet knownMessageIds = HashSet(); final HashSet animateMessageIds = HashSet(); + final HashSet reportedOpenedMessageIds = HashSet(); } class _ChatViewData { @@ -46,10 +49,15 @@ class _ChatViewData { List chatItems = []; List allMessages = []; + List latestMessages = []; + List olderMessages = []; Map messagesById = {}; List groupActions = []; List galleryItems = []; Set galleryMessageIds = {}; + Set watchedMessageIds = {}; + Set watchedContactIds = {}; + DateTime? watchedGroupActionsSince; } class _ChatSubscriptions { @@ -83,11 +91,12 @@ class ChatMessagesView extends StatefulWidget { class _ChatMessagesViewState extends State with WidgetsBindingObserver { - HashSet alreadyReportedOpened = HashSet(); + static const _messagePageSize = 100; final _animationState = _MessageAnimationState(); final _subscriptions = _ChatSubscriptions(); final _data = _ChatViewData(); + final ValueNotifier _messageDataVersion = ValueNotifier(0); Group? _group; List _groupContacts = []; @@ -95,8 +104,12 @@ class _ChatMessagesViewState extends State GlobalKey verifyShieldKey = GlobalKey(); FocusNode? textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); + final ItemPositionsListener itemPositionsListener = + ItemPositionsListener.create(); int? focusedScrollItem; bool _receiverDeletedAccount = false; + Future? _olderMessagesLoad; + bool _hasMoreMessages = true; Timer? _nextTypingIndicator; @@ -105,12 +118,15 @@ class _ChatMessagesViewState extends State super.initState(); textFieldFocus = FocusNode(); WidgetsBinding.instance.addObserver(this); + itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded); initStreams(); } @override void dispose() { _subscriptions.cancelAll(); + _messageDataVersion.dispose(); + itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded); _nextTypingIndicator?.cancel(); try { textFieldFocus?.dispose(); @@ -126,6 +142,10 @@ class _ChatMessagesViewState extends State (ModalRoute.of(context)?.isCurrent ?? false); } + void _notifyMessageDataChanged() { + _messageDataVersion.value++; + } + Future initStreams() async { final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); _subscriptions.group = groupStream.listen((newGroup) { @@ -134,71 +154,18 @@ class _ChatMessagesViewState extends State setState(() { _group = newGroup; }); - - if (_subscriptions.groupActions == null) { - final actionsStream = twonlyDB.groupsDao.watchGroupActions( - newGroup.groupId, - ); - _subscriptions.groupActions = actionsStream.listen((update) async { - _data.groupActions = update; - await setMessages(_data.allMessages, update); - }); - - final contactsStream = twonlyDB.contactsDao.watchAllContacts(); - _subscriptions.contacts = contactsStream.listen((contacts) { - final contactMap = {}; - for (final contact in contacts) { - contactMap[contact.userId] = contact; - } - if (mounted) setState(() => _data.contactsById = contactMap); - }); - } }); final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId); _subscriptions.messages = msgStream.listen((update) async { - _data.allMessages = update; - _data.messagesById = { - for (final message in update) message.messageId: message, - }; - await setMessages(update, _data.groupActions); + _data.latestMessages = update; + if (_data.olderMessages.isEmpty) { + _hasMoreMessages = update.length == _messagePageSize; + } + await _applyLoadedMessages(reportOpened: true); _animationState.hasReceivedFirstBatch = true; }); - _subscriptions.media = twonlyDB.mediaFilesDao - .watchMediaFilesForGroup(widget.groupId) - .listen((mediaFiles) { - if (!mounted) return; - setState( - () => _data.mediaFilesById = { - for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile, - }, - ); - }); - _subscriptions.reactions = twonlyDB.reactionsDao - .watchReactionsForGroup(widget.groupId) - .listen((reactions) { - if (!mounted) return; - final byMessage = >{}; - for (final reaction in reactions) { - byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction); - } - setState(() => _data.reactionsByMessageId = byMessage); - }); - _subscriptions.messageActions = twonlyDB.messagesDao - .watchMessageActionsForGroup(widget.groupId) - .listen((actions) { - if (!mounted) return; - setState( - () => _data.ackedMessageIds = actions - .where( - (action) => action.type == MessageActionType.ackByUserAt, - ) - .map((action) => action.messageId) - .toSet(), - ); - }); - final groupContacts = await twonlyDB.groupsDao.getGroupContact( widget.groupId, ); @@ -208,6 +175,7 @@ class _ChatMessagesViewState extends State _receiverDeletedAccount = groupContacts.length == 1 && groupContacts.first.accountDeleted; }); + _watchRelevantContacts(); } if (userService.currentUser.typingIndicators) { @@ -222,11 +190,172 @@ class _ChatMessagesViewState extends State } } + Future _applyLoadedMessages({required bool reportOpened}) async { + final byId = { + for (final message in _data.olderMessages) message.messageId: message, + for (final message in _data.latestMessages) message.messageId: message, + }; + final loadedMessages = byId.values.toList() + ..sort((a, b) { + final byTime = a.createdAt.compareTo(b.createdAt); + return byTime != 0 ? byTime : a.messageId.compareTo(b.messageId); + }); + _data.allMessages = loadedMessages; + _data.messagesById = byId; + _watchGroupActionsForLoadedRange(); + _watchLoadedMessageData(); + _watchRelevantContacts(); + await setMessages( + loadedMessages, + _data.groupActions, + reportOpened: reportOpened, + ); + } + + void _watchGroupActionsForLoadedRange() { + final since = _data.allMessages.firstOrNull?.createdAt; + if (_subscriptions.groupActions != null && + _data.watchedGroupActionsSince == since) { + return; + } + _data.watchedGroupActionsSince = since; + unawaited(_subscriptions.groupActions?.cancel()); + _subscriptions.groupActions = twonlyDB.groupsDao + .watchGroupActions(widget.groupId, since: since) + .listen((actions) async { + _data.groupActions = actions; + _watchRelevantContacts(); + await setMessages(_data.allMessages, actions); + }); + } + + void _loadOlderWhenNeeded() { + if (_olderMessagesLoad != null || !_hasMoreMessages) return; + final positions = itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + final oldestVisibleIndex = positions + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); + if (oldestVisibleIndex >= _data.chatItems.length - 10) { + unawaited(_loadOlderMessages()); + } + } + + Future _loadOlderMessages() async { + if (_olderMessagesLoad case final pending?) return pending; + if (!_hasMoreMessages) return; + final load = _performLoadOlderMessages(); + _olderMessagesLoad = load; + try { + await load; + } finally { + _olderMessagesLoad = null; + } + } + + Future _performLoadOlderMessages() async { + final oldestMessage = _data.allMessages.firstOrNull; + if (oldestMessage == null) return; + final older = await twonlyDB.messagesDao.getMessagesBefore( + widget.groupId, + oldestMessage.createdAt, + beforeMessageId: oldestMessage.messageId, + ); + _hasMoreMessages = older.length == _messagePageSize; + _data.olderMessages = [...older, ..._data.olderMessages]; + await _applyLoadedMessages(reportOpened: true); + } + + void _watchLoadedMessageData() { + final messageIds = _data.messagesById.keys.toSet(); + if (setEquals(_data.watchedMessageIds, messageIds)) return; + _data.watchedMessageIds = messageIds; + + unawaited(_subscriptions.media?.cancel()); + unawaited(_subscriptions.reactions?.cancel()); + unawaited(_subscriptions.messageActions?.cancel()); + + final mediaIds = _data.allMessages + .map((message) => message.mediaId) + .whereType() + .toSet(); + _subscriptions.media = twonlyDB.mediaFilesDao + .watchMediaFilesByIds(mediaIds) + .listen((mediaFiles) { + if (!mounted) return; + _data.mediaFilesById = { + for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile, + }; + _notifyMessageDataChanged(); + _updateGalleryItems(force: true); + }); + _subscriptions.reactions = twonlyDB.reactionsDao + .watchReactionsForMessages(messageIds) + .listen((reactions) { + if (!mounted) return; + final byMessage = >{}; + for (final reaction in reactions) { + byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction); + } + _data.reactionsByMessageId = byMessage; + _notifyMessageDataChanged(); + }); + _subscriptions.messageActions = twonlyDB.messagesDao + .watchAcknowledgementsForMessages(messageIds) + .listen((actions) { + if (!mounted) return; + _data.ackedMessageIds = actions + .map((action) => action.messageId) + .toSet(); + _notifyMessageDataChanged(); + }); + } + + void _watchRelevantContacts() { + final contactIds = { + ..._groupContacts.map((contact) => contact.userId), + ..._data.allMessages.map((message) => message.senderId).whereType(), + ..._data.groupActions + .expand((action) => [action.contactId, action.affectedContactId]) + .whereType(), + ..._referencedContactIds(), + }; + if (setEquals(_data.watchedContactIds, contactIds)) return; + _data.watchedContactIds = contactIds; + unawaited(_subscriptions.contacts?.cancel()); + _subscriptions.contacts = twonlyDB.contactsDao + .watchContactsByIds(contactIds) + .listen((contacts) { + if (!mounted) return; + _data.contactsById = { + for (final contact in contacts) contact.userId: contact, + }; + _notifyMessageDataChanged(); + }); + } + + Iterable _referencedContactIds() sync* { + for (final message in _data.allMessages) { + final bytes = message.additionalMessageData; + if (bytes == null) continue; + try { + final data = AdditionalMessageData.fromBuffer(bytes); + if (data.hasAskAboutUserId()) yield data.askAboutUserId.toInt(); + for (final contact in data.contacts) { + yield contact.userId.toInt(); + } + } catch (_) { + // Invalid additional data is handled by the corresponding bubble. + } + } + } + Future setMessages( List newMessages, - List groupActions, - ) async { - if (_isViewActive()) { + List groupActions, { + bool reportOpened = false, + }) async { + if (reportOpened && _isViewActive()) { unawaited(flutterLocalNotificationsPlugin.cancelAll()); } @@ -241,6 +370,12 @@ class _ChatMessagesViewState extends State final chatItems = []; final storedMediaFiles = []; + final oldestLoadedAt = newMessages.firstOrNull?.createdAt; + final visibleGroupActions = oldestLoadedAt == null + ? groupActions + : groupActions + .where((action) => !action.actionAt.isBefore(oldestLoadedAt)) + .toList(); DateTime? lastDate; @@ -249,11 +384,17 @@ class _ChatMessagesViewState extends State var groupHistoryIndex = 0; for (final msg in newMessages) { - if (groupHistoryIndex < groupActions.length) { - for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) { - if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) { + if (groupHistoryIndex < visibleGroupActions.length) { + for ( + ; + groupHistoryIndex < visibleGroupActions.length; + groupHistoryIndex++ + ) { + if (msg.createdAt.isAfter( + visibleGroupActions[groupHistoryIndex].actionAt, + )) { chatItems.add( - ChatItem.groupAction(groupActions[groupHistoryIndex]), + ChatItem.groupAction(visibleGroupActions[groupHistoryIndex]), ); // groupHistoryIndex++; } else { @@ -263,7 +404,8 @@ class _ChatMessagesViewState extends State } if (msg.type != MessageType.media.name && msg.senderId != null && - msg.openedAt == null) { + msg.openedAt == null && + !_animationState.reportedOpenedMessageIds.contains(msg.messageId)) { if (openedMessages[msg.senderId!] == null) { openedMessages[msg.senderId!] = []; } @@ -283,14 +425,17 @@ class _ChatMessagesViewState extends State } chatItems.add(ChatItem.message(msg)); } - if (groupHistoryIndex < groupActions.length) { - for (var i = groupHistoryIndex; i < groupActions.length; i++) { - chatItems.add(ChatItem.groupAction(groupActions[i])); + if (groupHistoryIndex < visibleGroupActions.length) { + for (var i = groupHistoryIndex; i < visibleGroupActions.length; i++) { + chatItems.add(ChatItem.groupAction(visibleGroupActions[i])); } } - if (_isViewActive()) { + if (reportOpened && _isViewActive()) { for (final contactId in openedMessages.keys) { + _animationState.reportedOpenedMessageIds.addAll( + openedMessages[contactId]!, + ); unawaited( notifyContactAboutOpeningMessage( contactId, @@ -306,9 +451,8 @@ class _ChatMessagesViewState extends State newMessages.last.senderId == null; if (!mounted) return; - setState(() { - _data.chatItems = chatItems.reversed.toList(); - }); + _data.chatItems = chatItems.reversed.toList(); + _notifyMessageDataChanged(); if (wasSentByMe) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -326,26 +470,56 @@ class _ChatMessagesViewState extends State }); } - final galleryMessageIds = storedMediaFiles + _updateGalleryItems(messages: storedMediaFiles); + } + + void _updateGalleryItems({List? messages, bool force = false}) { + final storedMediaMessages = + messages ?? + _data.allMessages + .where( + (message) => + message.type == MessageType.media.name && message.mediaStored, + ) + .toList(); + final messageIds = storedMediaMessages .map((message) => message.messageId) .toSet(); - if (setEquals(_data.galleryMessageIds, galleryMessageIds)) return; - final items = await MemoryItem.convertFromMessages(storedMediaFiles); + if (!force && setEquals(_data.galleryMessageIds, messageIds)) return; + + final items = {}; + for (final message in storedMediaMessages) { + final mediaFile = _data.mediaFilesById[message.mediaId]; + if (mediaFile == null) continue; + final mediaService = MediaFileService(mediaFile); + if (!mediaService.imagePreviewAvailable) continue; + items + .putIfAbsent( + mediaFile.mediaId, + () => MemoryItem(mediaService: mediaService, messages: []), + ) + .messages + .add(message); + } if (!mounted) return; - setState(() { - _data.galleryMessageIds = galleryMessageIds; - _data.galleryItems = items.values.toList(); - }); + _data.galleryMessageIds = messageIds; + _data.galleryItems = items.values.toList(); + _notifyMessageDataChanged(); } Future scrollToMessage(String messageId) async { - final index = _data.chatItems.indexWhere( + var index = _data.chatItems.indexWhere( (x) => x.isMessage && x.message!.messageId == messageId, ); + while (index == -1 && _hasMoreMessages) { + await _loadOlderMessages(); + index = _data.chatItems.indexWhere( + (item) => item.isMessage && item.message!.messageId == messageId, + ); + } if (index == -1) return; - setState(() { - focusedScrollItem = index; - }); + focusedScrollItem = index; + _notifyMessageDataChanged(); await itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 300), @@ -353,9 +527,8 @@ class _ChatMessagesViewState extends State ); Future.delayed(const Duration(milliseconds: 300), () { if (!context.mounted) return; - setState(() { - focusedScrollItem = null; - }); + focusedScrollItem = null; + _notifyMessageDataChanged(); }); } @@ -411,20 +584,9 @@ class _ChatMessagesViewState extends State FlameCounterWidget(group: group), ], ), - if (group.isDirectChat) - StreamBuilder>( - stream: twonlyDB.groupsDao.watchGroupContact( - group.groupId, - ), - builder: (context, snapshot) { - final contacts = snapshot.data ?? []; - if (contacts.isEmpty) { - return const SizedBox.shrink(); - } - return ContactLabels( - contactId: contacts.first.userId, - ); - }, + if (group.isDirectChat && _groupContacts.isNotEmpty) + ContactLabels( + contactId: _groupContacts.first.userId, ), ], ), @@ -438,85 +600,91 @@ class _ChatMessagesViewState extends State child: Column( children: [ Expanded( - child: Align( - alignment: Alignment.topCenter, - child: ChatMessageActionScope( - ackedMessageIds: _data.ackedMessageIds, - child: ScrollablePositionedList.builder( - reverse: true, - itemCount: _data.chatItems.length + 1 + 1, - itemScrollController: itemScrollController, - itemBuilder: (context, i) { - if (i == 0) { - return userService.currentUser.typingIndicators - ? TypingIndicator(group: group) - : Container(); - } - i -= 1; - if (i == _data.chatItems.length) { - return Padding( - key: Key('overview_${group.groupId}'), - padding: const EdgeInsets.only(top: 10), - child: InChatGroupOverview( - group: group, - ), - ); - } - if (_data.chatItems[i].isDate) { - return ChatDateChip( - item: _data.chatItems[i], - ); - } else if (_data.chatItems[i].isGroupAction) { - return ChatGroupAction( - key: Key( - _data.chatItems[i].groupAction!.groupHistoryId, - ), - action: _data.chatItems[i].groupAction!, - contactsById: _data.contactsById, - ); - } else { - final chatMessage = _data.chatItems[i].message!; - return BlinkWidget( - key: Key('blink_${chatMessage.messageId}'), - enabled: focusedScrollItem == i, - child: AnimatedNewMessage( - key: Key('anim_${chatMessage.messageId}'), - messageId: chatMessage.messageId, - animateIds: _animationState.animateMessageIds, - child: ChatListEntry( - key: Key(chatMessage.messageId), - message: _data.chatItems[i].message!, - nextMessage: (i > 0) - ? _data.chatItems[i - 1].message - : null, - prevMessage: ((i + 1) < _data.chatItems.length) - ? _data.chatItems[i + 1].message - : null, + child: ValueListenableBuilder( + valueListenable: _messageDataVersion, + builder: (context, _, _) => Align( + alignment: Alignment.topCenter, + child: ChatMessageActionScope( + ackedMessageIds: _data.ackedMessageIds, + child: ScrollablePositionedList.builder( + reverse: true, + itemCount: _data.chatItems.length + 1 + 1, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, i) { + if (i == 0) { + return userService.currentUser.typingIndicators + ? TypingIndicator(group: group) + : Container(); + } + i -= 1; + if (i == _data.chatItems.length) { + return Padding( + key: Key('overview_${group.groupId}'), + padding: const EdgeInsets.only(top: 10), + child: InChatGroupOverview( group: group, - galleryItems: _data.galleryItems, - userIdToContact: _data.contactsById, - mediaFile: chatMessage.mediaId == null - ? null - : _data.mediaFilesById[chatMessage.mediaId], - reactions: - _data.reactionsByMessageId[chatMessage - .messageId] ?? - const [], - messagesById: _data.messagesById, - mediaFilesById: _data.mediaFilesById, - useSharedData: true, - scrollToMessage: scrollToMessage, - onResponseTriggered: () { - setState(() { - quotesMessage = chatMessage; - }); - textFieldFocus?.requestFocus(); - }, ), - ), - ); - } - }, + ); + } + if (_data.chatItems[i].isDate) { + return ChatDateChip( + item: _data.chatItems[i], + ); + } else if (_data.chatItems[i].isGroupAction) { + return ChatGroupAction( + key: Key( + _data.chatItems[i].groupAction!.groupHistoryId, + ), + action: _data.chatItems[i].groupAction!, + contactsById: _data.contactsById, + ); + } else { + final chatMessage = _data.chatItems[i].message!; + return BlinkWidget( + key: Key('blink_${chatMessage.messageId}'), + enabled: focusedScrollItem == i, + child: AnimatedNewMessage( + key: Key('anim_${chatMessage.messageId}'), + messageId: chatMessage.messageId, + animateIds: _animationState.animateMessageIds, + child: ChatListEntry( + key: Key(chatMessage.messageId), + message: _data.chatItems[i].message!, + nextMessage: (i > 0) + ? _data.chatItems[i - 1].message + : null, + prevMessage: + ((i + 1) < _data.chatItems.length) + ? _data.chatItems[i + 1].message + : null, + group: group, + galleryItems: _data.galleryItems, + userIdToContact: _data.contactsById, + mediaFile: chatMessage.mediaId == null + ? null + : _data.mediaFilesById[chatMessage + .mediaId], + reactions: + _data.reactionsByMessageId[chatMessage + .messageId] ?? + const [], + messagesById: _data.messagesById, + mediaFilesById: _data.mediaFilesById, + useSharedData: true, + scrollToMessage: scrollToMessage, + onResponseTriggered: () { + setState(() { + quotesMessage = chatMessage; + }); + textFieldFocus?.requestFocus(); + }, + ), + ), + ); + } + }, + ), ), ), ), diff --git a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart index 27fe87b4..0e57796b 100644 --- a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart @@ -168,6 +168,7 @@ class _ChatListEntryState extends State { message: widget.message, borderRadius: borderRadius, info: info, + contactsById: widget.useSharedData ? widget.userIdToContact : null, ); } @@ -184,6 +185,7 @@ class _ChatListEntryState extends State { message: widget.message, borderRadius: borderRadius, info: info, + contactsById: widget.useSharedData ? widget.userIdToContact : null, ); } diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart index b3f84610..5c91a206 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart @@ -20,12 +20,14 @@ class ChatAskAFriendEntry extends StatefulWidget { required this.message, required this.borderRadius, required this.info, + this.contactsById, super.key, }); final Message message; final BorderRadiusGeometry borderRadius; final BubbleInfo info; + final Map? contactsById; @override State createState() => _ChatAskAFriendEntryState(); @@ -56,6 +58,11 @@ class _ChatAskAFriendEntryState extends State { Future _loadUser() async { if (_data == null || !_data!.hasAskAboutUserId()) return; final userId = _data!.askAboutUserId.toInt(); + final sharedContact = widget.contactsById?[userId]; + if (sharedContact != null) { + _username = sharedContact.displayName ?? sharedContact.username; + return; + } setState(() { _isLoading = true; }); @@ -162,7 +169,9 @@ class _ChatAskAFriendEntryState extends State { mainAxisSize: MainAxisSize.min, children: [ StreamBuilder( - stream: twonlyDB.contactsDao.watchContact(userId), + stream: widget.contactsById == null + ? twonlyDB.contactsDao.watchContact(userId) + : Stream.value(widget.contactsById![userId]), builder: (context, snapshot) { final contactInDb = snapshot.data; return GestureDetector( @@ -181,6 +190,9 @@ class _ChatAskAFriendEntryState extends State { children: [ AvatarIcon( contactId: userId, + contacts: widget.contactsById?[userId] == null + ? null + : [widget.contactsById![userId]!], fontSize: 12, ), const SizedBox(width: 8), @@ -255,7 +267,9 @@ class _ChatAskAFriendEntryState extends State { ), ] else ...[ StreamBuilder( - stream: twonlyDB.contactsDao.watchContact(userId), + stream: widget.contactsById == null + ? twonlyDB.contactsDao.watchContact(userId) + : Stream.value(widget.contactsById![userId]), builder: (context, contactSnapshot) { final contactInDb = contactSnapshot.data; if (contactInDb != null) { @@ -269,8 +283,9 @@ class _ChatAskAFriendEntryState extends State { } return StreamBuilder( - stream: - twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId), + stream: twonlyDB.userDiscoveryDao.watchAnnouncedUser( + userId, + ), builder: (context, userSnapshot) { final announcedUser = userSnapshot.data; if (announcedUser != null && announcedUser.isHidden) { diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart index a29455a6..3fe149b4 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart @@ -10,7 +10,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart'; -class ChatAudioEntry extends StatelessWidget { +class ChatAudioEntry extends StatefulWidget { const ChatAudioEntry({ required this.message, required this.mediaService, @@ -24,10 +24,50 @@ class ChatAudioEntry extends StatelessWidget { final BorderRadius borderRadius; final BubbleInfo info; + @override + State createState() => _ChatAudioEntryState(); +} + +class _ChatAudioEntryState extends State { + bool? _hasAudioFile; + bool _hasTempFile = false; + + Message get message => widget.message; + MediaFileService get mediaService => widget.mediaService; + BorderRadius get borderRadius => widget.borderRadius; + BubbleInfo get info => widget.info; + + @override + void initState() { + super.initState(); + _checkAudioFiles(); + } + + @override + void didUpdateWidget(ChatAudioEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.mediaService.mediaFile != oldWidget.mediaService.mediaFile) { + _checkAudioFiles(); + } + } + + Future _checkAudioFiles() async { + // Async checks keep audio discovery off the UI thread. + // ignore: avoid_slow_async_io + final hasTempFile = await mediaService.tempPath.exists(); + // ignore: avoid_slow_async_io + final hasOriginalFile = await mediaService.originalPath.exists(); + final hasAudioFile = hasTempFile || hasOriginalFile; + if (!mounted) return; + setState(() { + _hasTempFile = hasTempFile; + _hasAudioFile = hasAudioFile; + }); + } + @override Widget build(BuildContext context) { - if (!mediaService.tempPath.existsSync() && - !mediaService.originalPath.existsSync()) { + if (_hasAudioFile != true) { return Container(); // media file was purged } @@ -73,16 +113,20 @@ class ChatAudioEntry extends StatelessWidget { ) else Expanded( - child: mediaService.mediaFile.downloadState == + child: + mediaService.mediaFile.downloadState == DownloadState.ready || mediaService.mediaFile.downloadState == null - ? (mediaService.tempPath.existsSync() - ? InChatAudioPlayer( - path: mediaService.tempPath.path, - message: message, - ) - : Container()) - : MessageSendStateIcon([message], [mediaService.mediaFile]), + ? (_hasTempFile + ? InChatAudioPlayer( + path: mediaService.tempPath.path, + message: message, + ) + : Container()) + : MessageSendStateIcon( + [message], + [mediaService.mediaFile], + ), ), if (showTime) FriendlyMessageTime(message: message), ], diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart index db2b3f2c..04c1c2cc 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart @@ -20,12 +20,14 @@ class ChatContactsEntry extends StatefulWidget { required this.message, required this.borderRadius, required this.info, + this.contactsById, super.key, }); final Message message; final BorderRadiusGeometry borderRadius; final BubbleInfo info; + final Map? contactsById; @override State createState() => _ChatContactsEntryState(); @@ -73,6 +75,7 @@ class _ChatContactsEntryState extends State { _ContactRow( contact: data.contacts[i], message: widget.message, + contactsById: widget.contactsById, ), ], ], @@ -86,10 +89,12 @@ class _ContactRow extends StatefulWidget { const _ContactRow({ required this.contact, required this.message, + this.contactsById, }); final SharedContact contact; final Message message; + final Map? contactsById; @override State<_ContactRow> createState() => _ContactRowState(); @@ -161,61 +166,68 @@ class _ContactRowState extends State<_ContactRow> { @override Widget build(BuildContext context) { + if (widget.contactsById != null) { + return _buildContactRow( + widget.contactsById![widget.contact.userId.toInt()], + ); + } return StreamBuilder( stream: twonlyDB.contactsDao.watchContact(widget.contact.userId.toInt()), builder: (context, snapshot) { - final contactInDb = snapshot.data; - final isAdded = - contactInDb != null || - widget.contact.userId.toInt() == userService.currentUser.userId; - - return GestureDetector( - onTap: _isLoading ? null : () => _onContactClick(isAdded), - child: ColoredBox( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), - child: Row( - children: [ - const FaIcon( - FontAwesomeIcons.user, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 8), - Flexible( - child: BetterText( - text: widget.contact.displayName, - textColor: Colors.white, - ), - ), - if (widget.message.senderId != null && !isAdded) ...[ - const Spacer(), - const SizedBox(width: 8), - if (_isLoading) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white, - ), - ), - ) - else - const FaIcon( - FontAwesomeIcons.userPlus, - color: Colors.white, - size: 16, - ), - ], - ], - ), - ), - ), - ); + return _buildContactRow(snapshot.data); }, ); } + + Widget _buildContactRow(Contact? contactInDb) { + final isAdded = + contactInDb != null || + widget.contact.userId.toInt() == userService.currentUser.userId; + return GestureDetector( + onTap: _isLoading ? null : () => _onContactClick(isAdded), + child: ColoredBox( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Row( + children: [ + const FaIcon( + FontAwesomeIcons.user, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Flexible( + child: BetterText( + text: widget.contact.displayName, + textColor: Colors.white, + ), + ), + if (widget.message.senderId != null && !isAdded) ...[ + const Spacer(), + const SizedBox(width: 8), + if (_isLoading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + else + const FaIcon( + FontAwesomeIcons.userPlus, + color: Colors.white, + size: 16, + ), + ], + ], + ), + ), + ), + ); + } } diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart index eb7598c9..ffd3cebb 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart @@ -63,7 +63,9 @@ class _ChatMediaEntryState extends State { widget.mediaService.mediaFile.displayLimitInMilliseconds != null) { return; } - if (widget.mediaService.tempPath.existsSync() && mounted) { + // Async check keeps media discovery off the UI thread. + // ignore: avoid_slow_async_io + if (await widget.mediaService.tempPath.exists() && mounted) { setState(() { _canBeReopened = true; }); diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index d4c69fbe..b871aa4f 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -234,20 +234,19 @@ class _ResponsePreviewState extends State { final pathToCheck = isVideo ? _mediaService!.thumbnailPath : _mediaService!.storedPath; - if (pathToCheck.existsSync() && pathToCheck.lengthSync() > 0) { - imageWidget = Container( - height: 40, - width: 40, - margin: const EdgeInsets.only(left: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - pathToCheck, - fit: BoxFit.cover, - ), + imageWidget = Container( + height: 40, + width: 40, + margin: const EdgeInsets.only(left: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + pathToCheck, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), ), - ); - } + ), + ); } return Container( diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index c8be5d6f..a13865bd 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'dart:ui' as ui; import 'package:clock/clock.dart'; @@ -35,6 +36,7 @@ class DeveloperSettingsView extends StatefulWidget { class _DeveloperSettingsViewState extends State { bool _isGeneratingMockImages = false; + bool _isGeneratingMockMessages = false; @override void initState() { @@ -254,6 +256,105 @@ class _DeveloperSettingsViewState extends State { } } + Future _generate1000RandomMessages() async { + if (!kDebugMode || _isGeneratingMockMessages) return; + + final groups = await twonlyDB.groupsDao.getAllGroups(); + groups.sort((a, b) => a.groupName.compareTo(b.groupName)); + if (!mounted) return; + if (groups.isEmpty) { + showSnackbar(context, 'No groups available for message generation.'); + return; + } + + final group = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('Generate messages in group'), + children: [ + for (final group in groups) + SimpleDialogOption( + onPressed: () => Navigator.pop(context, group), + child: Text(group.groupName), + ), + ], + ), + ); + if (group == null || !mounted) return; + + setState(() => _isGeneratingMockMessages = true); + try { + final members = await twonlyDB.groupsDao.getGroupNonLeftMembers( + group.groupId, + ); + if (members.isEmpty) { + if (mounted) { + showSnackbar(context, 'The selected group has no active members.'); + } + return; + } + + const samples = [ + 'Hey! How is everyone doing?', + 'This is a randomly generated debug message.', + 'Did anyone see this?', + 'Sounds good to me!', + 'I will check that later.', + 'That made me laugh 😄', + 'What do you think?', + 'Let’s do it!', + 'Quick update from my side.', + 'Testing a longer message to see how the chat bubble wraps across multiple lines in this conversation.', + ]; + final random = Random(); + final now = clock.now(); + final idPrefix = 'debug_${now.microsecondsSinceEpoch}'; + + await twonlyDB.batch((batch) { + for (var i = 0; i < 1000; i++) { + final member = members[random.nextInt(members.length)]; + final createdAt = now.subtract(Duration(seconds: 1000 - i)); + final sample = samples[random.nextInt(samples.length)]; + batch.insert( + twonlyDB.messages, + MessagesCompanion( + groupId: Value(group.groupId), + messageId: Value('${idPrefix}_$i'), + senderId: Value(member.contactId), + type: const Value('text'), + content: Value('$sample #${i + 1}'), + openedAt: Value(now), + ackByServer: Value(createdAt), + ackByUser: Value(createdAt), + createdAt: Value(createdAt), + ), + ); + } + }); + await twonlyDB.groupsDao.updateGroup( + group.groupId, + GroupsCompanion( + archived: const Value(false), + deletedContent: const Value(false), + lastMessageReceived: Value(now), + lastMessageExchange: Value(now), + ), + ); + + if (mounted) { + showSnackbar( + context, + 'Generated 1000 messages in ${group.groupName}.', + level: SnackbarLevel.success, + ); + } + } catch (error) { + if (mounted) showSnackbar(context, 'Could not generate messages: $error'); + } finally { + if (mounted) setState(() => _isGeneratingMockMessages = false); + } + } + Future toggleDeveloperSettings() async { await UserService.update((u) => u.isDeveloper = !u.isDeveloper); } @@ -376,6 +477,23 @@ class _DeveloperSettingsViewState extends State { onTap: () => context.push(Routes.settingsDeveloperAutomatedTesting), ), + if (kDebugMode) + ListTile( + title: const Text('Generate 1000 Random Messages'), + subtitle: const Text('Choose a group to populate'), + trailing: _isGeneratingMockMessages + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ) + : null, + onTap: _isGeneratingMockMessages + ? null + : _generate1000RandomMessages, + ), if (kDebugMode) ListTile( title: const Text('Generate 1000 Mock Images'),