import 'dart:async'; import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:mutex/mutex.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.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/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/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/blink.component.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/views/contact/contact.view.dart'; import 'package:twonly/src/views/groups/group.view.dart'; Color getMessageColor(Message message) { return (message.senderId == null) ? const Color.fromARGB(255, 58, 136, 102) : const Color.fromARGB(233, 68, 137, 255); } class ChatItem { const ChatItem._({ this.message, this.date, this.lastOpenedPosition, this.groupAction, }); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } factory ChatItem.message(Message message) { return ChatItem._(message: message); } factory ChatItem.lastOpenedPosition(List contacts) { return ChatItem._(lastOpenedPosition: contacts); } factory ChatItem.groupAction(GroupHistory groupAction) { return ChatItem._(groupAction: groupAction); } final GroupHistory? groupAction; final Message? message; final DateTime? date; final List? lastOpenedPosition; bool get isMessage => message != null; bool get isDate => date != null; bool get isGroupAction => groupAction != null; bool get isLastOpenedPosition => lastOpenedPosition != null; } /// Displays detailed information about a SampleItem. class ChatMessagesView extends StatefulWidget { const ChatMessagesView(this.group, {super.key}); final Group group; @override State createState() => _ChatMessagesViewState(); } class _ChatMessagesViewState extends State { HashSet alreadyReportedOpened = HashSet(); late Group group; late StreamSubscription userSub; late StreamSubscription> messageSub; StreamSubscription>? groupActionsSub; StreamSubscription>? contactSub; StreamSubscription>>? lastOpenedMessageByContactSub; Map userIdToContact = {}; List messages = []; List allMessages = []; List<(Message, Contact)> lastOpenedMessageByContact = []; List groupActions = []; List galleryItems = []; Message? quotesMessage; GlobalKey verifyShieldKey = GlobalKey(); late FocusNode textFieldFocus; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; @override void initState() { super.initState(); group = widget.group; textFieldFocus = FocusNode(); initStreams(); } @override void dispose() { userSub.cancel(); messageSub.cancel(); contactSub?.cancel(); groupActionsSub?.cancel(); lastOpenedMessageByContactSub?.cancel(); super.dispose(); } Mutex protectMessageUpdating = Mutex(); Future initStreams() async { final groupStream = twonlyDB.groupsDao.watchGroup(group.groupId); userSub = groupStream.listen((newGroup) { if (newGroup == null) return; setState(() { group = newGroup; }); }); if (!widget.group.isDirectChat) { final lastOpenedStream = twonlyDB.messagesDao.watchLastOpenedMessagePerContact(group.groupId); lastOpenedMessageByContactSub = lastOpenedStream.listen((lastActionsFuture) async { final update = await lastActionsFuture; lastOpenedMessageByContact = update; await setMessages(allMessages, update, groupActions); }); final actionsStream = twonlyDB.groupsDao.watchGroupActions(group.groupId); groupActionsSub = actionsStream.listen((update) async { groupActions = update; await setMessages(allMessages, lastOpenedMessageByContact, update); }); final contactsStream = twonlyDB.contactsDao.watchAllContacts(); contactSub = contactsStream.listen((contacts) { for (final contact in contacts) { userIdToContact[contact.userId] = contact; } }); } final msgStream = twonlyDB.messagesDao.watchByGroupId(group.groupId); messageSub = msgStream.listen((update) async { allMessages = update; /// In case a message is not open yet the message is updated, which will trigger this watch to be called again. /// So as long as the Mutex is locked just return... if (protectMessageUpdating.isLocked) { // return; } await protectMessageUpdating.protect(() async { await setMessages(update, lastOpenedMessageByContact, groupActions); }); }); } Future setMessages( List newMessages, List<(Message, Contact)> lastOpenedMessageByContact, List groupActions, ) async { await flutterLocalNotificationsPlugin.cancelAll(); final chatItems = []; final storedMediaFiles = []; DateTime? lastDate; final openedMessages = >{}; final lastOpenedMessageToContact = >{}; final myLastMessageIndex = newMessages.lastIndexWhere((t) => t.senderId == null); for (final opened in lastOpenedMessageByContact) { if (!lastOpenedMessageToContact.containsKey(opened.$1.messageId)) { lastOpenedMessageToContact[opened.$1.messageId] = [opened.$2]; } else { lastOpenedMessageToContact[opened.$1.messageId]!.add(opened.$2); } } var index = 0; 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; } } } index += 1; if (msg.type == MessageType.text && 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 && 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 (index <= myLastMessageIndex || index == newMessages.length) { if (lastOpenedMessageToContact.containsKey(msg.messageId)) { chatItems.add( ChatItem.lastOpenedPosition( lastOpenedMessageToContact[msg.messageId]!, ), ); } } } 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) { 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 Navigator.push( context, MaterialPageRoute( builder: (context) { return ContactView(member.first.contactId); }, ), ); } else { await Navigator.push( context, MaterialPageRoute( builder: (context) { return GroupView(group); }, ), ); } }, 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, itemScrollController: itemScrollController, itemBuilder: (context, i) { 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].isLastOpenedPosition) { return Wrap( spacing: 8, alignment: WrapAlignment.center, children: messages[i].lastOpenedPosition!.map((w) { return AvatarIcon( contactId: w.userId, fontSize: 12, ); }).toList(), ); } 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) MessageInput( group: group, quotesMessage: quotesMessage, textFieldFocus: textFieldFocus, onMessageSend: () { setState(() { quotesMessage = null; }); }, ), ], ), ), ), ); } }