From 3379dcb7f35d87c9c6e8abcc6af97df9f0b16eb3 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 27 Sep 2025 19:25:24 +0800 Subject: [PATCH] :sparkles: Dynamic chat online counter basis --- lib/pods/{ => chat}/call.dart | 0 lib/pods/{ => chat}/call.freezed.dart | 0 lib/pods/{ => chat}/call.g.dart | 0 lib/pods/chat/chat_online_count.dart | 50 ++++ lib/pods/chat/chat_online_count.g.dart | 168 +++++++++++++ lib/pods/chat/chat_rooms.dart | 5 + lib/pods/chat/chat_subscribe.dart | 219 +++++++++++++++++ lib/pods/chat/chat_subscribe.g.dart | 176 ++++++++++++++ lib/pods/{ => chat}/chat_summary.dart | 0 lib/pods/{ => chat}/chat_summary.g.dart | 2 +- lib/pods/{ => chat}/messages_notifier.dart | 19 +- lib/pods/{ => chat}/messages_notifier.g.dart | 2 +- lib/pods/config.g.dart | 2 +- lib/pods/{chat_rooms.dart => lifecycle.dart} | 4 - lib/screens/chat/call.dart | 2 +- lib/screens/chat/chat.dart | 4 +- lib/screens/chat/public_room_preview.dart | 2 +- lib/screens/chat/room.dart | 239 ++++--------------- lib/screens/chat/search_messages.dart | 2 +- lib/widgets/chat/call_button.dart | 2 +- lib/widgets/chat/call_overlay.dart | 2 +- lib/widgets/chat/call_participant_card.dart | 2 +- lib/widgets/chat/call_participant_tile.dart | 2 +- lib/widgets/chat/message_content.dart | 2 +- lib/widgets/chat/message_item.dart | 2 +- lib/widgets/chat/public_room_preview.dart | 2 +- 26 files changed, 694 insertions(+), 216 deletions(-) rename lib/pods/{ => chat}/call.dart (100%) rename lib/pods/{ => chat}/call.freezed.dart (100%) rename lib/pods/{ => chat}/call.g.dart (100%) create mode 100644 lib/pods/chat/chat_online_count.dart create mode 100644 lib/pods/chat/chat_online_count.g.dart create mode 100644 lib/pods/chat/chat_rooms.dart create mode 100644 lib/pods/chat/chat_subscribe.dart create mode 100644 lib/pods/chat/chat_subscribe.g.dart rename lib/pods/{ => chat}/chat_summary.dart (100%) rename lib/pods/{ => chat}/chat_summary.g.dart (92%) rename lib/pods/{ => chat}/messages_notifier.dart (98%) rename lib/pods/{ => chat}/messages_notifier.g.dart (98%) rename lib/pods/{chat_rooms.dart => lifecycle.dart} (84%) diff --git a/lib/pods/call.dart b/lib/pods/chat/call.dart similarity index 100% rename from lib/pods/call.dart rename to lib/pods/chat/call.dart diff --git a/lib/pods/call.freezed.dart b/lib/pods/chat/call.freezed.dart similarity index 100% rename from lib/pods/call.freezed.dart rename to lib/pods/chat/call.freezed.dart diff --git a/lib/pods/call.g.dart b/lib/pods/chat/call.g.dart similarity index 100% rename from lib/pods/call.g.dart rename to lib/pods/chat/call.g.dart diff --git a/lib/pods/chat/chat_online_count.dart b/lib/pods/chat/chat_online_count.dart new file mode 100644 index 00000000..070ed996 --- /dev/null +++ b/lib/pods/chat/chat_online_count.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/pods/websocket.dart'; +import 'package:island/models/account.dart'; + +part 'chat_online_count.g.dart'; + +@riverpod +class ChatOnlineCountNotifier extends _$ChatOnlineCountNotifier { + @override + Future build(String chatroomId) async { + final apiClient = ref.watch(apiClientProvider); + final ws = ref.watch(websocketProvider); + + // Fetch initial online count + final response = await apiClient.get( + '/sphere/chat/$chatroomId/members/online', + ); + final initialCount = response.data as int; + + // Listen for websocket status updates + final subscription = ws.dataStream.listen((WebSocketPacket packet) { + if (packet.type == 'accounts.status.update') { + final data = packet.data; + if (data != null && data['chat_room_id'] == chatroomId) { + final status = SnAccountStatus.fromJson(data['status']); + var delta = status.isOnline ? 1 : -1; + if (status.clearedAt != null && + status.clearedAt!.isBefore(DateTime.now())) { + if (status.isInvisible) delta = 1; + } + // Update count based on online status + state.whenData((currentCount) { + final newCount = currentCount + delta; + state = AsyncData( + newCount.clamp(0, double.infinity).toInt(), + ); // Ensure non-negative + }); + } + } + }); + + ref.onDispose(() { + subscription.cancel(); + }); + + return initialCount; + } +} diff --git a/lib/pods/chat/chat_online_count.g.dart b/lib/pods/chat/chat_online_count.g.dart new file mode 100644 index 00000000..3d0705c6 --- /dev/null +++ b/lib/pods/chat/chat_online_count.g.dart @@ -0,0 +1,168 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_online_count.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatOnlineCountNotifierHash() => + r'254ed141ffd99585d898203b3d2b86c4d18db80d'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ChatOnlineCountNotifier + extends BuildlessAutoDisposeAsyncNotifier { + late final String chatroomId; + + FutureOr build(String chatroomId); +} + +/// See also [ChatOnlineCountNotifier]. +@ProviderFor(ChatOnlineCountNotifier) +const chatOnlineCountNotifierProvider = ChatOnlineCountNotifierFamily(); + +/// See also [ChatOnlineCountNotifier]. +class ChatOnlineCountNotifierFamily extends Family> { + /// See also [ChatOnlineCountNotifier]. + const ChatOnlineCountNotifierFamily(); + + /// See also [ChatOnlineCountNotifier]. + ChatOnlineCountNotifierProvider call(String chatroomId) { + return ChatOnlineCountNotifierProvider(chatroomId); + } + + @override + ChatOnlineCountNotifierProvider getProviderOverride( + covariant ChatOnlineCountNotifierProvider provider, + ) { + return call(provider.chatroomId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'chatOnlineCountNotifierProvider'; +} + +/// See also [ChatOnlineCountNotifier]. +class ChatOnlineCountNotifierProvider + extends AutoDisposeAsyncNotifierProviderImpl { + /// See also [ChatOnlineCountNotifier]. + ChatOnlineCountNotifierProvider(String chatroomId) + : this._internal( + () => ChatOnlineCountNotifier()..chatroomId = chatroomId, + from: chatOnlineCountNotifierProvider, + name: r'chatOnlineCountNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatOnlineCountNotifierHash, + dependencies: ChatOnlineCountNotifierFamily._dependencies, + allTransitiveDependencies: + ChatOnlineCountNotifierFamily._allTransitiveDependencies, + chatroomId: chatroomId, + ); + + ChatOnlineCountNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.chatroomId, + }) : super.internal(); + + final String chatroomId; + + @override + FutureOr runNotifierBuild(covariant ChatOnlineCountNotifier notifier) { + return notifier.build(chatroomId); + } + + @override + Override overrideWith(ChatOnlineCountNotifier Function() create) { + return ProviderOverride( + origin: this, + override: ChatOnlineCountNotifierProvider._internal( + () => create()..chatroomId = chatroomId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + chatroomId: chatroomId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement + createElement() { + return _ChatOnlineCountNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ChatOnlineCountNotifierProvider && + other.chatroomId == chatroomId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, chatroomId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ChatOnlineCountNotifierRef on AutoDisposeAsyncNotifierProviderRef { + /// The parameter `chatroomId` of this provider. + String get chatroomId; +} + +class _ChatOnlineCountNotifierProviderElement + extends + AutoDisposeAsyncNotifierProviderElement + with ChatOnlineCountNotifierRef { + _ChatOnlineCountNotifierProviderElement(super.provider); + + @override + String get chatroomId => + (origin as ChatOnlineCountNotifierProvider).chatroomId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/pods/chat/chat_rooms.dart b/lib/pods/chat/chat_rooms.dart new file mode 100644 index 00000000..3667bd81 --- /dev/null +++ b/lib/pods/chat/chat_rooms.dart @@ -0,0 +1,5 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; + +final isSyncingProvider = StateProvider.autoDispose((ref) => false); + +final flashingMessagesProvider = StateProvider>((ref) => {}); diff --git a/lib/pods/chat/chat_subscribe.dart b/lib/pods/chat/chat_subscribe.dart new file mode 100644 index 00000000..e4fde224 --- /dev/null +++ b/lib/pods/chat/chat_subscribe.dart @@ -0,0 +1,219 @@ +import "dart:async"; +import "dart:convert"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:island/models/chat.dart"; +import "package:island/pods/lifecycle.dart"; +import "package:island/pods/chat/messages_notifier.dart"; +import "package:island/pods/websocket.dart"; +import "package:island/screens/chat/chat.dart"; +import "package:island/widgets/chat/call_button.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +part 'chat_subscribe.g.dart'; + +@riverpod +class ChatSubscribeNotifier extends _$ChatSubscribeNotifier { + late final String _roomId; + late final SnChatRoom _chatRoom; + late final SnChatMember _chatIdentity; + late final MessagesNotifier _messagesNotifier; + + final List _typingStatuses = []; + Timer? _typingCleanupTimer; + Timer? _typingCooldownTimer; + Timer? _periodicSubscribeTimer; + StreamSubscription? _wsSubscription; + + @override + List build(String roomId) { + _roomId = roomId; + final ws = ref.watch(websocketProvider); + final chatRoomAsync = ref.watch(chatroomProvider(roomId)); + final chatIdentityAsync = ref.watch(chatroomIdentityProvider(roomId)); + _messagesNotifier = ref.watch(messagesNotifierProvider(roomId).notifier); + + if (chatRoomAsync.isLoading || chatIdentityAsync.isLoading) { + return []; + } + + if (chatRoomAsync.value == null || chatIdentityAsync.value == null) { + return []; + } + + _chatRoom = chatRoomAsync.value!; + _chatIdentity = chatIdentityAsync.value!; + + // Subscribe to messages + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.subscribe', + data: {'chat_room_id': roomId}, + endpoint: 'sphere', + ), + ), + ); + + // Send initial read receipt + sendReadReceipt(); + + // Set up WebSocket listener + _wsSubscription = ws.dataStream.listen(onMessage); + + // Set up typing status cleanup timer + _typingCleanupTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (_typingStatuses.isNotEmpty) { + // Remove typing statuses older than 5 seconds + final now = DateTime.now(); + _typingStatuses.removeWhere((member) { + final lastTyped = + member.lastTyped ?? + DateTime.now().subtract(const Duration(milliseconds: 1350)); + return now.difference(lastTyped).inSeconds > 5; + }); + state = List.of(_typingStatuses); + } + }); + + // Set up periodic subscribe timer (every 5 minutes) + _periodicSubscribeTimer = Timer.periodic(const Duration(minutes: 5), (_) { + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.subscribe', + data: {'chat_room_id': roomId}, + endpoint: 'sphere', + ), + ), + ); + }); + + // Listen to app lifecycle changes + ref.listen(appLifecycleStateProvider, (previous, next) { + final lifecycleState = next.value; + if (lifecycleState == AppLifecycleState.paused || + lifecycleState == AppLifecycleState.inactive) { + // Unsubscribe when app goes to background + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.unsubscribe', + data: {'chat_room_id': roomId}, + ), + ), + ); + } else if (lifecycleState == AppLifecycleState.resumed) { + // Resubscribe when app comes back to foreground + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.subscribe', + data: {'chat_room_id': roomId}, + ), + ), + ); + } + }); + + // Cleanup on dispose + ref.onDispose(() { + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.unsubscribe', + data: {'chat_room_id': roomId}, + ), + ), + ); + _wsSubscription?.cancel(); + _typingCleanupTimer?.cancel(); + _typingCooldownTimer?.cancel(); + _periodicSubscribeTimer?.cancel(); + }); + + return _typingStatuses; + } + + void onMessage(WebSocketPacket pkt) { + if (!pkt.type.startsWith('messages')) return; + if (['messages.read'].contains(pkt.type)) return; + + if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) { + if (pkt.data?['room_id'] != _chatRoom.id) return; + if (pkt.data?['sender_id'] == _chatIdentity.id) return; + + final sender = SnChatMember.fromJson( + pkt.data?['sender'], + ).copyWith(lastTyped: DateTime.now()); + + // Check if the sender is already in the typing list + final existingIndex = _typingStatuses.indexWhere( + (member) => member.id == sender.id, + ); + if (existingIndex >= 0) { + // Update the existing entry with new timestamp + _typingStatuses[existingIndex] = sender; + } else { + // Add new typing status + _typingStatuses.add(sender); + } + state = List.of(_typingStatuses); + return; + } + + final message = SnChatMessage.fromJson(pkt.data!); + if (message.chatRoomId != _chatRoom.id) return; + switch (pkt.type) { + case 'messages.new': + case 'messages.update': + case 'messages.delete': + if (message.type.startsWith('call')) { + // Handle the ongoing call. + ref.invalidate(ongoingCallProvider(message.chatRoomId)); + } + _messagesNotifier.receiveMessage(message); + // Send read receipt for new message + sendReadReceipt(); + } + } + + void sendReadReceipt() { + // Send websocket packet + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.read', + data: {'chat_room_id': _roomId}, + endpoint: 'sphere', + ), + ), + ); + } + + void sendTypingStatus() { + // Don't send if we're already in a cooldown period + if (_typingCooldownTimer != null) return; + + // Send typing status immediately + final wsState = ref.read(websocketStateProvider.notifier); + wsState.sendMessage( + jsonEncode( + WebSocketPacket( + type: 'messages.typing', + data: {'chat_room_id': _roomId}, + endpoint: 'sphere', + ), + ), + ); + + _typingCooldownTimer = Timer(const Duration(milliseconds: 850), () { + _typingCooldownTimer = null; + }); + } +} diff --git a/lib/pods/chat/chat_subscribe.g.dart b/lib/pods/chat/chat_subscribe.g.dart new file mode 100644 index 00000000..94d678ba --- /dev/null +++ b/lib/pods/chat/chat_subscribe.g.dart @@ -0,0 +1,176 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_subscribe.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatSubscribeNotifierHash() => + r'10a6b2c687149ebb419e4c96349d8bab1f183ec6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ChatSubscribeNotifier + extends BuildlessAutoDisposeNotifier> { + late final String roomId; + + List build(String roomId); +} + +/// See also [ChatSubscribeNotifier]. +@ProviderFor(ChatSubscribeNotifier) +const chatSubscribeNotifierProvider = ChatSubscribeNotifierFamily(); + +/// See also [ChatSubscribeNotifier]. +class ChatSubscribeNotifierFamily extends Family> { + /// See also [ChatSubscribeNotifier]. + const ChatSubscribeNotifierFamily(); + + /// See also [ChatSubscribeNotifier]. + ChatSubscribeNotifierProvider call(String roomId) { + return ChatSubscribeNotifierProvider(roomId); + } + + @override + ChatSubscribeNotifierProvider getProviderOverride( + covariant ChatSubscribeNotifierProvider provider, + ) { + return call(provider.roomId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'chatSubscribeNotifierProvider'; +} + +/// See also [ChatSubscribeNotifier]. +class ChatSubscribeNotifierProvider + extends + AutoDisposeNotifierProviderImpl< + ChatSubscribeNotifier, + List + > { + /// See also [ChatSubscribeNotifier]. + ChatSubscribeNotifierProvider(String roomId) + : this._internal( + () => ChatSubscribeNotifier()..roomId = roomId, + from: chatSubscribeNotifierProvider, + name: r'chatSubscribeNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatSubscribeNotifierHash, + dependencies: ChatSubscribeNotifierFamily._dependencies, + allTransitiveDependencies: + ChatSubscribeNotifierFamily._allTransitiveDependencies, + roomId: roomId, + ); + + ChatSubscribeNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.roomId, + }) : super.internal(); + + final String roomId; + + @override + List runNotifierBuild( + covariant ChatSubscribeNotifier notifier, + ) { + return notifier.build(roomId); + } + + @override + Override overrideWith(ChatSubscribeNotifier Function() create) { + return ProviderOverride( + origin: this, + override: ChatSubscribeNotifierProvider._internal( + () => create()..roomId = roomId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + roomId: roomId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement> + createElement() { + return _ChatSubscribeNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ChatSubscribeNotifierProvider && other.roomId == roomId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, roomId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ChatSubscribeNotifierRef + on AutoDisposeNotifierProviderRef> { + /// The parameter `roomId` of this provider. + String get roomId; +} + +class _ChatSubscribeNotifierProviderElement + extends + AutoDisposeNotifierProviderElement< + ChatSubscribeNotifier, + List + > + with ChatSubscribeNotifierRef { + _ChatSubscribeNotifierProviderElement(super.provider); + + @override + String get roomId => (origin as ChatSubscribeNotifierProvider).roomId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/pods/chat_summary.dart b/lib/pods/chat/chat_summary.dart similarity index 100% rename from lib/pods/chat_summary.dart rename to lib/pods/chat/chat_summary.dart diff --git a/lib/pods/chat_summary.g.dart b/lib/pods/chat/chat_summary.g.dart similarity index 92% rename from lib/pods/chat_summary.g.dart rename to lib/pods/chat/chat_summary.g.dart index 6cddaac0..b2ca0fe2 100644 --- a/lib/pods/chat_summary.g.dart +++ b/lib/pods/chat/chat_summary.g.dart @@ -6,7 +6,7 @@ part of 'chat_summary.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatSummaryHash() => r'87a10e4cefa37dc5fa8eadb175ef1b2bed6070bf'; +String _$chatSummaryHash() => r'7b79dba7445f634373fbb2ee0ced99b2302097c2'; /// See also [ChatSummary]. @ProviderFor(ChatSummary) diff --git a/lib/pods/messages_notifier.dart b/lib/pods/chat/messages_notifier.dart similarity index 98% rename from lib/pods/messages_notifier.dart rename to lib/pods/chat/messages_notifier.dart index 79b83f54..3dfb6bce 100644 --- a/lib/pods/messages_notifier.dart +++ b/lib/pods/chat/messages_notifier.dart @@ -10,13 +10,14 @@ import "package:island/models/chat.dart"; import "package:island/models/file.dart"; import "package:island/pods/config.dart"; import "package:island/pods/database.dart"; +import "package:island/pods/lifecycle.dart"; import "package:island/pods/network.dart"; import "package:island/services/file.dart"; import "package:island/widgets/alert.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:uuid/uuid.dart"; import "package:island/screens/chat/chat.dart"; -import "package:island/pods/chat_rooms.dart"; +import "package:island/pods/chat/chat_rooms.dart"; part 'messages_notifier.g.dart'; @@ -66,13 +67,15 @@ class MessagesNotifier extends _$MessagesNotifier { // Only setup sync and lifecycle listeners if user is a member if (identity != null) { ref.listen(appLifecycleStateProvider, (_, next) { - if (next.hasValue && next.value == AppLifecycleState.resumed) { - developer.log( - 'App resumed, syncing messages', - name: 'MessagesNotifier', - ); - syncMessages(); - } + next.whenData((state) { + if (state == AppLifecycleState.resumed) { + developer.log( + 'App resumed, syncing messages', + name: 'MessagesNotifier', + ); + syncMessages(); + } + }); }); } diff --git a/lib/pods/messages_notifier.g.dart b/lib/pods/chat/messages_notifier.g.dart similarity index 98% rename from lib/pods/messages_notifier.g.dart rename to lib/pods/chat/messages_notifier.g.dart index 063c5d74..1b200257 100644 --- a/lib/pods/messages_notifier.g.dart +++ b/lib/pods/chat/messages_notifier.g.dart @@ -6,7 +6,7 @@ part of 'messages_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237'; +String _$messagesNotifierHash() => r'4257c9b3792418e913d0bac3ef58e727314635af'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/pods/config.g.dart b/lib/pods/config.g.dart index 4e73ccad..b461eabc 100644 --- a/lib/pods/config.g.dart +++ b/lib/pods/config.g.dart @@ -7,7 +7,7 @@ part of 'config.dart'; // ************************************************************************** String _$appSettingsNotifierHash() => - r'b5e9b2ea9b01c236a68669a00eaa563c1fb4efa6'; + r'3b0967a39a375c664c3fd44cbee7936b8b2f5fec'; /// See also [AppSettingsNotifier]. @ProviderFor(AppSettingsNotifier) diff --git a/lib/pods/chat_rooms.dart b/lib/pods/lifecycle.dart similarity index 84% rename from lib/pods/chat_rooms.dart rename to lib/pods/lifecycle.dart index e061de36..9c32900e 100644 --- a/lib/pods/chat_rooms.dart +++ b/lib/pods/lifecycle.dart @@ -2,10 +2,6 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -final isSyncingProvider = StateProvider.autoDispose((ref) => false); - -final flashingMessagesProvider = StateProvider>((ref) => {}); - final appLifecycleStateProvider = StreamProvider((ref) { final controller = StreamController(); diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index c8874a49..7c8d0c42 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_overlay.dart'; diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 64175afa..fc1f486e 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -11,8 +11,8 @@ import 'package:image_picker/image_picker.dart'; import 'package:island/models/chat.dart'; import 'package:island/models/file.dart'; import 'package:island/models/realm.dart'; -import 'package:island/pods/call.dart'; -import 'package:island/pods/chat_summary.dart'; +import 'package:island/pods/chat/call.dart'; +import 'package:island/pods/chat/chat_summary.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/realm/realms.dart'; diff --git a/lib/screens/chat/public_room_preview.dart b/lib/screens/chat/public_room_preview.dart index 6c99dae9..349459db 100644 --- a/lib/screens/chat/public_room_preview.dart +++ b/lib/screens/chat/public_room_preview.dart @@ -17,7 +17,7 @@ import "package:island/widgets/chat/message_item.dart"; import "package:island/widgets/response.dart"; import "package:island/pods/network.dart"; import "package:island/services/responsive.dart"; -import "package:island/pods/messages_notifier.dart"; +import "package:island/pods/chat/messages_notifier.dart"; class PublicRoomPreview extends HookConsumerWidget { final String id; diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 0855653a..32445a5e 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,5 +1,4 @@ import "dart:async"; -import "dart:convert"; import "package:easy_localization/easy_localization.dart"; import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; @@ -10,10 +9,11 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; +import "package:island/pods/chat/chat_subscribe.dart"; import "package:island/pods/config.dart"; -import "package:island/pods/messages_notifier.dart"; +import "package:island/pods/chat/messages_notifier.dart"; import "package:island/pods/network.dart"; -import "package:island/pods/websocket.dart"; +import "package:island/pods/chat/chat_online_count.dart"; import "package:island/services/file.dart"; import "package:island/screens/chat/chat.dart"; import "package:island/services/responsive.dart"; @@ -37,33 +37,6 @@ final isSyncingProvider = StateProvider.autoDispose((ref) => false); final flashingMessagesProvider = StateProvider>((ref) => {}); -final appLifecycleStateProvider = StreamProvider((ref) { - final controller = StreamController(); - - final observer = _AppLifecycleObserver((state) { - if (controller.isClosed) return; - controller.add(state); - }); - WidgetsBinding.instance.addObserver(observer); - - ref.onDispose(() { - WidgetsBinding.instance.removeObserver(observer); - controller.close(); - }); - - return controller.stream; -}); - -class _AppLifecycleObserver extends WidgetsBindingObserver { - final ValueChanged onChange; - _AppLifecycleObserver(this.onChange); - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - onChange(state); - } -} - class ChatRoomScreen extends HookConsumerWidget { final String id; const ChatRoomScreen({super.key, required this.id}); @@ -73,6 +46,7 @@ class ChatRoomScreen extends HookConsumerWidget { final chatRoom = ref.watch(chatroomProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id)); final isSyncing = ref.watch(isSyncingProvider); + final onlineCount = ref.watch(chatOnlineCountNotifierProvider(id)); if (chatIdentity.isLoading || chatRoom.isLoading) { return AppScaffold( @@ -157,7 +131,10 @@ class ChatRoomScreen extends HookConsumerWidget { final messages = ref.watch(messagesNotifierProvider(id)); final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); - final ws = ref.watch(websocketProvider); + final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(id)); + final chatSubscribeNotifier = ref.read( + chatSubscribeNotifierProvider(id).notifier, + ); final messageController = useTextEditingController(); final scrollController = useScrollController(); @@ -168,65 +145,6 @@ class ChatRoomScreen extends HookConsumerWidget { final attachments = useState>([]); final attachmentProgress = useState>>({}); - // Function to send read receipt - void sendReadReceipt() async { - // Send websocket packet - final wsState = ref.read(websocketStateProvider.notifier); - wsState.sendMessage( - jsonEncode( - WebSocketPacket( - type: 'messages.read', - data: {'chat_room_id': id}, - endpoint: 'DysonNetwork.Sphere', - ), - ), - ); - } - - // Members who are typing - final typingStatuses = useState>([]); - final typingDebouncer = useState(null); - - void sendTypingStatus() { - // Don't send if we're already in a cooldown period - if (typingDebouncer.value != null) return; - - // Send typing status immediately - final wsState = ref.read(websocketStateProvider.notifier); - wsState.sendMessage( - jsonEncode( - WebSocketPacket( - type: 'messages.typing', - data: {'chat_room_id': id}, - endpoint: 'DysonNetwork.Sphere', - ), - ), - ); - - typingDebouncer.value = Timer(const Duration(milliseconds: 850), () { - typingDebouncer.value = null; - }); - } - - // Add timer to remove typing status after inactivity - useEffect(() { - final removeTypingTimer = Timer.periodic(const Duration(seconds: 5), (_) { - if (typingStatuses.value.isNotEmpty) { - // Remove typing statuses older than 5 seconds - final now = DateTime.now(); - typingStatuses.value = - typingStatuses.value.where((member) { - final lastTyped = - member.lastTyped ?? - DateTime.now().subtract(const Duration(milliseconds: 1350)); - return now.difference(lastTyped).inSeconds < 5; - }).toList(); - } - }); - - return () => removeTypingTimer.cancel(); - }, []); - var isLoading = false; final listController = useMemoized(() => ListController(), []); @@ -246,79 +164,6 @@ class ChatRoomScreen extends HookConsumerWidget { return () => scrollController.removeListener(onScroll); }, [scrollController]); - // Add websocket listener for new messages - useEffect(() { - void onMessage(WebSocketPacket pkt) { - if (!pkt.type.startsWith('messages')) return; - if (['messages.read'].contains(pkt.type)) return; - - if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) { - if (pkt.data?['room_id'] != chatRoom.value?.id) return; - if (pkt.data?['sender_id'] == chatIdentity.value?.id) return; - - final sender = SnChatMember.fromJson( - pkt.data?['sender'], - ).copyWith(lastTyped: DateTime.now()); - - // Check if the sender is already in the typing list - final existingIndex = typingStatuses.value.indexWhere( - (member) => member.id == sender.id, - ); - if (existingIndex >= 0) { - // Update the existing entry with new timestamp - final updatedList = [...typingStatuses.value]; - updatedList[existingIndex] = sender; - typingStatuses.value = updatedList; - } else { - // Add new typing status - typingStatuses.value = [...typingStatuses.value, sender]; - } - return; - } - - final message = SnChatMessage.fromJson(pkt.data!); - if (message.chatRoomId != chatRoom.value?.id) return; - switch (pkt.type) { - case 'messages.new': - case 'messages.update': - case 'messages.delete': - if (message.type.startsWith('call')) { - // Handle the ongoing call. - ref.invalidate(ongoingCallProvider(message.chatRoomId)); - } - messagesNotifier.receiveMessage(message); - // Send read receipt for new message - sendReadReceipt(); - } - } - - sendReadReceipt(); - final subscription = ws.dataStream.listen(onMessage); - return () => subscription.cancel(); - }, [ws, chatRoom]); - - useEffect(() { - final wsState = ref.read(websocketStateProvider.notifier); - wsState.sendMessage( - jsonEncode( - WebSocketPacket( - type: 'messages.subscribe', - data: {'chat_room_id': id}, - ), - ), - ); - return () { - wsState.sendMessage( - jsonEncode( - WebSocketPacket( - type: 'messages.unsubscribe', - data: {'chat_room_id': id}, - ), - ), - ); - }; - }, [id]); - Future pickPhotoMedia() async { final result = await FilePicker.platform.pickFiles( type: FileType.image, @@ -352,21 +197,19 @@ class ChatRoomScreen extends HookConsumerWidget { void sendMessage() { if (messageController.text.trim().isNotEmpty || attachments.value.isNotEmpty) { - messagesNotifier - .sendMessage( - messageController.text.trim(), - attachments.value, - editingTo: messageEditingTo.value, - forwardingTo: messageForwardingTo.value, - replyingTo: messageReplyingTo.value, - onProgress: (messageId, progress) { - attachmentProgress.value = { - ...attachmentProgress.value, - messageId: progress, - }; - }, - ) - .then((_) => sendReadReceipt()); + messagesNotifier.sendMessage( + messageController.text.trim(), + attachments.value, + editingTo: messageEditingTo.value, + forwardingTo: messageForwardingTo.value, + replyingTo: messageReplyingTo.value, + onProgress: (messageId, progress) { + attachmentProgress.value = { + ...attachmentProgress.value, + messageId: progress, + }; + }, + ); messageController.clear(); messageEditingTo.value = null; messageReplyingTo.value = null; @@ -379,7 +222,7 @@ class ChatRoomScreen extends HookConsumerWidget { useEffect(() { void onTextChange() { if (messageController.text.isNotEmpty) { - sendTypingStatus(); + chatSubscribeNotifier.sendTypingStatus(); } } @@ -714,15 +557,33 @@ class ChatRoomScreen extends HookConsumerWidget { ), const Gap(8), ], - bottom: - isSyncing - ? const PreferredSize( - preferredSize: Size.fromHeight(2), - child: LinearProgressIndicator( + bottom: () { + final hasProgress = isSyncing; + final hasOnlineCount = onlineCount.hasValue; + if (!hasProgress && !hasOnlineCount) return null; + return PreferredSize( + preferredSize: Size.fromHeight( + (hasProgress ? 2 : 0) + (hasOnlineCount ? 24 : 0), + ), + child: Column( + children: [ + if (hasProgress) + const LinearProgressIndicator( borderRadius: BorderRadius.zero, ), - ) - : null, + if (hasOnlineCount) + Container( + height: 24, + alignment: Alignment.center, + child: Text( + '${(onlineCount as AsyncData).value} online', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + }(), ), body: Stack( children: [ @@ -781,7 +642,7 @@ class ChatRoomScreen extends HookConsumerWidget { ); }, child: - typingStatuses.value.isNotEmpty + chatSubscribe.isNotEmpty ? Container( key: const ValueKey('typing-indicator'), width: double.infinity, @@ -799,9 +660,9 @@ class ChatRoomScreen extends HookConsumerWidget { Expanded( child: Text( 'typingHint'.plural( - typingStatuses.value.length, + chatSubscribe.length, args: [ - typingStatuses.value + chatSubscribe .map( (x) => x.nick ?? diff --git a/lib/screens/chat/search_messages.dart b/lib/screens/chat/search_messages.dart index 555aa286..1b0c6ce4 100644 --- a/lib/screens/chat/search_messages.dart +++ b/lib/screens/chat/search_messages.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/messages_notifier.dart'; +import 'package:island/pods/chat/messages_notifier.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; diff --git a/lib/widgets/chat/call_button.dart b/lib/widgets/chat/call_button.dart index 304f8e54..9d0508b4 100644 --- a/lib/widgets/chat/call_button.dart +++ b/lib/widgets/chat/call_button.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/chat.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index c0a9db80..4d0f0f16 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; diff --git a/lib/widgets/chat/call_participant_card.dart b/lib/widgets/chat/call_participant_card.dart index 268748cd..6f9f278d 100644 --- a/lib/widgets/chat/call_participant_card.dart +++ b/lib/widgets/chat/call_participant_card.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_popup_card/flutter_popup_card.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/widgets/account/account_nameplate.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart index d396853f..75f67278 100644 --- a/lib/widgets/chat/call_participant_tile.dart +++ b/lib/widgets/chat/call_participant_tile.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/screens/account/profile.dart'; import 'package:island/widgets/chat/call_participant_card.dart'; import 'package:island/widgets/content/cloud_files.dart'; diff --git a/lib/widgets/chat/message_content.dart b/lib/widgets/chat/message_content.dart index 36c99690..a5463841 100644 --- a/lib/widgets/chat/message_content.dart +++ b/lib/widgets/chat/message_content.dart @@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:island/models/chat.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/chat/call.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:pretty_diff_text/pretty_diff_text.dart'; diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index caf20748..ad3f7629 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -11,7 +11,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/embed.dart'; -import 'package:island/pods/messages_notifier.dart'; +import 'package:island/pods/chat/messages_notifier.dart'; import 'package:island/pods/translate.dart'; import 'package:island/pods/config.dart'; import 'package:island/screens/chat/room.dart'; diff --git a/lib/widgets/chat/public_room_preview.dart b/lib/widgets/chat/public_room_preview.dart index 7d404e32..3426b7b0 100644 --- a/lib/widgets/chat/public_room_preview.dart +++ b/lib/widgets/chat/public_room_preview.dart @@ -6,7 +6,7 @@ import "package:gap/gap.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; -import "package:island/pods/messages_notifier.dart"; +import "package:island/pods/chat/messages_notifier.dart"; import "package:island/pods/network.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/alert.dart";