diff --git a/lib/app/layouts/conversation_details/widgets/chat_options.dart b/lib/app/layouts/conversation_details/widgets/chat_options.dart index 2bca2f8e89..4e1c08d100 100644 --- a/lib/app/layouts/conversation_details/widgets/chat_options.dart +++ b/lib/app/layouts/conversation_details/widgets/chat_options.dart @@ -6,6 +6,7 @@ import 'package:bluebubbles/app/layouts/settings/pages/profile/poster_edit.dart' import 'package:bluebubbles/app/layouts/settings/pages/profile/posterkit.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; +import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/app/layouts/settings/widgets/settings_widgets.dart'; import 'package:bluebubbles/app/layouts/settings/pages/theming/avatar/avatar_crop.dart'; @@ -37,6 +38,69 @@ class ChatOptions extends StatefulWidget { class _ChatOptionsState extends OptimizedState { Chat get chat => widget.chat; + Future _linkConversation() async { + final query = Database.chats.query(Chat_.dateDeleted.isNull()).build(); + final candidates = query.find() + ..removeWhere((candidate) => candidate.guid == chat.guid || candidate.isGroup || candidate.isRoutingStub + || chats.primaryGuidFor(candidate.guid) == chats.primaryGuidFor(chat.guid)); + query.close(); + candidates.sort(Chat.sort); + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Link conversation'), + content: SizedBox( + width: 420, + height: 420, + child: candidates.isEmpty + ? const Center(child: Text('No other one-to-one conversations are available.')) + : ListView.builder( + itemCount: candidates.length, + itemBuilder: (context, index) { + final candidate = candidates[index]; + return ListTile( + title: Text(candidate.getTitle()), + subtitle: Text(candidate.participants.map((handle) => handle.address).join(', ')), + onTap: () => Navigator.of(context).pop(candidate), + ); + }, + ), + ), + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'))], + ), + ); + if (selected == null) return; + + await chats.linkChats(chat, selected); + eventDispatcher.emit('refresh-messagebloc', chat.guid); + if (!mounted) return; + setState(() {}); + showSnackbar('Conversations linked', 'Both histories now appear in this conversation.'); + } + + Future _unlinkConversation() async { + final shouldUnlink = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unlink conversations?'), + content: const Text('Each original conversation will appear separately again. No messages will be moved or deleted.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Unlink')), + ], + ), + ); + if (shouldUnlink != true) return; + + await chats.unlinkChats(chat.guid); + eventDispatcher.emit('refresh-messagebloc', chat.guid); + if (!mounted) return; + setState(() {}); + showSnackbar('Conversations unlinked', 'The original conversations are visible again.'); + } + @override Widget build(BuildContext context) { return Theme( @@ -54,6 +118,28 @@ class _ChatOptionsState extends OptimizedState { SettingsSection( backgroundColor: tileColor, children: [ + if (!kIsWeb && !chat.isGroup && chats.linkedGuidsFor(chat.guid).length == 1) + SettingsTile( + title: 'Link Conversation', + subtitle: 'Show another conversation as one combined history', + isThreeLine: true, + trailing: Padding( + padding: const EdgeInsets.only(right: 15.0), + child: Icon(iOS ? CupertinoIcons.link : Icons.link), + ), + onTap: _linkConversation, + ), + if (!kIsWeb && !chat.isGroup && chats.linkedGuidsFor(chat.guid).length > 1) + SettingsTile( + title: 'Unlink Conversations', + subtitle: 'Restore the original separate conversations without deleting data', + isThreeLine: true, + trailing: Padding( + padding: const EdgeInsets.only(right: 15.0), + child: Icon(iOS ? CupertinoIcons.link : Icons.link_off), + ), + onTap: _unlinkConversation, + ), if (!kIsWeb && !kIsDesktop && (fs.androidInfo?.version.sdkInt ?? 0) >= 30) SettingsTile( title: "Notification Settings", diff --git a/lib/services/ui/chat/chats_service.dart b/lib/services/ui/chat/chats_service.dart index 234849940a..32ff5c51d1 100644 --- a/lib/services/ui/chat/chats_service.dart +++ b/lib/services/ui/chat/chats_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:app_links/app_links.dart'; @@ -17,11 +18,11 @@ import 'package:get/get.dart' hide Response; import 'package:tuple/tuple.dart'; import 'package:universal_io/io.dart'; import 'package:bluebubbles/database/database.dart'; -import 'package:bluebubbles/src/rust/api/api.dart' as api; ChatsService chats = Get.isRegistered() ? Get.find() : Get.put(ChatsService()); class ChatsService extends GetxService { + static const _linkedChatsPreferenceKey = 'linked-chats-v1'; static const batchSize = 15; int currentCount = 0; late final StreamSubscription countSub; @@ -30,6 +31,7 @@ class ChatsService extends GetxService { Completer loadedAllChats = Completer(); final RxBool loadedChatBatch = false.obs; final RxList chats = [].obs; + final RxMap linkedChatParents = {}.obs; bool restoring = false; @@ -38,6 +40,7 @@ class ChatsService extends GetxService { @override void onInit() { super.onInit(); + _loadLinkedChats(); if (!kIsWeb) { // watch for new chats (() async { @@ -67,6 +70,86 @@ class ChatsService extends GetxService { } } + void _loadLinkedChats() { + final encoded = ss.prefs.getString(_linkedChatsPreferenceKey); + if (encoded == null) return; + try { + final decoded = (jsonDecode(encoded) as Map).cast(); + linkedChatParents.assignAll(decoded.map((key, value) => MapEntry(key, value as String))); + } catch (error, trace) { + Logger.warn('Ignoring invalid linked-chat preferences', error: error, trace: trace); + } + } + + Future _saveLinkedChats() async { + await ss.prefs.setString(_linkedChatsPreferenceKey, jsonEncode(linkedChatParents)); + } + + String primaryGuidFor(String guid) { + var current = guid; + final visited = {}; + while (linkedChatParents[current] != null && visited.add(current)) { + current = linkedChatParents[current]!; + } + return current; + } + + bool isLinkedSecondary(String guid) => primaryGuidFor(guid) != guid; + + List linkedGuidsFor(String guid) { + final primary = primaryGuidFor(guid); + return {primary, ...linkedChatParents.keys.where((candidate) => primaryGuidFor(candidate) == primary)}.toList(); + } + + List linkedChatsFor(String guid) => linkedGuidsFor(guid) + .map((chatGuid) => Chat.findOne(guid: chatGuid)) + .whereType() + .toList(); + + void _applyLinkedPresentation(List allChats) { + final byGuid = {for (final chat in allChats) chat.guid: chat}; + for (final entry in linkedChatParents.entries) { + final secondary = byGuid[entry.key]; + final primary = byGuid[primaryGuidFor(entry.value)]; + if (secondary == null || primary == null) continue; + if ((secondary.latestMessage.dateCreated ?? DateTime.fromMillisecondsSinceEpoch(0)) + .isAfter(primary.latestMessage.dateCreated ?? DateTime.fromMillisecondsSinceEpoch(0))) { + primary.latestMessage = secondary.latestMessage; + } + primary.hasUnreadMessage = (primary.hasUnreadMessage ?? false) || (secondary.hasUnreadMessage ?? false); + } + } + + Future linkChats(Chat primary, Chat secondary) async { + final primaryGuid = primaryGuidFor(primary.guid); + for (final guid in linkedGuidsFor(secondary.guid)) { + if (guid != primaryGuid) linkedChatParents[guid] = primaryGuid; + } + linkedChatParents.remove(primaryGuid); + await _saveLinkedChats(); + _applyLinkedPresentation([primary, secondary]); + chats.removeWhere((chat) => isLinkedSecondary(chat.guid)); + sort(); + } + + Future unlinkChats(String guid) async { + final primary = primaryGuidFor(guid); + final linkedGuids = linkedGuidsFor(primary).where((item) => item != primary).toList(); + for (final linkedGuid in linkedGuids) { + linkedChatParents.remove(linkedGuid); + final linked = Chat.findOne(guid: linkedGuid); + if (linked != null && !chats.any((chat) => chat.guid == linked.guid)) { + chats.add(linked); + cm.createChatController(linked); + } + } + final freshPrimary = Chat.findOne(guid: primary); + final primaryIndex = chats.indexWhere((chat) => chat.guid == primary); + if (freshPrimary != null && primaryIndex != -1) chats[primaryIndex] = freshPrimary; + await _saveLinkedChats(); + sort(); + } + RxList suggestedHandles = [].obs; Future loadChatSuggestions() async { @@ -128,8 +211,9 @@ class ChatsService extends GetxService { cm.createChatController(c, active: cm.activeChat?.chat.guid == c.guid); } newChats.addAll(temp); + _applyLinkedPresentation(newChats); newChats.sort(Chat.sort); - chats.value = newChats; + chats.value = newChats.where((chat) => !isLinkedSecondary(chat.guid)).toList(); loadedChatBatch.value = true; } loadChatSuggestions(); @@ -197,6 +281,19 @@ class ChatsService extends GetxService { } bool updateChat(Chat updated, {bool shouldSort = false, bool override = false}) { + if (isLinkedSecondary(updated.guid)) { + final primaryIndex = chats.indexWhere((chat) => chat.guid == primaryGuidFor(updated.guid)); + if (primaryIndex == -1) return false; + final primary = chats[primaryIndex]; + if ((updated.latestMessage.dateCreated ?? DateTime.fromMillisecondsSinceEpoch(0)) + .isAfter(primary.latestMessage.dateCreated ?? DateTime.fromMillisecondsSinceEpoch(0))) { + primary.latestMessage = updated.latestMessage; + } + primary.hasUnreadMessage = (primary.hasUnreadMessage ?? false) || (updated.hasUnreadMessage ?? false); + chats.refresh(); + if (shouldSort) sort(); + return true; + } final index = chats.indexWhere((e) => updated.guid == e.guid); if (index != -1) { // delete @@ -215,7 +312,8 @@ class ChatsService extends GetxService { } Future addChat(Chat toAdd) async { - if (toAdd.isRoutingStub) return; + if (toAdd.isRoutingStub || isLinkedSecondary(toAdd.guid)) return; + if (updateChat(toAdd, shouldSort: true)) return; chats.add(toAdd); cm.createChatController(toAdd); sort(); diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 83ec39d670..2f51d1fab7 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -19,7 +19,8 @@ String? lastReloadedChat() => Get.isRegistered(tag: 'lastReloadedChat') class MessagesService extends GetxController { static final Map cachedBubbleSizes = {}; late Chat chat; - late StreamSubscription countSub; + final List countSubs = []; + final Map currentCounts = {}; final ChatMessages struct = ChatMessages(); late Function(Message) newFunc; late Function(Message, {String? oldGuid}) updateFunc; @@ -55,28 +56,34 @@ class MessagesService extends GetxController { // watch for new messages if (!_init) { if (chat.id != null) { - final countQuery = (Database.messages.query(Message_.dateDeleted.isNull()) - ..link(Message_.chat, Chat_.id.equals(chat.id!)) - ..order(Message_.id, flags: Order.descending)).watch(triggerImmediately: true); - countSub = countQuery.listen((event) async { - if (!ss.settings.finishedSetup.value) return; - final newCount = event.count(); - if (!isFetching && newCount > currentCount && currentCount != 0) { - event.limit = newCount - currentCount; - final messages = event.find(); - event.limit = 0; - for (Message message in messages) { - await _handleNewMessage(message); + for (final linkedChat in chats.linkedChatsFor(chat.guid)) { + final chatId = linkedChat.id; + if (chatId == null) continue; + final countQuery = (Database.messages.query(Message_.dateDeleted.isNull()) + ..link(Message_.chat, Chat_.id.equals(chatId)) + ..order(Message_.id, flags: Order.descending)).watch(triggerImmediately: true); + countSubs.add(countQuery.listen((event) async { + if (!ss.settings.finishedSetup.value) return; + final newCount = event.count(); + final oldCount = currentCounts[chatId] ?? 0; + if (!isFetching && newCount > oldCount && oldCount != 0) { + event.limit = newCount - oldCount; + final messages = event.find(); + event.limit = 0; + for (Message message in messages) { + await _handleNewMessage(message); + } } - } - currentCount = newCount; - }); + currentCounts[chatId] = newCount; + currentCount = currentCounts.values.fold(0, (total, count) => total + count); + })); + } } else if (kIsWeb) { - countSub = WebListeners.newMessage.listen((tuple) { + countSubs.add(WebListeners.newMessage.listen((tuple) { if (tuple.item2?.guid == chat.guid) { _handleNewMessage(tuple.item1); } - }); + })); } } _init = true; @@ -85,7 +92,11 @@ class MessagesService extends GetxController { @override void onClose() { if (_init) { - countSub.cancel(); + for (final subscription in countSubs) { + subscription.cancel(); + } + countSubs.clear(); + currentCounts.clear(); } _init = false; super.onClose(); @@ -155,7 +166,21 @@ class MessagesService extends GetxController { List _messages = []; offset = offset + struct.reactions.length; try { - _messages = await Chat.getMessagesAsync(chat, offset: offset, limit: limit); + final linkedChats = chats.linkedChatsFor(chat.guid); + if (linkedChats.length <= 1) { + _messages = await Chat.getMessagesAsync(chat, offset: offset, limit: limit); + } else { + final requested = offset + limit; + final combined = {}; + for (final linkedChat in linkedChats) { + final messages = await Chat.getMessagesAsync(linkedChat, offset: 0, limit: requested); + for (final message in messages) { + if (message.guid != null) combined[message.guid!] = message; + } + } + final ordered = combined.values.toList()..sort(Message.sort); + _messages = ordered.skip(offset).take(limit).toList(); + } if (_messages.isEmpty) { // get from server and save final fromServer = await cm.getMessages(chat.guid, offset: offset, limit: limit); @@ -271,4 +296,4 @@ class MessagesService extends GetxController { return completer.future; } -} \ No newline at end of file +}