Implemented per stream subscription recovery with polling fallback
This commit is contained in:
+118
-104
@@ -30,11 +30,12 @@ class TypingIndicatorState {
|
||||
}
|
||||
}
|
||||
|
||||
final typingIndicatorProvider = StateNotifierProvider.autoDispose
|
||||
.family<TypingIndicatorController, TypingIndicatorState, String>((
|
||||
ref,
|
||||
ticketId,
|
||||
) {
|
||||
final typingIndicatorProvider =
|
||||
StateNotifierProvider.family<
|
||||
TypingIndicatorController,
|
||||
TypingIndicatorState,
|
||||
String
|
||||
>((ref, ticketId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final controller = TypingIndicatorController(client, ticketId);
|
||||
return controller;
|
||||
@@ -65,145 +66,158 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||
channel.onBroadcast(
|
||||
event: 'typing',
|
||||
callback: (payload) {
|
||||
// Prevent any work if we're already disposing. Log stack for diagnostics.
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController: onBroadcast skipped (disposed|unmounted)',
|
||||
);
|
||||
debugPrint(StackTrace.current.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (_disposed || !mounted) return;
|
||||
|
||||
final Map<String, dynamic> data = _extractPayload(payload);
|
||||
final userId = data['user_id'] as String?;
|
||||
final rawType = data['type']?.toString();
|
||||
final currentUserId = _client.auth.currentUser?.id;
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController: payload received but controller disposed/unmounted',
|
||||
);
|
||||
debugPrint(StackTrace.current.toString());
|
||||
final Map<String, dynamic> data = _extractPayload(payload);
|
||||
final userId = data['user_id'] as String?;
|
||||
final rawType = data['type']?.toString();
|
||||
final currentUserId = _client.auth.currentUser?.id;
|
||||
if (_disposed || !mounted) return;
|
||||
state = state.copyWith(lastPayload: data);
|
||||
if (userId == null || userId == currentUserId) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
if (rawType == 'stop') {
|
||||
_clearRemoteTyping(userId);
|
||||
return;
|
||||
}
|
||||
_markRemoteTyping(userId);
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController: broadcast callback error: $e\n$st',
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastPayload: data);
|
||||
if (userId == null || userId == currentUserId) {
|
||||
return;
|
||||
}
|
||||
if (rawType == 'stop') {
|
||||
_clearRemoteTyping(userId);
|
||||
return;
|
||||
}
|
||||
_markRemoteTyping(userId);
|
||||
},
|
||||
);
|
||||
channel.subscribe((status, error) {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)',
|
||||
);
|
||||
debugPrint(StackTrace.current.toString());
|
||||
try {
|
||||
if (_disposed || !mounted) return;
|
||||
state = state.copyWith(channelStatus: status.name);
|
||||
if (error != null) {
|
||||
debugPrint('TypingIndicatorController: subscribe error: $error');
|
||||
}
|
||||
return;
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController: subscribe callback error: $e\n$st',
|
||||
);
|
||||
}
|
||||
state = state.copyWith(channelStatus: status.name);
|
||||
});
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractPayload(dynamic payload) {
|
||||
if (payload is Map<String, dynamic>) {
|
||||
final inner = payload['payload'];
|
||||
if (inner is Map<String, dynamic>) {
|
||||
return inner;
|
||||
// The realtime client can wrap the actual broadcast payload inside
|
||||
// several nested fields (e.g. {payload: {payload: {...}}}). Walk the
|
||||
// object until we find a map containing `user_id` or `type` keys which
|
||||
// represent the actual typing payload.
|
||||
try {
|
||||
dynamic current = payload;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
if (current is Map<String, dynamic>) {
|
||||
// Only return when we actually find the `user_id`, otherwise try
|
||||
// to unwrap nested envelopes. Some wrappers include `type: broadcast`
|
||||
// at the top-level which should not be treated as the message.
|
||||
if (current.containsKey('user_id')) {
|
||||
return Map<String, dynamic>.from(current);
|
||||
}
|
||||
if (current.containsKey('payload') &&
|
||||
current['payload'] is Map<String, dynamic>) {
|
||||
current = current['payload'];
|
||||
continue;
|
||||
}
|
||||
// Some realtime envelope stores the payload at `data`.
|
||||
if (current.containsKey('data') &&
|
||||
current['data'] is Map<String, dynamic>) {
|
||||
current = current['data'];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Try common field on wrapper objects (e.g. RealtimeMessage.payload)
|
||||
try {
|
||||
final dyn = (current as dynamic).payload;
|
||||
if (dyn is Map<String, dynamic>) {
|
||||
current = dyn;
|
||||
continue;
|
||||
}
|
||||
} catch (_) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
final dynamic inner = payload.payload;
|
||||
if (inner is Map<String, dynamic>) {
|
||||
return inner;
|
||||
}
|
||||
} catch (_) {}
|
||||
// As a last-resort, do a shallow recursive search for a map containing
|
||||
// `user_id` in case the realtime client used a Map<dynamic,dynamic>
|
||||
// shape that wasn't caught above.
|
||||
try {
|
||||
Map<String, dynamic>? found;
|
||||
void search(dynamic node, int depth) {
|
||||
if (found != null || depth > 4) return;
|
||||
if (node is Map) {
|
||||
try {
|
||||
final m = Map<String, dynamic>.from(node);
|
||||
if (m.containsKey('user_id')) {
|
||||
found = m;
|
||||
return;
|
||||
}
|
||||
for (final v in m.values) {
|
||||
search(v, depth + 1);
|
||||
if (found != null) return;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore conversion errors
|
||||
}
|
||||
} else if (node is Iterable) {
|
||||
for (final v in node) {
|
||||
search(v, depth + 1);
|
||||
if (found != null) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
search(payload, 0);
|
||||
if (found != null) return found!;
|
||||
} catch (_) {}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
void userTyping() {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController.userTyping() ignored after dispose',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_disposed || !mounted) return;
|
||||
if (_client.auth.currentUser?.id == null) return;
|
||||
_sendTypingEvent('start');
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController._typingTimer callback ignored after dispose',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Debounce sending the stop event slightly so quick pauses don't spam
|
||||
// the network. 150ms was short and caused frequent start/stop bursts;
|
||||
// increase to 600ms to stabilize UX.
|
||||
_typingTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
if (_disposed || !mounted) return;
|
||||
_sendTypingEvent('stop');
|
||||
});
|
||||
}
|
||||
|
||||
void stopTyping() {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController.stopTyping() ignored after dispose',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_disposed || !mounted) return;
|
||||
_typingTimer?.cancel();
|
||||
_sendTypingEvent('stop');
|
||||
}
|
||||
|
||||
void _markRemoteTyping(String userId) {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController._markRemoteTyping ignored after dispose for user: $userId',
|
||||
);
|
||||
debugPrint(StackTrace.current.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_disposed || !mounted) return;
|
||||
final updated = {...state.userIds, userId};
|
||||
if (_disposed || !mounted) return;
|
||||
state = state.copyWith(userIds: updated);
|
||||
_remoteTimeouts[userId]?.cancel();
|
||||
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Extend timeout to 2500ms to accommodate brief realtime interruptions
|
||||
// (auth refresh, channel reconnect, message processing, etc.) without
|
||||
// clearing the presence. This gives a smoother typing experience.
|
||||
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 3500), () {
|
||||
if (_disposed || !mounted) return;
|
||||
_clearRemoteTyping(userId);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRemoteTyping(String userId) {
|
||||
if (_disposed || !mounted) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_disposed || !mounted) return;
|
||||
final updated = {...state.userIds}..remove(userId);
|
||||
if (_disposed || !mounted) return;
|
||||
state = state.copyWith(userIds: updated);
|
||||
|
||||
Reference in New Issue
Block a user