Skip to content

Commit 8d41049

Browse files
authored
Merge pull request #1676 from lichess-org/refactor_chat
Tournament public chat, help and stats
2 parents f584097 + 7afab55 commit 8d41049

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1175
-687
lines changed

lib/src/app.dart

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
1515
import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
1616
import 'package:lichess_mobile/src/navigation.dart';
1717
import 'package:lichess_mobile/src/network/connectivity.dart';
18-
import 'package:lichess_mobile/src/network/http.dart';
1918
import 'package:lichess_mobile/src/network/socket.dart';
2019
import 'package:lichess_mobile/src/theme.dart';
2120
import 'package:lichess_mobile/src/utils/navigation.dart';
@@ -62,30 +61,8 @@ class _AppState extends ConsumerState<Application> {
6261
/// Whether the app has checked for online status for the first time.
6362
bool _firstTimeOnlineCheck = false;
6463

65-
AppLifecycleListener? _appLifecycleListener;
66-
67-
DateTime? _pausedAt;
68-
6964
@override
7065
void initState() {
71-
_appLifecycleListener = AppLifecycleListener(
72-
onPause: () {
73-
_pausedAt = DateTime.now();
74-
},
75-
onRestart: () async {
76-
// Invalidate ongoing games if the app was paused for more than 10 minutes.
77-
// In theory we shouldn't need to do this, because correspondence games are updated by
78-
// fcm messages, but in practice it's not always reliable.
79-
// See also: [CorrespondenceService].
80-
final online = await isOnline(ref.read(defaultClientProvider));
81-
if (online &&
82-
_pausedAt != null &&
83-
DateTime.now().difference(_pausedAt!) >= const Duration(minutes: 10)) {
84-
ref.invalidate(ongoingGamesProvider);
85-
}
86-
},
87-
);
88-
8966
// Start services
9067
ref.read(notificationServiceProvider).start();
9168
ref.read(challengeServiceProvider).start();
@@ -124,12 +101,6 @@ class _AppState extends ConsumerState<Application> {
124101
super.initState();
125102
}
126103

127-
@override
128-
void dispose() {
129-
_appLifecycleListener?.dispose();
130-
super.dispose();
131-
}
132-
133104
@override
134105
Widget build(BuildContext context) {
135106
final generalPrefs = ref.watch(generalPreferencesProvider);

lib/src/db/database.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const kLichessDatabaseName = 'lichess_mobile.db';
1414
const puzzleTTL = Duration(days: 60);
1515
const corresGameTTL = Duration(days: 60);
1616
const gameTTL = Duration(days: 90);
17-
const chatReadMessagesTTL = Duration(days: 60);
17+
const chatReadMessagesTTL = Duration(days: 180);
1818
const httpLogTTL = Duration(days: 7);
1919

2020
const kStorageAnonId = '**anonymous**';

lib/src/model/chat/chat.dart

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import 'package:deep_pick/deep_pick.dart';
2+
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
3+
import 'package:freezed_annotation/freezed_annotation.dart';
4+
import 'package:lichess_mobile/src/model/common/id.dart';
5+
import 'package:lichess_mobile/src/model/user/user.dart';
6+
7+
part 'chat.freezed.dart';
8+
9+
typedef ChatData = ({IList<ChatMessage> lines, bool writeable});
10+
11+
ChatData chatDataFromPick(RequiredPick pick) {
12+
return (
13+
lines: pick('lines').asListOrThrow((it) => ChatMessage.fromPick(it)).toIList(),
14+
writeable: pick('writeable').asBoolOrTrue(),
15+
);
16+
}
17+
18+
@freezed
19+
class ChatMessage with _$ChatMessage {
20+
const ChatMessage._();
21+
22+
const factory ChatMessage({
23+
required String message,
24+
required String? username,
25+
required bool troll,
26+
required bool deleted,
27+
required bool patron,
28+
String? flair,
29+
String? title,
30+
}) = _ChatMessage;
31+
32+
LightUser? get user =>
33+
username != null
34+
? LightUser(
35+
id: UserId.fromUserName(username!),
36+
name: username!,
37+
title: title,
38+
flair: flair,
39+
isPatron: patron,
40+
)
41+
: null;
42+
43+
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
44+
ChatMessage.fromPick(pick(json).required());
45+
46+
factory ChatMessage.fromPick(RequiredPick pick) {
47+
return ChatMessage(
48+
message: pick('t').asStringOrThrow(),
49+
username: pick('u').asStringOrNull(),
50+
troll: pick('r').asBoolOrNull() ?? false,
51+
deleted: pick('d').asBoolOrNull() ?? false,
52+
patron: pick('p').asBoolOrNull() ?? false,
53+
flair: pick('f').asStringOrNull(),
54+
title: pick('title').asStringOrNull(),
55+
);
56+
}
57+
58+
bool get isSpam => spamRegex.hasMatch(message) || followMeRegex.hasMatch(message);
59+
}
60+
61+
final RegExp spamRegex = RegExp(
62+
[
63+
'xcamweb.com',
64+
'(^|[^i])chess-bot',
65+
'chess-cheat',
66+
'coolteenbitch',
67+
'letcafa.webcam',
68+
'tinyurl.com/',
69+
'wooga.info/',
70+
'bit.ly/',
71+
'wbt.link/',
72+
'eb.by/',
73+
'001.rs/',
74+
'shr.name/',
75+
'u.to/',
76+
'.3-a.net',
77+
'.ssl443.org',
78+
'.ns02.us',
79+
'.myftp.info',
80+
'.flinkup.com',
81+
'.serveusers.com',
82+
'badoogirls.com',
83+
'hide.su',
84+
'wyon.de',
85+
'sexdatingcz.club',
86+
'qps.ru',
87+
'tiny.cc/',
88+
'trasderk.blogspot.com',
89+
't.ly/',
90+
'shorturl.at/',
91+
].map((url) => url.replaceAll('.', '\\.').replaceAll('/', '\\/')).join('|'),
92+
);
93+
94+
final followMeRegex = RegExp('follow me|join my team', caseSensitive: false);
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)