mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			561 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			561 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | 
						|
 | 
						|
import Foundation
 | 
						|
import GRDB
 | 
						|
import PromiseKit
 | 
						|
import SessionMessagingKit
 | 
						|
import SignalUtilitiesKit
 | 
						|
 | 
						|
/// There are two primary components in our system notification integration:
 | 
						|
///
 | 
						|
///     1. The `NotificationPresenter` shows system notifications to the user.
 | 
						|
///     2. The `NotificationActionHandler` handles the users interactions with these
 | 
						|
///        notifications.
 | 
						|
///
 | 
						|
/// The NotificationPresenter is driven by the adapter pattern to provide a unified interface to
 | 
						|
/// presenting notifications on iOS9, which uses UINotifications vs iOS10+ which supports
 | 
						|
/// UNUserNotifications.
 | 
						|
///
 | 
						|
/// The `NotificationActionHandler`s also need slightly different integrations for UINotifications
 | 
						|
/// vs. UNUserNotifications, but because they are integrated at separate system defined callbacks,
 | 
						|
/// there is no need for an Adapter, and instead the appropriate NotificationActionHandler is
 | 
						|
/// wired directly into the appropriate callback point.
 | 
						|
 | 
						|
enum AppNotificationCategory: CaseIterable {
 | 
						|
    case incomingMessage
 | 
						|
    case incomingMessageFromNoLongerVerifiedIdentity
 | 
						|
    case errorMessage
 | 
						|
    case threadlessErrorMessage
 | 
						|
}
 | 
						|
 | 
						|
enum AppNotificationAction: CaseIterable {
 | 
						|
    case markAsRead
 | 
						|
    case reply
 | 
						|
    case showThread
 | 
						|
}
 | 
						|
 | 
						|
struct AppNotificationUserInfoKey {
 | 
						|
    static let threadId = "Signal.AppNotificationsUserInfoKey.threadId"
 | 
						|
    static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber"
 | 
						|
    static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId"
 | 
						|
    static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter"
 | 
						|
}
 | 
						|
 | 
						|
extension AppNotificationCategory {
 | 
						|
    var identifier: String {
 | 
						|
        switch self {
 | 
						|
        case .incomingMessage:
 | 
						|
            return "Signal.AppNotificationCategory.incomingMessage"
 | 
						|
        case .incomingMessageFromNoLongerVerifiedIdentity:
 | 
						|
            return "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity"
 | 
						|
        case .errorMessage:
 | 
						|
            return "Signal.AppNotificationCategory.errorMessage"
 | 
						|
        case .threadlessErrorMessage:
 | 
						|
            return "Signal.AppNotificationCategory.threadlessErrorMessage"
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    var actions: [AppNotificationAction] {
 | 
						|
        switch self {
 | 
						|
        case .incomingMessage:
 | 
						|
            return [.markAsRead, .reply]
 | 
						|
        case .incomingMessageFromNoLongerVerifiedIdentity:
 | 
						|
            return [.markAsRead, .showThread]
 | 
						|
        case .errorMessage:
 | 
						|
            return [.showThread]
 | 
						|
        case .threadlessErrorMessage:
 | 
						|
            return []
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
extension AppNotificationAction {
 | 
						|
    var identifier: String {
 | 
						|
        switch self {
 | 
						|
        case .markAsRead:
 | 
						|
            return "Signal.AppNotifications.Action.markAsRead"
 | 
						|
        case .reply:
 | 
						|
            return "Signal.AppNotifications.Action.reply"
 | 
						|
        case .showThread:
 | 
						|
            return "Signal.AppNotifications.Action.showThread"
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// Delay notification of incoming messages when it's a background polling to
 | 
						|
// avoid too many notifications fired at the same time
 | 
						|
let kNotificationDelayForBackgroumdPoll: TimeInterval = 5
 | 
						|
 | 
						|
let kAudioNotificationsThrottleCount = 2
 | 
						|
let kAudioNotificationsThrottleInterval: TimeInterval = 5
 | 
						|
 | 
						|
protocol NotificationPresenterAdaptee: AnyObject {
 | 
						|
 | 
						|
    func registerNotificationSettings() -> Promise<Void>
 | 
						|
 | 
						|
    func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?)
 | 
						|
    func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?)
 | 
						|
 | 
						|
    func cancelNotifications(threadId: String)
 | 
						|
    func cancelNotifications(identifiers: [String])
 | 
						|
    func clearAllNotifications()
 | 
						|
}
 | 
						|
 | 
						|
@objc(OWSNotificationPresenter)
 | 
						|
public class NotificationPresenter: NSObject, NotificationsProtocol {
 | 
						|
 | 
						|
    private let adaptee: NotificationPresenterAdaptee
 | 
						|
 | 
						|
    @objc
 | 
						|
    public override init() {
 | 
						|
        self.adaptee = UserNotificationPresenterAdaptee()
 | 
						|
 | 
						|
        super.init()
 | 
						|
 | 
						|
        AppReadiness.runNowOrWhenAppDidBecomeReady {
 | 
						|
            NotificationCenter.default.addObserver(self, selector: #selector(self.handleMessageRead), name: .incomingMessageMarkedAsRead, object: nil)
 | 
						|
        }
 | 
						|
        SwiftSingletons.register(self)
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: -
 | 
						|
 | 
						|
    @objc
 | 
						|
    func handleMessageRead(notification: Notification) {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
 | 
						|
        switch notification.object {
 | 
						|
            case let interaction as Interaction:
 | 
						|
                guard interaction.variant == .standardIncoming else { return }
 | 
						|
        
 | 
						|
                Logger.debug("canceled notification for message: \(interaction)")
 | 
						|
                cancelNotifications(identifiers: interaction.notificationIdentifiers)
 | 
						|
            
 | 
						|
            default: break
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: - Presenting Notifications
 | 
						|
 | 
						|
    func registerNotificationSettings() -> Promise<Void> {
 | 
						|
        return adaptee.registerNotificationSettings()
 | 
						|
    }
 | 
						|
 | 
						|
    public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
 | 
						|
        let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
 | 
						|
        
 | 
						|
        // Ensure we should be showing a notification for the thread
 | 
						|
        guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
 | 
						|
            return
 | 
						|
        }
 | 
						|
        
 | 
						|
        let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
 | 
						|
 | 
						|
        // While batch processing, some of the necessary changes have not been commited.
 | 
						|
        let rawMessageText = interaction.previewText(db)
 | 
						|
 | 
						|
        // iOS strips anything that looks like a printf formatting character from
 | 
						|
        // the notification body, so if we want to dispay a literal "%" in a notification
 | 
						|
        // it must be escaped.
 | 
						|
        // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
 | 
						|
        // for more details.
 | 
						|
        let messageText: String? = String.filterNotificationText(rawMessageText)
 | 
						|
        let notificationTitle: String?
 | 
						|
        var notificationBody: String?
 | 
						|
        
 | 
						|
        let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant)
 | 
						|
        let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
 | 
						|
            .defaulting(to: .nameAndPreview)
 | 
						|
        
 | 
						|
        switch previewType {
 | 
						|
            case .noNameNoPreview:
 | 
						|
                notificationTitle = "Session"
 | 
						|
                
 | 
						|
            case .nameNoPreview, .nameAndPreview:
 | 
						|
                switch thread.variant {
 | 
						|
                    case .contact:
 | 
						|
                        notificationTitle = (isMessageRequest ? "Session" : senderName)
 | 
						|
                        
 | 
						|
                    case .closedGroup, .openGroup:
 | 
						|
                        let groupName: String = SessionThread
 | 
						|
                            .displayName(
 | 
						|
                                threadId: thread.id,
 | 
						|
                                variant: thread.variant,
 | 
						|
                                closedGroupName: try? thread.closedGroup
 | 
						|
                                    .select(.name)
 | 
						|
                                    .asRequest(of: String.self)
 | 
						|
                                    .fetchOne(db),
 | 
						|
                                openGroupName: try? thread.openGroup
 | 
						|
                                    .select(.name)
 | 
						|
                                    .asRequest(of: String.self)
 | 
						|
                                    .fetchOne(db)
 | 
						|
                            )
 | 
						|
                        
 | 
						|
                        notificationTitle = (isBackgroundPoll ? groupName :
 | 
						|
                            String(
 | 
						|
                                format: NotificationStrings.incomingGroupMessageTitleFormat,
 | 
						|
                                senderName,
 | 
						|
                                groupName
 | 
						|
                            )
 | 
						|
                        )
 | 
						|
                }
 | 
						|
        }
 | 
						|
        
 | 
						|
        switch previewType {
 | 
						|
            case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody
 | 
						|
            case .nameAndPreview: notificationBody = messageText
 | 
						|
        }
 | 
						|
        
 | 
						|
        // If it's a message request then overwrite the body to be something generic (only show a notification
 | 
						|
        // when receiving a new message request if there aren't any others or the user had hidden them)
 | 
						|
        if isMessageRequest {
 | 
						|
            notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized()
 | 
						|
        }
 | 
						|
 | 
						|
        guard notificationBody != nil || notificationTitle != nil else {
 | 
						|
            SNLog("AppNotifications error: No notification content")
 | 
						|
            return
 | 
						|
        }
 | 
						|
 | 
						|
        // Don't reply from lockscreen if anyone in this conversation is
 | 
						|
        // "no longer verified".
 | 
						|
        let category = AppNotificationCategory.incomingMessage
 | 
						|
 | 
						|
        let userInfo = [
 | 
						|
            AppNotificationUserInfoKey.threadId: thread.id
 | 
						|
        ]
 | 
						|
        
 | 
						|
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | 
						|
        let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
 | 
						|
            threadId: thread.id,
 | 
						|
            threadVariant: thread.variant
 | 
						|
        )
 | 
						|
 | 
						|
        DispatchQueue.main.async {
 | 
						|
            notificationBody = MentionUtilities.highlightMentions(
 | 
						|
                in: (notificationBody ?? ""),
 | 
						|
                threadVariant: thread.variant,
 | 
						|
                currentUserPublicKey: userPublicKey,
 | 
						|
                currentUserBlindedPublicKey: userBlindedKey
 | 
						|
            )
 | 
						|
            let sound: Preferences.Sound? = self.requestSound(thread: thread)
 | 
						|
            
 | 
						|
            self.adaptee.notify(
 | 
						|
                category: category,
 | 
						|
                title: notificationTitle,
 | 
						|
                body: notificationBody ?? "",
 | 
						|
                userInfo: userInfo,
 | 
						|
                sound: sound,
 | 
						|
                replacingIdentifier: identifier
 | 
						|
            )
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
 | 
						|
        // No call notifications for muted or group threads
 | 
						|
        guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
 | 
						|
        guard thread.variant != .closedGroup && thread.variant != .openGroup else { return }
 | 
						|
        guard
 | 
						|
            interaction.variant == .infoCall,
 | 
						|
            let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
 | 
						|
            let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
 | 
						|
                CallMessage.MessageInfo.self,
 | 
						|
                from: infoMessageData
 | 
						|
            )
 | 
						|
        else { return }
 | 
						|
        
 | 
						|
        // Only notify missed calls
 | 
						|
        guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return }
 | 
						|
        
 | 
						|
        let category = AppNotificationCategory.errorMessage
 | 
						|
 | 
						|
        let userInfo = [
 | 
						|
            AppNotificationUserInfoKey.threadId: thread.id
 | 
						|
        ]
 | 
						|
        
 | 
						|
        let notificationTitle = interaction.previewText(db)
 | 
						|
        var notificationBody: String?
 | 
						|
        
 | 
						|
        if messageInfo.state == .permissionDenied {
 | 
						|
            notificationBody = String(
 | 
						|
                format: "modal_call_missed_tips_explanation".localized(),
 | 
						|
                SessionThread.displayName(
 | 
						|
                    threadId: thread.id,
 | 
						|
                    variant: thread.variant,
 | 
						|
                    closedGroupName: nil,       // Not supported
 | 
						|
                    openGroupName: nil          // Not supported
 | 
						|
                )
 | 
						|
            )
 | 
						|
        }
 | 
						|
        
 | 
						|
        DispatchQueue.main.async {
 | 
						|
            let sound = self.requestSound(thread: thread)
 | 
						|
            
 | 
						|
            self.adaptee.notify(
 | 
						|
                category: category,
 | 
						|
                title: notificationTitle,
 | 
						|
                body: notificationBody ?? "",
 | 
						|
                userInfo: userInfo,
 | 
						|
                sound: sound,
 | 
						|
                replacingIdentifier: UUID().uuidString
 | 
						|
            )
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public func notifyForFailedSend(_ db: Database, in thread: SessionThread) {
 | 
						|
        let notificationTitle: String?
 | 
						|
        let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
 | 
						|
            .defaulting(to: .nameAndPreview)
 | 
						|
        
 | 
						|
        switch previewType {
 | 
						|
            case .noNameNoPreview: notificationTitle = nil
 | 
						|
            case .nameNoPreview, .nameAndPreview:
 | 
						|
                notificationTitle = SessionThread.displayName(
 | 
						|
                    threadId: thread.id,
 | 
						|
                    variant: thread.variant,
 | 
						|
                    closedGroupName: try? thread.closedGroup
 | 
						|
                        .select(.name)
 | 
						|
                        .asRequest(of: String.self)
 | 
						|
                        .fetchOne(db),
 | 
						|
                    openGroupName: try? thread.openGroup
 | 
						|
                        .select(.name)
 | 
						|
                        .asRequest(of: String.self)
 | 
						|
                        .fetchOne(db),
 | 
						|
                    isNoteToSelf: (thread.isNoteToSelf(db) == true),
 | 
						|
                    profile: try? Profile.fetchOne(db, id: thread.id)
 | 
						|
                )
 | 
						|
        }
 | 
						|
 | 
						|
        let notificationBody = NotificationStrings.failedToSendBody
 | 
						|
 | 
						|
        let userInfo = [
 | 
						|
            AppNotificationUserInfoKey.threadId: thread.id
 | 
						|
        ]
 | 
						|
 | 
						|
        DispatchQueue.main.async {
 | 
						|
            let sound: Preferences.Sound? = self.requestSound(thread: thread)
 | 
						|
            
 | 
						|
            self.adaptee.notify(
 | 
						|
                category: .errorMessage,
 | 
						|
                title: notificationTitle,
 | 
						|
                body: notificationBody,
 | 
						|
                userInfo: userInfo,
 | 
						|
                sound: sound
 | 
						|
            )
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    @objc
 | 
						|
    public func cancelNotifications(identifiers: [String]) {
 | 
						|
        DispatchQueue.main.async {
 | 
						|
            self.adaptee.cancelNotifications(identifiers: identifiers)
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public func cancelNotifications(threadId: String) {
 | 
						|
        self.adaptee.cancelNotifications(threadId: threadId)
 | 
						|
    }
 | 
						|
 | 
						|
    @objc
 | 
						|
    public func clearAllNotifications() {
 | 
						|
        adaptee.clearAllNotifications()
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: -
 | 
						|
 | 
						|
    var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
 | 
						|
 | 
						|
    private func requestSound(thread: SessionThread) -> Preferences.Sound? {
 | 
						|
        guard checkIfShouldPlaySound() else {
 | 
						|
            return nil
 | 
						|
        }
 | 
						|
 | 
						|
        return thread.notificationSound
 | 
						|
    }
 | 
						|
 | 
						|
    private func checkIfShouldPlaySound() -> Bool {
 | 
						|
        AssertIsOnMainThread()
 | 
						|
 | 
						|
        guard UIApplication.shared.applicationState == .active else { return true }
 | 
						|
        guard Storage.shared[.playNotificationSoundInForeground] else { return false }
 | 
						|
 | 
						|
        let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
 | 
						|
        let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
 | 
						|
 | 
						|
        let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
 | 
						|
 | 
						|
        guard recentNotifications.count < kAudioNotificationsThrottleCount else {
 | 
						|
            return false
 | 
						|
        }
 | 
						|
 | 
						|
        mostRecentNotifications.append(nowMs)
 | 
						|
        return true
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class NotificationActionHandler {
 | 
						|
 | 
						|
    static let shared: NotificationActionHandler = NotificationActionHandler()
 | 
						|
 | 
						|
    // MARK: - Dependencies
 | 
						|
 | 
						|
    var notificationPresenter: NotificationPresenter {
 | 
						|
        return AppEnvironment.shared.notificationPresenter
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: -
 | 
						|
 | 
						|
    func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
 | 
						|
        guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
 | 
						|
            throw NotificationError.failDebug("threadId was unexpectedly nil")
 | 
						|
        }
 | 
						|
 | 
						|
        guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
 | 
						|
            throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
 | 
						|
        }
 | 
						|
 | 
						|
        return markAsRead(thread: thread)
 | 
						|
    }
 | 
						|
 | 
						|
    func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise<Void> {
 | 
						|
        guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
 | 
						|
            throw NotificationError.failDebug("threadId was unexpectedly nil")
 | 
						|
        }
 | 
						|
 | 
						|
        guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
 | 
						|
            throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
 | 
						|
        }
 | 
						|
        
 | 
						|
        let (promise, seal) = Promise<Void>.pending()
 | 
						|
        
 | 
						|
        Storage.shared.writeAsync { db in
 | 
						|
            let interaction: Interaction = try Interaction(
 | 
						|
                threadId: thread.id,
 | 
						|
                authorId: getUserHexEncodedPublicKey(db),
 | 
						|
                variant: .standardOutgoing,
 | 
						|
                body: replyText,
 | 
						|
                timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
 | 
						|
                hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText)
 | 
						|
            ).inserted(db)
 | 
						|
            
 | 
						|
            try Interaction.markAsRead(
 | 
						|
                db,
 | 
						|
                interactionId: interaction.id,
 | 
						|
                threadId: thread.id,
 | 
						|
                includingOlder: true,
 | 
						|
                trySendReadReceipt: true
 | 
						|
            )
 | 
						|
            
 | 
						|
            return try MessageSender.sendNonDurably(
 | 
						|
                db,
 | 
						|
                interaction: interaction,
 | 
						|
                in: thread
 | 
						|
            )
 | 
						|
        }
 | 
						|
        .done { seal.fulfill(()) }
 | 
						|
        .catch { error in
 | 
						|
            Storage.shared.read { [weak self] db in
 | 
						|
                self?.notificationPresenter.notifyForFailedSend(db, in: thread)
 | 
						|
            }
 | 
						|
            
 | 
						|
            seal.reject(error)
 | 
						|
        }
 | 
						|
        .retainUntilComplete()
 | 
						|
        
 | 
						|
        return promise
 | 
						|
    }
 | 
						|
 | 
						|
    func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
 | 
						|
        guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
 | 
						|
            return showHomeVC()
 | 
						|
        }
 | 
						|
 | 
						|
        // If this happens when the the app is not, visible we skip the animation so the thread
 | 
						|
        // can be visible to the user immediately upon opening the app, rather than having to watch
 | 
						|
        // it animate in from the homescreen.
 | 
						|
        let shouldAnimate: Bool = (UIApplication.shared.applicationState == .active)
 | 
						|
        SessionApp.presentConversation(for: threadId, animated: shouldAnimate)
 | 
						|
        return Promise.value(())
 | 
						|
    }
 | 
						|
    
 | 
						|
    func showHomeVC() -> Promise<Void> {
 | 
						|
        SessionApp.showHomeView()
 | 
						|
        return Promise.value(())
 | 
						|
    }
 | 
						|
 | 
						|
    private func markAsRead(thread: SessionThread) -> Promise<Void> {
 | 
						|
        let (promise, seal) = Promise<Void>.pending()
 | 
						|
        
 | 
						|
        Storage.shared.writeAsync(
 | 
						|
            updates: { db in
 | 
						|
                try Interaction.markAsRead(
 | 
						|
                    db,
 | 
						|
                    interactionId: try thread.interactions
 | 
						|
                        .select(.id)
 | 
						|
                        .order(Interaction.Columns.timestampMs.desc)
 | 
						|
                        .asRequest(of: Int64?.self)
 | 
						|
                        .fetchOne(db),
 | 
						|
                    threadId: thread.id,
 | 
						|
                    includingOlder: true,
 | 
						|
                    trySendReadReceipt: true
 | 
						|
                )
 | 
						|
            },
 | 
						|
            completion: { _, result in
 | 
						|
                switch result {
 | 
						|
                    case .success: seal.fulfill(())
 | 
						|
                    case .failure(let error): seal.reject(error)
 | 
						|
                }
 | 
						|
            }
 | 
						|
        )
 | 
						|
        
 | 
						|
        return promise
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
enum NotificationError: Error {
 | 
						|
    case assertionError(description: String)
 | 
						|
}
 | 
						|
 | 
						|
extension NotificationError {
 | 
						|
    static func failDebug(_ description: String) -> NotificationError {
 | 
						|
        owsFailDebug(description)
 | 
						|
        return NotificationError.assertionError(description: description)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
struct TruncatedList<Element> {
 | 
						|
    let maxLength: Int
 | 
						|
    private var contents: [Element] = []
 | 
						|
 | 
						|
    init(maxLength: Int) {
 | 
						|
        self.maxLength = maxLength
 | 
						|
    }
 | 
						|
 | 
						|
    mutating func append(_ newElement: Element) {
 | 
						|
        var newElements = self.contents
 | 
						|
        newElements.append(newElement)
 | 
						|
        self.contents = Array(newElements.suffix(maxLength))
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
extension TruncatedList: Collection {
 | 
						|
    typealias Index = Int
 | 
						|
 | 
						|
    var startIndex: Index {
 | 
						|
        return contents.startIndex
 | 
						|
    }
 | 
						|
 | 
						|
    var endIndex: Index {
 | 
						|
        return contents.endIndex
 | 
						|
    }
 | 
						|
 | 
						|
    subscript (position: Index) -> Element {
 | 
						|
        return contents[position]
 | 
						|
    }
 | 
						|
 | 
						|
    func index(after i: Index) -> Index {
 | 
						|
        return contents.index(after: i)
 | 
						|
    }
 | 
						|
}
 |