Skip to content

Commit bcac6fc

Browse files
CubeRomanMagellanMagellan
authored andcommitted
SK-402: Implement 'Typing...' feature (#77)
* add typingMessageListener * add TypingManager * add send typing * add TypingIndicator * update TypingIndicator * fix stop typing when message received --------- Co-authored-by: Magellan <magellan@connectycube.com>
1 parent 01653af commit bcac6fc

21 files changed

Lines changed: 622 additions & 55 deletions

lib/src/api/api.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export 'chats/attachments_api.dart';
22
export 'chats/messages_api.dart';
33
export 'chats/realtime/messages_manager.dart';
44
export 'chats/realtime/models/models.dart';
5+
export 'chats/realtime/typing_manager.dart';
56
export 'connection/connection.dart';
67
export 'connection/exceptions.dart';
78
export 'conversations/models/models.dart';

lib/src/api/chats/messages_api.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const String messageEditRequestName = 'message_edit';
88
const String messagesListRequestName = 'message_list';
99
const String messagesReadRequestName = 'message_read';
1010
const String messagesDeleteRequestName = 'message_delete';
11+
const String messageTypingName = 'typing';
1112

1213
String linkPreviewUrl = dotenv.env['LINK_PREVIEW_URL'] ?? '';
1314

@@ -91,3 +92,12 @@ Future<LinkPreview> linkPreviewData(String url) {
9192
return LinkPreview.fromJson(response);
9293
});
9394
}
95+
96+
Future<bool> sendTypingStatus(TypingMessageStatus typing) {
97+
return SamaConnectionService.instance
98+
.sendRequest(messageTypingName, typing.toJson(), shouldAwaiting: false)
99+
.then((response) {
100+
print('sendTypingStatus response $response');
101+
return true;
102+
});
103+
}

lib/src/api/chats/realtime/messages_manager.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ class MessagesManager {
5353
Stream<DeleteMessagesStatus> get deletedMessageStatusStream =>
5454
_deletedMessagesStatusController.stream;
5555

56+
final StreamController<TypingMessageStatus> _typingStatusController =
57+
StreamController.broadcast();
58+
59+
Stream<TypingMessageStatus> get typingStatusStream =>
60+
_typingStatusController.stream;
61+
5662
_init() {
5763
if (dataListener != null) return;
5864

@@ -69,6 +75,8 @@ class MessagesManager {
6975
_processDeleteMessagePackage(data['message_delete']);
7076
} else if (data['system_message'] != null) {
7177
_processSystemMessagePackage(data['system_message']);
78+
} else if (data['typing'] != null) {
79+
_processTypingPackage(data['typing']);
7280
}
7381
});
7482
}
@@ -114,4 +122,9 @@ class MessagesManager {
114122
_systemChatMessagesController.add(systemMessage);
115123
}
116124
}
125+
126+
void _processTypingPackage(Map<String, dynamic> data) {
127+
var typingStatus = TypingMessageStatus.fromJson(data);
128+
_typingStatusController.add(typingStatus);
129+
}
117130
}

lib/src/api/chats/realtime/models/message_statuses.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,18 @@ class DeleteMessagesStatus extends MessageStatus {
8888
}
8989

9090
enum DeleteMessageType { myself, all }
91+
92+
class TypingMessageStatus {
93+
final String? cid; // cid
94+
final String? type; // c_type
95+
final String? from; // from
96+
final int? t; // t
97+
98+
TypingMessageStatus.fromJson(Map<String, dynamic> json)
99+
: cid = json['cid'],
100+
type = json['c_type'],
101+
from = json['from'],
102+
t = json['t'];
103+
104+
Map<String, dynamic> toJson() => {'cid': cid};
105+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'dart:async';
2+
3+
import 'package:equatable/equatable.dart';
4+
5+
import '../../api.dart';
6+
7+
const stopTypingTime = 6;
8+
9+
enum TypingState { start, stop }
10+
11+
class TypingStatus extends Equatable {
12+
final TypingState state;
13+
final String? cid;
14+
final String? from;
15+
16+
const TypingStatus(this.state, this.cid, this.from);
17+
18+
TypingStatus copyWith({
19+
TypingState? state,
20+
String? cid,
21+
String? from,
22+
}) {
23+
return TypingStatus(
24+
state ?? this.state,
25+
cid ?? this.cid,
26+
from ?? this.from,
27+
);
28+
}
29+
30+
@override
31+
List<Object?> get props => [state, cid, from];
32+
}
33+
34+
class TypingManager {
35+
TypingManager._() {
36+
_init();
37+
}
38+
39+
static final _instance = TypingManager._();
40+
41+
static TypingManager get instance {
42+
return _instance;
43+
}
44+
45+
Map<String, Timer> clearTypingTimers = {};
46+
StreamSubscription<TypingMessageStatus>? typingMessageSubscription;
47+
48+
final StreamController<TypingStatus> _typingStatusController =
49+
StreamController.broadcast();
50+
51+
Stream<TypingStatus> get typingStatusStream => _typingStatusController.stream;
52+
53+
_init() {
54+
typingMessageSubscription = MessagesManager.instance.typingStatusStream
55+
.listen((typingStatus) async {
56+
var typing =
57+
TypingStatus(TypingState.start, typingStatus.cid, typingStatus.from);
58+
_typingStatusController.add(typing);
59+
60+
restartTypingTimer(typingStatus.cid ?? '', () {
61+
_typingStatusController.add(typing.copyWith(state: TypingState.stop));
62+
});
63+
});
64+
}
65+
66+
void restartTypingTimer(String cid, void Function() callback) {
67+
clearTypingTimers[cid]?.cancel();
68+
var timer = Timer(const Duration(seconds: stopTypingTime), callback);
69+
clearTypingTimers[cid] = timer;
70+
}
71+
72+
destroy() {
73+
typingMessageSubscription?.cancel();
74+
clearTypingTimers.clear();
75+
}
76+
}

lib/src/api/connection/connection.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,19 @@ class SamaConnectionService {
121121
String? retryRequestId,
122122
Completer<Map<String, dynamic>>? retryCompleter,
123123
bool shouldRetry = true,
124+
bool shouldAwaiting = true,
124125
}) {
125126
var requestId = retryRequestId ??= const Uuid().v4().toString();
126127

127128
var requestCompleter = retryCompleter ??= Completer<Map<String, dynamic>>();
128129

129-
awaitingRequests[requestId] = RequestInfo(
130-
name: requestName,
131-
data: requestData,
132-
completer: requestCompleter,
133-
shouldRetry: shouldRetry);
130+
if (shouldAwaiting) {
131+
awaitingRequests[requestId] = RequestInfo(
132+
name: requestName,
133+
data: requestData,
134+
completer: requestCompleter,
135+
shouldRetry: shouldRetry);
136+
}
134137

135138
var request = {
136139
'request': {

lib/src/features/conversation/bloc/conversation_bloc.dart

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ EventTransformer<E> throttleDroppable<E>(Duration duration) {
2626
};
2727
}
2828

29-
EventTransformer<Event> debounce<Event>({
29+
EventTransformer<E> typingThrottleDroppable<E>() {
30+
Duration duration = const Duration(milliseconds: 5000);
31+
return (events, mapper) {
32+
return droppable<E>().call(events.throttle(duration), mapper);
33+
};
34+
}
35+
36+
EventTransformer<Event> readDebounce<Event>({
3037
Duration duration = const Duration(milliseconds: 500),
3138
}) {
3239
return (events, mapper) => events.debounce(duration).switchMap(mapper);
@@ -40,6 +47,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
4047

4148
StreamSubscription<ChatMessage>? incomingMessagesSubscription;
4249
StreamSubscription<MessageSendStatus>? statusMessagesSubscription;
50+
StreamSubscription<TypingStatus>? typingMessageSubscription;
4351
StreamSubscription<Map<String, dynamic>>? lastActivitySubscription;
4452
StreamSubscription<ConversationModel?>? conversationWatcher;
4553

@@ -76,7 +84,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
7684
);
7785
on<_ReadStatusReceived>(
7886
_onReadStatusReceived,
79-
transformer: debounce(),
87+
transformer: readDebounce(),
8088
);
8189
on<_FailedStatusReceived>(
8290
_onFailedStatusReceived,
@@ -87,6 +95,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
8795
on<ConversationDeleted>(
8896
_onConversationDeleted,
8997
);
98+
on<TypingStatusStartReceived>(
99+
_onTypingStatusStartReceived,
100+
transformer: typingThrottleDroppable(),
101+
);
102+
on<TypingStatusStopReceived>(
103+
_onTypingStatusStopReceived,
104+
);
90105

91106
add(const ParticipantsReceived());
92107

@@ -126,6 +141,17 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
126141
}
127142
});
128143

144+
typingMessageSubscription =
145+
messagesRepository.typingMessageStream.listen((typing) async {
146+
if (typing.cid == currentConversation.id) {
147+
if (typing.state == TypingState.start) {
148+
add(TypingStatusStartReceived(typing.from!));
149+
} else if (typing.state == TypingState.stop) {
150+
add(TypingStatusStopReceived(typing.from!));
151+
}
152+
}
153+
});
154+
129155
lastActivitySubscription =
130156
userRepository.lastActivityStream.listen((data) async {
131157
var recentActivity = data[currentConversation.opponent?.id];
@@ -270,6 +296,20 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
270296
: emit(state.copyWith(status: ConversationStatus.failure));
271297
}
272298

299+
Future<void> _onTypingStatusStartReceived(
300+
TypingStatusStartReceived event, Emitter<ConversationState> emit) async {
301+
var user = await userRepository.getUserById(event.from);
302+
emit(state.copyWith(
303+
typingStatus: TypingMessageStatus(TypingState.start, user)));
304+
}
305+
306+
Future<void> _onTypingStatusStopReceived(
307+
TypingStatusStopReceived event, Emitter<ConversationState> emit) async {
308+
var user = await userRepository.getUserById(event.from);
309+
emit(state.copyWith(
310+
typingStatus: TypingMessageStatus(TypingState.stop, user)));
311+
}
312+
273313
FutureOr<void> _onMessageReceived(
274314
_MessageReceived event, Emitter<ConversationState> emit) {
275315
var messages = [...state.messages];
@@ -365,6 +405,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
365405
unsubscribeOpponentLastActivity();
366406
incomingMessagesSubscription?.cancel();
367407
statusMessagesSubscription?.cancel();
408+
typingMessageSubscription?.cancel();
368409
lastActivitySubscription?.cancel();
369410
conversationWatcher?.cancel();
370411
return super.close();

lib/src/features/conversation/bloc/conversation_event.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ final class _ConversationUpdated extends ConversationEvent {
7171
final class ConversationDeleted extends ConversationEvent {
7272
const ConversationDeleted();
7373
}
74+
75+
final class TypingStatusStartReceived extends ConversationEvent {
76+
final String from;
77+
78+
const TypingStatusStartReceived(this.from);
79+
}
80+
81+
final class TypingStatusStopReceived extends ConversationEvent {
82+
final String from;
83+
84+
const TypingStatusStopReceived(this.from);
85+
}

lib/src/features/conversation/bloc/conversation_state.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ part of 'conversation_bloc.dart';
22

33
enum ConversationStatus { initial, success, failure, delete }
44

5+
class TypingMessageStatus {
6+
final TypingState typingState;
7+
final UserModel? user;
8+
9+
TypingMessageStatus(this.typingState, this.user);
10+
}
11+
512
final class ConversationState extends Equatable {
613
const ConversationState({
714
required this.conversation,
@@ -11,6 +18,7 @@ final class ConversationState extends Equatable {
1118
this.hasReachedMax = false,
1219
this.initial = false,
1320
this.draftMessage,
21+
this.typingStatus,
1422
});
1523

1624
final ConversationModel conversation;
@@ -20,6 +28,7 @@ final class ConversationState extends Equatable {
2028
final bool hasReachedMax;
2129
final bool initial;
2230
final MessageModel? draftMessage;
31+
final TypingMessageStatus? typingStatus;
2332

2433
ConversationState copyWith({
2534
ConversationModel? conversation,
@@ -29,6 +38,7 @@ final class ConversationState extends Equatable {
2938
bool? hasReachedMax,
3039
bool? initial,
3140
MessageModel? Function()? draftMessage,
41+
TypingMessageStatus? typingStatus,
3242
}) {
3343
return ConversationState(
3444
conversation: conversation ?? this.conversation,
@@ -38,6 +48,7 @@ final class ConversationState extends Equatable {
3848
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
3949
initial: initial ?? this.initial,
4050
draftMessage: draftMessage != null ? draftMessage() : this.draftMessage,
51+
typingStatus: typingStatus,
4152
);
4253
}
4354

@@ -54,6 +65,7 @@ final class ConversationState extends Equatable {
5465
hasReachedMax,
5566
initial,
5667
participants,
57-
draftMessage
68+
draftMessage,
69+
typingStatus
5870
];
5971
}

lib/src/features/conversation/bloc/send_message/send_message_bloc.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'dart:async';
22

3+
import 'package:bloc_concurrency/bloc_concurrency.dart';
34
import 'package:equatable/equatable.dart';
45
import 'package:flutter_bloc/flutter_bloc.dart';
6+
import 'package:stream_transform/stream_transform.dart';
57

68
import '../../../../api/api.dart';
79
import '../../../../db/models/conversation_model.dart';
@@ -12,6 +14,15 @@ part 'send_message_event.dart';
1214

1315
part 'send_message_state.dart';
1416

17+
const typingThrottleDuration = 5;
18+
19+
EventTransformer<E> typingThrottleDroppable<E>() {
20+
Duration duration = const Duration(seconds: typingThrottleDuration);
21+
return (events, mapper) {
22+
return droppable<E>().call(events.throttle(duration), mapper);
23+
};
24+
}
25+
1526
class SendMessageBloc extends Bloc<SendMessageEvent, SendMessageState> {
1627
final ConversationModel currentConversation;
1728
final ConversationRepository conversationRepository;
@@ -34,6 +45,8 @@ class SendMessageBloc extends Bloc<SendMessageEvent, SendMessageState> {
3445
on<SendStatusReadMessages>(
3546
_onSendStatusReadMessages,
3647
);
48+
on<SendTypingChanged>(_onSendTypingChanged,
49+
transformer: typingThrottleDroppable());
3750
}
3851

3952
Future<void> _onSendTextMessage(
@@ -75,6 +88,13 @@ class SendMessageBloc extends Bloc<SendMessageEvent, SendMessageState> {
7588
} catch (_) {}
7689
}
7790

91+
Future<FutureOr<void>> _onSendTypingChanged(
92+
SendTypingChanged event, Emitter<SendMessageState> emit) async {
93+
try {
94+
await messagesRepository.sendTypingStatus(currentConversation.id);
95+
} catch (_) {}
96+
}
97+
7898
saveDraftIfExist() {
7999
if (state.text.isNotEmpty) {
80100
messagesRepository.saveDraftMessage(state.text, currentConversation.id);

0 commit comments

Comments
 (0)