Skip to content

Commit 27040fd

Browse files
committed
Custom typing indicators
1 parent ac59407 commit 27040fd

File tree

14 files changed

+394
-149
lines changed

14 files changed

+394
-149
lines changed

.dart_tool/build/fcd1995bc647fb959e82ea360c6c2c9a/asset_graph.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

lib/app/layouts/conversation_view/widgets/message/typing/typing_indicator.dart

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:math' as math;
22

3+
import 'package:bluebubbles/app/components/avatars/contact_avatar_group_widget.dart';
34
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/typing/typing_clipper.dart';
45
import 'package:bluebubbles/app/components/avatars/contact_avatar_widget.dart';
56
import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart';
@@ -34,37 +35,38 @@ class _TypingIndicatorState extends OptimizedState<TypingIndicator> {
3435
clipper: const TypingClipper(),
3536
child: Container(
3637
height: 50,
37-
width: 80,
3838
color: context.theme.colorScheme.properSurface,
39-
child: Stack(
40-
alignment: Alignment.center,
39+
padding: const EdgeInsets.fromLTRB(30, 10, 14, 20),
40+
child: Row(
4141
children: [
42-
Positioned(
43-
top: 15,
44-
right: 12,
45-
child: Row(
46-
children: [
47-
AnimatedDot(index: 2),
48-
AnimatedDot(index: 1),
49-
AnimatedDot(index: 0),
50-
],
51-
mainAxisSize: MainAxisSize.min,
52-
),
53-
)
42+
if (widget.controller != null && widget.controller!.showTypingIndicatorFor.length == 1 && widget.controller!.typingIndicatorData[widget.controller!.showTypingIndicatorFor.first.address]?.$2 != null)
43+
Container(
44+
child: ClipRRect(child: Image.memory(widget.controller!.typingIndicatorData[widget.controller!.showTypingIndicatorFor.first.address]!.$2!), borderRadius: BorderRadius.circular(99),),
45+
padding: const EdgeInsets.symmetric(horizontal: 10),
46+
),
47+
AnimatedDot(index: 2),
48+
AnimatedDot(index: 1),
49+
AnimatedDot(index: 0),
5450
],
51+
mainAxisSize: MainAxisSize.min,
5552
),
5653
),
5754
) : Row(
5855
children: [
5956
Padding(
6057
padding: const EdgeInsets.only(left: 10, right: 10),
61-
child: ContactAvatarWidget(
62-
handle: cm.activeChat!.chat.participants.first,
58+
child: ContactAvatarGroupWidget(
59+
participants: [...(widget.controller?.showTypingIndicatorFor ?? [])],
6360
size: 25,
64-
fontSize: context.theme.textTheme.bodyMedium!.fontSize!,
65-
borderThickness: 0.1,
61+
editable: false,
6662
),
6763
),
64+
if (widget.controller != null && widget.controller!.showTypingIndicatorFor.length == 1 && widget.controller!.typingIndicatorData[widget.controller!.showTypingIndicatorFor.first.address]?.$2 != null)
65+
Container(
66+
child: ClipRRect(child: Image.memory(widget.controller!.typingIndicatorData[widget.controller!.showTypingIndicatorFor.first.address]!.$2!), borderRadius: BorderRadius.circular(99),),
67+
padding: const EdgeInsets.symmetric(horizontal: 10),
68+
height: 25,
69+
),
6870
AnimatedDot(index: 2),
6971
AnimatedDot(index: 1),
7072
AnimatedDot(index: 0),

lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
6262

6363
// typing indicators
6464
String oldText = "\n";
65-
Timer? _debounceTyping;
6665

6766
// previous text state
6867
TextSelection oldTextFieldSelection = const TextSelection.collapsed(offset: 0);
@@ -185,18 +184,8 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
185184
// typing indicators
186185
final newText = "${controller.subjectTextController.text}\n${controller.textController.text}";
187186
if (newText != oldText) {
188-
_debounceTyping?.cancel();
189187
oldText = newText;
190-
// don't send a bunch of duplicate events for every typing change
191-
if (ss.settings.enablePrivateAPI.value && (chat.autoSendTypingIndicators ?? ss.settings.privateSendTypingIndicators.value) && newText.trim() != "") {
192-
if (_debounceTyping == null) {
193-
backend.startedTyping(chat);
194-
}
195-
_debounceTyping = Timer(const Duration(seconds: 3), () {
196-
backend.stoppedTyping(chat);
197-
_debounceTyping = null;
198-
});
199-
}
188+
if (newText.trim() != "") controller.triggerTypingIndicator();
200189
}
201190
// emoji picker
202191
final _controller = subject ? controller.subjectTextController : controller.textController;
@@ -366,7 +355,7 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
366355
controller.subjectTextController.clear();
367356
controller.replyToMessage = null;
368357
controller.scheduledDate.value = null;
369-
_debounceTyping = null;
358+
controller.clearTypingState();
370359
// Remove the saved text field draft
371360
if ((chat.textFieldText ?? "").isNotEmpty) {
372361
chat.textFieldText = "";

lib/services/network/backend_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ abstract class BackendService {
4747
bool supportsFindMy();
4848
bool canCreateGroupChats();
4949
bool supportsSmsForwarding();
50-
void startedTyping(Chat c);
50+
void startedTyping(Chat c, [iMessageAppData? appdata]);
5151
void stoppedTyping(Chat c);
5252
void updateTypingStatus(Chat c);
5353
Future<bool> handleiMessageState(String address);

lib/services/network/http_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class HttpBackend implements BackendService {
4949
}
5050

5151
@override
52-
void startedTyping(Chat c) {
52+
void startedTyping(Chat c, [iMessageAppData? appdata]) {
5353
socket.sendMessage("started-typing", {"chatGuid": c.guid});
5454
}
5555

lib/services/rustpush/rustpush_service.dart

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,13 +1208,16 @@ class RustPushBackend implements BackendService {
12081208
}
12091209

12101210
@override
1211-
void startedTyping(Chat c) async {
1211+
void startedTyping(Chat c, [iMessageAppData? appdata]) async {
12121212
if (c.isRpSms) return;
12131213
var msg = await api.newMsg(
12141214
state: pushService.state,
12151215
conversation: await c.getConversationData(),
12161216
sender: await c.ensureHandle(),
1217-
message: const api.Message.typing(true)
1217+
message: api.Message.typing(true, appdata?.appIcon != null ? api.TypingApp(
1218+
bundleId: appdata!.bundleId,
1219+
icon: base64Decode(appdata.appIcon!),
1220+
) : null)
12181221
);
12191222
await sendMsg(msg);
12201223
}
@@ -3049,21 +3052,31 @@ class RustPushService extends GetxService {
30493052
final controller = cvc(chat);
30503053
var handle = RustPushBBUtils.rustHandleToBB(myMsg.sender!);
30513054

3052-
if (controller.cancelTypingIndicator[handle.address] != null) {
3053-
controller.cancelTypingIndicator[handle.address]?.cancel();
3054-
controller.cancelTypingIndicator.remove(handle.address);
3055+
if (controller.typingIndicatorData[handle.address] != null) {
3056+
controller.typingIndicatorData[handle.address]?.$1.cancel();
3057+
controller.typingIndicatorData.remove(handle.address);
30553058
}
30563059

3057-
if ((myMsg.message as api.Message_Typing).field0) {
3060+
var typing = myMsg.message as api.Message_Typing;
3061+
if (typing.field0) {
30583062
if (!controller.showTypingIndicatorFor.any((h) => handle.address == h.address)) {
30593063
controller.showTypingIndicatorFor.add(handle);
30603064
}
30613065
var future = Future.delayed(const Duration(minutes: 1));
30623066
var subscription = future.asStream().listen((any) {
30633067
controller.showTypingIndicatorFor.remove(handle);
3064-
controller.cancelTypingIndicator.remove(handle.address);
3068+
controller.typingIndicatorData.remove(handle.address);
30653069
});
3066-
controller.cancelTypingIndicator[handle.address] = subscription;
3070+
Uint8List? icon;
3071+
if (typing.field1 != null) {
3072+
String? i = es.cachedStatus.firstWhereOrNull((i) => i.madridBundleId == typing.field1!.bundleId)?.available?.icon;
3073+
if (i != null) {
3074+
icon = base64Decode(i);
3075+
} else {
3076+
icon = typing.field1!.icon;
3077+
}
3078+
}
3079+
controller.typingIndicatorData[handle.address] = (subscription, icon);
30673080
} else {
30683081
var existing = controller.showTypingIndicatorFor.firstWhereOrNull((h) => handle.address == h.address);
30693082
if (existing != null) {
@@ -3080,9 +3093,9 @@ class RustPushService extends GetxService {
30803093
if (existing != null) {
30813094
controller.showTypingIndicatorFor.remove(existing);
30823095
}
3083-
if (controller.cancelTypingIndicator[handle.address] != null) {
3084-
controller.cancelTypingIndicator[handle.address]?.cancel();
3085-
controller.cancelTypingIndicator.remove(handle.address);
3096+
if (controller.typingIndicatorData[handle.address] != null) {
3097+
controller.typingIndicatorData[handle.address]?.$1.cancel();
3098+
controller.typingIndicatorData.remove(handle.address);
30863099
}
30873100

30883101
if (chat.isRpSms && !myMsg.verificationFailed) {

lib/services/ui/chat/conversation_view_controller.dart

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:bluebubbles/app/components/custom_text_editing_controllers.dart'
66
import 'package:bluebubbles/app/layouts/settings/pages/profile/posterkit.dart';
77
import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart';
88
import 'package:bluebubbles/database/models.dart';
9+
import 'package:bluebubbles/services/network/backend_service.dart';
910
import 'package:bluebubbles/services/services.dart';
1011
import 'package:emojis/emoji.dart';
1112
import 'package:flutter/foundation.dart';
@@ -57,7 +58,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker
5758
final RxBool recipientNotifsSilenced = false.obs;
5859
bool showingOverlays = false;
5960
bool _subjectWasLastFocused = false; // If this is false, then message field was last focused (default)
60-
final Map<String, StreamSubscription<dynamic>> cancelTypingIndicator = {};
61+
final Map<String, (StreamSubscription<dynamic>, Uint8List?)> typingIndicatorData = {};
6162

6263
FocusNode get lastFocusedNode => _subjectWasLastFocused ? subjectFocusNode : focusNode;
6364
SpellCheckTextEditingController get lastFocusedTextController => _subjectWasLastFocused ? subjectTextController : textController;
@@ -103,6 +104,26 @@ class ConversationViewController extends StatefulController with GetSingleTicker
103104
Map<String, ui.Image> images = {};
104105

105106
final RxBool reportJunkAvailable = false.obs;
107+
Timer? _debounceTyping;
108+
109+
void clearTypingState() {
110+
_debounceTyping = null;
111+
}
112+
113+
void triggerTypingIndicator() {
114+
// don't send a bunch of duplicate events for every typing change
115+
if (!ss.settings.enablePrivateAPI.value || !(chat.autoSendTypingIndicators ?? ss.settings.privateSendTypingIndicators.value)) return;
116+
_debounceTyping?.cancel();
117+
if (_debounceTyping == null) {
118+
var a = pickedApp.value?.$2.appData?.firstOrNull;
119+
// only other app is Polls atm. Built-in apps have a circle icon which does not work with typing indicators.
120+
backend.startedTyping(chat, a?.appId != null ? a : null);
121+
}
122+
_debounceTyping = Timer(const Duration(seconds: 5), () {
123+
backend.stoppedTyping(chat);
124+
_debounceTyping = null;
125+
});
126+
}
106127

107128
void updateContactInfo() {
108129
if (chat.participants.length == 1) {

lib/services/ui/extension_service.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ class ExtensionService extends GetxService {
213213
}
214214

215215
cm.activeChat!.controller!.pickedApp.value = (file, payload);
216+
cm.activeChat!.controller!.triggerTypingIndicator();
216217
Logger.debug("set");
217218
}
218219
}

lib/src/rust/api/api.dart

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,8 +1907,9 @@ sealed class Message with _$Message {
19071907
const factory Message.delivered() = Message_Delivered;
19081908
const factory Message.read() = Message_Read;
19091909
const factory Message.typing(
1910-
bool field0,
1911-
) = Message_Typing;
1910+
bool field0, [
1911+
TypingApp? field1,
1912+
]) = Message_Typing;
19121913
const factory Message.unsend(
19131914
UnsendMessage field0,
19141915
) = Message_Unsend;
@@ -3475,6 +3476,27 @@ class TrustedPhoneNumber {
34753476
id == other.id;
34763477
}
34773478

3479+
class TypingApp {
3480+
final String bundleId;
3481+
final Uint8List icon;
3482+
3483+
const TypingApp({
3484+
required this.bundleId,
3485+
required this.icon,
3486+
});
3487+
3488+
@override
3489+
int get hashCode => bundleId.hashCode ^ icon.hashCode;
3490+
3491+
@override
3492+
bool operator ==(Object other) =>
3493+
identical(this, other) ||
3494+
other is TypingApp &&
3495+
runtimeType == other.runtimeType &&
3496+
bundleId == other.bundleId &&
3497+
icon == other.icon;
3498+
}
3499+
34783500
@freezed
34793501
sealed class UIColor with _$UIColor {
34803502
const UIColor._();

0 commit comments

Comments
 (0)