|
| 1 | +import 'dart:async'; |
| 2 | +import 'dart:math' as math; |
| 3 | + |
| 4 | +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; |
| 5 | +import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 6 | +import 'package:freezed_annotation/freezed_annotation.dart'; |
| 7 | +import 'package:lichess_mobile/src/db/database.dart'; |
| 8 | +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; |
| 9 | +import 'package:lichess_mobile/src/model/chat/chat.dart'; |
| 10 | +import 'package:lichess_mobile/src/model/common/id.dart'; |
| 11 | +import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; |
| 12 | +import 'package:lichess_mobile/src/model/common/socket.dart'; |
| 13 | +import 'package:lichess_mobile/src/model/game/game_controller.dart'; |
| 14 | +import 'package:lichess_mobile/src/model/tournament/tournament_controller.dart'; |
| 15 | +import 'package:lichess_mobile/src/model/user/user.dart'; |
| 16 | +import 'package:lichess_mobile/src/network/socket.dart'; |
| 17 | +import 'package:riverpod_annotation/riverpod_annotation.dart'; |
| 18 | +import 'package:sqflite/sqflite.dart'; |
| 19 | + |
| 20 | +part 'chat_controller.freezed.dart'; |
| 21 | +part 'chat_controller.g.dart'; |
| 22 | + |
| 23 | +const _tableName = 'chat_read_messages'; |
| 24 | +String _storeKey(StringId id) => 'chat.$id'; |
| 25 | + |
| 26 | +@immutable |
| 27 | +sealed class ChatOptions { |
| 28 | + const ChatOptions(); |
| 29 | + |
| 30 | + StringId get id; |
| 31 | + LightUser? get opponent; |
| 32 | + bool get isPublic; |
| 33 | + bool get writeable; |
| 34 | + |
| 35 | + @override |
| 36 | + String toString() => |
| 37 | + 'ChatOptions(id: $id, opponent: $opponent, isPublic: $isPublic, writeable: $writeable)'; |
| 38 | +} |
| 39 | + |
| 40 | +@freezed |
| 41 | +class GameChatOptions extends ChatOptions with _$GameChatOptions { |
| 42 | + const GameChatOptions._(); |
| 43 | + const factory GameChatOptions({required GameFullId id, required LightUser? opponent}) = |
| 44 | + _GameChatOptions; |
| 45 | + |
| 46 | + @override |
| 47 | + bool get isPublic => false; |
| 48 | + |
| 49 | + @override |
| 50 | + bool get writeable => true; |
| 51 | +} |
| 52 | + |
| 53 | +@freezed |
| 54 | +class TournamentChatOptions extends ChatOptions with _$TournamentChatOptions { |
| 55 | + const TournamentChatOptions._(); |
| 56 | + const factory TournamentChatOptions({required TournamentId id, required bool writeable}) = |
| 57 | + _TournamentChatOptions; |
| 58 | + |
| 59 | + @override |
| 60 | + LightUser? get opponent => null; |
| 61 | + |
| 62 | + @override |
| 63 | + bool get isPublic => true; |
| 64 | +} |
| 65 | + |
| 66 | +/// A provider that gets the chat unread messages |
| 67 | +@riverpod |
| 68 | +Future<int> chatUnread(Ref ref, ChatOptions options) async { |
| 69 | + return ref.watch(chatControllerProvider(options).selectAsync((s) => s.unreadMessages)); |
| 70 | +} |
| 71 | + |
| 72 | +const IList<ChatMessage> _kEmptyMessages = IListConst([]); |
| 73 | + |
| 74 | +@riverpod |
| 75 | +class ChatController extends _$ChatController { |
| 76 | + StreamSubscription<SocketEvent>? _subscription; |
| 77 | + |
| 78 | + LightUser? get _me => ref.read(authSessionProvider)?.user; |
| 79 | + |
| 80 | + @override |
| 81 | + Future<ChatState> build(ChatOptions options) async { |
| 82 | + _subscription?.cancel(); |
| 83 | + _subscription = socketGlobalStream.listen(_handleSocketEvent); |
| 84 | + |
| 85 | + ref.onDispose(() { |
| 86 | + _subscription?.cancel(); |
| 87 | + }); |
| 88 | + |
| 89 | + final initialMessages = await switch (options) { |
| 90 | + GameChatOptions(:final id) => ref.watch( |
| 91 | + gameControllerProvider(id).selectAsync((s) => s.game.chat?.lines), |
| 92 | + ), |
| 93 | + TournamentChatOptions(:final id) => ref.watch( |
| 94 | + tournamentControllerProvider(id).selectAsync((s) => s.tournament.chat?.lines), |
| 95 | + ), |
| 96 | + }; |
| 97 | + |
| 98 | + final filteredMessages = _selectMessages(initialMessages ?? _kEmptyMessages); |
| 99 | + final readMessagesCount = await _getReadMessagesCount(); |
| 100 | + |
| 101 | + return ChatState( |
| 102 | + messages: filteredMessages, |
| 103 | + unreadMessages: math.max(0, filteredMessages.length - readMessagesCount), |
| 104 | + ); |
| 105 | + } |
| 106 | + |
| 107 | + /// Sends a message to the chat. |
| 108 | + void postMessage(String message) { |
| 109 | + ref.read(socketPoolProvider).currentClient.send('talk', message); |
| 110 | + } |
| 111 | + |
| 112 | + /// Resets the unread messages count to 0 and saves the number of read messages. |
| 113 | + Future<void> markMessagesAsRead() async { |
| 114 | + if (state.hasValue) { |
| 115 | + await _setReadMessagesCount(state.requireValue.messages.length); |
| 116 | + } |
| 117 | + state = state.whenData((s) => s.copyWith(unreadMessages: 0)); |
| 118 | + } |
| 119 | + |
| 120 | + IList<ChatMessage> _selectMessages(IList<ChatMessage> all) { |
| 121 | + return all |
| 122 | + .where( |
| 123 | + (m) => |
| 124 | + !m.deleted && (!m.troll || m.username?.toLowerCase() == _me?.id.value) && !m.isSpam, |
| 125 | + ) |
| 126 | + .toIList(); |
| 127 | + } |
| 128 | + |
| 129 | + Future<int> _getReadMessagesCount() async { |
| 130 | + final db = await ref.read(databaseProvider.future); |
| 131 | + final result = await db.query( |
| 132 | + _tableName, |
| 133 | + columns: ['nbRead'], |
| 134 | + where: 'id = ?', |
| 135 | + whereArgs: [_storeKey(options.id)], |
| 136 | + ); |
| 137 | + return result.firstOrNull?['nbRead'] as int? ?? 0; |
| 138 | + } |
| 139 | + |
| 140 | + Future<void> _setReadMessagesCount(int count) async { |
| 141 | + final db = await ref.read(databaseProvider.future); |
| 142 | + await db.insert(_tableName, { |
| 143 | + 'id': _storeKey(options.id), |
| 144 | + 'lastModified': DateTime.now().toIso8601String(), |
| 145 | + 'nbRead': count, |
| 146 | + }, conflictAlgorithm: ConflictAlgorithm.replace); |
| 147 | + } |
| 148 | + |
| 149 | + void _handleSocketEvent(SocketEvent event) { |
| 150 | + if (!state.hasValue) return; |
| 151 | + |
| 152 | + if (event.topic == 'message') { |
| 153 | + final data = event.data as Map<String, dynamic>; |
| 154 | + final message = ChatMessage.fromJson(data); |
| 155 | + state = state.whenData((s) { |
| 156 | + final oldMessages = s.messages; |
| 157 | + final newMessages = _selectMessages(oldMessages.add(message)); |
| 158 | + final newUnread = newMessages.length - oldMessages.length; |
| 159 | + if (options.isPublic == false && newUnread > 0) { |
| 160 | + ref.read(soundServiceProvider).play(Sound.confirmation, volume: 0.5); |
| 161 | + } |
| 162 | + return s.copyWith(messages: newMessages, unreadMessages: s.unreadMessages + newUnread); |
| 163 | + }); |
| 164 | + } |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +@freezed |
| 169 | +class ChatState with _$ChatState { |
| 170 | + const ChatState._(); |
| 171 | + |
| 172 | + const factory ChatState({required IList<ChatMessage> messages, required int unreadMessages}) = |
| 173 | + _ChatState; |
| 174 | +} |
0 commit comments