Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Notifications/NotificationPresenterImpl.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import Intents
public import LibSignalClient

/// There are two primary components in our system notification integration:
///
///     1. The `NotificationPresenterImpl` shows system notifications to the user.
///     2. The `NotificationActionHandler` handles the users interactions with these
///        notifications.
///
/// Our `NotificationActionHandler`s need slightly different integrations for UINotifications (iOS 9)
/// vs. UNUserNotifications (iOS 10+), but because they are integrated at separate system defined callbacks,
/// there is no need for an adapter pattern, and instead the appropriate NotificationActionHandler is
/// wired directly into the appropriate callback point.

/// Represents "custom" notification actions. These are the ones that appear
/// when long-pressing a notification. Their identifiers (rawValues) are
/// passed to iOS via UNNotificationAction.
///
/// These are persisted (via notifications) and must remain stable.
public enum AppNotificationAction: String {
    case callBack = "Signal.AppNotifications.Action.callBack"
    case markAsRead = "Signal.AppNotifications.Action.markAsRead"
    case reply = "Signal.AppNotifications.Action.reply"
    case showThread = "Signal.AppNotifications.Action.showThread"
    case reactWithThumbsUp = "Signal.AppNotifications.Action.reactWithThumbsUp"
}

/// Represents "default" notification actions. These happen when you tap a
/// notification to launch Signal. These are a Signal concept -- they are
/// stored inside a notification's userInfo.
///
/// These are persisted (via notifications) and must remain stable.
public enum AppNotificationDefaultAction: String {
    case showThread
    case showMyStories
    case showCallLobby
    case submitDebugLogs
    case submitDebugLogsForBackupsMediaError
    case reregister
    case showChatList
    case showLinkedDevices
    case showBackupsSettings
    case showMessage
}

public struct AppNotificationUserInfo {
    public var callBackAci: Aci?
    public var callBackPhoneNumber: String?
    public var defaultAction: AppNotificationDefaultAction?
    public var isMissedCall: Bool?
    public var messageId: String?
    public var reactionId: String?
    public var roomId: Data?
    public var storyMessageId: String?
    public var storyTimestamp: UInt64?
    public var threadId: String?
    public var voteAuthorServiceIdBinary: Data?

    public init() {
    }

    public init(_ userInfo: [AnyHashable: Any]) {
        self.callBackAci = (userInfo[UserInfoKey.callBackAciString] as? String).flatMap {
            let result = Aci.parseFrom(aciString: $0)
            owsAssertDebug(result != nil, "Couldn't parse callBackAciString.")
            return result
        }
        self.callBackPhoneNumber = userInfo[UserInfoKey.callBackPhoneNumber] as? String
        self.defaultAction = (userInfo[UserInfoKey.defaultAction] as? String).flatMap {
            let result = AppNotificationDefaultAction(rawValue: $0)
            owsAssertDebug(result != nil, "Couldn't parse default action. Did the identifiers change?")
            return result
        }
        self.isMissedCall = userInfo[UserInfoKey.isMissedCall] as? Bool
        self.messageId = userInfo[UserInfoKey.messageId] as? String
        self.reactionId = userInfo[UserInfoKey.reactionId] as? String
        self.roomId = (userInfo[UserInfoKey.roomId] as? String).flatMap {
            let result = Data(base64Encoded: $0)
            owsAssertDebug(result != nil, "Couldn't parse roomId.")
            return result
        }
        self.storyMessageId = userInfo[UserInfoKey.storyMessageId] as? String
        self.storyTimestamp = userInfo[UserInfoKey.storyTimestamp] as? UInt64
        self.threadId = userInfo[UserInfoKey.threadId] as? String
        self.voteAuthorServiceIdBinary = userInfo[UserInfoKey.voteAuthorServiceIdBinary] as? Data
    }

    private enum UserInfoKey {
        static let callBackAciString = "Signal.AppNotificationsUserInfoKey.callBackUuid"
        static let callBackPhoneNumber = "Signal.AppNotificationsUserInfoKey.callBackPhoneNumber"
        static let defaultAction = "Signal.AppNotificationsUserInfoKey.defaultAction"
        static let isMissedCall = "Signal.AppNotificationsUserInfoKey.isMissedCall"
        static let messageId = "Signal.AppNotificationsUserInfoKey.messageId"
        static let reactionId = "Signal.AppNotificationsUserInfoKey.reactionId"
        static let roomId = "Signal.AppNotificationsUserInfoKey.roomId"
        static let storyMessageId = "Signal.AppNotificationsUserInfoKey.storyMessageId"
        static let storyTimestamp = "Signal.AppNotificationsUserInfoKey.storyTimestamp"
        static let threadId = "Signal.AppNotificationsUserInfoKey.threadId"
        static let voteAuthorServiceIdBinary = "Signal.AppNotificationsUserInfoKey.voteAuthorServiceIdBinary"
    }

    func build() -> [String: Any] {
        var result = [String: Any]()
        if let callBackAci {
            result[UserInfoKey.callBackAciString] = callBackAci.serviceIdString
        }
        if let callBackPhoneNumber {
            result[UserInfoKey.callBackPhoneNumber] = callBackPhoneNumber
        }
        if let defaultAction {
            result[UserInfoKey.defaultAction] = defaultAction.rawValue
        }
        if let isMissedCall {
            result[UserInfoKey.isMissedCall] = isMissedCall
        }
        if let messageId {
            result[UserInfoKey.messageId] = messageId
        }
        if let reactionId {
            result[UserInfoKey.reactionId] = reactionId
        }
        if let roomId {
            result[UserInfoKey.roomId] = roomId.base64EncodedString()
        }
        if let storyMessageId {
            result[UserInfoKey.storyMessageId] = storyMessageId
        }
        if let storyTimestamp {
            result[UserInfoKey.storyTimestamp] = storyTimestamp
        }
        if let threadId {
            result[UserInfoKey.threadId] = threadId
        }
        if let voteAuthorServiceIdBinary {
            result[UserInfoKey.voteAuthorServiceIdBinary] = voteAuthorServiceIdBinary
        }
        return result
    }
}

// MARK: -

public enum AppNotificationCategory: String, CaseIterable {
    case incomingMessageWithActions_CanReply = "Signal.AppNotificationCategory.incomingMessageWithActions"
    case incomingMessageWithActions_CannotReply = "Signal.AppNotificationCategory.incomingMessageWithActionsNoReply"
    case incomingMessageWithoutActions = "Signal.AppNotificationCategory.incomingMessage"
    case incomingMessageFromNoLongerVerifiedIdentity = "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity"
    case incomingReactionWithActions_CanReply = "Signal.AppNotificationCategory.incomingReactionWithActions"
    case incomingReactionWithActions_CannotReply = "Signal.AppNotificationCategory.incomingReactionWithActionsNoReply"
    case infoOrErrorMessage = "Signal.AppNotificationCategory.infoOrErrorMessage"
    case missedCallWithActions = "Signal.AppNotificationCategory.missedCallWithActions"
    case missedCallWithoutActions = "Signal.AppNotificationCategory.missedCall"
    case missedCallFromNoLongerVerifiedIdentity = "Signal.AppNotificationCategory.missedCallFromNoLongerVerifiedIdentity"
    case internalError = "Signal.AppNotificationCategory.internalError"
    case incomingGroupStoryReply = "Signal.AppNotificationCategory.incomingGroupStoryReply"
    case failedStorySend = "Signal.AppNotificationCategory.failedStorySend"
    case transferRelaunch = "Signal.AppNotificationCategory.transferRelaunch"
    case deregistration = "Signal.AppNotificationCategory.authErrorLogout"
    case newDeviceLinked = "Signal.AppNotificationCategory.newDeviceLinked"
    case backupsEnabled = "Signal.AppNotificationCategory.backupsEnabled"
    case backupsMediaTierQuotaConsumed = "Signal.AppNotificationCategory.backupsMediaTierQuotaConsumed"
    case listMediaIntegrityCheckFailure = "Signal.AppNotificationCategory.listMediaIntegrityCheckFailure"
    case pollEndNotification = "Signal.AppNotificationCategory.pollEndNotification"
    case pollVoteNotification = "Signal.AppNotificationCategory.pollVoteNotification"
    case attachmentBackfill = "Signal.AppNotificationCategory.attachmentBackfill"

    var shouldClearOnAppActivate: Bool {
        switch self {
        case
            .incomingMessageWithActions_CanReply,
            .incomingMessageWithActions_CannotReply,
            .incomingMessageWithoutActions,
            .incomingMessageFromNoLongerVerifiedIdentity,
            .incomingReactionWithActions_CanReply,
            .incomingReactionWithActions_CannotReply,
            .infoOrErrorMessage,
            .missedCallWithActions,
            .missedCallWithoutActions,
            .missedCallFromNoLongerVerifiedIdentity,
            .incomingGroupStoryReply,
            .failedStorySend,
            .transferRelaunch,
            .deregistration,
            .pollEndNotification,
            .pollVoteNotification,
            .attachmentBackfill:
            return true
        case
            .newDeviceLinked,
            .backupsEnabled,
            .backupsMediaTierQuotaConsumed,
            .listMediaIntegrityCheckFailure,
            .internalError:
            return false
        }
    }

    var actions: [AppNotificationAction] {
        switch self {
        case .incomingMessageWithActions_CanReply:
            return [.markAsRead, .reply, .reactWithThumbsUp]
        case .incomingMessageWithActions_CannotReply:
            return [.markAsRead]
        case .incomingReactionWithActions_CanReply:
            return [.markAsRead, .reply]
        case .incomingReactionWithActions_CannotReply:
            return [.markAsRead]
        case .incomingMessageWithoutActions,
             .incomingMessageFromNoLongerVerifiedIdentity:
            return []
        case .infoOrErrorMessage:
            return []
        case .missedCallWithActions:
            return [.callBack, .showThread]
        case .missedCallWithoutActions:
            return []
        case .missedCallFromNoLongerVerifiedIdentity:
            return []
        case .internalError:
            return []
        case .incomingGroupStoryReply:
            return [.reply]
        case .failedStorySend:
            return []
        case .transferRelaunch:
            return []
        case .deregistration:
            return []
        case .newDeviceLinked:
            return []
        case .backupsEnabled:
            return []
        case .backupsMediaTierQuotaConsumed:
            return []
        case .listMediaIntegrityCheckFailure:
            return []
        case .pollEndNotification:
            return []
        case .pollVoteNotification:
            return []
        case .attachmentBackfill:
            return []
        }
    }
}

// MARK: -

let kAudioNotificationsThrottleCount = 2
let kAudioNotificationsThrottleInterval: TimeInterval = 5

public class NotificationPresenterImpl: NotificationPresenter {
    private let presenter = UserNotificationPresenter()

    private var contactManager: any ContactManager { SSKEnvironment.shared.contactManagerRef }
    private var databaseStorage: SDSDatabaseStorage { SSKEnvironment.shared.databaseStorageRef }
    private var identityManager: any OWSIdentityManager { DependenciesBridge.shared.identityManager }
    private var preferences: Preferences { SSKEnvironment.shared.preferencesRef }
    private var tsAccountManager: any TSAccountManager { DependenciesBridge.shared.tsAccountManager }

    public init() {
        SwiftSingletons.register(self)
    }

    func previewType(tx: DBReadTransaction) -> NotificationType {
        return preferences.notificationPreviewType(tx: tx)
    }

    static func shouldShowActions(for previewType: NotificationType) -> Bool {
        return previewType == .namePreview
    }

    // MARK: - Notifications Permissions

    public func registerNotificationSettings() async {
        return await presenter.registerNotificationSettings()
    }

    private func notificationSuppressionRuleIfMainAppAndActive() async -> NotificationSuppressionRule? {
        guard CurrentAppContext().isMainApp else {
            return nil
        }
        return await self._notificationSuppressionRuleIfMainAppAndActive()
    }

    @MainActor
    private func _notificationSuppressionRuleIfMainAppAndActive() async -> NotificationSuppressionRule? {
        guard CurrentAppContext().isMainAppAndActive else {
            return nil
        }
        return .some({ () -> NotificationSuppressionRule in
            switch CurrentAppContext().frontmostViewController() {
            case let linkAndSyncProgressUI as LinkAndSyncProgressUI where linkAndSyncProgressUI.shouldSuppressNotifications:
                return .all
            case let conversationSplit as ConversationSplit:
                return conversationSplit.visibleThread.map {
                    return .messagesInThread(threadUniqueId: $0.uniqueId)
                } ?? .none
            case let storyGroupReply as StoryGroupReplier:
                return .groupStoryReplies(
                    threadUniqueId: storyGroupReply.threadUniqueId,
                    storyMessageTimestamp: storyGroupReply.storyMessage.timestamp,
                )
            case is FailedStorySendDisplayController:
                return .failedStorySends
            default:
                return .none
            }
        }())
    }

    // MARK: - Calls

    private struct CallPreview {
        let notificationTitle: ResolvableValue<String>
        let threadIdentifier: String
        let shouldShowActions: Bool
    }

    private func fetchCallPreview(thread: NotifiableThread, tx: DBReadTransaction) -> CallPreview? {
        let previewType = self.previewType(tx: tx)
        return self.notificationTitle(
            for: thread,
            senderAddress: nil,
            isGroupStoryReply: false,
            previewType: previewType,
            tx: tx,
        ).map {
            return CallPreview(
                notificationTitle: $0,
                threadIdentifier: thread.rawValue.uniqueId,
                shouldShowActions: Self.shouldShowActions(for: previewType),
            )
        }
    }

    /// Classifies a timestamp based on how it should be included in a notification.
    ///
    /// In particular, a notification already comes with its own timestamp, so any information we put in has to be
    /// relevant (different enough from the notification's own timestamp to be useful) and absolute (because if a
    /// thirty-minute-old notification says "five minutes ago", that's not great).
    private enum TimestampClassification {
        case lastFewMinutes
        case last24Hours
        case lastWeek
        case other

        init(_ timestamp: Date) {
            switch -timestamp.timeIntervalSinceNow {
            case ..<0:
                owsFailDebug("Formatting a notification for an event in the future")
                self = .other
            case ...(5 * .minute):
                self = .lastFewMinutes
            case ...(.day):
                self = .last24Hours
            case ...(.week):
                self = .lastWeek
            default:
                self = .other
            }
        }
    }

    public func notifyUserOfMissedCall(
        notificationInfo: CallNotificationInfo,
        offerMediaType: TSRecentCallOfferType,
        sentAt timestamp: Date,
        tx: DBReadTransaction,
    ) {
        let thread = notificationInfo.thread
        let callPreview = fetchCallPreview(thread: .individualThread(thread), tx: tx)

        let timestampClassification = TimestampClassification(timestamp)
        let timestampArgument: String
        switch timestampClassification {
        case .lastFewMinutes:
            // will be ignored
            timestampArgument = ""
        case .last24Hours:
            timestampArgument = DateUtil.formatDateAsTime(timestamp)
        case .lastWeek:
            timestampArgument = DateUtil.weekdayFormatter.string(from: timestamp)
        case .other:
            timestampArgument = DateUtil.monthAndDayFormatter.string(from: timestamp)
        }

        // We could build these localized string keys by interpolating the two pieces,
        // but then genstrings wouldn't pick them up.
        let notificationBodyFormat: String
        switch (offerMediaType, timestampClassification) {
        case (.audio, .lastFewMinutes):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_AUDIO_MISSED_NOTIFICATION_BODY",
                comment: "notification body for a call that was just missed",
            )
        case (.audio, .last24Hours):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_AUDIO_MISSED_24_HOURS_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call in the last 24 hours. Embeds {{time}}, e.g. '3:30 PM'.",
            )
        case (.audio, .lastWeek):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_AUDIO_MISSED_WEEK_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call from the last week. Embeds {{weekday}}, e.g. 'Monday'.",
            )
        case (.audio, .other):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_AUDIO_MISSED_PAST_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call from more than a week ago. Embeds {{short date}}, e.g. '6/28'.",
            )
        case (.video, .lastFewMinutes):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_VIDEO_MISSED_NOTIFICATION_BODY",
                comment: "notification body for a call that was just missed",
            )
        case (.video, .last24Hours):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_VIDEO_MISSED_24_HOURS_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call in the last 24 hours. Embeds {{time}}, e.g. '3:30 PM'.",
            )
        case (.video, .lastWeek):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_VIDEO_MISSED_WEEK_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call from the last week. Embeds {{weekday}}, e.g. 'Monday'.",
            )
        case (.video, .other):
            notificationBodyFormat = OWSLocalizedString(
                "CALL_VIDEO_MISSED_PAST_NOTIFICATION_BODY_FORMAT",
                comment: "notification body for a missed call from more than a week ago. Embeds {{short date}}, e.g. '6/28'.",
            )
        }
        let notificationBody = String.nonPluralLocalizedStringWithFormat(notificationBodyFormat, timestampArgument)

        let userInfo = userInfoForMissedCall(thread: thread, remoteAci: notificationInfo.caller)

        let category: AppNotificationCategory = (
            callPreview?.shouldShowActions == true
                ? .missedCallWithActions
                : .missedCallWithoutActions,
        )

        var intent: ResolvableValue<INIntent>?
        if callPreview != nil {
            intent = thread.generateIncomingCallIntent(callerAci: notificationInfo.caller, tx: tx)
        }

        let threadUniqueId = thread.uniqueId
        enqueueNotificationAction(afterCommitting: tx) {
            await self.notifyViaPresenter(
                category: category,
                title: callPreview?.notificationTitle,
                body: notificationBody,
                threadIdentifier: callPreview?.threadIdentifier,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: .thread(threadUniqueId),
                replacingIdentifier: notificationInfo.groupingId.uuidString,
            )
        }
    }

    public func notifyUserOfMissedCallBecauseOfNoLongerVerifiedIdentity(
        notificationInfo: CallNotificationInfo,
        tx: DBWriteTransaction,
    ) {
        let thread = notificationInfo.thread
        let callPreview = fetchCallPreview(thread: .individualThread(thread), tx: tx)

        let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = thread.uniqueId

        let threadUniqueId = thread.uniqueId
        enqueueNotificationAction(afterCommitting: tx) {
            await self.notifyViaPresenter(
                category: .missedCallFromNoLongerVerifiedIdentity,
                title: callPreview?.notificationTitle,
                body: notificationBody,
                threadIdentifier: callPreview?.threadIdentifier,
                userInfo: userInfo,
                soundQuery: .thread(threadUniqueId),
                replacingIdentifier: notificationInfo.groupingId.uuidString,
            )
        }
    }

    public func notifyUserOfMissedCallBecauseOfNewIdentity(
        notificationInfo: CallNotificationInfo,
        tx: DBWriteTransaction,
    ) {
        let thread = notificationInfo.thread
        let callPreview = fetchCallPreview(thread: .individualThread(thread), tx: tx)

        let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody
        let userInfo = userInfoForMissedCall(thread: thread, remoteAci: notificationInfo.caller)

        let category: AppNotificationCategory = (
            callPreview?.shouldShowActions == true
                ? .missedCallWithActions
                : .missedCallWithoutActions,
        )

        let threadUniqueId = thread.uniqueId
        enqueueNotificationAction(afterCommitting: tx) {
            await self.notifyViaPresenter(
                category: category,
                title: callPreview?.notificationTitle,
                body: notificationBody,
                threadIdentifier: callPreview?.threadIdentifier,
                userInfo: userInfo,
                soundQuery: .thread(threadUniqueId),
                replacingIdentifier: notificationInfo.groupingId.uuidString,
            )
        }
    }

    private func userInfoForMissedCall(thread: TSThread, remoteAci: Aci) -> AppNotificationUserInfo {
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = thread.uniqueId
        userInfo.callBackAci = remoteAci
        userInfo.isMissedCall = true
        return userInfo
    }

    // MARK: - Notify

    public func isThreadMuted(_ thread: TSThread, transaction: DBReadTransaction) -> Bool {
        ThreadAssociatedData.fetchOrDefault(for: thread, transaction: transaction).isMuted
    }

    public func canNotify(
        for incomingMessage: TSIncomingMessage,
        thread: TSThread,
        transaction: DBReadTransaction,
    ) -> Bool {
        if isThreadMuted(thread, transaction: transaction) {
            guard thread.isGroupThread else { return false }

            guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction) else {
                owsFailDebug("Missing local address")
                return false
            }

            let mentionedAcis = MentionFinder.mentionedAcis(for: incomingMessage, tx: transaction)
            let localUserIsQuoted = incomingMessage.quotedMessage?.authorAddress.isEqualToAddress(localIdentifiers.aciAddress) ?? false
            guard mentionedAcis.contains(localIdentifiers.aci) || localUserIsQuoted else {
                return false
            }

            switch thread.mentionNotificationMode {
            case .default, .always:
                return true
            case .never:
                return false
            }
        } else if incomingMessage.isGroupStoryReply {
            guard
                let storyTimestamp = incomingMessage.storyTimestamp?.uint64Value,
                let storyAuthorAci = incomingMessage.storyAuthorAci?.wrappedAciValue
            else {
                return false
            }

            let localAci = tsAccountManager.localIdentifiers(tx: transaction)?.aci

            // Always notify for replies to group stories you sent
            if storyAuthorAci == localAci { return true }

            // Always notify if you have been @mentioned
            if
                let mentionedAcis = incomingMessage.bodyRanges?.mentions.values,
                mentionedAcis.contains(where: { $0 == localAci })
            {
                return true
            }

            // Notify people who did not author the story if they've previously replied to it
            return InteractionFinder.hasLocalUserReplied(
                storyTimestamp: storyTimestamp,
                storyAuthorAci: storyAuthorAci,
                transaction: transaction,
            )
        } else {
            return true
        }
    }

    public func notifyUser(
        forIncomingMessage incomingMessage: TSIncomingMessage,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        _notifyUser(
            forIncomingMessage: incomingMessage,
            editTarget: nil,
            thread: thread,
            transaction: transaction,
        )
    }

    public func notifyUser(
        forIncomingMessage incomingMessage: TSIncomingMessage,
        editTarget: TSIncomingMessage,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        _notifyUser(
            forIncomingMessage: incomingMessage,
            editTarget: editTarget,
            thread: thread,
            transaction: transaction,
        )
    }

    public func notifyUserOfPollEnd(
        forMessage message: TSIncomingMessage,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        guard !isThreadMuted(thread, transaction: transaction) else { return }

        // Poll terminate notifications only get displayed if we can include the poll details.
        let previewType = self.previewType(tx: transaction)
        guard previewType == .namePreview else {
            return
        }
        owsPrecondition(Self.shouldShowActions(for: previewType))

        let notificationTitle = self.notificationTitle(
            for: notifiableThread,
            senderAddress: message.authorAddress,
            isGroupStoryReply: false,
            previewType: previewType,
            tx: transaction,
        )

        let pollEndedFormat = OWSLocalizedString(
            "POLL_ENDED_NOTIFICATION",
            comment: "Notification that {{contact}} ended a poll with question {{poll question}}",
        )
        guard let pollQuestion = message.body else {
            return
        }

        let pollAuthorName = SSKEnvironment.shared.contactManagerRef.nameForAddress(
            message.authorAddress,
            localUserDisplayMode: .noteToSelf,
            short: false,
            transaction: transaction,
        )

        let notificationBody: String = "\u{1F4CA}" + String.nonPluralLocalizedStringWithFormat(pollEndedFormat, pollAuthorName.string, pollQuestion)

        let intent = thread.generateSendMessageIntent(context: .senderAddress(message.authorAddress), transaction: transaction)

        var userInfo = AppNotificationUserInfo()
        let threadUniqueId = thread.uniqueId
        userInfo.threadId = threadUniqueId

        enqueueNotificationAction(afterCommitting: transaction) {
            await self.notifyViaPresenter(
                category: .pollEndNotification,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: threadUniqueId,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: .thread(threadUniqueId),
            )
        }
    }

    public func notifyUserOfPollVote(
        forMessage message: TSOutgoingMessage,
        voteAuthor: Aci,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        guard !isThreadMuted(thread, transaction: transaction) else { return }

        // Poll vote notifications only get displayed if we can include the poll details.
        let previewType = self.previewType(tx: transaction)
        guard previewType == .namePreview else {
            return
        }
        owsPrecondition(Self.shouldShowActions(for: previewType))

        let notificationTitle = self.notificationTitle(
            for: notifiableThread,
            senderAddress: SignalServiceAddress(voteAuthor),
            isGroupStoryReply: false,
            previewType: previewType,
            tx: transaction,
        )

        let pollVotedFormat = OWSLocalizedString(
            "POLL_VOTED_NOTIFICATION",
            comment: "Notification that {{contact}} voted in a poll with question {{poll question}}",
        )
        guard let pollQuestion = message.body else {
            return
        }

        let voteAuthorName = SSKEnvironment.shared.contactManagerRef.nameForAddress(
            SignalServiceAddress(voteAuthor),
            localUserDisplayMode: .noteToSelf,
            short: false,
            transaction: transaction,
        )

        let notificationBody: String = "\u{1F4CA}" + String.nonPluralLocalizedStringWithFormat(pollVotedFormat, voteAuthorName.string, pollQuestion)

        var userInfo = AppNotificationUserInfo()
        let threadUniqueId = thread.uniqueId
        userInfo.threadId = threadUniqueId
        userInfo.voteAuthorServiceIdBinary = voteAuthor.serviceIdBinary
        userInfo.messageId = message.uniqueId

        let intent = thread.generateSendMessageIntent(context: .senderAddress(SignalServiceAddress(voteAuthor)), transaction: transaction)

        enqueueNotificationAction(afterCommitting: transaction) {
            if await self.presenter.existingPollVoteNotification(author: voteAuthor.serviceIdBinary, pollId: message.uniqueId) {
                return
            }

            await self.notifyViaPresenter(
                category: .pollVoteNotification,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: threadUniqueId,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: .thread(threadUniqueId),
            )
        }
    }

    private enum NotifiableThread {
        case individualThread(TSContactThread)
        case groupThread(TSGroupThread)

        init?(_ thread: TSThread) {
            switch thread {
            case let thread as TSContactThread:
                self = .individualThread(thread)
            case let thread as TSGroupThread:
                self = .groupThread(thread)
            default:
                return nil
            }
        }

        var rawValue: TSThread {
            switch self {
            case .individualThread(let thread):
                return thread
            case .groupThread(let thread):
                return thread
            }
        }
    }

    private func notificationTitle(
        for thread: NotifiableThread,
        senderAddress: SignalServiceAddress?,
        isGroupStoryReply: Bool,
        previewType: NotificationType,
        tx: DBReadTransaction,
    ) -> ResolvableValue<String>? {
        switch previewType {
        case .noNameNoPreview:
            return nil
        case .nameNoPreview, .namePreview:
            switch thread {
            case .individualThread(let thread):
                owsAssertDebug(senderAddress == nil || senderAddress == thread.contactAddress)
                return resolvableValue(
                    withDisplayNameForAddress: thread.contactAddress,
                    transformedBy: { displayName in displayName.resolvedValue() },
                    tx: tx,
                )
            case .groupThread(let thread):
                let groupName = thread.groupNameOrDefault
                if let senderAddress {
                    let format = (
                        isGroupStoryReply
                            ? NotificationStrings.incomingGroupStoryReplyTitleFormat
                            : NotificationStrings.incomingGroupMessageTitleFormat,
                    )
                    return resolvableValue(
                        withDisplayNameForAddress: senderAddress,
                        transformedBy: { displayName in String.nonPluralLocalizedStringWithFormat(format, displayName.resolvedValue(), groupName) },
                        tx: tx,
                    )
                } else {
                    return ResolvableValue(resolvedValue: groupName)
                }
            }
        }
    }

    private func resolvableValue(
        withDisplayNameForAddress address: SignalServiceAddress,
        transformedBy transform: @escaping (DisplayName) -> String,
        tx: DBReadTransaction,
    ) -> ResolvableValue<String> {
        // TODO: Stop using SSKEnvironment.shared once dependencies are injected.
        return ResolvableDisplayNameBuilder(
            displayNameForAddress: address,
            transformedBy: { displayName, _ in return transform(displayName) },
            contactManager: SSKEnvironment.shared.contactManagerRef,
        ).resolvableValue(
            db: SSKEnvironment.shared.databaseStorageRef,
            profileFetcher: SSKEnvironment.shared.profileFetcherRef,
            tx: tx,
        )
    }

    private func _notifyUser(
        forIncomingMessage incomingMessage: TSIncomingMessage,
        editTarget: TSIncomingMessage?,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        guard canNotify(for: incomingMessage, thread: thread, transaction: transaction) else {
            return
        }

        // While batch processing, some of the necessary changes have not been committed.
        let rawMessageText = incomingMessage.notificationPreviewText(transaction)

        let messageText = rawMessageText.filterStringForDisplay()

        let previewType = self.previewType(tx: transaction)

        let threadIdentifier: String?
        switch previewType {
        case .noNameNoPreview:
            threadIdentifier = nil
        case .nameNoPreview, .namePreview:
            threadIdentifier = thread.uniqueId
        }

        let notificationTitle = self.notificationTitle(
            for: notifiableThread,
            senderAddress: incomingMessage.authorAddress,
            isGroupStoryReply: incomingMessage.isGroupStoryReply,
            previewType: previewType,
            tx: transaction,
        )

        let notificationBody: String = {
            if thread.hasPendingMessageRequest(transaction: transaction) {
                return NotificationStrings.incomingMessageRequestNotification
            }

            switch previewType {
            case .noNameNoPreview, .nameNoPreview:
                return NotificationStrings.genericIncomingMessageNotification
            case .namePreview:
                return messageText
            }
        }()

        // Don't reply from lockscreen if anyone in this conversation is
        // "no longer verified".
        var didIdentityChange = false
        for address in thread.recipientAddresses(with: transaction) {
            if identityManager.verificationState(for: address, tx: transaction) == .noLongerVerified {
                didIdentityChange = true
                break
            }
        }

        let category: AppNotificationCategory
        if didIdentityChange {
            category = .incomingMessageFromNoLongerVerifiedIdentity
        } else if !Self.shouldShowActions(for: previewType) {
            category = .incomingMessageWithoutActions
        } else if incomingMessage.isGroupStoryReply {
            category = .incomingGroupStoryReply
        } else {
            category = (
                thread.canSendChatMessagesToThread()
                    ? .incomingMessageWithActions_CanReply
                    : .incomingMessageWithActions_CannotReply,
            )
        }
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = thread.uniqueId
        userInfo.messageId = incomingMessage.uniqueId
        userInfo.storyTimestamp = incomingMessage.storyTimestamp?.uint64Value

        var intent: ResolvableValue<INIntent>?
        if previewType != .noNameNoPreview {
            intent = thread.generateSendMessageIntent(context: .incomingMessage(incomingMessage), transaction: transaction)
        }

        let threadUniqueId = thread.uniqueId
        let editTargetUniqueId = editTarget?.uniqueId
        enqueueNotificationAction(afterCommitting: transaction) {
            if let editTargetUniqueId, await !self.presenter.replaceNotification(messageId: editTargetUniqueId) {
                // The original notification was already dismissed. Don't show the edited one either.
                return
            }
            await self.notifyViaPresenter(
                category: category,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: threadIdentifier,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: (editTargetUniqueId != nil) ? .none : .thread(threadUniqueId),
            )
        }
    }

    public func notifyUser(
        forReaction reaction: OWSReaction,
        onOutgoingMessage message: TSOutgoingMessage,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        guard !isThreadMuted(thread, transaction: transaction) else { return }

        // Reaction notifications only get displayed if we can include the reaction
        // details, otherwise we don't disturb the user for a non-message
        let previewType = self.previewType(tx: transaction)
        guard previewType == .namePreview else {
            return
        }
        owsPrecondition(Self.shouldShowActions(for: previewType))

        let notificationTitle = self.notificationTitle(
            for: notifiableThread,
            senderAddress: reaction.reactor,
            isGroupStoryReply: false,
            previewType: previewType,
            tx: transaction,
        )

        let notificationBody: String
        if let bodyDescription: String = {
            if let messageBody = message.notificationPreviewText(transaction).nilIfEmpty {
                return messageBody
            } else {
                return nil
            }
        }() {
            notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionTextMessageFormat, reaction.emoji, bodyDescription)
        } else if message.isViewOnceMessage {
            notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionViewOnceMessageFormat, reaction.emoji)
        } else if message.messageSticker != nil {
            notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionStickerMessageFormat, reaction.emoji)
        } else if message.contactShare != nil {
            notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionContactShareMessageFormat, reaction.emoji)
        } else if
            let messageRowId = message.sqliteRowId,
            let mediaAttachments = DependenciesBridge.shared.attachmentStore
                .fetchReferencedAttachments(
                    for: .messageBodyAttachment(messageRowId: messageRowId),
                    tx: transaction,
                )
                .nilIfEmpty,
            let firstAttachment = mediaAttachments.first
        {
            let firstRenderingFlag = firstAttachment.reference.renderingFlag
            // Mime type is spoofable by the sender but for the purpose of showing notifications,
            // trust the sender (if they _intended_ to send an image, say they sent an image).
            let firstMimeType = firstAttachment.attachment.mimeType

            if mediaAttachments.count > 1 {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji)
            } else if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(firstMimeType) {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji)
            } else if MimeTypeUtil.isSupportedImageMimeType(firstMimeType) {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji)
            } else if
                MimeTypeUtil.isSupportedVideoMimeType(firstMimeType),
                firstRenderingFlag == .shouldLoop
            {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji)
            } else if MimeTypeUtil.isSupportedVideoMimeType(firstMimeType) {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji)
            } else if firstRenderingFlag == .voiceMessage {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji)
            } else if MimeTypeUtil.isSupportedAudioMimeType(firstMimeType) {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionAudioMessageFormat, reaction.emoji)
            } else {
                notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionFileMessageFormat, reaction.emoji)
            }
        } else {
            notificationBody = String.nonPluralLocalizedStringWithFormat(NotificationStrings.incomingReactionFormat, reaction.emoji)
        }

        // Don't reply from lockscreen if anyone in this conversation is
        // "no longer verified".
        var didIdentityChange = false
        for address in thread.recipientAddresses(with: transaction) {
            if identityManager.verificationState(for: address, tx: transaction) == .noLongerVerified {
                didIdentityChange = true
                break
            }
        }

        let category: AppNotificationCategory
        if didIdentityChange {
            category = .incomingMessageFromNoLongerVerifiedIdentity
        } else {
            category = (
                thread.canSendChatMessagesToThread()
                    ? .incomingReactionWithActions_CanReply
                    : .incomingReactionWithActions_CannotReply,
            )
        }
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = thread.uniqueId
        userInfo.messageId = message.uniqueId
        userInfo.reactionId = reaction.uniqueId

        let intent = thread.generateSendMessageIntent(context: .senderAddress(reaction.reactor), transaction: transaction)

        let threadUniqueId = thread.uniqueId
        enqueueNotificationAction(afterCommitting: transaction) {
            await self.notifyViaPresenter(
                category: category,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: threadUniqueId,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: .thread(threadUniqueId),
            )
        }
    }

    public func notifyUserOfFailedSend(inThread thread: TSThread) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        let notificationTitle = databaseStorage.read { tx in
            return self.notificationTitle(
                for: notifiableThread,
                senderAddress: nil,
                isGroupStoryReply: false,
                previewType: self.previewType(tx: tx),
                tx: tx,
            )
        }

        let notificationBody = NotificationStrings.failedToSendBody
        let threadId = thread.uniqueId
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = threadId

        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .infoOrErrorMessage,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: nil, // show ungrouped
                userInfo: userInfo,
                soundQuery: .thread(threadId),
            )
        }
    }

    public func notifyTestPopulation(ofErrorMessage errorString: String) {
        // External devices should still log the error string.
        Logger.warn("Potentially notifying about: \(errorString).")
        guard DebugFlags.testPopulationErrorAlerts else {
            return
        }

        let title = OWSLocalizedString(
            "ERROR_NOTIFICATION_TITLE",
            comment: "Format string for an error alert notification title.",
        )
        let messageFormat = OWSLocalizedString(
            "ERROR_NOTIFICATION_MESSAGE_FORMAT",
            comment: "Format string for an error alert notification message. Embeds {{ error string }}",
        )
        let message = String.nonPluralLocalizedStringWithFormat(messageFormat, errorString)

        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .submitDebugLogs

        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .internalError,
                title: ResolvableValue(resolvedValue: title),
                body: message,
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
                forceBeforeRegistered: true,
            )
        }
    }

    public func notifyForGroupCallSafetyNumberChange(
        callTitle: String,
        threadUniqueId: String?,
        roomId: Data?,
        presentAtJoin: Bool,
    ) {
        let notificationTitle = databaseStorage.read { tx -> ResolvableValue<String>? in
            switch previewType(tx: tx) {
            case .noNameNoPreview:
                return nil
            case .nameNoPreview, .namePreview:
                return ResolvableValue(resolvedValue: callTitle)
            }
        }

        let notificationBody = (
            presentAtJoin
                ? NotificationStrings.groupCallSafetyNumberChangeAtJoinBody
                : NotificationStrings.groupCallSafetyNumberChangeBody,
        )

        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showCallLobby
        userInfo.threadId = threadUniqueId
        userInfo.roomId = roomId

        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .infoOrErrorMessage,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: nil, // show ungrouped
                userInfo: userInfo,
                soundQuery: threadUniqueId.map({ .thread($0) }) ?? .global,
            )
        }
    }

    public func scheduleNotifyForNewLinkedDevice(deviceLinkTimestamp: Date) {
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showLinkedDevices
        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .newDeviceLinked,
                title: ResolvableValue(resolvedValue: OWSLocalizedString(
                    "LINKED_DEVICE_NOTIFICATION_TITLE",
                    comment: "Title for system notification when a new device is linked.",
                )),
                body: String.nonPluralLocalizedStringWithFormat(
                    OWSLocalizedString(
                        "LINKED_DEVICE_NOTIFICATION_BODY",
                        comment: "Body for system notification when a new device is linked. Embeds {{ time the device was linked }}",
                    ),
                    deviceLinkTimestamp.formatted(date: .omitted, time: .shortened),
                ),
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
            )
        }
    }

    public func scheduleNotifyForBackupsEnabled(backupsTimestamp: Date) {
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showBackupsSettings
        enqueueNotificationAction {
            await self.presenter.cancelPendingNotificationsForBackupsEnabled()

            await self.notifyViaPresenter(
                category: .backupsEnabled,
                title: ResolvableValue(resolvedValue: OWSLocalizedString(
                    "BACKUPS_TURNED_ON_TITLE",
                    comment: "Title for system notification or megaphone when backups is enabled",
                )),
                body: String.nonPluralLocalizedStringWithFormat(
                    OWSLocalizedString(
                        "BACKUPS_TURNED_ON_NOTIFICATION_BODY_FORMAT",
                        comment: "Body for system notification or megaphone when backups is enabled. Embeds {{ time backups was enabled }}",
                    ),
                    backupsTimestamp.formatted(date: .omitted, time: .shortened),
                ),
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
            )
        }
    }

    public func notifyUserOfAttachmentBackfill(
        threadUniqueId: String,
        messageUniqueId: String,
        body: String,
    ) {
        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = threadUniqueId
        userInfo.messageId = messageUniqueId
        userInfo.defaultAction = .showMessage
        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .attachmentBackfill,
                title: nil,
                body: body,
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .none,
                replacingIdentifier: "attachmentBackfill-\(messageUniqueId)",
            )
        }
    }

    public func notifyUserOfMediaTierQuotaConsumed() {
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showBackupsSettings
        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .backupsMediaTierQuotaConsumed,
                title: ResolvableValue(resolvedValue: OWSLocalizedString(
                    "BACKUP_SETTINGS_OUT_OF_STORAGE_SPACE_NOTIFICATION_TITLE",
                    comment: "Title for a notification telling the user they are out of remote storage space.",
                )),
                body: OWSLocalizedString(
                    "BACKUP_SETTINGS_OUT_OF_STORAGE_SPACE_NOTIFICATION_SUBTITLE",
                    comment: "Subtitle for a notification telling the user they are out of remote storage space.",
                ),
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
            )
        }
    }

    public func notifyUserOfBackupsMediaError() {
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .submitDebugLogsForBackupsMediaError
        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .listMediaIntegrityCheckFailure,
                title: ResolvableValue(resolvedValue: OWSLocalizedString(
                    "BACKUPS_MEDIA_ERROR_NOTIFICATION_TITLE",
                    comment: "Title for system notification when we detect an issue with Backup media.",
                )),
                body: OWSLocalizedString(
                    "BACKUPS_MEDIA_ERROR_NOTIFICATION_BODY",
                    comment: "Body for system notification when we detect an issue with Backup media.",
                ),
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
            )
        }
    }

    public func notifyUser(
        forErrorMessage errorMessage: TSErrorMessage,
        thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        if errorMessage is OWSRecoverableDecryptionPlaceholder {
            return
        }

        switch errorMessage.errorType {
        case .noSession,
             .wrongTrustedIdentityKey,
             .invalidKeyException,
             .missingKeyId,
             .invalidMessage,
             .duplicateMessage,
             .invalidVersion,
             .nonBlockingIdentityChange,
             .unknownContactBlockOffer,
             .decryptionFailure,
             .groupCreationFailed:
            return
        case .sessionRefresh:
            notifyUser(
                forTSMessage: errorMessage as TSMessage,
                thread: thread,
                wantsSound: true,
                transaction: transaction,
            )
        }
    }

    public func notifyUser(
        forTSMessage message: TSMessage,
        thread: TSThread,
        wantsSound: Bool,
        transaction: DBWriteTransaction,
    ) {
        notifyUser(
            tsInteraction: message,
            previewProvider: { tx in
                return message.notificationPreviewText(tx)
            },
            thread: thread,
            wantsSound: wantsSound,
            transaction: transaction,
        )
    }

    public func notifyUser(
        forPreviewableInteraction previewableInteraction: TSInteraction & OWSPreviewText,
        thread: TSThread,
        wantsSound: Bool,
        transaction: DBWriteTransaction,
    ) {
        notifyUser(
            tsInteraction: previewableInteraction,
            previewProvider: { tx in
                return previewableInteraction.previewText(transaction: tx)
            },
            thread: thread,
            wantsSound: wantsSound,
            transaction: transaction,
        )
    }

    private func notifyUser(
        tsInteraction: TSInteraction,
        previewProvider: (DBWriteTransaction) -> String,
        thread: TSThread,
        wantsSound: Bool,
        transaction: DBWriteTransaction,
    ) {
        guard let notifiableThread = NotifiableThread(thread) else {
            owsFailDebug("Can't notify for \(type(of: thread))")
            return
        }

        guard !isThreadMuted(thread, transaction: transaction) else { return }

        let previewType = self.previewType(tx: transaction)

        let threadIdentifier: String?
        switch previewType {
        case .noNameNoPreview:
            threadIdentifier = nil
        case .namePreview, .nameNoPreview:
            threadIdentifier = thread.uniqueId
        }

        let notificationTitle = self.notificationTitle(
            for: notifiableThread,
            senderAddress: nil,
            isGroupStoryReply: false,
            previewType: previewType,
            tx: transaction,
        )

        let notificationBody: String
        switch previewType {
        case .noNameNoPreview, .nameNoPreview:
            notificationBody = NotificationStrings.genericIncomingMessageNotification
        case .namePreview:
            notificationBody = previewProvider(transaction)
        }

        let threadId = thread.uniqueId

        var userInfo = AppNotificationUserInfo()
        userInfo.threadId = threadId
        userInfo.messageId = tsInteraction.uniqueId

        let isGroupCallMessage = tsInteraction is OWSGroupCallMessage
        userInfo.defaultAction = isGroupCallMessage ? .showCallLobby : .showThread

        // Some types of generic messages (locally generated notifications) have a defacto
        // "sender". If so, generate an interaction so the notification renders as if it
        // is from that user.
        var intent: ResolvableValue<INIntent>?
        if previewType != .noNameNoPreview {
            if let infoMessage = tsInteraction as? TSInfoMessage {
                guard
                    let localIdentifiers = tsAccountManager.localIdentifiers(
                        tx: transaction,
                    )
                else {
                    owsFailDebug("Missing local identifiers!")
                    return
                }
                switch infoMessage.messageType {
                case .typeGroupUpdate:
                    let groupUpdateAuthor: SignalServiceAddress?
                    switch infoMessage.groupUpdateMetadata(localIdentifiers: localIdentifiers) {
                    case .legacyRawString, .nonGroupUpdate:
                        groupUpdateAuthor = nil
                    case .newGroup(_, let source), .modelDiff(_, _, let source):
                        switch source {
                        case .unknown, .localUser:
                            groupUpdateAuthor = nil
                        case .legacyE164(let e164):
                            groupUpdateAuthor = .legacyAddress(serviceId: nil, phoneNumber: e164.stringValue)
                        case .aci(let aci):
                            groupUpdateAuthor = .init(aci)
                        case .rejectedInviteToPni(let pni):
                            groupUpdateAuthor = .init(pni)
                        }
                    case .precomputed(let persistableGroupUpdateItemsWrapper):
                        groupUpdateAuthor = persistableGroupUpdateItemsWrapper
                            .asSingleUpdateItem?.senderForNotification
                    }
                    if let groupUpdateAuthor {
                        intent = thread.generateSendMessageIntent(context: .senderAddress(groupUpdateAuthor), transaction: transaction)
                    }
                case .userJoinedSignal:
                    if let thread = thread as? TSContactThread {
                        intent = thread.generateSendMessageIntent(context: .senderAddress(thread.contactAddress), transaction: transaction)
                    }
                default:
                    break
                }
            } else if let callCreator = (tsInteraction as? OWSGroupCallMessage)?.creatorAci?.wrappedAciValue {
                intent = thread.generateSendMessageIntent(context: .senderAddress(SignalServiceAddress(callCreator)), transaction: transaction)
            }
        }

        enqueueNotificationAction(afterCommitting: transaction) {
            await self.notifyViaPresenter(
                category: .infoOrErrorMessage,
                title: notificationTitle,
                body: notificationBody,
                threadIdentifier: threadIdentifier,
                userInfo: userInfo,
                intent: intent.map { ($0, .incoming) },
                soundQuery: wantsSound ? .thread(threadId) : .none,
            )
        }
    }

    public func notifyUser(
        forFailedStorySend storyMessage: StoryMessage,
        to thread: TSThread,
        transaction: DBWriteTransaction,
    ) {
        guard StoryManager.areStoriesEnabled(transaction: transaction) else {
            return
        }

        let storyName = StoryManager.storyName(for: thread)
        let conversationIdentifier = thread.uniqueId + "_failedStorySend"

        let handle = INPersonHandle(value: nil, type: .unknown)
        let image = thread.intentStoryAvatarImage(tx: transaction)
        let person = INPerson(
            personHandle: handle,
            nameComponents: nil,
            displayName: storyName,
            image: image,
            contactIdentifier: nil,
            customIdentifier: nil,
            isMe: false,
            suggestionType: .none,
        )

        let sendMessageIntent = INSendMessageIntent(
            recipients: nil,
            outgoingMessageType: .outgoingMessageText,
            content: nil,
            speakableGroupName: INSpeakableString(spokenPhrase: storyName),
            conversationIdentifier: conversationIdentifier,
            serviceName: nil,
            sender: person,
            attachments: nil,
        )

        let notificationTitle = storyName
        let notificationBody = OWSLocalizedString(
            "STORY_SEND_FAILED_NOTIFICATION_BODY",
            comment: "Body for notification shown when a story fails to send.",
        )
        let threadIdentifier = thread.uniqueId
        let storyMessageId = storyMessage.uniqueId

        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showMyStories
        userInfo.storyMessageId = storyMessageId

        enqueueNotificationAction(afterCommitting: transaction) {
            await self.notifyViaPresenter(
                category: .failedStorySend,
                title: ResolvableValue(resolvedValue: notificationTitle),
                body: notificationBody,
                threadIdentifier: threadIdentifier,
                userInfo: userInfo,
                intent: (ResolvableValue(resolvedValue: sendMessageIntent), .outgoing),
                soundQuery: .global,
            )
        }
    }

    public func notifyUserToRelaunchAfterTransfer(completion: @escaping () -> Void) {
        let notificationBody = OWSLocalizedString(
            "TRANSFER_RELAUNCH_NOTIFICATION",
            comment: "Notification prompting the user to relaunch Signal after a device transfer completed.",
        )
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .showChatList
        enqueueNotificationAction {
            await self.notifyViaPresenter(
                category: .transferRelaunch,
                title: nil,
                body: notificationBody,
                threadIdentifier: nil,
                userInfo: userInfo,
                // Use a default sound so we don't read from
                // the db (which doesn't work until we relaunch)
                soundQuery: .constant(.standard(.note)),
                forceBeforeRegistered: true,
            )
            completion()
        }
    }

    public func notifyUserOfDeregistration(tx: DBWriteTransaction) {
        let notificationBody = OWSLocalizedString(
            "DEREGISTRATION_NOTIFICATION",
            comment: "Notification warning the user that they have been de-registered.",
        )
        var userInfo = AppNotificationUserInfo()
        userInfo.defaultAction = .reregister
        enqueueNotificationAction(afterCommitting: tx) {
            await self.notifyViaPresenter(
                category: .deregistration,
                title: nil,
                body: notificationBody,
                threadIdentifier: nil,
                userInfo: userInfo,
                soundQuery: .global,
                forceBeforeRegistered: true,
            )
        }
    }

    private enum SoundQuery {
        case none
        case global
        case thread(String)
        case constant(Sound)
    }

    private func notifyViaPresenter(
        category: AppNotificationCategory,
        title resolvableTitle: ResolvableValue<String>?,
        body: String,
        threadIdentifier: String?,
        userInfo: AppNotificationUserInfo,
        intent intentPair: (resolvableIntent: ResolvableValue<INIntent>, direction: INInteractionDirection)? = nil,
        soundQuery: SoundQuery,
        replacingIdentifier: String? = nil,
        forceBeforeRegistered: Bool = false,
    ) async {
        let notificationSuppressionRule = await self.notificationSuppressionRuleIfMainAppAndActive()
        let sound: Sound?
        switch soundQuery {
        case .none:
            sound = nil
        case .global:
            sound = self.requestGlobalSound(isMainAppAndActive: notificationSuppressionRule != nil)
        case .thread(let threadUniqueId):
            sound = self.requestSound(forThreadUniqueId: threadUniqueId, isMainAppAndActive: notificationSuppressionRule != nil)
        case .constant(let constantSound):
            sound = constantSound
        }

        // Fetching these is currently best effort. (This could be improved in the
        // future.)
        let kProfileNameFetchTimeout: TimeInterval = 5

        async let resolvedTitle = resolvableTitle?.resolve(timeout: kProfileNameFetchTimeout)

        let resolvedInteraction: INInteraction?
        if let intentPair {
            let intent = await intentPair.resolvableIntent.resolve(timeout: kProfileNameFetchTimeout)
            let interaction = INInteraction(intent: intent, response: nil)
            interaction.direction = intentPair.direction
            resolvedInteraction = interaction
        } else {
            resolvedInteraction = nil
        }

        await self.presenter.notify(
            category: category,
            title: await resolvedTitle,
            body: body,
            threadIdentifier: threadIdentifier,
            userInfo: userInfo,
            interaction: resolvedInteraction,
            sound: sound,
            replacingIdentifier: replacingIdentifier,
            forceBeforeRegistered: forceBeforeRegistered,
            isMainAppAndActive: notificationSuppressionRule != nil,
            notificationSuppressionRule: notificationSuppressionRule ?? .none,
        )
    }

    // MARK: - Cancellation

    public func cancelNotifications(threadId: String) {
        enqueueNotificationAction {
            await self.presenter.cancelNotifications(threadId: threadId)
        }
    }

    public func cancelNotifications(messageIds: [String]) {
        enqueueNotificationAction {
            await self.presenter.cancelNotifications(messageIds: messageIds)
        }
    }

    public func cancelNotifications(reactionId: String) {
        enqueueNotificationAction {
            await self.presenter.cancelNotifications(reactionId: reactionId)
        }
    }

    public func cancelNotificationsForMissedCalls(threadUniqueId: String) {
        enqueueNotificationAction {
            await self.presenter.cancelNotificationsForMissedCalls(withThreadUniqueId: threadUniqueId)
        }
    }

    public func cancelNotifications(for storyMessage: StoryMessage) {
        let storyMessageId = storyMessage.uniqueId
        enqueueNotificationAction {
            await self.presenter.cancelNotificationsForStoryMessage(withUniqueId: storyMessageId)
        }
    }

    public func clearAllNotifications() {
        presenter.clearAllNotifications()
    }

    public func clearNotificationsForAppActivate() {
        presenter.clearNotificationsForAppActivate()
    }

    public func clearDeliveredNewLinkedDevicesNotifications() {
        presenter.clearDeliveredNewLinkedDevicesNotifications()
    }

    // MARK: - Serialization

    private static let pendingTasks = PendingTasks()

    public static func waitForPendingNotifications() async throws {
        try await pendingTasks.waitForPendingTasks()
    }

    private let mostRecentTask = AtomicValue<Task<Void, Never>?>(nil, lock: .init())

    private func enqueueNotificationAction(afterCommitting tx: DBReadTransaction? = nil, _ block: @escaping () async -> Void) {
        let startTime = CACurrentMediaTime()
        let pendingTask = Self.pendingTasks.buildPendingTask()
        let commitGuarantee = (tx as? DBWriteTransaction).map {
            let (guarantee, future) = Guarantee<Void>.pending()
            $0.addSyncCompletion { future.resolve() }
            return guarantee
        }
        self.mostRecentTask.update {
            let oldTask = $0
            $0 = Task {
                defer { pendingTask.complete() }
                await oldTask?.value
                await commitGuarantee?.awaitable()
                let queueTime = CACurrentMediaTime()
                await block()
                let endTime = CACurrentMediaTime()

                let tooLargeThreshold: TimeInterval = 2
                if endTime - startTime >= tooLargeThreshold {
                    let formattedQueueDuration = String(format: "%.2f", queueTime - startTime)
                    let formattedNotifyDuration = String(format: "%.2f", endTime - queueTime)
                    Logger.warn("Couldn't post notification within \(tooLargeThreshold) seconds; \(formattedQueueDuration)s + \(formattedNotifyDuration)s")
                }
            }
        }
    }

    // MARK: -

    private let unfairLock = UnfairLock()
    private var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)

    private func requestSound(forThreadUniqueId threadUniqueId: String, isMainAppAndActive: Bool) -> Sound? {
        return checkIfShouldPlaySound(isMainAppAndActive: isMainAppAndActive) ? Sounds.notificationSoundWithSneakyTransaction(forThreadUniqueId: threadUniqueId) : nil
    }

    private func requestGlobalSound(isMainAppAndActive: Bool) -> Sound? {
        return checkIfShouldPlaySound(isMainAppAndActive: isMainAppAndActive) ? Sounds.globalNotificationSound : nil
    }

    private func checkIfShouldPlaySound(isMainAppAndActive: Bool) -> Bool {
        guard isMainAppAndActive else {
            return true
        }

        guard preferences.soundInForeground else {
            return false
        }

        let now = NSDate.ows_millisecondTimeStamp()
        let recentThreshold = now - UInt64(kAudioNotificationsThrottleInterval * Double(UInt64.secondInMs))

        return unfairLock.withLock {
            let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }

            guard recentNotifications.count < kAudioNotificationsThrottleCount else {
                return false
            }

            mostRecentNotifications.append(now)
            return true
        }
    }
}

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