Tusker/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift

432 lines
20 KiB
Swift

//
// 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<Void, any Error> {
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.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
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(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted)
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
@Published public var attachmentAltBadgeInverted = false
// 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<FeatureFlag> = []
// 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 attachmentAltBadgeInverted
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
}
}