diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index b8e060d..931c023 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -25,6 +25,7 @@ import "package:nexus/models/paginate.dart"; import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; +import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/join_room.dart"; @@ -272,6 +273,12 @@ class ClientController extends AsyncNotifier { return .new(response?.map((event) => .fromJson(event))); } + Future> getMentions(GetMentionsRequest request) async => .new( + ((await _sendCommand("get_mentions", request.toJson())) as List).map( + (event) => .fromJson(event), + ), + ); + Future getEvent(GetEventRequest request) async { final json = await _sendCommand("get_event", request.toJson()); return json == null ? null : .fromJson(json); diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart new file mode 100644 index 0000000..a8b9e6c --- /dev/null +++ b/lib/controllers/notification.dart @@ -0,0 +1,120 @@ +import "dart:io"; + +import "package:material_ui/material_ui.dart"; +import "package:nexus/main.dart"; +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +// ignore: implementation_imports +import "package:flutter_local_notifications_linux/src/model/hint.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/pages/notifications.dart"; + +class NotificationController + extends AsyncNotifier { + @override + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); + + final darwin = DarwinInitializationSettings(); + + await notifications.initialize( + settings: .new( + windows: .new( + appName: "Nexus", + appUserModelId: "nexus.federated.nexus", + guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + ), + android: .new("ic_launcher_foreground"), + iOS: darwin, + macOS: darwin, + linux: .new(defaultActionName: "Open"), + ), + onDidReceiveNotificationResponse: (details) { + if (details.payload case final eventId?) { + if (navigatorKey.currentContext case final context?) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage(eventId: eventId), + ), + ); + } + } + }, + ); + + return notifications; + } + + Future requestPermissions() async { + final controller = await future; + + if (Platform.isIOS) { + return await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isMacOS) { + return await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + } + + return true; + } + + Future send({ + required int id, + required String title, + String? body, + File? icon, + String? payload, + }) async { + final notificationDetails = NotificationDetails( + android: .new( + "messages", + "Messages", + largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + ), + linux: .new( + customHints: icon == null + ? null + : [ + .new( + name: "image-path", + value: LinuxHintStringValue(icon.path), + ), + ], + ), + // TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68) + iOS: .new(), + macOS: .new(), + windows: .new(), + ); + + final controller = await future; + await controller.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + } + + static final provider = + AsyncNotifierProvider< + NotificationController, + FlutterLocalNotificationsPlugin + >(NotificationController.new); +} diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index bd80467..e57025e 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,120 +1,59 @@ -import "dart:io"; - -import "package:material_ui/material_ui.dart"; -import "package:nexus/main.dart"; -import "package:flutter_local_notifications/flutter_local_notifications.dart"; -// ignore: implementation_imports -import "package:flutter_local_notifications_linux/src/model/hint.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/pages/notifications.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/event.dart"; + +typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); + +class NotificationsController([final NotificationsRequest? request]) + extends AsyncNotifier> { + static const limit = 20; -class NotificationsController - extends AsyncNotifier { @override - Future build() async { - final notifications = FlutterLocalNotificationsPlugin(); + Future> build() async { + final client = ref.watch(ClientController.provider.notifier); - final darwin = DarwinInitializationSettings(); + final (unreadType, roomId) = request ?? (null, null); - await notifications.initialize( - settings: .new( - windows: .new( - appName: "Nexus", - appUserModelId: "nexus.federated.nexus", - guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + final mentions = await client.getMentions( + .new( + maxTimestamp: .now(), + unreadType: unreadType ?? .highlight, + limit: limit, + roomId: roomId, + ), + ); + + return mentions; + } + + Future loadOlder() async { + final currentNotifications = await future; + state = .loading(); + state = await .guard(() async { + final lastTs = currentNotifications.lastOrNull?.timestamp; + if (lastTs == null) return const .empty(); + + final client = ref.watch(ClientController.provider.notifier); + final (unreadType, roomId) = request ?? (null, null); + + final newNotifications = await client.getMentions( + .new( + maxTimestamp: lastTs, + unreadType: unreadType ?? .highlight, + limit: limit, + roomId: roomId, ), - android: .new("ic_launcher_foreground"), - iOS: darwin, - macOS: darwin, - linux: .new(defaultActionName: "Open"), - ), - onDidReceiveNotificationResponse: (details) { - if (details.payload case final eventId?) { - if (navigatorKey.currentContext case final context?) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NotificationsPage(eventId: eventId), - ), - ); - } - } - }, - ); + ); - return notifications; + return currentNotifications.addAll(newNotifications); + }); } - Future requestPermissions() async { - final controller = await future; - - if (Platform.isIOS) { - return await controller - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true) ?? - true; - } else if (Platform.isMacOS) { - return await controller - .resolvePlatformSpecificImplementation< - MacOSFlutterLocalNotificationsPlugin - >() - ?.requestPermissions(alert: true, badge: true, sound: true) ?? - true; - } else if (Platform.isAndroid) { - return await controller - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.requestNotificationsPermission() ?? - true; - } - - return true; - } - - Future send({ - required int id, - required String title, - String? body, - File? icon, - String? payload, - }) async { - final notificationDetails = NotificationDetails( - android: .new( - "messages", - "Messages", - largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), - ), - linux: .new( - customHints: icon == null - ? null - : [ - .new( - name: "image-path", - value: LinuxHintStringValue(icon.path), - ), - ], - ), - // TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68) - iOS: .new(), - macOS: .new(), - windows: .new(), - ); - - final controller = await future; - await controller.show( - id: id, - title: title, - body: body, - notificationDetails: notificationDetails, - payload: payload, - ); - } - - static final provider = - AsyncNotifierProvider< + static final provider = AsyncNotifierProvider.family + .autoDispose< NotificationsController, - FlutterLocalNotificationsPlugin + IList, + NotificationsRequest? >(NotificationsController.new); } diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 2227225..d761ecf 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -74,7 +74,7 @@ class RoomChatController(final String roomId) ); Future loadOlder() async { - state = AsyncLoading(); + state = .loading(); final timelineKeys = ref .read(RoomsController.provider.select((value) => value[roomId])) ?.timeline diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 1e60263..7f25cb9 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,7 +9,7 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; @@ -143,7 +143,7 @@ class SettingsSectionsController if (value) { if (await ref .watch( - NotificationsController + NotificationController .provider .notifier, ) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 1c1b41a..0be46cb 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -5,7 +5,7 @@ import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:intl/intl.dart"; import "package:nexus/controllers/key.dart"; -import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/main.dart"; @@ -61,7 +61,7 @@ class UnifiedPushController extends AsyncNotifier { ); if (event == null || - event.unreadType?.shouldNotify != true || + event.unreadType?.shouldNotify() != true || (!isInBackground && await windowManager.isFocused().onError((_, _) => false) && ref.watch(KeyController.provider(KeyController.roomKey)) == @@ -81,7 +81,7 @@ class UnifiedPushController extends AsyncNotifier { .downloadMedia(.new(mxc: avatar, isAvatar: true)); await ref - .read(NotificationsController.provider.notifier) + .read(NotificationController.provider.notifier) .send( id: event.eventId.hashCode & 0x7fffffff, title: room?.metadata?.name ?? "New Event", diff --git a/lib/main.dart b/lib/main.dart index aaed5a0..80c2649 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/multi_provider.dart"; -import "package:nexus/controllers/notifications.dart"; +import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/unified_push.dart"; @@ -147,7 +147,7 @@ class const App(final bool isInBackground, {super.key}) IListConst([ SharedPrefsController.provider, ClientController.provider, - NotificationsController.provider, + NotificationController.provider, UnifiedPushController.provider, ]), ), diff --git a/lib/models/event.dart b/lib/models/event.dart index 0c8fc84..dc3dd46 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -29,7 +29,7 @@ class const Event({ final String? sendError, final IMap reactions = const IMap.empty(), @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, - @UnreadTypeConverter() final UnreadType? unreadType, + final UnreadType? unreadType, final Profile? pmp, required final Content content, required final Content? previousContent, @@ -91,22 +91,10 @@ class const LocalContent({ _$LocalContentFromJson(json); } -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -// I think this is correct but I'm not sure, its some type of bitmask. -@immutable -class UnreadType { - final int value; - - const UnreadType(this.value); +@Freezed(toJson: false, fromJson: false) +class const UnreadType(final int value) with _$UnreadType { + factory UnreadType.fromJson(int json) => UnreadType(json); + int toJson() => value; static const none = UnreadType(0); static const normal = UnreadType(1); @@ -114,9 +102,9 @@ class UnreadType { static const highlight = UnreadType(4); static const sound = UnreadType(8); - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; + bool isNone() => value == 0; + bool isNormal() => (value & 1) != 0; + bool shouldNotify() => (value & 2) != 0; + bool isHighlighted() => (value & 4) != 0; + bool playsSound() => (value & 8) != 0; } diff --git a/lib/models/requests/get_mentions.dart b/lib/models/requests/get_mentions.dart new file mode 100644 index 0000000..5df6870 --- /dev/null +++ b/lib/models/requests/get_mentions.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +part "get_mentions.freezed.dart"; +part "get_mentions.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class GetMentionsRequest({ + @EpochDateTimeConverter() required final DateTime maxTimestamp, + @JsonKey(name: "type") required final UnreadType unreadType, + required final int limit, + final String? roomId, +}) with _$GetMentionsRequest { + Map toJson() => _$GetMentionsRequestToJson(this); + + factory GetMentionsRequest.fromJson(Map json) => + _$GetMentionsRequestFromJson(json); +} diff --git a/lib/pages/notifications.dart b/lib/pages/notifications.dart index ceca514..56afdad 100644 --- a/lib/pages/notifications.dart +++ b/lib/pages/notifications.dart @@ -1,14 +1,86 @@ import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/notifications.dart"; import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; class const NotificationsPage({final String? eventId, super.key}) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final provider = NotificationsController.provider(null); + final notifications = ref.watch(provider); + final notifier = ref.watch(provider.notifier); + + final scrollController = useScrollController(); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || notifications.isLoading) return; + + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent) { + await notifier.loadOlder(); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController, notifications]); + return Scaffold( appBar: Appbar(title: Text("Notifications")), - body: Placeholder(), + body: Column( + children: [ + if (notifications is AsyncLoading && notifications.value != null) + const LinearProgressIndicator(minHeight: 2), + Expanded( + child: switch (notifications) { + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Text( + "No notifications yet", + style: Theme.of(context).textTheme.headlineMedium, + ), + ) + : SuperListView.builder( + controller: scrollController, + itemCount: value.length, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + reverse: true, + itemBuilder: (context, index) { + final event = value[index]; + final isHighlighted = event.eventId == eventId; + + return HighlightWrapper( + InkWell( + onTap: () { + //TODO + }, + child: EventRenderer(event), + ), + isHighlighted: isHighlighted, + ); + }, + ), + AsyncLoading() => const Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ], + ), ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 456109e..dd6ed25 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -78,10 +78,12 @@ final class const RoomChat({ final hasMore = useState(true); Future jumpToId(String eventId) async { - final index = controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ); - if (index == null) return; + final index = + controllerData.value?.indexWhere( + (element) => element.eventId == eventId, + ) ?? + -1; + if (index == -1) return; listController.value.animateToItem( index: index, diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 6473987..c2a0170 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -6,6 +6,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/spaces.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/pages/notifications.dart"; import "package:nexus/pages/settings.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_widget.dart"; @@ -189,6 +190,15 @@ class const Sidebar({required final bool isDesktop, super.key}) onPressed: null, icon: Icon(Icons.explore), ), + IconButton( + tooltip: "Open notifications", + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationsPage(), + ), + ), + icon: Icon(Icons.notifications), + ), IconButton( tooltip: "Open settings", onPressed: () => showDialog(