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:
Henry Hiles 2026-10-18 16:01:37 -04:00
commit d1724932b2
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 139 additions and 39 deletions

2
gomuks

@ -1 +1 @@
Subproject commit ad275f7613894d9d2fa4d16d733a6816e2978de9
Subproject commit 7e43a63e578b0d6f52269d65297198077bf5b45d

View file

@ -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<int> {
}
}
Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
Future<PushNotification?> handlePush(Map<String, dynamic> 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<dynamic> _sendCommand(

View file

@ -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
: "<b>${escape.convert(prefix)}</b>: ${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,
);

View file

@ -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<String?> {
"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);

View 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);
}