Merge branch 'main' into nav-client-events
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user