Skip to content

Commit e8b2cb4

Browse files
authored
Implement lichess announces notifications (#2786)
1 parent 2ec71c3 commit e8b2cb4

File tree

6 files changed

+539
-1
lines changed

6 files changed

+539
-1
lines changed

lib/src/app.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:lichess_mobile/src/app_links.dart';
1111
import 'package:lichess_mobile/src/log.dart';
1212
import 'package:lichess_mobile/src/model/account/account_service.dart';
1313
import 'package:lichess_mobile/src/model/account/ongoing_game.dart';
14+
import 'package:lichess_mobile/src/model/announce/announce_service.dart';
1415
import 'package:lichess_mobile/src/model/challenge/challenge_service.dart';
1516
import 'package:lichess_mobile/src/model/common/preloaded_data.dart';
1617
import 'package:lichess_mobile/src/model/correspondence/correspondence_service.dart';
@@ -82,6 +83,7 @@ class _AppState extends ConsumerState<Application> {
8283
ref.read(accountServiceProvider).start();
8384
ref.read(correspondenceServiceProvider).start();
8485
ref.read(quickActionServiceProvider).start();
86+
ref.read(announceServiceProvider).start();
8587

8688
// Listen for connectivity changes and perform actions accordingly.
8789
ref.listenManual(connectivityChangesProvider, (prev, current) async {

lib/src/init.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Future<void> initializeLocalNotifications(Locale locale) async {
8181
notificationCategories: <DarwinNotificationCategory>[
8282
ChallengeNotification.darwinPlayableVariantCategory(l10n),
8383
ChallengeNotification.darwinUnplayableVariantCategory(l10n),
84+
AnnounceNotification.darwinCategory(l10n),
8485
],
8586
),
8687
linux: const LinuxInitializationSettings(defaultActionName: 'Action'),
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter_riverpod/flutter_riverpod.dart';
4+
import 'package:lichess_mobile/src/model/common/socket.dart';
5+
import 'package:lichess_mobile/src/model/notifications/notification_service.dart';
6+
import 'package:lichess_mobile/src/model/notifications/notifications.dart';
7+
import 'package:lichess_mobile/src/network/socket.dart';
8+
9+
final announceServiceProvider = Provider<AnnounceService>((ref) {
10+
final service = AnnounceService(ref);
11+
ref.onDispose(service._dispose);
12+
return service;
13+
});
14+
15+
class AnnounceService {
16+
AnnounceService(this._ref);
17+
18+
final Ref _ref;
19+
20+
StreamSubscription<SocketEvent>? _socketSubscription;
21+
StreamSubscription<ParsedLocalNotification>? _responseSubscription;
22+
Timer? _dismissTimer;
23+
24+
void start() {
25+
_socketSubscription = socketGlobalStream.listen(_handleSocketEvent);
26+
_responseSubscription = NotificationService.responseStream.listen(_handleNotificationResponse);
27+
}
28+
29+
void _dispose() {
30+
_socketSubscription?.cancel();
31+
_responseSubscription?.cancel();
32+
_dismissTimer?.cancel();
33+
}
34+
35+
void _handleNotificationResponse(ParsedLocalNotification event) {
36+
final (response, notification) = event;
37+
if (notification is AnnounceNotification &&
38+
response.actionId == AnnounceNotification.dismissActionId) {
39+
_cancelAnnounce();
40+
}
41+
}
42+
43+
void _handleSocketEvent(SocketEvent event) {
44+
if (event.topic != 'announce') return;
45+
46+
final data = event.data;
47+
if (data == null || data is! Map<String, dynamic> || data.isEmpty) {
48+
_cancelAnnounce();
49+
return;
50+
}
51+
52+
final msg = data['msg'] as String?;
53+
if (msg == null || msg.isEmpty) {
54+
_cancelAnnounce();
55+
return;
56+
}
57+
58+
final dateStr = data['date'] as String?;
59+
final date = dateStr != null ? DateTime.tryParse(dateStr) : null;
60+
61+
_dismissTimer?.cancel();
62+
_dismissTimer = null;
63+
64+
if (date != null) {
65+
final remaining = date.difference(DateTime.now());
66+
if (remaining.isNegative) {
67+
_cancelAnnounce();
68+
return;
69+
}
70+
_dismissTimer = Timer(remaining, _cancelAnnounce);
71+
}
72+
73+
_ref.read(notificationServiceProvider).show(AnnounceNotification(msg, date: date));
74+
}
75+
76+
void _cancelAnnounce() {
77+
_dismissTimer?.cancel();
78+
_dismissTimer = null;
79+
_ref.read(notificationServiceProvider).cancel(AnnounceNotification.notificationId);
80+
}
81+
}

lib/src/model/notifications/notifications.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:lichess_mobile/src/model/common/id.dart';
99
import 'package:lichess_mobile/src/model/game/playable_game.dart';
1010
import 'package:lichess_mobile/src/model/user/user.dart' show TemporaryBan;
1111
import 'package:lichess_mobile/src/utils/json.dart';
12+
import 'package:lichess_mobile/src/utils/l10n.dart' show relativeDate;
1213
import 'package:meta/meta.dart';
1314

1415
/// FCM Messages
@@ -226,6 +227,8 @@ sealed class LocalNotification {
226227
return ChallengeAcceptedNotification.fromJson(json);
227228
case 'challengeCreate':
228229
return ChallengeCreatedNotification.fromJson(json);
230+
case 'announce':
231+
return AnnounceNotification.fromJson(json);
229232
default:
230233
throw ArgumentError('Unknown notification channel: $channel');
231234
}
@@ -518,6 +521,84 @@ class ChallengeCreatedNotification extends LocalNotification {
518521
);
519522
}
520523

524+
/// A notification for a server-wide announcement.
525+
///
526+
/// There can only be one announce notification at a time. It is shown when the server
527+
/// sends an announce message through the WebSocket, and cancelled when the server
528+
/// clears it or the optional countdown date is reached.
529+
class AnnounceNotification extends LocalNotification {
530+
const AnnounceNotification(this.message, {this.date});
531+
532+
final String message;
533+
534+
/// Optional date shown as a relative time in the notification body.
535+
final DateTime? date;
536+
537+
static final int notificationId = 'announce'.hashCode;
538+
539+
static const _channelId = 'announce';
540+
541+
static const dismissActionId = 'dismiss';
542+
543+
static const darwinCategoryId = 'announce-notification';
544+
545+
factory AnnounceNotification.fromJson(Map<String, dynamic> json) {
546+
final dateStr = json['date'] as String?;
547+
return AnnounceNotification(
548+
json['message'] as String,
549+
date: dateStr != null ? DateTime.parse(dateStr) : null,
550+
);
551+
}
552+
553+
static DarwinNotificationCategory darwinCategory(AppLocalizations l10n) =>
554+
DarwinNotificationCategory(
555+
darwinCategoryId,
556+
actions: [
557+
DarwinNotificationAction.plain(dismissActionId, l10n.mobileCustomizeHomeTipDismiss),
558+
],
559+
);
560+
561+
@override
562+
int get id => notificationId;
563+
564+
@override
565+
String get channelId => _channelId;
566+
567+
@override
568+
Map<String, dynamic> get _concretePayload => {
569+
'message': message,
570+
if (date != null) 'date': date!.toIso8601String(),
571+
};
572+
573+
@override
574+
String title(AppLocalizations _) => message;
575+
576+
@override
577+
String? body(AppLocalizations l10n) => date != null ? relativeDate(l10n, date!) : null;
578+
579+
@override
580+
NotificationDetails details(AppLocalizations l10n) => NotificationDetails(
581+
android: AndroidNotificationDetails(
582+
_channelId,
583+
'Lichess Announcements',
584+
importance: Importance.high,
585+
priority: Priority.high,
586+
autoCancel: false,
587+
actions: [
588+
AndroidNotificationAction(
589+
dismissActionId,
590+
l10n.mobileCustomizeHomeTipDismiss,
591+
showsUserInterface: false,
592+
),
593+
],
594+
),
595+
iOS: const DarwinNotificationDetails(
596+
threadIdentifier: _channelId,
597+
categoryIdentifier: darwinCategoryId,
598+
),
599+
);
600+
}
601+
521602
/// A notification for a received challenge.
522603
///
523604
/// This notification is shown when a challenge is received from the server through

lib/src/network/socket.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const _kDisconnectOnBackgroundTimeout = Duration(minutes: 1);
4040
final _logger = Logger('Socket');
4141

4242
/// Set of topics that are allowed to be broadcasted to the global stream.
43-
const _globalSocketStreamAllowedTopics = {'n', 'message', 'challenges'};
43+
const _globalSocketStreamAllowedTopics = {'n', 'message', 'challenges', 'announce'};
4444

4545
final _globalStreamController = StreamController<SocketEvent>.broadcast();
4646

0 commit comments

Comments
 (0)