// // Preferences.swift // TuskerPreferences // // Created by Shadowfacts on 8/28/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine public final class Preferences: Codable, ObservableObject { @MainActor public static var shared: Preferences = load() private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") @MainActor public static func save() { let encoder = PropertyListEncoder() let data = try? encoder.encode(shared) try? data?.write(to: archiveURL, options: .noFileProtection) } public static func load() -> Preferences { let decoder = PropertyListDecoder() if let data = try? Data(contentsOf: archiveURL), let preferences = try? decoder.decode(Preferences.self, from: data) { return preferences } return Preferences() } @MainActor public static func migrate(from url: URL) -> Result { do { try? FileManager.default.removeItem(at: archiveURL) try FileManager.default.moveItem(at: url, to: archiveURL) } catch { return .failure(error) } shared = load() return .success(()) } private init() {} public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle) self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames) self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon) self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon) self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { self.defaultPostVisibility = .visibility(existing) } else { self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) } self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) { self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting } else { self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode) } self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? [] self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init)) self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(theme, forKey: .theme) try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode) try container.encode(accentColor, forKey: .accentColor) try container.encode(avatarStyle, forKey: .avatarStyle) try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames) try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon) try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon) try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline) try container.encode(showLinkPreviews, forKey: .showLinkPreviews) try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) try container.encode(underlineTextLinks, forKey: .underlineTextLinks) try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions) try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode) try container.encode(mentionReblogger, forKey: .mentionReblogger) try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard) try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode) try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning) try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline) try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges) try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(useInAppSafari, forKey: .useInAppSafari) try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode) try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings) try container.encode(collapseLongPosts, forKey: .collapseLongPosts) try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords) try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog) try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration) try container.encode(timelineSyncMode, forKey: .timelineSyncMode) try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines) try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines) try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) try container.encode(grayscaleImages, forKey: .grayscaleImages) try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling) try container.encode(hideTrends, forKey: .hideTrends) try container.encode(statusContentType, forKey: .statusContentType) try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically) try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags) try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription) try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription) } // MARK: Appearance @Published public var theme = UIUserInterfaceStyle.unspecified @Published public var pureBlackDarkMode = true @Published public var accentColor = AccentColor.default @Published public var avatarStyle = AvatarStyle.roundRect @Published public var hideCustomEmojiInUsernames = false @Published public var showIsStatusReplyIcon = false @Published public var alwaysShowStatusVisibilityIcon = false @Published public var hideActionsInTimeline = false @Published public var showLinkPreviews = true @Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog] @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var underlineTextLinks = false @Published public var showAttachmentsInTimeline = true // MARK: Composing @Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var requireAttachmentDescriptions = false @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published public var mentionReblogger = false @Published public var useTwitterKeyboard = false // MARK: Media @Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting { didSet { if attachmentBlurMode == .always { blurMediaBehindContentWarning = true } else if attachmentBlurMode == .never { blurMediaBehindContentWarning = false } } } @Published public var blurMediaBehindContentWarning = true @Published public var automaticallyPlayGifs = true @Published public var showUncroppedMediaInline = true @Published public var showAttachmentBadges = true // MARK: Behavior @Published public var openLinksInApps = true @Published public var useInAppSafari = true @Published public var inAppSafariAutomaticReaderMode = false @Published public var expandAllContentWarnings = false @Published public var collapseLongPosts = true @Published public var oppositeCollapseKeywords: [String] = [] @Published public var confirmBeforeReblog = false @Published public var timelineStateRestoration = true @Published public var timelineSyncMode = TimelineSyncMode.icloud @Published public var hideReblogsInTimelines = false @Published public var hideRepliesInTimelines = false // MARK: Digital Wellness @Published public var showFavoriteAndReblogCounts = true @Published public var defaultNotificationsMode = NotificationsMode.allNotifications @Published public var grayscaleImages = false @Published public var disableInfiniteScrolling = false @Published public var hideTrends = false // MARK: Advanced @Published public var statusContentType: StatusContentType = .plain @Published public var reportErrorsAutomatically = true @Published public var enabledFeatureFlags: Set = [] // MARK: @Published public var hasShownLocalTimelineDescription = false @Published public var hasShownFederatedTimelineDescription = false public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool { enabledFeatureFlags.contains(flag) } private enum CodingKeys: String, CodingKey { case theme case pureBlackDarkMode case accentColor case avatarStyle case hideCustomEmojiInUsernames case showIsStatusReplyIcon case alwaysShowStatusVisibilityIcon case hideActionsInTimeline case showLinkPreviews case leadingStatusSwipeActions case trailingStatusSwipeActions case widescreenNavigationMode case underlineTextLinks case showAttachmentsInTimeline case defaultPostVisibility case defaultReplyVisibility case requireAttachmentDescriptions case contentWarningCopyMode case mentionReblogger case useTwitterKeyboard case blurAllMedia // only used for migration case attachmentBlurMode case blurMediaBehindContentWarning case automaticallyPlayGifs case showUncroppedMediaInline case showAttachmentBadges case openLinksInApps case useInAppSafari case inAppSafariAutomaticReaderMode case expandAllContentWarnings case collapseLongPosts case oppositeCollapseKeywords case confirmBeforeReblog case timelineStateRestoration case timelineSyncMode case hideReblogsInTimelines case hideRepliesInTimelines case showFavoriteAndReblogCounts case defaultNotificationsType case grayscaleImages case disableInfiniteScrolling case hideTrends = "hideDiscover" case statusContentType case reportErrorsAutomatically case enabledFeatureFlags case hasShownLocalTimelineDescription case hasShownFederatedTimelineDescription } } extension Preferences { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { case useStatusSetting case always case never public var displayName: String { switch self { case .useStatusSetting: return "Default" case .always: return "Always" case .never: return "Never" } } } } extension UIUserInterfaceStyle: Codable {} extension Preferences { public enum AccentColor: String, Codable, CaseIterable { case `default` case purple case indigo case blue case cyan case teal case mint case green // case yellow case orange case red case pink // case brown public var color: UIColor? { switch self { case .default: return nil case .blue: return .systemBlue // case .brown: // return .systemBrown case .cyan: return .systemCyan case .green: return .systemGreen case .indigo: return .systemIndigo case .mint: return .systemMint case .orange: return .systemOrange case .pink: return .systemPink case .purple: return .systemPurple case .red: return .systemRed case .teal: return .systemTeal // case .yellow: // return .systemYellow } } public var name: String { switch self { case .default: return "Default" case .blue: return "Blue" // case .brown: // return "Brown" case .cyan: return "Cyan" case .green: return "Green" case .indigo: return "Indigo" case .mint: return "Mint" case .orange: return "Orange" case .pink: return "Pink" case .purple: return "Purple" case .red: return "Red" case .teal: return "Teal" // case .yellow: // return "Yellow" } } } } extension Preferences { public enum TimelineSyncMode: String, Codable { case mastodon case icloud } } extension Preferences { public enum FeatureFlag: String, Codable { case iPadMultiColumn = "ipad-multi-column" case iPadBrowserNavigation = "ipad-browser-navigation" } } extension Preferences { public enum WidescreenNavigationMode: String, Codable { case stack case splitScreen case multiColumn } }