Dynamic chat online counter basis

This commit is contained in:
2025-09-27 19:25:24 +08:00
parent eb5a849e1f
commit 3379dcb7f3
26 changed files with 694 additions and 216 deletions

View File

@@ -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<int> 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;
}
}

View File

@@ -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<int> {
late final String chatroomId;
FutureOr<int> build(String chatroomId);
}
/// See also [ChatOnlineCountNotifier].
@ProviderFor(ChatOnlineCountNotifier)
const chatOnlineCountNotifierProvider = ChatOnlineCountNotifierFamily();
/// See also [ChatOnlineCountNotifier].
class ChatOnlineCountNotifierFamily extends Family<AsyncValue<int>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'chatOnlineCountNotifierProvider';
}
/// See also [ChatOnlineCountNotifier].
class ChatOnlineCountNotifierProvider
extends AutoDisposeAsyncNotifierProviderImpl<ChatOnlineCountNotifier, int> {
/// 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<int> 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<ChatOnlineCountNotifier, int>
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<int> {
/// The parameter `chatroomId` of this provider.
String get chatroomId;
}
class _ChatOnlineCountNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<ChatOnlineCountNotifier, int>
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

View File

@@ -0,0 +1,5 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});

View File

@@ -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<SnChatMember> _typingStatuses = [];
Timer? _typingCleanupTimer;
Timer? _typingCooldownTimer;
Timer? _periodicSubscribeTimer;
StreamSubscription? _wsSubscription;
@override
List<SnChatMember> 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;
});
}
}

View File

@@ -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<List<SnChatMember>> {
late final String roomId;
List<SnChatMember> build(String roomId);
}
/// See also [ChatSubscribeNotifier].
@ProviderFor(ChatSubscribeNotifier)
const chatSubscribeNotifierProvider = ChatSubscribeNotifierFamily();
/// See also [ChatSubscribeNotifier].
class ChatSubscribeNotifierFamily extends Family<List<SnChatMember>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'chatSubscribeNotifierProvider';
}
/// See also [ChatSubscribeNotifier].
class ChatSubscribeNotifierProvider
extends
AutoDisposeNotifierProviderImpl<
ChatSubscribeNotifier,
List<SnChatMember>
> {
/// 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<SnChatMember> 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<ChatSubscribeNotifier, List<SnChatMember>>
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<List<SnChatMember>> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _ChatSubscribeNotifierProviderElement
extends
AutoDisposeNotifierProviderElement<
ChatSubscribeNotifier,
List<SnChatMember>
>
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

View File

@@ -6,7 +6,7 @@ part of 'chat_summary.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatSummaryHash() => r'87a10e4cefa37dc5fa8eadb175ef1b2bed6070bf';
String _$chatSummaryHash() => r'7b79dba7445f634373fbb2ee0ced99b2302097c2';
/// See also [ChatSummary].
@ProviderFor(ChatSummary)

View File

@@ -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();
}
});
});
}

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237';
String _$messagesNotifierHash() => r'4257c9b3792418e913d0bac3ef58e727314635af';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -7,7 +7,7 @@ part of 'config.dart';
// **************************************************************************
String _$appSettingsNotifierHash() =>
r'b5e9b2ea9b01c236a68669a00eaa563c1fb4efa6';
r'3b0967a39a375c664c3fd44cbee7936b8b2f5fec';
/// See also [AppSettingsNotifier].
@ProviderFor(AppSettingsNotifier)

View File

@@ -2,10 +2,6 @@ import "dart:async";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>();