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.
661 lines
26 KiB
Swift
661 lines
26 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import SessionMessagingKit
|
|
import SignalUtilitiesKit
|
|
import SessionUtilitiesKit
|
|
|
|
// MARK: - NotificationPresenter
|
|
|
|
public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, NotificationsManagerType {
|
|
private static let audioNotificationsThrottleCount = 2
|
|
private static let audioNotificationsThrottleInterval: TimeInterval = 5
|
|
|
|
private let dependencies: Dependencies
|
|
private let notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()
|
|
@ThreadSafeObject private var notifications: [String: UNNotificationRequest] = [:]
|
|
@ThreadSafeObject private var mostRecentNotifications: TruncatedList<UInt64> = TruncatedList<UInt64>(maxLength: NotificationPresenter.audioNotificationsThrottleCount)
|
|
|
|
// MARK: - Initialization
|
|
|
|
required public init(using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
}
|
|
|
|
// MARK: - Registration
|
|
|
|
public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {
|
|
notificationCenter.delegate = delegate
|
|
}
|
|
|
|
public func registerNotificationSettings() -> AnyPublisher<Void, Never> {
|
|
return Deferred { [notificationCenter] in
|
|
Future { resolver in
|
|
notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in
|
|
notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories)
|
|
|
|
switch (granted, error) {
|
|
case (true, _): break
|
|
case (false, .some(let error)): Log.error("[NotificationPresenter] Register settings failed with error: \(error)")
|
|
case (false, .none): Log.error("[NotificationPresenter] Register settings failed without error.")
|
|
}
|
|
|
|
// Note that the promise is fulfilled regardless of if notification permssions were
|
|
// granted. This promise only indicates that the user has responded, so we can
|
|
// proceed with requesting push tokens and complete registration.
|
|
resolver(Result.success(()))
|
|
}
|
|
}
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
|
|
// MARK: - Presentation
|
|
|
|
public func notifyUser(
|
|
_ db: Database,
|
|
for interaction: Interaction,
|
|
in thread: SessionThread,
|
|
applicationState: UIApplication.State
|
|
) {
|
|
let isMessageRequest: Bool = SessionThread.isMessageRequest(
|
|
db,
|
|
threadId: thread.id,
|
|
userSessionId: dependencies[cache: .general].sessionId,
|
|
includeNonVisible: true
|
|
)
|
|
|
|
// Ensure we should be showing a notification for the thread
|
|
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest, using: dependencies) else {
|
|
return
|
|
}
|
|
|
|
// Try to group notifications for interactions from open groups
|
|
let identifier: String = Interaction.notificationIdentifier(
|
|
for: (interaction.id ?? 0),
|
|
threadId: thread.id,
|
|
shouldGroupMessagesForThread: (thread.variant == .community)
|
|
)
|
|
|
|
// While batch processing, some of the necessary changes have not been commited.
|
|
let rawMessageText: String = Interaction.notificationPreviewText(db, interaction: interaction, using: dependencies)
|
|
|
|
// 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, using: dependencies)
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .defaultPreviewType)
|
|
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)
|
|
)
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview:
|
|
notificationTitle = Constants.app_name
|
|
|
|
case .nameNoPreview, .nameAndPreview:
|
|
switch (thread.variant, isMessageRequest) {
|
|
case (.contact, true), (.group, true): notificationTitle = Constants.app_name
|
|
case (.contact, false): notificationTitle = senderName
|
|
|
|
case (.legacyGroup, _), (.group, false), (.community, _):
|
|
notificationTitle = "notificationsIosGroup"
|
|
.put(key: "name", value: senderName)
|
|
.put(key: "conversation_name", value: groupName)
|
|
.localized()
|
|
}
|
|
}
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview, .nameNoPreview: notificationBody = "messageNewYouveGot"
|
|
.putNumber(1)
|
|
.localized()
|
|
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 = "messageRequestsNew".localized()
|
|
}
|
|
|
|
guard notificationBody != nil || notificationTitle != nil else {
|
|
Log.info("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: [AnyHashable: Any] = [
|
|
AppNotificationUserInfoKey.threadId: thread.id,
|
|
AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue
|
|
]
|
|
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let userBlinded15SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId(
|
|
db,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
blindingPrefix: .blinded15,
|
|
using: dependencies
|
|
)
|
|
let userBlinded25SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId(
|
|
db,
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant,
|
|
blindingPrefix: .blinded25,
|
|
using: dependencies
|
|
)
|
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
|
|
|
let sound: Preferences.Sound? = requestSound(
|
|
thread: thread,
|
|
fallbackSound: fallbackSound,
|
|
applicationState: applicationState
|
|
)
|
|
|
|
notificationBody = MentionUtilities.highlightMentionsNoAttributes(
|
|
in: (notificationBody ?? ""),
|
|
threadVariant: thread.variant,
|
|
currentUserSessionId: userSessionId.hexString,
|
|
currentUserBlinded15SessionId: userBlinded15SessionId?.hexString,
|
|
currentUserBlinded25SessionId: userBlinded25SessionId?.hexString,
|
|
using: dependencies
|
|
)
|
|
|
|
notify(
|
|
category: category,
|
|
title: notificationTitle,
|
|
body: (notificationBody ?? ""),
|
|
userInfo: userInfo,
|
|
previewType: previewType,
|
|
sound: sound,
|
|
threadVariant: thread.variant,
|
|
threadName: groupName,
|
|
applicationState: applicationState,
|
|
replacingIdentifier: identifier
|
|
)
|
|
}
|
|
|
|
public func notifyUser(
|
|
_ db: Database,
|
|
forIncomingCall interaction: Interaction,
|
|
in thread: SessionThread,
|
|
applicationState: UIApplication.State
|
|
) {
|
|
// No call notifications for muted or group threads
|
|
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
|
guard
|
|
thread.variant != .legacyGroup &&
|
|
thread.variant != .group &&
|
|
thread.variant != .community
|
|
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
|
|
switch messageInfo.state {
|
|
case .missed, .permissionDenied, .permissionDeniedMicrophone: break
|
|
default: return
|
|
}
|
|
|
|
let category = AppNotificationCategory.errorMessage
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .nameAndPreview)
|
|
|
|
let userInfo: [AnyHashable: Any] = [
|
|
AppNotificationUserInfoKey.threadId: thread.id,
|
|
AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue
|
|
]
|
|
|
|
let notificationTitle: String = Constants.app_name
|
|
let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies)
|
|
let notificationBody: String? = {
|
|
switch messageInfo.state {
|
|
case .permissionDenied:
|
|
return "callsYouMissedCallPermissions"
|
|
.put(key: "name", value: senderName)
|
|
.localizedDeformatted()
|
|
case .permissionDeniedMicrophone, .missed:
|
|
return "callsMissedCallFrom"
|
|
.put(key: "name", value: senderName)
|
|
.localized()
|
|
default:
|
|
return nil
|
|
}
|
|
}()
|
|
|
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
|
let sound = self.requestSound(
|
|
thread: thread,
|
|
fallbackSound: fallbackSound,
|
|
applicationState: applicationState
|
|
)
|
|
|
|
notify(
|
|
category: category,
|
|
title: notificationTitle,
|
|
body: (notificationBody ?? ""),
|
|
userInfo: userInfo,
|
|
previewType: previewType,
|
|
sound: sound,
|
|
threadVariant: thread.variant,
|
|
threadName: senderName,
|
|
applicationState: applicationState,
|
|
replacingIdentifier: UUID().uuidString
|
|
)
|
|
}
|
|
|
|
public func notifyUser(
|
|
_ db: Database,
|
|
forReaction reaction: Reaction,
|
|
in thread: SessionThread,
|
|
applicationState: UIApplication.State
|
|
) {
|
|
let isMessageRequest: Bool = SessionThread.isMessageRequest(
|
|
db,
|
|
threadId: thread.id,
|
|
userSessionId: dependencies[cache: .general].sessionId,
|
|
includeNonVisible: true
|
|
)
|
|
|
|
// No reaction notifications for muted, group threads or message requests
|
|
guard dependencies.dateNow.timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
|
guard
|
|
thread.variant != .legacyGroup &&
|
|
thread.variant != .group &&
|
|
thread.variant != .community
|
|
else { return }
|
|
guard !isMessageRequest else { return }
|
|
|
|
let notificationTitle = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant, using: dependencies)
|
|
var notificationBody = "emojiReactsNotification"
|
|
.put(key: "emoji", value: reaction.emoji)
|
|
.localized()
|
|
|
|
// Title & body
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .nameAndPreview)
|
|
|
|
switch previewType {
|
|
case .nameAndPreview: break
|
|
default: notificationBody = "messageNewYouveGot"
|
|
.putNumber(1)
|
|
.localized()
|
|
}
|
|
|
|
let category = AppNotificationCategory.incomingMessage
|
|
|
|
let userInfo: [AnyHashable: Any] = [
|
|
AppNotificationUserInfoKey.threadId: thread.id,
|
|
AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue
|
|
]
|
|
|
|
let threadName: String = SessionThread.displayName(
|
|
threadId: thread.id,
|
|
variant: thread.variant,
|
|
closedGroupName: nil, // Not supported
|
|
openGroupName: nil // Not supported
|
|
)
|
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
|
let sound = self.requestSound(
|
|
thread: thread,
|
|
fallbackSound: fallbackSound,
|
|
applicationState: applicationState
|
|
)
|
|
|
|
notify(
|
|
category: category,
|
|
title: notificationTitle,
|
|
body: notificationBody,
|
|
userInfo: userInfo,
|
|
previewType: previewType,
|
|
sound: sound,
|
|
threadVariant: thread.variant,
|
|
threadName: threadName,
|
|
applicationState: applicationState,
|
|
replacingIdentifier: UUID().uuidString
|
|
)
|
|
}
|
|
|
|
public func notifyForFailedSend(
|
|
_ db: Database,
|
|
in thread: SessionThread,
|
|
applicationState: UIApplication.State
|
|
) {
|
|
let notificationTitle: String?
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .defaultPreviewType)
|
|
let threadName: 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),
|
|
isNoteToSelf: (thread.isNoteToSelf(db, using: dependencies) == true),
|
|
profile: try? Profile.fetchOne(db, id: thread.id)
|
|
)
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview: notificationTitle = nil
|
|
case .nameNoPreview, .nameAndPreview: notificationTitle = threadName
|
|
}
|
|
|
|
let notificationBody = "messageErrorDelivery".localized()
|
|
let userInfo: [AnyHashable: Any] = [
|
|
AppNotificationUserInfoKey.threadId: thread.id,
|
|
AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue
|
|
]
|
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
|
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
|
let sound: Preferences.Sound? = self.requestSound(
|
|
thread: thread,
|
|
fallbackSound: fallbackSound,
|
|
applicationState: applicationState
|
|
)
|
|
|
|
notify(
|
|
category: .errorMessage,
|
|
title: notificationTitle,
|
|
body: notificationBody,
|
|
userInfo: userInfo,
|
|
previewType: previewType,
|
|
sound: sound,
|
|
threadVariant: thread.variant,
|
|
threadName: threadName,
|
|
applicationState: applicationState
|
|
)
|
|
}
|
|
|
|
// MARK: - Clearing
|
|
|
|
public func cancelNotifications(identifiers: [String]) {
|
|
_notifications.performUpdate { notifications in
|
|
var updatedNotifications: [String: UNNotificationRequest] = notifications
|
|
identifiers.forEach { updatedNotifications.removeValue(forKey: $0) }
|
|
return updatedNotifications
|
|
}
|
|
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
|
|
}
|
|
|
|
public func clearAllNotifications() {
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
notificationCenter.removeAllDeliveredNotifications()
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
private extension NotificationPresenter {
|
|
func notify(
|
|
category: AppNotificationCategory,
|
|
title: String?,
|
|
body: String,
|
|
userInfo: [AnyHashable: Any],
|
|
previewType: Preferences.NotificationPreviewType,
|
|
sound: Preferences.Sound?,
|
|
threadVariant: SessionThread.Variant,
|
|
threadName: String,
|
|
applicationState: UIApplication.State,
|
|
replacingIdentifier: String? = nil
|
|
) {
|
|
let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String)
|
|
let content = UNMutableNotificationContent()
|
|
content.categoryIdentifier = category.identifier
|
|
content.userInfo = userInfo
|
|
content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier)
|
|
|
|
let shouldGroupNotification: Bool = (
|
|
threadVariant == .community &&
|
|
replacingIdentifier == threadIdentifier
|
|
)
|
|
if let sound = sound, sound != .none {
|
|
content.sound = sound.notificationSound(isQuiet: (applicationState == .active))
|
|
}
|
|
|
|
let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString)
|
|
let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil)
|
|
let shouldPresentNotification: Bool = shouldPresentNotification(
|
|
category: category,
|
|
applicationState: applicationState,
|
|
userInfo: userInfo,
|
|
using: dependencies
|
|
)
|
|
var trigger: UNNotificationTrigger?
|
|
|
|
if shouldPresentNotification {
|
|
if let displayableTitle = title?.filteredForDisplay {
|
|
content.title = displayableTitle
|
|
}
|
|
content.body = body.filteredForDisplay
|
|
|
|
if shouldGroupNotification {
|
|
trigger = UNTimeIntervalNotificationTrigger(
|
|
timeInterval: Notifications.delayForGroupedNotifications,
|
|
repeats: false
|
|
)
|
|
|
|
let numberExistingNotifications: Int? = notifications[notificationIdentifier]?
|
|
.content
|
|
.userInfo[AppNotificationUserInfoKey.threadNotificationCounter]
|
|
.asType(Int.self)
|
|
var numberOfNotifications: Int = (numberExistingNotifications ?? 1)
|
|
|
|
if numberExistingNotifications != nil {
|
|
numberOfNotifications += 1 // Add one for the current notification
|
|
|
|
content.title = (previewType == .noNameNoPreview ?
|
|
content.title :
|
|
threadName
|
|
)
|
|
content.body = "messageNewYouveGot"
|
|
.putNumber(numberOfNotifications)
|
|
.localized()
|
|
}
|
|
|
|
content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications
|
|
}
|
|
}
|
|
else {
|
|
// Play sound and vibrate, but without a `body` no banner will show.
|
|
Log.debug("supressing notification body")
|
|
}
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: notificationIdentifier,
|
|
content: content,
|
|
trigger: trigger
|
|
)
|
|
|
|
Log.debug("presenting notification with identifier: \(notificationIdentifier)")
|
|
|
|
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
|
|
|
|
notificationCenter.add(request)
|
|
_notifications.performUpdate { $0.setting(notificationIdentifier, request) }
|
|
}
|
|
|
|
private func requestSound(
|
|
thread: SessionThread,
|
|
fallbackSound: Preferences.Sound,
|
|
applicationState: UIApplication.State
|
|
) -> Preferences.Sound? {
|
|
guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil }
|
|
|
|
return (thread.notificationSound ?? fallbackSound)
|
|
}
|
|
|
|
private func shouldPresentNotification(
|
|
category: AppNotificationCategory,
|
|
applicationState: UIApplication.State,
|
|
userInfo: [AnyHashable: Any],
|
|
using dependencies: Dependencies
|
|
) -> Bool {
|
|
guard applicationState == .active else { return true }
|
|
guard category == .incomingMessage || category == .errorMessage else { return true }
|
|
|
|
guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
Log.error("[UserNotificationPresenter] threadId was unexpectedly nil")
|
|
return true
|
|
}
|
|
|
|
/// Check whether the current `frontMostViewController` is a `ConversationVC` for the conversation this notification
|
|
/// would belong to then we don't want to show the notification, so retrieve the `frontMostViewController` (from the main
|
|
/// thread) and check
|
|
guard
|
|
let frontMostViewController: UIViewController = DispatchQueue.main.sync(execute: {
|
|
dependencies[singleton: .appContext].frontMostViewController
|
|
}),
|
|
let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC
|
|
else { return true }
|
|
|
|
/// Show notifications for any **other** threads
|
|
return (conversationViewController.viewModel.threadData.threadId != notificationThreadId)
|
|
}
|
|
|
|
private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool {
|
|
guard applicationState == .active else { return true }
|
|
guard dependencies[singleton: .storage, key: .playNotificationSoundInForeground] else { return false }
|
|
|
|
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
|
let recentThreshold = nowMs - UInt64(NotificationPresenter.audioNotificationsThrottleInterval * 1000)
|
|
|
|
let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
|
|
|
|
guard recentNotifications.count < NotificationPresenter.audioNotificationsThrottleCount else { return false }
|
|
|
|
_mostRecentNotifications.performUpdate { $0.appending(nowMs) }
|
|
return true
|
|
}
|
|
}
|
|
|
|
enum NotificationError: Error {
|
|
case assertionError(description: String)
|
|
}
|
|
|
|
extension NotificationError {
|
|
static func failDebug(_ description: String) -> NotificationError {
|
|
Log.error("[NotificationActionHandler] Failed with error: \(description)")
|
|
return NotificationError.assertionError(description: description)
|
|
}
|
|
}
|
|
|
|
// MARK: - TruncatedList
|
|
|
|
struct TruncatedList<Element>: RangeReplaceableCollection {
|
|
let maxLength: Int
|
|
private var contents: [Element] = []
|
|
|
|
// MARK: - Initialization
|
|
|
|
init() {
|
|
self.maxLength = 0
|
|
}
|
|
|
|
init(maxLength: Int) {
|
|
self.maxLength = maxLength
|
|
}
|
|
|
|
mutating func append(_ newElement: Element) {
|
|
var updatedContents: [Element] = self.contents
|
|
updatedContents.append(newElement)
|
|
self.contents = Array(updatedContents.suffix(maxLength))
|
|
}
|
|
|
|
mutating func append<S: Sequence>(contentsOf newElements: S) where S.Element == Element {
|
|
var updatedContents: [Element] = self.contents
|
|
updatedContents.append(contentsOf: newElements)
|
|
self.contents = Array(updatedContents.suffix(maxLength))
|
|
}
|
|
|
|
mutating func insert(_ newElement: Element, at i: Int) {
|
|
var updatedContents: [Element] = self.contents
|
|
updatedContents.insert(newElement, at: i)
|
|
self.contents = updatedContents
|
|
}
|
|
|
|
mutating func remove(at position: Int) -> Element {
|
|
var updatedContents: [Element] = self.contents
|
|
let result: Element = updatedContents.remove(at: position)
|
|
self.contents = updatedContents
|
|
|
|
return result
|
|
}
|
|
|
|
mutating func removeSubrange(_ bounds: Range<Int>) {
|
|
var updatedContents: [Element] = self.contents
|
|
updatedContents.removeSubrange(bounds)
|
|
self.contents = updatedContents
|
|
}
|
|
|
|
mutating func replaceSubrange<C: Collection>(_ subrange: Range<Int>, with newElements: C) where C.Element == Element {
|
|
var updatedContents: [Element] = self.contents
|
|
updatedContents.replaceSubrange(subrange, with: newElements)
|
|
self.contents = Array(updatedContents.suffix(maxLength))
|
|
}
|
|
}
|
|
|
|
extension TruncatedList {
|
|
func appending(_ other: Element?) -> TruncatedList<Element> {
|
|
guard let other: Element = other else { return self }
|
|
|
|
var result: TruncatedList<Element> = self
|
|
result.append(other)
|
|
return result
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|