Skip to content
Open
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
2 changes: 2 additions & 0 deletions lib/services/backend/filesystem/filesystem_service.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:bluebubbles/helpers/helpers.dart';
import 'package:bluebubbles/database/database.dart' as db;
import 'package:bluebubbles/services/ui/contact_service.dart';
import 'package:bluebubbles/services/ui/chat/chat_manager.dart';
import 'package:bluebubbles/utils/logger/logger.dart';
import 'package:collection/collection.dart';
import 'package:device_info_plus/device_info_plus.dart';
Expand Down Expand Up @@ -114,6 +115,7 @@ class FilesystemService extends GetxService {
if (kIsWeb) return;
db.Database.reset();
cs.contacts.clear();
cm.clearTyping();
}

String uriToFilename(String? uri, String? mimeType) {
Expand Down
41 changes: 12 additions & 29 deletions lib/services/rustpush/rustpush_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3729,25 +3729,18 @@ class RustPushService extends GetxService {
return;
}
if (myMsg.message is api.Message_Typing) {
if (myMsg.verificationFailed) return;
final controller = cvc(chat);
if (myMsg.verificationFailed) return;
// typing state lives on ChatManager (not the view controller) so it survives leaving the chat
final typingState = cm.typingFor(chat.guid);
var handle = RustPushBBUtils.rustHandleToBB(myMsg.sender!);

if (controller.typingIndicatorData[handle.address] != null) {
controller.typingIndicatorData[handle.address]?.$1.cancel();
controller.typingIndicatorData.remove(handle.address);
}
// a fresh packet restarts the expiry clock
typingState.data.remove(handle.address)?.$1.cancel();

var typing = myMsg.message as api.Message_Typing;
if (typing.field0) {
if (!controller.showTypingIndicatorFor.any((h) => handle.address == h.address)) {
controller.showTypingIndicatorFor.add(handle);
if (!typingState.handles.any((h) => handle.address == h.address)) {
typingState.handles.add(handle);
}
var future = Future.delayed(const Duration(minutes: 1));
var subscription = future.asStream().listen((any) {
controller.showTypingIndicatorFor.remove(handle);
controller.typingIndicatorData.remove(handle.address);
});
Uint8List? icon;
if (typing.field1 != null) {
String? i = es.cachedStatus.firstWhereOrNull((i) => i.madridBundleId == typing.field1!.bundleId)?.available?.icon;
Expand All @@ -3757,27 +3750,17 @@ class RustPushService extends GetxService {
icon = typing.field1!.icon;
}
}
controller.typingIndicatorData[handle.address] = (subscription, icon);
// iMessage only announces when typing starts, so expire on our own if nothing follows
final timer = Timer(const Duration(minutes: 1), () => typingState.stop(handle.address));
typingState.data[handle.address] = (timer, icon);
} else {
var existing = controller.showTypingIndicatorFor.firstWhereOrNull((h) => handle.address == h.address);
if (existing != null) {
controller.showTypingIndicatorFor.remove(existing);
}
typingState.stop(handle.address);
}
return;
}
if (myMsg.message is api.Message_Message) {
final controller = cvc(chat);

var handle = RustPushBBUtils.rustHandleToBB(myMsg.sender!);
var existing = controller.showTypingIndicatorFor.firstWhereOrNull((h) => handle.address == h.address);
if (existing != null) {
controller.showTypingIndicatorFor.remove(existing);
}
if (controller.typingIndicatorData[handle.address] != null) {
controller.typingIndicatorData[handle.address]?.$1.cancel();
controller.typingIndicatorData.remove(handle.address);
}
cm.typingFor(chat.guid).stop(handle.address);

if (chat.isRpSms && !myMsg.verificationFailed) {
var myHandles = await api.getMyPhoneHandles(state: pushService.state!.client);
Expand Down
32 changes: 32 additions & 0 deletions lib/services/ui/chat/chat_manager.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:bluebubbles/utils/logger/logger.dart';
import 'package:bluebubbles/database/models.dart';
Expand All @@ -16,6 +17,18 @@ class ChatManager extends GetxService {
ChatLifecycleManager? activeChat;
api.ChannelInterestToken? provider;
final Map<String, ChatLifecycleManager> _chatControllers = {};
/// Typing indicator state keyed by chat guid. Lives here rather than on the
/// ConversationViewController so it survives leaving and re-entering a chat.
final Map<String, TypingState> _typing = {};

TypingState typingFor(String guid) => _typing.putIfAbsent(guid, TypingState.new);

void clearTyping() {
for (final state in _typing.values) {
state.clear();
}
_typing.clear();
}

/// Same as setAllInactive but but removes lastOpenedChat from prefs on next frame
void setAllInactiveSync({save = true, bool clearActive = true}) {
Expand Down Expand Up @@ -228,3 +241,22 @@ class ChatManager extends GetxService {
return completer.future;
}
}

/// Who is currently typing in a chat, with a per-handle expiry timer and optional app icon.
class TypingState {
final RxList<Handle> handles = <Handle>[].obs;
final Map<String, (Timer, Uint8List?)> data = {};

void stop(String address) {
data.remove(address)?.$1.cancel();
handles.removeWhere((h) => h.address == address);
}

void clear() {
for (final entry in data.values) {
entry.$1.cancel();
}
data.clear();
handles.clear();
}
}
5 changes: 3 additions & 2 deletions lib/services/ui/chat/conversation_view_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class ConversationViewController extends StatefulController with GetSingleTicker
final Map<String, List<EntityAnnotation>> mlKitParsedText = {};

// message view items
final RxList<Handle> showTypingIndicatorFor = <Handle>[].obs;
// typing state is owned by ChatManager so it outlives this controller (see cm.typingFor)
RxList<Handle> get showTypingIndicatorFor => cm.typingFor(chat.guid).handles;
final RxBool showScrollDown = false.obs;
final RxDouble timestampOffset = 0.0.obs;
final RxBool inSelectMode = false.obs;
Expand All @@ -58,7 +59,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker
final RxBool recipientNotifsSilenced = false.obs;
bool showingOverlays = false;
bool _subjectWasLastFocused = false; // If this is false, then message field was last focused (default)
final Map<String, (StreamSubscription<dynamic>, Uint8List?)> typingIndicatorData = {};
Map<String, (Timer, Uint8List?)> get typingIndicatorData => cm.typingFor(chat.guid).data;

FocusNode get lastFocusedNode => _subjectWasLastFocused ? subjectFocusNode : focusNode;
SpellCheckTextEditingController get lastFocusedTextController => _subjectWasLastFocused ? subjectTextController : textController;
Expand Down