clean up and improve notifications
Thanks to Gomuks' new handling, we have sender displayname and avatar, and can use android's conversation notifications. On other platforms, we just append sender name to the body, bolded if possible. This also improves the logic on when to send notifications, as we drop notifs where `response["notification"]` is null. We also now correctly set if notifications should play a sound or not, based on `PushNotification.sound`. `PushNotification.image` handling might come sometime in the future, but it seems hard on Android.
This commit is contained in:
parent
a6fe80fea2
commit
d1724932b2
5 changed files with 139 additions and 39 deletions
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9
|
Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d
|
||||||
|
|
@ -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/get_space_hierarchy.dart";
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.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/recovery_key.dart";
|
||||||
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
||||||
import "package:nexus/models/requests/oauth/get_auth_url.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/set_state.dart";
|
||||||
import "package:nexus/models/requests/upload_media.dart";
|
import "package:nexus/models/requests/upload_media.dart";
|
||||||
import "package:nexus/models/room.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/room_summary.dart";
|
||||||
import "package:nexus/models/space_hierarchy.dart";
|
import "package:nexus/models/space_hierarchy.dart";
|
||||||
import "package:nexus/models/spec_versions_response.dart";
|
import "package:nexus/models/spec_versions_response.dart";
|
||||||
|
|
@ -128,18 +128,15 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
|
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
||||||
final response = await callGomuksMethod(
|
final response = await callGomuksMethod(
|
||||||
data,
|
data,
|
||||||
(handle, data) async => GomuksHandlePush(handle, data),
|
(handle, data) async => GomuksHandlePush(handle, data),
|
||||||
);
|
);
|
||||||
|
|
||||||
return response == null
|
return response?["notification"] == null
|
||||||
? null
|
? null
|
||||||
: (
|
: .fromJson(response["notification"]);
|
||||||
Event.fromJson(response["event"]),
|
|
||||||
RoomMetadata.fromJson(response["room"]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _sendCommand(
|
Future<dynamic> _sendCommand(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
import "dart:convert";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
|
|
@ -109,18 +110,32 @@ class NotificationController
|
||||||
String? body,
|
String? body,
|
||||||
File? icon,
|
File? icon,
|
||||||
String? payload,
|
String? payload,
|
||||||
|
String? senderName,
|
||||||
|
File? senderAvatar,
|
||||||
|
String? selfName,
|
||||||
|
bool isGroup = false,
|
||||||
|
bool sound = true,
|
||||||
|
DateTime? timestamp,
|
||||||
}) async {
|
}) async {
|
||||||
debugPrint("Sending notification for $id");
|
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) {
|
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(
|
await portal.notification.addNotification(
|
||||||
id.toString(),
|
id.toString(),
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: prefix == null || body == null ? body : "$prefix: $body",
|
||||||
|
markupBody: prefix == null || body == null
|
||||||
|
? null
|
||||||
|
: "<b>${escape.convert(prefix)}</b>: ${escape.convert(body)}",
|
||||||
defaultAction: "app.event",
|
defaultAction: "app.event",
|
||||||
defaultActionTarget: payload,
|
defaultActionTarget: payload,
|
||||||
|
sound: sound ? null : const XdgNotificationSoundSilent(),
|
||||||
category: XdgNotificationCategory.imReceived,
|
category: XdgNotificationCategory.imReceived,
|
||||||
priority: .normal,
|
priority: .normal,
|
||||||
buttons: [
|
buttons: [
|
||||||
|
|
@ -133,25 +148,57 @@ class NotificationController
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} 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(
|
final notificationDetails = NotificationDetails(
|
||||||
android: .new(
|
android: .new(
|
||||||
"messages",
|
"messages",
|
||||||
"Messages",
|
"Messages",
|
||||||
|
silent: !sound,
|
||||||
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
|
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
|
// TODO: See if icons can be added to iOS, macOS, and Windows
|
||||||
// notifications (#68)
|
// notifications (#68)
|
||||||
iOS: .new(),
|
iOS: darwin,
|
||||||
macOS: .new(),
|
macOS: darwin,
|
||||||
windows: .new(),
|
windows: .new(audio: sound ? null : .silent()),
|
||||||
);
|
);
|
||||||
|
|
||||||
final controller = await future;
|
|
||||||
|
|
||||||
await controller.show(
|
await controller.show(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: prefix == null || body == null ? body : "$prefix: $body",
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import "package:nexus/controllers/push_key.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.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/unifiedpush.dart";
|
||||||
import "package:unifiedpush_storage_shared_preferences/storage.dart";
|
import "package:unifiedpush_storage_shared_preferences/storage.dart";
|
||||||
import "package:window_manager/window_manager.dart";
|
import "package:window_manager/window_manager.dart";
|
||||||
|
|
@ -79,42 +77,56 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
||||||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pushResponse = await ref
|
final notification = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
||||||
|
|
||||||
if (pushResponse == null) return;
|
if (notification == null ||
|
||||||
|
|
||||||
final (event, roomMetadata) = pushResponse;
|
|
||||||
|
|
||||||
if (event.unreadType?.has(.notify) != true ||
|
|
||||||
(!isInBackground &&
|
(!isInBackground &&
|
||||||
await windowManager.isFocused().onError((_, _) => true) &&
|
await windowManager.isFocused().onError((_, _) => true) &&
|
||||||
ref.read(SelectedRoomController.provider) == event.roomId)) {
|
ref.read(SelectedRoomController.provider) ==
|
||||||
|
notification.roomId)) {
|
||||||
if (isInBackground) exit(0);
|
if (isInBackground) exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final icon = roomMetadata.avatar == null
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
|
final icon = notification.roomAvatar == null
|
||||||
? null
|
? null
|
||||||
: await ref
|
: await client.downloadMedia(
|
||||||
.read(ClientController.provider.notifier)
|
.new(mxc: notification.roomAvatar!, isAvatar: true),
|
||||||
.downloadMedia(
|
);
|
||||||
.new(mxc: roomMetadata.avatar!, 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
|
await ref
|
||||||
.read(NotificationController.provider.notifier)
|
.read(NotificationController.provider.notifier)
|
||||||
.send(
|
.send(
|
||||||
id: event.eventId.hashCode & 0x7fffffff,
|
// Android stacks messages into one conversation per room
|
||||||
title: roomMetadata.name ?? "New Event",
|
id:
|
||||||
|
(Platform.isAndroid
|
||||||
|
? notification.roomId
|
||||||
|
: notification.eventId)
|
||||||
|
.hashCode &
|
||||||
|
0x7fffffff,
|
||||||
|
title: notification.roomName,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
payload: event.rowId.toString(),
|
payload: notification.eventRowId.toString(),
|
||||||
body: switch (event.content) {
|
body: notification.text,
|
||||||
MessageContent(:final body?) ||
|
senderName: pmp?.name ?? notification.sender.name,
|
||||||
StickerContent(:final body) => body,
|
senderAvatar: senderAvatar,
|
||||||
_ => null,
|
selfName: notification.self.name,
|
||||||
},
|
isGroup: !notification.isDm,
|
||||||
|
sound: notification.sound,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isInBackground) exit(0);
|
if (isInBackground) exit(0);
|
||||||
|
|
|
||||||
44
lib/models/push_notification.dart
Normal file
44
lib/models/push_notification.dart
Normal file
|
|
@ -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<String, Object?> 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<String, Object?> json) =>
|
||||||
|
_$NotificationUserFromJson(json);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue