diff --git a/gomuks b/gomuks index ad275f7..7e43a63 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9 +Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index a282f81..52b49ee 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -31,6 +31,7 @@ import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; +import "package:nexus/models/push_notification.dart"; import "package:nexus/models/recovery_key.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; @@ -47,7 +48,6 @@ import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/models/room_metadata.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/spec_versions_response.dart"; @@ -128,18 +128,15 @@ class ClientController extends AsyncNotifier { } } - Future<(Event, RoomMetadata)?> handlePush(Map data) async { + Future handlePush(Map data) async { final response = await callGomuksMethod( data, (handle, data) async => GomuksHandlePush(handle, data), ); - return response == null + return response?["notification"] == null ? null - : ( - Event.fromJson(response["event"]), - RoomMetadata.fromJson(response["room"]), - ); + : .fromJson(response["notification"]); } Future _sendCommand( diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 27c2f91..4016a2c 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "dart:convert"; import "dart:io"; import "package:flutter/services.dart"; @@ -109,18 +110,32 @@ class NotificationController String? body, File? icon, String? payload, + String? senderName, + File? senderAvatar, + String? selfName, + bool isGroup = false, + bool sound = true, + DateTime? timestamp, }) async { debugPrint("Sending notification for $id"); + // In DMs the title is already the sender, so only prefix in groups + final prefix = isGroup ? senderName : null; + if (Platform.isLinux) { - final portal = await ref.watch(PortalController.provider.future); + final portal = await ref.read(PortalController.provider.future); + const escape = HtmlEscape(HtmlEscapeMode.element); await portal.notification.addNotification( id.toString(), title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", + markupBody: prefix == null || body == null + ? null + : "${escape.convert(prefix)}: ${escape.convert(body)}", defaultAction: "app.event", defaultActionTarget: payload, + sound: sound ? null : const XdgNotificationSoundSilent(), category: XdgNotificationCategory.imReceived, priority: .normal, buttons: [ @@ -133,25 +148,57 @@ class NotificationController ], ); } else { + final controller = await future; + + // Append to the conversation already shown under this id, if any + final existingStyle = Platform.isAndroid + ? await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.getActiveNotificationMessagingStyle(id: id) + .onError((_, _) => null) + : null; + + final darwin = DarwinNotificationDetails(presentSound: sound); final notificationDetails = NotificationDetails( android: .new( "messages", "Messages", + silent: !sound, largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path), + styleInformation: senderName == null || body == null + ? null + : MessagingStyleInformation( + .new(name: selfName ?? "You"), + conversationTitle: isGroup ? title : null, + groupConversation: isGroup, + messages: [ + ...?existingStyle?.messages, + .new( + body, + timestamp ?? .now(), + .new( + name: senderName, + icon: senderAvatar == null + ? null + : BitmapFilePathAndroidIcon(senderAvatar.path), + ), + ), + ], + ), ), // TODO: See if icons can be added to iOS, macOS, and Windows // notifications (#68) - iOS: .new(), - macOS: .new(), - windows: .new(), + iOS: darwin, + macOS: darwin, + windows: .new(audio: sound ? null : .silent()), ); - final controller = await future; - await controller.show( id: id, title: title, - body: body, + body: prefix == null || body == null ? body : "$prefix: $body", notificationDetails: notificationDetails, payload: payload, ); diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 8dcb295..87ec4e8 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -11,8 +11,6 @@ import "package:nexus/controllers/push_key.dart"; import "package:nexus/main.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/models/content/message.dart"; -import "package:nexus/models/content/sticker.dart"; import "package:unifiedpush/unifiedpush.dart"; import "package:unifiedpush_storage_shared_preferences/storage.dart"; import "package:window_manager/window_manager.dart"; @@ -79,42 +77,56 @@ class UnifiedPushController extends AsyncNotifier { "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", ); } - final pushResponse = await ref + final notification = await ref .read(ClientController.provider.notifier) .handlePush(json.decode(String.fromCharCodes(message.content))); - if (pushResponse == null) return; - - final (event, roomMetadata) = pushResponse; - - if (event.unreadType?.has(.notify) != true || + if (notification == null || (!isInBackground && await windowManager.isFocused().onError((_, _) => true) && - ref.read(SelectedRoomController.provider) == event.roomId)) { + ref.read(SelectedRoomController.provider) == + notification.roomId)) { if (isInBackground) exit(0); return; } - final icon = roomMetadata.avatar == null + final client = ref.read(ClientController.provider.notifier); + + final icon = notification.roomAvatar == null ? null - : await ref - .read(ClientController.provider.notifier) - .downloadMedia( - .new(mxc: roomMetadata.avatar!, isAvatar: true), - ); + : await client.downloadMedia( + .new(mxc: notification.roomAvatar!, isAvatar: true), + ); + + final pmp = notification.perMessageProfile; + final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar; + // Only Android can show the sender's avatar + final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid + ? null + : await client.downloadMedia( + .new(mxc: senderAvatarUrl, isAvatar: true), + ); await ref .read(NotificationController.provider.notifier) .send( - id: event.eventId.hashCode & 0x7fffffff, - title: roomMetadata.name ?? "New Event", + // Android stacks messages into one conversation per room + id: + (Platform.isAndroid + ? notification.roomId + : notification.eventId) + .hashCode & + 0x7fffffff, + title: notification.roomName, icon: icon, - payload: event.rowId.toString(), - body: switch (event.content) { - MessageContent(:final body?) || - StickerContent(:final body) => body, - _ => null, - }, + payload: notification.eventRowId.toString(), + body: notification.text, + senderName: pmp?.name ?? notification.sender.name, + senderAvatar: senderAvatar, + selfName: notification.self.name, + isGroup: !notification.isDm, + sound: notification.sound, + timestamp: notification.timestamp, ); if (isInBackground) exit(0); diff --git a/lib/models/push_notification.dart b/lib/models/push_notification.dart new file mode 100644 index 0000000..859b457 --- /dev/null +++ b/lib/models/push_notification.dart @@ -0,0 +1,44 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "push_notification.freezed.dart"; +part "push_notification.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PushNotification({ + @EpochDateTimeConverter() required final DateTime timestamp, + + required final String eventId, + @JsonKey(name: "event_rowid") required final int eventRowId, + + required final String roomId, + required final String roomName, + final Uri? roomAvatar, + final bool isDm = false, + + required final NotificationUser sender, + required final NotificationUser self, + final NotificationUser? perMessageProfile, + + required final String text, + final Uri? image, + + final bool mention = false, + final bool reply = false, + final bool sound = false, +}) with _$PushNotification { + factory PushNotification.fromJson(Map json) => + _$PushNotificationFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const NotificationUser({ + required final String id, + required final String name, + final Uri? avatar, +}) with _$NotificationUser { + factory NotificationUser.fromJson(Map json) => + _$NotificationUserFromJson(json); +}