Implements push notifications for LLS via MSC4505 (#5872)

* Render live location share start push notifications in the NSE

Live location sharing starts with a cleartext org.matrix.msc3672.beacon_info
STATE event, which the NSE previously rendered as the generic fallback
notification because NotificationContentBuilder only handled message-like
event content.

- Handle .state(.beaconInfo) in NotificationContentBuilder: sender display
  name as the title (same as other messages) with a new localized body,
  "Started sharing their live location".
- Let beaconInfo state events through NotificationHandler's preprocessing
  (all other state events are still discarded).
- Add notification_live_location_started_body, imported from Localazy.
- Unit tests for the new state-event notification paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* rename processMessageLike in processAsMessage

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mauro Romito <mauro.romito@element.io>
This commit is contained in:
Matthew Hodgson
2026-07-27 15:31:47 +01:00
committed by GitHub
parent 5262350b40
commit a8c79fdd46
6 changed files with 97 additions and 27 deletions
@@ -519,6 +519,7 @@
"notification_invitation_action_reject" = "Reject";
"notification_invite_body" = "Invited you to chat";
"notification_invite_body_with_sender" = "%1$@ invited you to chat";
"notification_live_location_started_body" = "Started sharing their live location";
"notification_mentioned_you_body" = "Mentioned you: %1$@";
"notification_new_messages" = "New Messages";
"notification_reaction_body" = "Reacted with %1$@";
@@ -519,6 +519,7 @@
"notification_invitation_action_reject" = "Reject";
"notification_invite_body" = "Invited you to chat";
"notification_invite_body_with_sender" = "%1$@ invited you to chat";
"notification_live_location_started_body" = "Started sharing their live location";
"notification_mentioned_you_body" = "Mentioned you: %1$@";
"notification_new_messages" = "New Messages";
"notification_reaction_body" = "Reacted with %1$@";
+2
View File
@@ -1192,6 +1192,8 @@ internal nonisolated enum L10n {
internal static func notificationInviteBodyWithSender(_ p1: Any) -> String {
return L10n.tr("Localizable", "notification_invite_body_with_sender", String(describing: p1))
}
/// Started sharing their live location
internal static var notificationLiveLocationStartedBody: String { return L10n.tr("Localizable", "notification_live_location_started_body") }
/// Mentioned you: %1$@
internal static func notificationMentionedYouBody(_ p1: Any) -> String {
return L10n.tr("Localizable", "notification_mentioned_you_body", String(describing: p1))
+35 -25
View File
@@ -59,28 +59,38 @@ nonisolated struct NotificationContentBuilder {
notificationItem: notificationItem,
mediaProvider: mediaProvider)
case .timeline(let event):
guard case let .messageLike(messageContent) = try? event.content() else {
processEmpty(&notificationContent)
return
}
await processMessageLike(notificationContent: &notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
switch messageContent {
case .roomMessage(let messageType, _):
await processRoomMessage(notificationContent: &notificationContent,
notificationItem: notificationItem,
messageType: messageType,
mediaProvider: mediaProvider)
case .poll(let question):
notificationContent.body = L10n.commonPollSummary(question)
case .callInvite:
notificationContent.body = L10n.commonUnsupportedCall
case .rtcNotification:
notificationContent.body = L10n.notificationIncomingCall
default:
switch try? event.content() {
case .messageLike(let messageContent):
await processAsMessage(notificationContent: &notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
switch messageContent {
case .roomMessage(let messageType, _):
await processRoomMessage(notificationContent: &notificationContent,
notificationItem: notificationItem,
messageType: messageType,
mediaProvider: mediaProvider)
case .poll(let question):
notificationContent.body = L10n.commonPollSummary(question)
case .callInvite:
notificationContent.body = L10n.commonUnsupportedCall
case .rtcNotification:
notificationContent.body = L10n.notificationIncomingCall
default:
processEmpty(&notificationContent)
}
case .state(let stateContent):
switch stateContent {
case .beaconInfo:
await processAsMessage(notificationContent: &notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
notificationContent.body = L10n.notificationLiveLocationStartedBody
default:
processEmpty(&notificationContent)
}
case .none:
processEmpty(&notificationContent)
}
}
@@ -117,9 +127,9 @@ nonisolated struct NotificationContentBuilder {
mediaProvider: mediaProvider)
}
private func processMessageLike(notificationContent: inout UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
mediaProvider: MediaProviderProtocol) async {
private func processAsMessage(notificationContent: inout UNMutableNotificationContent,
notificationItem: NotificationItemProxyProtocol,
mediaProvider: MediaProviderProtocol) async {
notificationContent.title = notificationItem.senderDisplayName ?? notificationItem.roomDisplayName
if notificationContent.title != notificationItem.roomDisplayName {
notificationContent.subtitle = notificationItem.roomDisplayName
+7 -2
View File
@@ -148,8 +148,13 @@ nonisolated class NotificationHandler {
.beacon:
return .unsupportedShouldDiscard
}
case .state:
return .unsupportedShouldDiscard
case .state(let stateContent):
switch stateContent {
case .beaconInfo:
return .shouldDisplay
default:
return .unsupportedShouldDiscard
}
case .none:
return .unsupportedShouldDiscard
}
@@ -8,6 +8,7 @@
import Dynamic
@testable import ElementX
import MatrixRustSDK
import MatrixRustSDKMocks
import Testing
import UserNotifications
@@ -221,6 +222,56 @@ nonisolated struct NotificationContentBuilderTests {
#expect(notificationContent.attachments == [])
}
@Test
mutating func liveLocationStartNotification() async {
let event = TimelineEventSDKMock()
event.eventIdReturnValue = UUID().uuidString
event.contentReturnValue = .state(content: .beaconInfo)
let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event),
roomID: "!test:matrix.org",
receiverID: "@bob:matrix.org",
senderDisplayName: "Alice",
roomDisplayName: "Alice",
roomJoinedMembers: 2,
isRoomDirect: true,
isRoomPrivate: true,
isNoisy: true))
await notificationContentBuilder.process(notificationContent: &notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
let communicationContext = Dynamic(notificationContent, memberName: "communicationContext")
#expect(communicationContext.sender.displayName == "Alice")
#expect(notificationContent.body == L10n.notificationLiveLocationStartedBody)
#expect(notificationContent.categoryIdentifier == NotificationConstants.Category.message)
#expect(notificationContent.sound != nil)
}
@Test
mutating func otherStateEventNotification() async {
let event = TimelineEventSDKMock()
event.eventIdReturnValue = UUID().uuidString
event.contentReturnValue = .state(content: .roomName)
let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event),
roomID: "!test:matrix.org",
receiverID: "@bob:matrix.org",
senderDisplayName: "Alice",
roomDisplayName: "Alice",
roomJoinedMembers: 2,
isRoomDirect: true,
isRoomPrivate: true,
isNoisy: true))
await notificationContentBuilder.process(notificationContent: &notificationContent,
notificationItem: notificationItem,
mediaProvider: mediaProvider)
#expect(notificationContent.body == L10n.notification)
}
@Test
mutating func roomMessageNotificationWithThreadAndMention() async {
let notificationItem = NotificationItemProxyMock(.init(roomID: "!testroom:matrix.org",