diff --git a/README.md b/README.md index 1c10866..22c2173 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,8 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus). - [x] Android - - [x] MacOS - - [x] Unsigned .ipa - - [ ] App Store - - [x] iOS - - [x] Unsigned .ipa - - [ ] App Store + - [x] MacOS - Unsigned .dmg only + - [x] iOS - Unsigned .ipa only - [ ] Web (may not be possible) - [x] Login (via OAuth) - [x] Rooms / Spaces diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 931c023..b8e060d 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -25,7 +25,6 @@ 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"; @@ -273,12 +272,6 @@ 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 deleted file mode 100644 index d3db8b7..0000000 --- a/lib/controllers/notification.dart +++ /dev/null @@ -1,123 +0,0 @@ -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, - defaultToAllNotifications: true, - ), - ), - ); - } - } - }, - ); - - 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 e57025e..bd80467 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,59 +1,120 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; +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/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; +import "package:nexus/pages/notifications.dart"; +class NotificationsController + extends AsyncNotifier { @override - Future> build() async { - final client = ref.watch(ClientController.provider.notifier); + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); - final (unreadType, roomId) = request ?? (null, null); + final darwin = DarwinInitializationSettings(); - final mentions = await client.getMentions( - .new( - maxTimestamp: .now(), - unreadType: unreadType ?? .highlight, - limit: limit, - roomId: roomId, + 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 mentions; + return notifications; } - Future loadOlder() async { - final currentNotifications = await future; - state = .loading(); - state = await .guard(() async { - final lastTs = currentNotifications.lastOrNull?.timestamp; - if (lastTs == null) return const .empty(); + Future requestPermissions() async { + final controller = await future; - final client = ref.watch(ClientController.provider.notifier); - final (unreadType, roomId) = request ?? (null, null); + 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; + } - final newNotifications = await client.getMentions( - .new( - maxTimestamp: lastTs, - unreadType: unreadType ?? .highlight, - limit: limit, - roomId: roomId, - ), - ); - - return currentNotifications.addAll(newNotifications); - }); + return true; } - static final provider = AsyncNotifierProvider.family - .autoDispose< + 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< NotificationsController, - IList, - NotificationsRequest? + FlutterLocalNotificationsPlugin >(NotificationsController.new); } diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index d761ecf..2227225 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 = .loading(); + state = AsyncLoading(); 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 7f25cb9..1e60263 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/notification.dart"; +import "package:nexus/controllers/notifications.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( - NotificationController + NotificationsController .provider .notifier, ) diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 0be46cb..1c1b41a 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/notification.dart"; +import "package:nexus/controllers/notifications.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(NotificationController.provider.notifier) + .read(NotificationsController.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 80c2649..aaed5a0 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/notification.dart"; +import "package:nexus/controllers/notifications.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, - NotificationController.provider, + NotificationsController.provider, UnifiedPushController.provider, ]), ), diff --git a/lib/models/event.dart b/lib/models/event.dart index dc3dd46..0c8fc84 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, - final UnreadType? unreadType, + @UnreadTypeConverter() final UnreadType? unreadType, final Profile? pmp, required final Content content, required final Content? previousContent, @@ -91,10 +91,22 @@ class const LocalContent({ _$LocalContentFromJson(json); } -@Freezed(toJson: false, fromJson: false) -class const UnreadType(final int value) with _$UnreadType { - factory UnreadType.fromJson(int json) => UnreadType(json); - int toJson() => value; +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); static const none = UnreadType(0); static const normal = UnreadType(1); @@ -102,9 +114,9 @@ class const UnreadType(final int value) with _$UnreadType { static const highlight = UnreadType(4); static const sound = UnreadType(8); - bool isNone() => value == 0; - bool isNormal() => (value & 1) != 0; - bool shouldNotify() => (value & 2) != 0; - bool isHighlighted() => (value & 4) != 0; - bool playsSound() => (value & 8) != 0; + 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; } diff --git a/lib/models/requests/get_mentions.dart b/lib/models/requests/get_mentions.dart deleted file mode 100644 index 5df6870..0000000 --- a/lib/models/requests/get_mentions.dart +++ /dev/null @@ -1,19 +0,0 @@ -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 0592acd..ceca514 100644 --- a/lib/pages/notifications.dart +++ b/lib/pages/notifications.dart @@ -1,128 +1,14 @@ -import "package:m3e_buttons/m3e_buttons.dart"; 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/models/event.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, - final bool defaultToAllNotifications = false, - super.key, -}) extends HookConsumerWidget { +class const NotificationsPage({final String? eventId, super.key}) + extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final options = { - M3EToggleButtonGroupAction( - label: Text("Mentions"), - icon: Icon(Icons.alternate_email), - ): .highlight, - M3EToggleButtonGroupAction( - label: Text("All Notifications"), - icon: Icon(Icons.notifications), - ): .notify, - }; - final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0); - - final provider = NotificationsController.provider(( - options.values.toList()[unreadTypeIndex.value], - 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: Stack( - children: [ - 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 Padding( - padding: .only(top: 8), - child: HighlightWrapper( - InkWell( - onTap: () { - //TODO - }, - child: EventRenderer(event), - ), - isHighlighted: isHighlighted, - ), - ); - }, - ), - AsyncLoading() => const Loading(), - AsyncError(:final error, :final stackTrace) => ErrorDialog( - error, - stackTrace, - ), - }, - ), - ], - ), - Align( - alignment: .topRight, - child: Card( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Padding( - padding: .all(12), - child: M3EToggleButtonGroup( - selectedIndex: unreadTypeIndex.value, - onSelectedIndexChanged: (index) => - unreadTypeIndex.value = index ?? unreadTypeIndex.value, - actions: options.keys.toList(), - ), - ), - ), - ), - ], - ), + body: Placeholder(), ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index dd6ed25..456109e 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -78,12 +78,10 @@ final class const RoomChat({ final hasMore = useState(true); Future jumpToId(String eventId) async { - final index = - controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ) ?? - -1; - if (index == -1) return; + final index = controllerData.value?.indexWhere( + (element) => element.eventId == eventId, + ); + if (index == null) return; listController.value.animateToItem( index: index, diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index c2a0170..6473987 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -6,7 +6,6 @@ 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"; @@ -190,15 +189,6 @@ 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(