import 'dart:async'; import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:mutex/mutex.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/routes.keys.dart'; 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/services/api/messages.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/themes/colors.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/chats/chat_messages_components/blink.component.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_group_action.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_date_chip.dart'; import 'package:twonly/src/views/chats/chat_messages_components/message_input.dart'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/views/chats/chat_messages_components/typing_indicator.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; class ChatMessagesView extends StatefulWidget { const ChatMessagesView(this.groupId, {super.key}); final String groupId; @override State createState() => _ChatMessagesViewState(); } class _ChatMessagesViewState extends State { HashSet alreadyReportedOpened = HashSet(); late StreamSubscription userSub; late StreamSubscription> messageSub; StreamSubscription>? groupActionsSub; StreamSubscription>? contactSub; Group? _group; Map userIdToContact = {}; List messages = []; List allMessages = []; List groupActions = []; List galleryItems = []; Message? quotesMessage; GlobalKey verifyShieldKey = GlobalKey(); late FocusNode textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; bool _receiverDeletedAccount = false; Timer? _nextTypingIndicator; @override void initState() { super.initState(); textFieldFocus = FocusNode(); initStreams(); } @override void dispose() { userSub.cancel(); messageSub.cancel(); contactSub?.cancel(); groupActionsSub?.cancel(); _nextTypingIndicator?.cancel(); super.dispose(); } Mutex protectMessageUpdating = Mutex(); Future initStreams() async { final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); userSub = groupStream.listen((newGroup) { if (newGroup == null) return; setState(() { _group = newGroup; }); protectMessageUpdating.protect(() async { if (groupActionsSub == null && !newGroup.isDirectChat) { final actionsStream = twonlyDB.groupsDao.watchGroupActions( newGroup.groupId, ); groupActionsSub = actionsStream.listen((update) async { groupActions = update; await setMessages(allMessages, update); }); final contactsStream = twonlyDB.contactsDao.watchAllContacts(); contactSub = contactsStream.listen((contacts) { for (final contact in contacts) { userIdToContact[contact.userId] = contact; } }); } }); }); final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId); messageSub = msgStream.listen((update) async { allMessages = update; await protectMessageUpdating.protect(() async { await setMessages(update, groupActions); }); }); final groupContacts = await twonlyDB.groupsDao.getGroupContact( widget.groupId, ); if (groupContacts.length == 1) { _receiverDeletedAccount = groupContacts.first.accountDeleted; } if (gUser.typingIndicators) { unawaited(sendTypingIndication(widget.groupId, false)); _nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), ( _, ) async { await sendTypingIndication(widget.groupId, false); }); } } Future setMessages( List newMessages, List groupActions, ) async { await flutterLocalNotificationsPlugin.cancelAll(); final chatItems = []; final storedMediaFiles = []; DateTime? lastDate; final openedMessages = >{}; var groupHistoryIndex = 0; for (final msg in newMessages) { if (groupHistoryIndex < groupActions.length) { for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) { if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) { chatItems.add( ChatItem.groupAction(groupActions[groupHistoryIndex]), ); // groupHistoryIndex++; } else { break; } } } if (msg.type != MessageType.media.name && msg.senderId != null && msg.openedAt == null) { if (openedMessages[msg.senderId!] == null) { openedMessages[msg.senderId!] = []; } openedMessages[msg.senderId!]!.add(msg.messageId); } if (msg.type == MessageType.media.name && msg.mediaStored) { storedMediaFiles.add(msg); } if (lastDate == null || msg.createdAt.day != lastDate.day || msg.createdAt.month != lastDate.month || msg.createdAt.year != lastDate.year) { chatItems.add(ChatItem.date(msg.createdAt)); lastDate = msg.createdAt; } chatItems.add(ChatItem.message(msg)); } if (groupHistoryIndex < groupActions.length) { for (var i = groupHistoryIndex; i < groupActions.length; i++) { chatItems.add(ChatItem.groupAction(groupActions[i])); } } for (final contactId in openedMessages.keys) { await notifyContactAboutOpeningMessage( contactId, openedMessages[contactId]!, ); } if (!mounted) return; setState(() { messages = chatItems.reversed.toList(); }); final items = await MemoryItem.convertFromMessages(storedMediaFiles); if (!mounted) return; galleryItems = items.values.toList(); setState(() {}); } Future scrollToMessage(String messageId) async { final index = messages.indexWhere( (x) => x.isMessage && x.message!.messageId == messageId, ); if (index == -1) return; setState(() { focusedScrollItem = index; }); await itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 300), alignment: 0.5, ); Future.delayed(const Duration(milliseconds: 300), () { if (!context.mounted) return; setState(() { focusedScrollItem = null; }); }); } @override Widget build(BuildContext context) { if (_group == null) return Container(); final group = _group!; return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: Scaffold( appBar: AppBar( title: GestureDetector( onTap: () async { if (group.isDirectChat) { final member = await twonlyDB.groupsDao.getAllGroupMembers( group.groupId, ); if (!context.mounted) return; if (member.isEmpty) return; await context.push( Routes.profileContact(member.first.contactId), ); } else { await context.push(Routes.profileGroup(group.groupId)); } }, child: Row( children: [ AvatarIcon( group: group, fontSize: 19, ), const SizedBox(width: 10), Expanded( child: ColoredBox( color: Colors.transparent, child: Row( children: [ Text( substringBy(group.groupName, 20), ), const SizedBox(width: 10), VerifiedShield(key: verifyShieldKey, group: group), const SizedBox(width: 10), FlameCounterWidget(groupId: group.groupId), ], ), ), ), ], ), ), ), body: SafeArea( child: Column( children: [ Expanded( child: ScrollablePositionedList.builder( reverse: true, itemCount: messages.length + 1 + 1, itemScrollController: itemScrollController, itemBuilder: (context, i) { if (i == 0) { return gUser.typingIndicators ? TypingIndicator(group: group) : Container(); } i -= 1; if (i == messages.length) { return const Padding( padding: EdgeInsetsGeometry.only(top: 10), ); } if (messages[i].isDate) { return ChatDateChip( item: messages[i], ); } else if (messages[i].isGroupAction) { return ChatGroupAction( key: Key(messages[i].groupAction!.groupHistoryId), action: messages[i].groupAction!, ); } else { final chatMessage = messages[i].message!; return BlinkWidget( enabled: focusedScrollItem == i, child: ChatListEntry( key: Key(chatMessage.messageId), message: messages[i].message!, nextMessage: (i > 0) ? messages[i - 1].message : null, prevMessage: ((i + 1) < messages.length) ? messages[i + 1].message : null, group: group, galleryItems: galleryItems, userIdToContact: userIdToContact, scrollToMessage: scrollToMessage, onResponseTriggered: () { setState(() { quotesMessage = chatMessage; }); textFieldFocus.requestFocus(); }, ), ); } }, ), ), if (quotesMessage != null) Container( padding: const EdgeInsets.only( left: 20, right: 20, top: 10, ), child: Row( children: [ Expanded( child: ResponsePreview( message: quotesMessage, showBorder: true, group: group, ), ), IconButton( onPressed: () { setState(() { quotesMessage = null; }); }, icon: const FaIcon( FontAwesomeIcons.xmark, size: 16, ), ), ], ), ), if (!group.leftGroup && !_receiverDeletedAccount) MessageInput( group: group, quotesMessage: quotesMessage, textFieldFocus: textFieldFocus, onMessageSend: () { setState(() { quotesMessage = null; }); }, ), if (_receiverDeletedAccount) Text(context.lang.userDeletedAccount), ], ), ), ), ); } } Color getMessageColor(bool isOther) { return isOther ? DefaultColors.messageSelf : DefaultColors.messageOther; } class ChatItem { const ChatItem._({ this.message, this.date, this.groupAction, }); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } factory ChatItem.message(Message message) { return ChatItem._(message: message); } factory ChatItem.groupAction(GroupHistory groupAction) { return ChatItem._(groupAction: groupAction); } final GroupHistory? groupAction; final Message? message; final DateTime? date; bool get isMessage => message != null; bool get isDate => date != null; bool get isGroupAction => groupAction != null; }