Merge branch 'main' into nav-client-events

This commit is contained in:
Alex Benzer
2026-05-25 21:18:43 -07:00
parent f3d2470766
commit bc731ef0d3
137 changed files with 9143 additions and 6464 deletions
+100 -11
View File
@@ -1,6 +1,6 @@
import UserNotifications
import UIKit
import Intents
import UIKit
import UserNotifications
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
@@ -30,11 +30,14 @@ class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ContentHandler?
private var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let bestAttempt = NSEUtil.createCopy(request.content),
let reason = request.content.userInfo["reason"] as? String
let reason = request.content.userInfo["reason"] as? String
else {
contentHandler(request.content)
return
@@ -44,11 +47,26 @@ class NotificationService: UNNotificationServiceExtension {
if reason == "chat-message" || reason == "chat-reaction" {
mutateWithChatMessage(bestAttempt)
// Only apply the title->body swap for chat-message. For chat-reaction,
// `messageKind` refers to the reacted-to message, so we'd clobber the
// descriptive reaction body with the title for reactions on system
// messages.
if reason == "chat-message" {
mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo)
}
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
let finalContent = createCommunicationNotification(
from: bestAttempt,
userInfo: request.content.userInfo
)
contentHandler(finalContent)
} else if reason == "chat-added-to-group"
|| reason == "chat-removed-from-group"
|| reason == "chat-join-request-rejected" {
mutateWithChatMessage(bestAttempt)
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
contentHandler(bestAttempt)
@@ -57,7 +75,8 @@ class NotificationService: UNNotificationServiceExtension {
override func serviceExtensionTimeWillExpire() {
guard let contentHandler = self.contentHandler,
let bestAttempt = self.bestAttempt else {
let bestAttempt = self.bestAttempt
else {
return
}
contentHandler(bestAttempt)
@@ -71,7 +90,7 @@ class NotificationService: UNNotificationServiceExtension {
) -> UNNotificationContent {
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
let convoId = userInfo["convoId"] as? String
var avatarImage: INImage? = nil
var avatarImage: INImage?
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
avatarImage = downloadAvatarImage(from: avatarUrlString)
}
@@ -87,17 +106,53 @@ class NotificationService: UNNotificationServiceExtension {
customIdentifier: nil
)
var speakableGroupName: INSpeakableString?
if userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty {
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
speakableGroupName: speakableGroupName,
conversationIdentifier: convoId,
serviceName: nil,
sender: sender,
attachments: nil
)
// The push payload omits the full recipient list for group convos. Without
// any signal of multiple recipients, iOS classifies the notification as
// `MessagingDirect` and ignores `speakableGroupName`. Setting
// `recipientCount` on the donation metadata is Apple's documented escape
// hatch for this case, and overrides the (zero-length) recipients array.
if userInfo["convoKind"] as? String == "group",
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
groupMemberCount > 0 {
let metadata = INSendMessageIntentDonationMetadata()
metadata.recipientCount = groupMemberCount
intent.donationMetadata = metadata
}
// For group convos, attach the composite group avatar (rendered by the
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
// alongside the sender on the Communication Notification.
// Disabled: the composite avatar renders poorly at notification size.
// if userInfo["convoKind"] as? String == "group",
// let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
// let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
// intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
// }
// Set the group image to the sender avatar instead
if userInfo["convoKind"] as? String == "group",
let avatarImage {
intent.setImage(avatarImage, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
@@ -120,13 +175,13 @@ class NotificationService: UNNotificationServiceExtension {
var request = URLRequest(url: url)
request.timeoutInterval = 5
var imageData: Data? = nil
var imageData: Data?
let semaphore = DispatchSemaphore(value: 0)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
imageData = data
}
semaphore.signal()
@@ -157,6 +212,40 @@ class NotificationService: UNNotificationServiceExtension {
}
}
// For group convos, surface the group name as the notification subtitle.
// The sender's display name is shown as the title (overridden by
// `INSendMessageIntent` for chat-message/chat-reaction).
func mutateWithGroupSubtitle(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard userInfo["convoKind"] as? String == "group",
let groupName = userInfo["groupName"] as? String,
!groupName.isEmpty
else {
return
}
content.subtitle = groupName
}
// System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are
// delivered through `chat-message` but have a server-rendered description
// in `title`. iOS overrides the title with the sender name once we apply
// `INSendMessageIntent`, so we move the title text into the body before
// building the intent.
func mutateChatMessageBody(
_ content: UNMutableNotificationContent,
userInfo: [AnyHashable: Any]
) {
guard let messageKind = userInfo["messageKind"] as? String,
messageKind != "message",
!content.title.isEmpty
else {
return
}
content.body = content.title
}
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default
}
@@ -1,11 +1,12 @@
import ExpoModulesCore
import WebKit
import MCEmojiPicker
class EmojiPickerView: ExpoView, MCEmojiPickerDelegate {
let onEmojiSelected = EventDispatcher()
override func layoutSubviews() {
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
self.addGestureRecognizer(tapGesture)
}
@@ -19,9 +20,19 @@ class EmojiPickerView: ExpoView, MCEmojiPickerDelegate {
let reactRootVC = reactViewController()
emojiPicker.sourceView = self
emojiPicker.delegate = self
emojiPicker.arrowDirection = preferredArrowDirection()
reactRootVC?.present(emojiPicker, animated: true)
}
/// If the trigger sits in the bottom third of the screen there isn't enough
/// room below it for a usable popover, so anchor the picker above instead.
private func preferredArrowDirection() -> MCPickerArrowDirection {
guard let window = self.window else { return .up }
let frameInWindow = self.convert(self.bounds, to: window)
let threshold = window.bounds.height * (2.0 / 3.0)
return frameInWindow.midY >= threshold ? .down : .up
}
func didGetEmoji(emoji: String) {
onEmojiSelected([
"emoji": emoji