Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions lib/app/layouts/conversation_details/widgets/chat_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +38,69 @@ class ChatOptions extends StatefulWidget {
class _ChatOptionsState extends OptimizedState<ChatOptions> {
Chat get chat => widget.chat;

Future<void> _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<Chat>(
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<void> _unlinkConversation() async {
final shouldUnlink = await showDialog<bool>(
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(
Expand All @@ -54,6 +118,28 @@ class _ChatOptionsState extends OptimizedState<ChatOptions> {
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",
Expand Down
104 changes: 101 additions & 3 deletions lib/services/ui/chat/chats_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';

import 'package:app_links/app_links.dart';
Expand All @@ -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<ChatsService>() ? Get.find<ChatsService>() : Get.put(ChatsService());

class ChatsService extends GetxService {
static const _linkedChatsPreferenceKey = 'linked-chats-v1';
static const batchSize = 15;
int currentCount = 0;
late final StreamSubscription countSub;
Expand All @@ -30,6 +31,7 @@ class ChatsService extends GetxService {
Completer<void> loadedAllChats = Completer();
final RxBool loadedChatBatch = false.obs;
final RxList<Chat> chats = <Chat>[].obs;
final RxMap<String, String> linkedChatParents = <String, String>{}.obs;

bool restoring = false;

Expand All @@ -38,6 +40,7 @@ class ChatsService extends GetxService {
@override
void onInit() {
super.onInit();
_loadLinkedChats();
if (!kIsWeb) {
// watch for new chats
(() async {
Expand Down Expand Up @@ -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<String, dynamic>();
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<void> _saveLinkedChats() async {
await ss.prefs.setString(_linkedChatsPreferenceKey, jsonEncode(linkedChatParents));
}

String primaryGuidFor(String guid) {
var current = guid;
final visited = <String>{};
while (linkedChatParents[current] != null && visited.add(current)) {
current = linkedChatParents[current]!;
}
return current;
}

bool isLinkedSecondary(String guid) => primaryGuidFor(guid) != guid;

List<String> linkedGuidsFor(String guid) {
final primary = primaryGuidFor(guid);
return <String>{primary, ...linkedChatParents.keys.where((candidate) => primaryGuidFor(candidate) == primary)}.toList();
}

List<Chat> linkedChatsFor(String guid) => linkedGuidsFor(guid)
.map((chatGuid) => Chat.findOne(guid: chatGuid))
.whereType<Chat>()
.toList();

void _applyLinkedPresentation(List<Chat> 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<void> 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<void> 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<Handle> suggestedHandles = <Handle>[].obs;

Future<void> loadChatSuggestions() async {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -215,7 +312,8 @@ class ChatsService extends GetxService {
}

Future<void> 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();
Expand Down
67 changes: 46 additions & 21 deletions lib/services/ui/message/messages_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ String? lastReloadedChat() => Get.isRegistered<String>(tag: 'lastReloadedChat')
class MessagesService extends GetxController {
static final Map<String, Size> cachedBubbleSizes = {};
late Chat chat;
late StreamSubscription countSub;
final List<StreamSubscription> countSubs = [];
final Map<int, int> currentCounts = {};
final ChatMessages struct = ChatMessages();
late Function(Message) newFunc;
late Function(Message, {String? oldGuid}) updateFunc;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -155,7 +166,21 @@ class MessagesService extends GetxController {
List<Message> _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 = <String, Message>{};
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);
Expand Down Expand Up @@ -271,4 +296,4 @@ class MessagesService extends GetxController {

return completer.future;
}
}
}