271 lines
8.4 KiB
Dart
271 lines
8.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import 'supabase_provider.dart';
|
|
|
|
class TypingIndicatorState {
|
|
const TypingIndicatorState({
|
|
required this.userIds,
|
|
required this.channelStatus,
|
|
required this.lastPayload,
|
|
});
|
|
|
|
final Set<String> userIds;
|
|
final String channelStatus;
|
|
final Map<String, dynamic> lastPayload;
|
|
|
|
TypingIndicatorState copyWith({
|
|
Set<String>? userIds,
|
|
String? channelStatus,
|
|
Map<String, dynamic>? lastPayload,
|
|
}) {
|
|
return TypingIndicatorState(
|
|
userIds: userIds ?? this.userIds,
|
|
channelStatus: channelStatus ?? this.channelStatus,
|
|
lastPayload: lastPayload ?? this.lastPayload,
|
|
);
|
|
}
|
|
}
|
|
|
|
final typingIndicatorProvider =
|
|
StateNotifierProvider.family<
|
|
TypingIndicatorController,
|
|
TypingIndicatorState,
|
|
String
|
|
>((ref, ticketId) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final controller = TypingIndicatorController(client, ticketId);
|
|
return controller;
|
|
});
|
|
|
|
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
|
TypingIndicatorController(this._client, this._ticketId)
|
|
: super(
|
|
const TypingIndicatorState(
|
|
userIds: {},
|
|
channelStatus: 'init',
|
|
lastPayload: {},
|
|
),
|
|
) {
|
|
_initChannel();
|
|
}
|
|
|
|
final SupabaseClient _client;
|
|
final String _ticketId;
|
|
RealtimeChannel? _channel;
|
|
Timer? _typingTimer;
|
|
final Map<String, Timer> _remoteTimeouts = {};
|
|
// Marked when dispose() starts to prevent late async callbacks mutating state.
|
|
bool _disposed = false;
|
|
|
|
void _initChannel() {
|
|
final channel = _client.channel('typing:$_ticketId');
|
|
channel.onBroadcast(
|
|
event: 'typing',
|
|
callback: (payload) {
|
|
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) return;
|
|
state = state.copyWith(lastPayload: data);
|
|
if (userId == null || userId == currentUserId) {
|
|
return;
|
|
}
|
|
if (rawType == 'stop') {
|
|
_clearRemoteTyping(userId);
|
|
return;
|
|
}
|
|
_markRemoteTyping(userId);
|
|
} catch (e, st) {
|
|
debugPrint(
|
|
'TypingIndicatorController: broadcast callback error: $e\n$st',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
channel.subscribe((status, error) {
|
|
try {
|
|
if (_disposed || !mounted) return;
|
|
state = state.copyWith(channelStatus: status.name);
|
|
if (error != null) {
|
|
debugPrint('TypingIndicatorController: subscribe error: $error');
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint(
|
|
'TypingIndicatorController: subscribe callback error: $e\n$st',
|
|
);
|
|
}
|
|
});
|
|
_channel = channel;
|
|
}
|
|
|
|
Map<String, dynamic> _extractPayload(dynamic payload) {
|
|
// 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;
|
|
}
|
|
}
|
|
} 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) return;
|
|
if (_client.auth.currentUser?.id == null) return;
|
|
_sendTypingEvent('start');
|
|
_typingTimer?.cancel();
|
|
// 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) return;
|
|
_typingTimer?.cancel();
|
|
_sendTypingEvent('stop');
|
|
}
|
|
|
|
void _markRemoteTyping(String userId) {
|
|
if (_disposed || !mounted) return;
|
|
final updated = {...state.userIds, userId};
|
|
if (_disposed || !mounted) return;
|
|
state = state.copyWith(userIds: updated);
|
|
_remoteTimeouts[userId]?.cancel();
|
|
// 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) return;
|
|
final updated = {...state.userIds}..remove(userId);
|
|
if (_disposed || !mounted) return;
|
|
state = state.copyWith(userIds: updated);
|
|
_remoteTimeouts[userId]?.cancel();
|
|
_remoteTimeouts.remove(userId);
|
|
}
|
|
|
|
void _sendTypingEvent(String type) {
|
|
if (_disposed || !mounted) return;
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null || _channel == null) return;
|
|
_channel!.sendBroadcastMessage(
|
|
event: 'typing',
|
|
payload: {'user_id': userId, 'type': type},
|
|
);
|
|
}
|
|
|
|
// Exposed for tests only: simulate a remote typing broadcast.
|
|
@visibleForTesting
|
|
void debugSimulateRemoteTyping(String userId, {bool stop = false}) {
|
|
if (_disposed || !mounted) return;
|
|
final data = {'user_id': userId, 'type': stop ? 'stop' : 'start'};
|
|
state = state.copyWith(lastPayload: data);
|
|
if (stop) {
|
|
_clearRemoteTyping(userId);
|
|
} else {
|
|
_markRemoteTyping(userId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Mark disposed first so any late async callbacks will no-op.
|
|
_disposed = true;
|
|
|
|
// Cancel local timers and remote timeouts; do NOT send network events during
|
|
// dispose (prevents broadcasts from re-entering callbacks after disposal).
|
|
_typingTimer?.cancel();
|
|
for (final timer in _remoteTimeouts.values) {
|
|
timer.cancel();
|
|
}
|
|
_remoteTimeouts.clear();
|
|
|
|
// Unsubscribe from realtime channel.
|
|
_channel?.unsubscribe();
|
|
_channel = null;
|
|
|
|
super.dispose();
|
|
}
|
|
}
|