diff --git a/App/Composition/ComposeTextViewController.swift b/App/Composition/ComposeTextViewController.swift index 27050a767..28f998336 100644 --- a/App/Composition/ComposeTextViewController.swift +++ b/App/Composition/ComposeTextViewController.swift @@ -133,6 +133,7 @@ class ComposeTextViewController: ViewController { guard textDidChangeObserver == nil else { return } textDidChangeObserver = NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: textView, queue: OperationQueue.main, using: { [weak self] (note: Notification) in self?.updateSubmitButtonItem() + self?.bodyTextDidChange() }) } private func endObservingTextChangeNotification() { @@ -141,6 +142,11 @@ class ComposeTextViewController: ViewController { textDidChangeObserver = nil } private var textDidChangeObserver: NSObjectProtocol? + + /// Override in subclasses to react to body text changes (e.g. to auto-save a draft). + /// Called from the same `UITextView.textDidChangeNotification` observer that updates the + /// submit button. + func bodyTextDidChange() {} fileprivate func beginObservingKeyboardNotifications() { guard keyboardWillChangeFrameObserver == nil else { return } @@ -340,7 +346,6 @@ class ComposeTextViewController: ViewController { override func loadView() { let textView = ComposeTextView() - textView.restorationIdentifier = "ComposeTextView" textView.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -0.5, weight: .regular) textView.delegate = self view = textView @@ -393,29 +398,6 @@ class ComposeTextViewController: ViewController { view.endEditing(true) } - - // MARK: State restoration - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(textView.attributedText, forKey: Keys.AttributedText.rawValue) - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - textView.attributedText = coder.decodeObject(forKey: Keys.AttributedText.rawValue) as? NSAttributedString - - // -viewDidLoad gets called before -decodeRestorableStateWithCoder: and so the text color gets loaded from the saved attributed string. Reapply the theme after restoring state. - themeDidChange() - - updateSubmitButtonItem() - } -} - -fileprivate enum Keys: String { - case AttributedText } // For benefit of subclasses. diff --git a/App/Composition/CompositionViewController.swift b/App/Composition/CompositionViewController.swift index 0af9d1dfe..d64bb19fa 100644 --- a/App/Composition/CompositionViewController.swift +++ b/App/Composition/CompositionViewController.swift @@ -20,7 +20,6 @@ final class CompositionViewController: ViewController { override init(nibName: String?, bundle: Bundle?) { super.init(nibName: nil, bundle: nil) - restorationClass = type(of: self) } required init?(coder: NSCoder) { @@ -61,7 +60,6 @@ final class CompositionViewController: ViewController { view = containerView _textView = CompositionTextView() - _textView.restorationIdentifier = "Composition text view" _textView.translatesAutoresizingMaskIntoConstraints = false attachmentPreviewView.translatesAutoresizingMaskIntoConstraints = false @@ -277,18 +275,6 @@ final class CompositionViewController: ViewController { super.viewWillAppear(animated) textView.becomeFirstResponder() - - // Leave an escape hatch in case we were restored without an associated workspace. This can happen when a crash leaves old state information behind. - if navigationItem.leftBarButtonItem == nil { - let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(CompositionViewController.didTapCancel)) - // Only set explicit tint color for iOS < 26 - if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically - } else { - cancelButton.tintColor = theme["navigationBarTextColor"] - } - navigationItem.leftBarButtonItem = cancelButton - } } override func viewDidAppear(_ animated: Bool) { @@ -461,14 +447,6 @@ final class CompositionViewController: ViewController { } } -extension CompositionViewController: UIViewControllerRestoration { - class func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - let composition = self.init() - composition.restorationIdentifier = identifierComponents.last - return composition - } -} - final class CompositionTextView: UITextView, CompositionHidesMenuItems { var hidesBuiltInMenuItems: Bool = false diff --git a/App/Extensions/UIKit.swift b/App/Extensions/UIKit.swift index 39d61ddde..6a16faa27 100644 --- a/App/Extensions/UIKit.swift +++ b/App/Extensions/UIKit.swift @@ -199,9 +199,6 @@ extension UIViewController { if let nav = navigationController { return nav } let nav = NavigationController(rootViewController: self) nav.modalPresentationStyle = modalPresentationStyle - if let identifier = restorationIdentifier { - nav.restorationIdentifier = "\(identifier) navigation" - } return nav } } diff --git a/App/Main/AppDelegate.swift b/App/Main/AppDelegate.swift index f0fe31c7f..c43e1fbd6 100644 --- a/App/Main/AppDelegate.swift +++ b/App/Main/AppDelegate.swift @@ -27,9 +27,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { @FoilDefaultStorage(Settings.defaultLightThemeName) private var defaultLightTheme private var inboxRefresher: PrivateMessageInboxRefresher? var managedObjectContext: NSManagedObjectContext { return dataStore.mainManagedObjectContext } - private var openCopiedURLController: OpenCopiedURLController? @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @FoilDefaultStorage(Settings.enableCustomTitlePostLayout) private var showCustomTitles + /// Owned by `SceneDelegate`; set here so the existing AppDelegate-side helpers (theming + /// snapshots, login flow, etc.) keep working without each having to walk `connectedScenes`. var window: UIWindow? func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -57,36 +58,25 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { return URLCache(memoryCapacity: megabytes(5), diskCapacity: megabytes(50), diskPath: nil) #endif }() - - window = UIWindow(frame: UIScreen.main.bounds) - window?.tintColor = Theme.defaultTheme()["tintColor"] - + + return true + } + + /// Called by `SceneDelegate` once its window exists. Installs either the logged-in root view + /// controller stack or the login screen as the window's root view controller. + func installInitialRootViewController(in window: UIWindow) { if ForumsClient.shared.isLoggedIn { - setRootViewController(rootViewControllerStack.rootViewController, animated: false, completion: nil) + window.rootViewController = rootViewControllerStack.rootViewController } else { - setRootViewController(loginViewController.enclosingNavigationController, animated: false, completion: nil) + window.rootViewController = loginViewController.enclosingNavigationController } - - openCopiedURLController = OpenCopiedURLController(window: window!, router: { - [unowned self] in - self.open(route: $0) - }) - - window?.makeKeyAndVisible() - - return true } func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Don't want to lazily create it now. - _rootViewControllerStack?.didAppear() - ignoreSilentSwitchWhenPlayingEmbeddedVideo() - - showPromptIfLoginCookieExpiresSoon() announcementListRefresher = AnnouncementListRefresher(client: ForumsClient.shared, minder: RefreshMinder.sharedMinder) inboxRefresher = PrivateMessageInboxRefresher(client: ForumsClient.shared, minder: RefreshMinder.sharedMinder) @@ -135,13 +125,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { return true } - + func applicationWillResignActive(_ application: UIApplication) { SmilieKeyboardSetIsAwfulAppActive(false) - + updateShortcutItems() } - + func applicationDidBecomeActive(_ application: UIApplication) { SmilieKeyboardSetIsAwfulAppActive(true) @@ -149,42 +139,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { automaticallyUpdateDarkModeEnabledIfNecessary() } - func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool { - return ForumsClient.shared.isLoggedIn - } - - func application(_ application: UIApplication, willEncodeRestorableStateWith coder: NSCoder) { - coder.encode(currentInterfaceVersion.rawValue, forKey: interfaceVersionKey) - } - - func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { - guard ForumsClient.shared.isLoggedIn else { return false } - return coder.decodeInteger(forKey: interfaceVersionKey) == currentInterfaceVersion.rawValue - } - - func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - return rootViewControllerStack.viewControllerWithRestorationIdentifierPath(identifierComponents) - } - - func application(_ application: UIApplication, didDecodeRestorableStateWith coder: NSCoder) { - try! managedObjectContext.save() - } - - func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { - guard - ForumsClient.shared.isLoggedIn, - let route = try? AwfulRoute(url) - else { return false } - - open(route: route) - return true - } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - guard let router = urlRouter, let route = userActivity.route else { return false } - return router.route(route) - } - func logOut() { // Logging out doubles as an "empty cache" button. let cookieJar = HTTPCookieStorage.shared @@ -256,17 +210,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.shortcutItems = shortcutItems } - func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { - guard - let url = URL(string: shortcutItem.type), - let route = try? AwfulRoute(url) - else { return completionHandler(false) } - - open(route: route) - completionHandler(true) - } - private var _rootViewControllerStack: RootViewControllerStack? + var rootViewControllerStackIfLoaded: RootViewControllerStack? { _rootViewControllerStack } private var urlRouter: AwfulURLRouter? private var rootViewControllerStack: RootViewControllerStack { if let stack = _rootViewControllerStack { return stack } @@ -291,7 +236,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { }() } -private extension AppDelegate { +extension AppDelegate { func setRootViewController(_ rootViewController: UIViewController, animated: Bool, completion: (() -> Void)?) { guard let window = window else { return } UIView.transition(with: window, duration: animated ? 0.3 : 0, options: .transitionCrossDissolve, animations: { @@ -421,19 +366,6 @@ private func days(_ days: Int) -> TimeInterval { return TimeInterval(days) * 24 * 60 * 60 } -// Value is an InterfaceVersion integer. Encoded when preserving state, and possibly useful for determining whether to decode state or to somehow migrate the preserved state. -private let interfaceVersionKey = "AwfulInterfaceVersion" - -private enum InterfaceVersion: Int { - /// Interface for Awful 2, the version that runs on iOS 7. On iPhone, a basement-style menu is the root view controller. On iPad, a custom split view controller is the root view controller, and it hosts a vertical tab bar controller as its primary view controller. - case version2 - - /// Interface for Awful 3, the version that runs on iOS 8. The primary view controller is a UISplitViewController on both iPhone and iPad. - case version3 -} - -private let currentInterfaceVersion: InterfaceVersion = .version3 - private func ignoreSilentSwitchWhenPlayingEmbeddedVideo() { do { try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default) diff --git a/App/Main/RestorableLocation.swift b/App/Main/RestorableLocation.swift new file mode 100644 index 000000000..11da2946b --- /dev/null +++ b/App/Main/RestorableLocation.swift @@ -0,0 +1,12 @@ +// RestorableLocation.swift +// +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation + +/// A view controller that knows how to express its current location as an `AwfulRoute`, +/// so the scene's `stateRestorationActivity` can be replayed via `AwfulURLRouter` after the +/// system has killed and re-created the scene. +protocol RestorableLocation: AnyObject { + var restorationRoute: AwfulRoute? { get } +} diff --git a/App/Main/RootViewControllerStack.swift b/App/Main/RootViewControllerStack.swift index f645cc786..73fbc3326 100644 --- a/App/Main/RootViewControllerStack.swift +++ b/App/Main/RootViewControllerStack.swift @@ -7,7 +7,7 @@ import Combine import CoreData import UIKit -/// The RootViewControllerStack initializes the logged-in root view controller, implements releated delegate methods, and handles state restoration. +/// The RootViewControllerStack initializes the logged-in root view controller and implements related delegate methods. final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate { private var cancellables: Set = [] @@ -19,7 +19,6 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate lazy private(set) var rootViewController: UIViewController = { // This was a fun one! If you change the app icon (using `UIApplication.setAlternateIconName(…)`), the alert it presents causes `UISplitViewController` to dismiss its primary view controller. Even on a phone when there is no secondary view controller. The fix? It seems like the alert is presented on the current `rootViewController`, so if that isn't the split view controller then we're all set! let container = PassthroughViewController() - container.restorationIdentifier = "Root container" container.userInterfaceStyleDidChange = { [weak self] in self?.userInterfaceStyleDidChange() } container.addChild(self.splitViewController) self.splitViewController.view.frame = CGRect(origin: .zero, size: container.view.bounds.size) @@ -41,34 +40,17 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate super.init() let forums = ForumsTableViewController(managedObjectContext: managedObjectContext) - forums.restorationIdentifier = "Forum list" - let bookmarks = BookmarksTableViewController(managedObjectContext: managedObjectContext) - bookmarks.restorationIdentifier = "Bookmarks" - let lepers = RapSheetViewController() - lepers.restorationIdentifier = "Leper's Colony" - let settings = SettingsViewController(managedObjectContext: managedObjectContext) - settings.restorationIdentifier = "Settings" - tabBarController.restorationIdentifier = "Tabbar" - tabBarController.viewControllers = [forums, bookmarks, lepers, settings].map() { - let navigationController = $0.enclosingNavigationController - - // We want the root navigation controllers to preserve their state, but we want to provide the restored instance ourselves. - navigationController.restorationClass = nil - navigationController.restorationIdentifier = navigationIdentifier($0.restorationIdentifier) - - return navigationController - } - + tabBarController.viewControllers = [forums, bookmarks, lepers, settings].map { $0.enclosingNavigationController } + let emptyNavigationController = createEmptyDetailNavigationController() emptyNavigationController.pushViewController(EmptyViewController(), animated: false) - + splitViewController.viewControllers = [tabBarController, emptyNavigationController] splitViewController.delegate = self - splitViewController.restorationIdentifier = "Root splitview" splitViewController.maximumPrimaryColumnWidth = 350 splitViewController.preferredPrimaryColumnWidthFraction = 0.5 @@ -90,38 +72,22 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate } private func createEmptyDetailNavigationController() -> UINavigationController { - let emptyNavigationController = NavigationController() - emptyNavigationController.restorationIdentifier = navigationIdentifier("Detail") - emptyNavigationController.restorationClass = nil - return emptyNavigationController + return NavigationController() } - + private func updateMessagesTabPresence() { let roots = tabBarController.mutableArrayValue(forKey: "viewControllers") - let messagesRestorationIdentifier = "Messages" - var messagesTabIndex: Int? - for (i, root) in roots.enumerated() { - let navigationController = root as! UINavigationController - let viewController = navigationController.viewControllers[0] - if viewController.restorationIdentifier == messagesRestorationIdentifier { - messagesTabIndex = i - break - } - } - + let messagesTabIndex = roots.indexOfObject(passingTest: { root, _, _ in + (root as? UINavigationController)?.viewControllers.first is MessageListViewController + }) + if canSendPrivateMessages { - if messagesTabIndex == nil { + if messagesTabIndex == NSNotFound { let messages = MessageListViewController(managedObjectContext: managedObjectContext) - messages.restorationIdentifier = messagesRestorationIdentifier - let navigationController = messages.enclosingNavigationController - navigationController.restorationIdentifier = navigationIdentifier(messages.restorationIdentifier) - navigationController.restorationClass = nil - roots.insert(navigationController, at: 2) - } - } else { - if let messagesTabIndex = messagesTabIndex { - roots.removeObject(at: messagesTabIndex) + roots.insert(messages.enclosingNavigationController, at: 2) } + } else if messagesTabIndex != NSNotFound { + roots.removeObject(at: messagesTabIndex) } } @@ -140,42 +106,19 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate } } - func viewControllerWithRestorationIdentifierPath(_ identifierComponents: [String]) -> UIViewController? { - // I can't recursively call a nested function? Toss it in a closure then I guess. - var search: ([String], [UIViewController]) -> UIViewController? = { _, _ in nil } - search = { identifierComponents, viewControllers in - if let i = viewControllers.map({ $0.restorationIdentifier ?? "" }).firstIndex(of: identifierComponents[0]) { - let currentViewController = viewControllers[i] - if identifierComponents.count == 1 { - return currentViewController - } - else { - // dropFirst(identifierComponents) did weird stuff here, so I guess let's turn up the awkwardness. - let remainingPath = identifierComponents[1...] - let subsequentViewControllers = currentViewController.immediateDescendants - return search(Array(remainingPath), subsequentViewControllers) - } - } - return nil - } - return search(identifierComponents, [rootViewController]) - } - func didAppear() { // Believe me, it occurs to me that this is highly suspicious and probably indicates misuse of the split view controller. I would happily welcome corrected impressions and/or simplification suggestions. This is ugly. - + // I can't seem to get the iPhone 6+ to open in landscape to a primary overlay display mode. This makes that happen. kindaFixReallyAnnoyingSplitViewHideSidebarInLandscapeBehavior() - - // Sometimes after restoring state the split view decides to get the wrong display mode, possibly through some combination of state restoration goofiness (e.g. preserving in one orientation then restoring in another) and the "Hide sidebar in landscape" setting (set to NO in both cases). + + // Sometimes after restoring scene state the split view decides to get the wrong display mode, possibly through some combination of preserving in one orientation then restoring in another and the "Hide sidebar in landscape" setting (set to NO in both cases). let isPortrait = splitViewController.view.frame.width < splitViewController.view.frame.height if !splitViewController.isCollapsed { - // One possibility is restoring in portrait orientation with the sidebar always visible. if isPortrait && splitViewController.displayMode == .oneBesideSecondary { splitViewController.preferredDisplayMode = .secondaryOnly } - - // Another possibility is restoring in landscape orientation with the sidebar always hidden, and no button to show it. + if !isPortrait && splitViewController.displayMode == .secondaryOnly && splitViewController.preferredDisplayMode == .automatic { splitViewController.preferredDisplayMode = .oneBesideSecondary splitViewController.preferredDisplayMode = .automatic @@ -185,7 +128,6 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate let updateLeftButtonItem = { [weak self] in guard let self = self else { return } if let detail = self.detailNavigationController?.viewControllers.first { - // Our UISplitViewControllerDelegate methods get called *before* we're done restoring state, so the "show sidebar" button item doesn't get put in place properly. Fix that here. if self.splitViewController.displayMode != .oneBesideSecondary { detail.navigationItem.leftBarButtonItem = self.backBarButtonItem } @@ -205,6 +147,47 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate ] } + /// Route describing the deepest visible `RestorableLocation`, used by `SceneDelegate` to + /// build the scene's `stateRestorationActivity`. + var currentRestorationRoute: AwfulRoute? { + firstVisibleViewController { ($0 as? RestorableLocation)?.restorationRoute } + } + + /// Topmost visible `PostsPageViewController`, used by `SceneDelegate` to apply restored + /// scroll fraction and hidden-posts state after the URL router has pushed a fresh instance. + var topPostsPageViewController: PostsPageViewController? { + firstVisibleViewController { $0 as? PostsPageViewController } + } + + /// Topmost visible `MessageViewController`, used by `SceneDelegate` to apply a restored + /// scroll fraction after the URL router has pushed a fresh instance. + var topMessageViewController: MessageViewController? { + firstVisibleViewController { $0 as? MessageViewController } + } + + /// The currently selected tab's `NavigationController`, used by `SceneDelegate` to save and + /// restore its swipe-from-right-edge unpop stack. + var currentPrimaryNavigationController: NavigationController? { + primaryNavigationController as? NavigationController + } + + private func firstVisibleViewController(matching transform: (UIViewController) -> T?) -> T? { + let navs: [UINavigationController] + if splitViewController.isCollapsed { + navs = [primaryNavigationController] + } else { + navs = [detailNavigationController, primaryNavigationController].compactMap { $0 } + } + for nav in navs { + for vc in nav.viewControllers.reversed() { + if let result = transform(vc) { + return result + } + } + } + return nil + } + private var primaryNavigationController: UINavigationController { return tabBarController.selectedViewController as! UINavigationController } @@ -337,14 +320,6 @@ extension RootViewControllerStack { } } -private func navigationIdentifier(_ rootIdentifier: String?) -> String { - if let identifier = rootIdentifier { - return "\(identifier) navigation" - } else { - return "Navigation" - } -} - func partition(_ c: C, test: (C.Iterator.Element) -> Bool) -> (C.SubSequence, C.SubSequence) { if let i = c.firstIndex(where: test) { return (c.prefix(upTo: i), c.suffix(from: i)) @@ -405,17 +380,6 @@ private final class PassthroughViewController: UIViewController { override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return children.first?.supportedInterfaceOrientations ?? super.supportedInterfaceOrientations } - - private enum StateKeys { - static let childViewControllers = "childViewControllers" - } - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - // Just need to save them. No real need to decode; we'll set up the root stack outside of the state restoration system. - coder.encode(children, forKey: StateKeys.childViewControllers) - } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) diff --git a/App/Main/SceneDelegate.swift b/App/Main/SceneDelegate.swift new file mode 100644 index 000000000..a02bcbd01 --- /dev/null +++ b/App/Main/SceneDelegate.swift @@ -0,0 +1,284 @@ +// SceneDelegate.swift +// +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import AwfulCore +import AwfulTheming +import CoreData +import os +import UIKit + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SceneDelegate") + +/// Dedicated `NSUserActivity.activityType` for scene state restoration. Distinct from the +/// `Handoff.ActivityType` values so the restoration payload can carry any `AwfulRoute` +/// (including list tabs like `.bookmarks` / `.forumList`) without having to fit into Handoff's +/// narrower schema. +private let restorationActivityType = "com.awfulapp.Awful.activity.scene-restoration" + +/// `NSUserActivity.userInfo` key carrying the restored primary route's `httpURL` string. +private let restorationPrimaryRouteKey = "AwfulRestorationPrimaryRoute" + +/// `NSUserActivity.userInfo` key carrying the vertical scroll fraction (Double, 0...1) for a +/// restored `PostsPageViewController` or `MessageViewController`. +private let restorationScrollFractionKey = "AwfulRestorationScrollFraction" + +/// `NSUserActivity.userInfo` key carrying the `hiddenPosts` count for a restored +/// `PostsPageViewController`. +private let restorationHiddenPostsKey = "AwfulRestorationHiddenPosts" + +/// `NSUserActivity.userInfo` key carrying the swipe-from-right-edge unpop stack for the visible +/// primary `NavigationController`, encoded as an array of `AwfulRoute.httpURL` strings. +private let restorationUnpopRoutesKey = "AwfulRestorationUnpopRoutes" + +/// `UserDefaults` key for a fallback copy of the scene's most recent restoration activity. +/// +/// iOS only calls `stateRestorationActivity(for:)` when the scene is actually disconnected +/// (app-switcher kill, system memory reclaim). A plain Home-press keeps the scene connected, +/// so a subsequent crash or `Stop` from Xcode leaves `session.stateRestorationActivity` nil and +/// restoration silently fails. We work around this by snapshotting the same payload into +/// `UserDefaults` on every background transition, and falling back to it on cold launch. +private let restorationFallbackDefaultsKey = "AwfulSceneRestorationFallback" + +/// Single window scene delegate. Adopting `UIScene` is what gives us iOS-managed state restoration +/// on iOS 13+: when the system kills our scene to reclaim memory, it will replay the +/// `NSUserActivity` we hand back from `stateRestorationActivity(for:)` on next launch, and we route +/// straight back to the previous thread/PM via the existing `AwfulURLRouter`. +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + private var openCopiedURLController: OpenCopiedURLController? + + /// Held between `scene(_:willConnectTo:options:)` and `sceneDidBecomeActive` so routing + /// happens after the root stack has finished its initial layout. A pending launch route from + /// `connectionOptions` (deep link, shortcut, handoff) takes precedence over the restored + /// activity from a previous scene session. + private var pendingLaunchRoute: AwfulRoute? + private var pendingRestorationActivity: NSUserActivity? + + private var didProcessConnectionLaunch = false + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + window.tintColor = Theme.defaultTheme()["tintColor"] + self.window = window + + AppDelegate.instance.window = window + AppDelegate.instance.installInitialRootViewController(in: window) + + openCopiedURLController = OpenCopiedURLController(window: window) { route in + AppDelegate.instance.open(route: route) + } + + window.makeKeyAndVisible() + + if let urlContext = connectionOptions.urlContexts.first, + let route = try? AwfulRoute(urlContext.url) { + pendingLaunchRoute = route + } else if let userActivity = connectionOptions.userActivities.first, + let route = userActivity.route { + pendingLaunchRoute = route + } else if let shortcutItem = connectionOptions.shortcutItem, + let url = URL(string: shortcutItem.type), + let route = try? AwfulRoute(url) { + pendingLaunchRoute = route + } else if let activity = session.stateRestorationActivity { + pendingRestorationActivity = activity + } else { + pendingRestorationActivity = loadFallbackRestorationActivity() + } + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Snapshot the current restoration activity to UserDefaults as a fallback for the cases + // where iOS doesn't get a chance to call `stateRestorationActivity(for:)` itself (Xcode + // Stop, crash while backgrounded). Scene disconnect will still go through the regular + // path and overwrite whatever UIKit persists on `session.stateRestorationActivity`. + if let activity = stateRestorationActivity(for: scene) { + saveFallbackRestorationActivity(activity) + } else { + clearFallbackRestorationActivity() + } + } + + func sceneDidBecomeActive(_ scene: UIScene) { + guard !didProcessConnectionLaunch else { return } + didProcessConnectionLaunch = true + + // Only run the split-view display-mode fix-up on first activation after the scene + // connects. Running it on every foregrounding can clobber a user-adjusted display mode. + AppDelegate.instance.rootViewControllerStackIfLoaded?.didAppear() + + AppDelegate.instance.showPromptIfLoginCookieExpiresSoon() + + if let route = pendingLaunchRoute { + pendingLaunchRoute = nil + pendingRestorationActivity = nil + DispatchQueue.main.async { + AppDelegate.instance.open(route: route) + } + } else if let activity = pendingRestorationActivity { + pendingRestorationActivity = nil + clearFallbackRestorationActivity() + guard let route = restoredRoute(from: activity) else { + logger.debug("no route in restoration activity \(activity.activityType); skipping") + return + } + logger.debug("restoring scene to \(activity.activityType)") + let savedFraction = (activity.userInfo?[restorationScrollFractionKey] as? Double).map { CGFloat($0) } + let savedHiddenPosts = activity.userInfo?[restorationHiddenPostsKey] as? Int + let savedUnpopRoutes = (activity.userInfo?[restorationUnpopRoutesKey] as? [String])? + .compactMap(URL.init(string:)) + .compactMap { try? AwfulRoute($0) } ?? [] + DispatchQueue.main.async { + AppDelegate.instance.open(route: route) + guard let stack = AppDelegate.instance.rootViewControllerStackIfLoaded else { return } + if let topPosts = stack.topPostsPageViewController { + topPosts.prepareForRestoration(scrollFraction: savedFraction, hiddenPosts: savedHiddenPosts) + } else if let topMessage = stack.topMessageViewController, let fraction = savedFraction { + topMessage.prepareForRestoration(scrollFraction: fraction) + } + if !savedUnpopRoutes.isEmpty, let primaryNav = stack.currentPrimaryNavigationController { + let context = AppDelegate.instance.managedObjectContext + let restoredVCs = savedUnpopRoutes.compactMap { makeUnpopViewController(for: $0, in: context) } + primaryNav.setUnpopStack(restoredVCs) + } + } + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard ForumsClient.shared.isLoggedIn, + let urlContext = URLContexts.first, + let route = try? AwfulRoute(urlContext.url) + else { return } + AppDelegate.instance.open(route: route) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard ForumsClient.shared.isLoggedIn, let route = userActivity.route else { return } + AppDelegate.instance.open(route: route) + } + + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + guard let url = URL(string: shortcutItem.type), + let route = try? AwfulRoute(url) + else { return completionHandler(false) } + AppDelegate.instance.open(route: route) + completionHandler(true) + } + + /// Returns an `NSUserActivity` wrapping the deepest visible `RestorableLocation`'s route, + /// the current scroll fraction and hidden-posts count where applicable, and the + /// swipe-from-right-edge unpop stack of the visible primary navigation. UIKit hands this back + /// to us in `connectionOptions.session.stateRestorationActivity` after killing the scene for + /// memory pressure, and we replay it through the existing `AwfulURLRouter`. + func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { + guard let stack = AppDelegate.instance.rootViewControllerStackIfLoaded, + let route = stack.currentRestorationRoute + else { return nil } + let activity = NSUserActivity(activityType: restorationActivityType) + activity.addUserInfoEntries(from: [restorationPrimaryRouteKey: route.httpURL.absoluteString]) + if let topPosts = stack.topPostsPageViewController { + var extras: [AnyHashable: Any] = [restorationHiddenPostsKey: topPosts.currentHiddenPosts] + if let fraction = topPosts.currentScrollFraction { + extras[restorationScrollFractionKey] = Double(fraction) + } + activity.addUserInfoEntries(from: extras) + } else if let topMessage = stack.topMessageViewController, + let fraction = topMessage.currentScrollFraction { + activity.addUserInfoEntries(from: [restorationScrollFractionKey: Double(fraction)]) + } + let unpopURLs = stack.currentPrimaryNavigationController?.unpopRoutes.map(\.httpURL.absoluteString) ?? [] + if !unpopURLs.isEmpty { + activity.addUserInfoEntries(from: [restorationUnpopRoutesKey: unpopURLs]) + } + return activity + } +} + +/// Persists the given restoration activity's payload to `UserDefaults` as a fallback for +/// scenarios where iOS never calls `stateRestorationActivity(for:)` (Xcode Stop, crash in +/// background). Only plist-safe keys are written. +private func saveFallbackRestorationActivity(_ activity: NSUserActivity) { + guard let userInfo = activity.userInfo else { + UserDefaults.standard.removeObject(forKey: restorationFallbackDefaultsKey) + return + } + var payload: [String: Any] = ["activityType": activity.activityType] + for (key, value) in userInfo { + guard let key = key as? String else { continue } + payload[key] = value + } + UserDefaults.standard.set(payload, forKey: restorationFallbackDefaultsKey) +} + +/// Reconstructs an `NSUserActivity` from the `UserDefaults` fallback, if present. +private func loadFallbackRestorationActivity() -> NSUserActivity? { + guard let payload = UserDefaults.standard.dictionary(forKey: restorationFallbackDefaultsKey), + let activityType = payload["activityType"] as? String + else { return nil } + let activity = NSUserActivity(activityType: activityType) + var userInfo = payload + userInfo.removeValue(forKey: "activityType") + activity.addUserInfoEntries(from: userInfo) + return activity +} + +private func clearFallbackRestorationActivity() { + UserDefaults.standard.removeObject(forKey: restorationFallbackDefaultsKey) +} + +/// Decodes an `AwfulRoute` from a saved scene-restoration activity. Prefers the dedicated +/// `restorationPrimaryRouteKey` (which carries the route's `httpURL` string and covers every +/// `AwfulRoute` case), and falls back to the Handoff `NSUserActivity.route` getter so activities +/// surfaced via the handoff path still work. +private func restoredRoute(from activity: NSUserActivity) -> AwfulRoute? { + if let urlString = activity.userInfo?[restorationPrimaryRouteKey] as? String, + let url = URL(string: urlString), + let route = try? AwfulRoute(url) + { + return route + } + return activity.route +} + +/// Builds a fresh view controller for a route from the swipe-to-unpop restoration stack. Returns +/// nil for routes that can't be reconstructed standalone (e.g. `.post`, which needs a network +/// lookup), in which case that entry is silently dropped from the restored unpop stack. +private func makeUnpopViewController(for route: AwfulRoute, in context: NSManagedObjectContext) -> UIViewController? { + switch route { + case .bookmarks: + return BookmarksTableViewController(managedObjectContext: context) + case let .forum(id: forumID): + let forum = Forum.objectForKey(objectKey: ForumKey(forumID: forumID), in: context) + return ThreadsTableViewController(forum: forum) + case let .message(id: messageID): + let message = PrivateMessage.objectForKey(objectKey: PrivateMessageKey(messageID: messageID), in: context) + return MessageViewController(privateMessage: message) + case let .threadPage(threadID: threadID, page: page, _): + let thread = AwfulThread.objectForKey(objectKey: ThreadKey(threadID: threadID), in: context) + let postsVC = PostsPageViewController(thread: thread) + postsVC.loadPage(page, updatingCache: false, updatingLastReadPost: true) + return postsVC + case let .threadPageSingleUser(threadID: threadID, userID: userID, page: page, _): + let thread = AwfulThread.objectForKey(objectKey: ThreadKey(threadID: threadID), in: context) + let user = User.objectForKey(objectKey: UserKey(userID: userID, username: nil), in: context) + let postsVC = PostsPageViewController(thread: thread, author: user) + postsVC.loadPage(page, updatingCache: false, updatingLastReadPost: true) + return postsVC + default: + return nil + } +} diff --git a/App/Misc/AwfulBrowser.swift b/App/Misc/AwfulBrowser.swift index d88212f41..ec7856a91 100644 --- a/App/Misc/AwfulBrowser.swift +++ b/App/Misc/AwfulBrowser.swift @@ -12,7 +12,6 @@ final class AwfulBrowser: NSObject { { let browser = SFSafariViewController(url: url) browser.delegate = sharedInstance - browser.restorationIdentifier = "Awful Browser" presentingViewController.present(browser, animated: true) return browser } diff --git a/App/Misc/AwfulRoute.swift b/App/Misc/AwfulRoute.swift index 6f698c834..955eeb873 100644 --- a/App/Misc/AwfulRoute.swift +++ b/App/Misc/AwfulRoute.swift @@ -244,6 +244,9 @@ extension AwfulRoute { } switch url.path.caseInsensitive { + case "", "/": + return .forumList + case "/banlist.php": if let userID = url.valueForFirstQueryItem(named: "userid"), !userID.isEmpty { return .rapSheet(userID: userID) @@ -251,6 +254,9 @@ extension AwfulRoute { return .lepersColony } + case "/bookmarkthreads.php": + return .bookmarks + case "/forumdisplay.php": guard let forumID = url.valueForFirstQueryItem(named: "forumid"), !forumID.isEmpty else { throw ParseError.missingForumID @@ -263,6 +269,18 @@ extension AwfulRoute { } return .profile(userID: userID) + case "/private.php": + if url.valueForFirstQueryItem(named: "action") == "show", + let messageID = url.valueForFirstQueryItem(named: "privatemessageid"), + !messageID.isEmpty + { + return .message(id: messageID) + } + return .messagesList + + case "/usercp.php": + return .settings + case "/showthread.php": // A specific post in an unknown thread. Post ID can come via query item or in the fragment. diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 8cb92d620..19120367f 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -9,7 +9,7 @@ import UIKit Navigation controller with special powers: - Theming support. - - Custom navbar class `NavigationBar`, including after state restoration. + - Custom navbar class `NavigationBar`. - Shows and hides the toolbar depending on whether the view controller has toolbar items. - On iPhone, allows swiping from the *right* screen edge to unpop a view controller. */ @@ -30,15 +30,14 @@ final class NavigationController: UINavigationController, Themeable { }() fileprivate var pushAnimationInProgress = false - // We cannot override the designated initializer, -initWithNibName:bundle:, and call -initWithNavigationBarClass:toolbarClass: within. So we override what we can, and handle our own restoration, to ensure our navigation bar and toolbar classes are used. - + // We cannot override the designated initializer, -initWithNibName:bundle:, and call -initWithNavigationBarClass:toolbarClass: within. So we override what we can to ensure our navigation bar and toolbar classes are used. + override init(nibName: String?, bundle: Bundle?) { super.init(nibName: nibName, bundle: bundle) } - + required init() { super.init(navigationBarClass: NavigationBar.self, toolbarClass: Toolbar.self) - restorationClass = type(of: self) delegate = self } @@ -51,6 +50,22 @@ final class NavigationController: UINavigationController, Themeable { fatalError("init(coder:) has not been implemented") } + /// Routes describing the swipe-from-right-edge "unpop" stack, used by `SceneDelegate` to + /// preserve it across cold launches. View controllers that don't conform to + /// `RestorableLocation` (or whose `restorationRoute` is nil) are dropped, since the scene + /// activity can only carry route-shaped data. + var unpopRoutes: [AwfulRoute] { + guard let handler = unpopHandler else { return [] } + return handler.viewControllers.compactMap { ($0 as? RestorableLocation)?.restorationRoute } + } + + /// Replaces the unpop stack contents with the given view controllers without performing any + /// navigation. Caller is responsible for constructing the view controllers (typically from + /// previously saved `unpopRoutes`). + func setUnpopStack(_ viewControllers: [UIViewController]) { + unpopHandler?.viewControllers = viewControllers + } + private var awfulNavigationBar: NavigationBar { return navigationBar as! NavigationBar } @@ -434,20 +449,6 @@ final class NavigationController: UINavigationController, Themeable { } } - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(unpopHandler?.viewControllers, forKey: Key.FutureViewControllers.rawValue) - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - if let viewControllers = coder.decodeObject(forKey: Key.FutureViewControllers.rawValue) as? [UIViewController] { - unpopHandler?.viewControllers = viewControllers - } - } - // MARK: Delegate delegation override weak var delegate: UINavigationControllerDelegate? { @@ -473,10 +474,6 @@ final class NavigationController: UINavigationController, Themeable { } } -private enum Key: String { - case FutureViewControllers = "AwfulFutureViewControllers" -} - extension NavigationController: UIGestureRecognizerDelegate { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { // Disable swipe-to-pop gesture recognizer during pop animations and when we have nothing to pop. If we don't do this, something bad happens in conjunction with the swipe-to-unpop that causes a pushed view controller not to actually appear on the screen. It looks like the app has simply frozen. @@ -593,11 +590,3 @@ extension NavigationController: UINavigationControllerDelegate { return realDelegate?.navigationController?(navigationController, animationControllerFor: operation, from: fromVC, to: toVC) } } - -extension NavigationController: UIViewControllerRestoration { - static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - let nav = self.init() - nav.restorationIdentifier = identifierComponents.last - return nav - } -} diff --git a/App/Posts/NewThreadDraft.swift b/App/Posts/NewThreadDraft.swift new file mode 100644 index 000000000..b00937f42 --- /dev/null +++ b/App/Posts/NewThreadDraft.swift @@ -0,0 +1,60 @@ +// NewThreadDraft.swift +// +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import AwfulCore +import Foundation + +/// On-disk draft for an in-progress new thread, recovered when the user re-opens the compose +/// flow on the same forum. +@objc(NewThreadDraft) +final class NewThreadDraft: NSObject, NSCoding, StorableDraft { + let forum: Forum + var subject: String + var threadTag: ThreadTag? + var secondaryThreadTag: ThreadTag? + var text: NSAttributedString? + + init(forum: Forum) { + self.forum = forum + self.subject = "" + super.init() + } + + var storePath: String { + return "newThreads/\(forum.forumID)" + } + + private enum Keys { + static let forumKey = "forumKey" + static let subject = "subject" + static let threadTagKey = "threadTagKey" + static let secondaryThreadTagKey = "secondaryThreadTagKey" + static let text = "text" + } + + convenience init?(coder: NSCoder) { + guard let forumKey = coder.decodeObject(forKey: Keys.forumKey) as? ForumKey else { + return nil + } + let context = AppDelegate.instance.managedObjectContext + let forum = Forum.objectForKey(objectKey: forumKey, in: context) + self.init(forum: forum) + self.subject = (coder.decodeObject(forKey: Keys.subject) as? String) ?? "" + if let tagKey = coder.decodeObject(forKey: Keys.threadTagKey) as? ThreadTagKey { + self.threadTag = ThreadTag.objectForKey(objectKey: tagKey, in: context) + } + if let secondaryKey = coder.decodeObject(forKey: Keys.secondaryThreadTagKey) as? ThreadTagKey { + self.secondaryThreadTag = ThreadTag.objectForKey(objectKey: secondaryKey, in: context) + } + self.text = coder.decodeObject(forKey: Keys.text) as? NSAttributedString + } + + func encode(with coder: NSCoder) { + coder.encode(forum.objectKey, forKey: Keys.forumKey) + coder.encode(subject, forKey: Keys.subject) + coder.encode(threadTag?.objectKey, forKey: Keys.threadTagKey) + coder.encode(secondaryThreadTag?.objectKey, forKey: Keys.secondaryThreadTagKey) + coder.encode(text, forKey: Keys.text) + } +} diff --git a/App/Posts/ReplyWorkspace.swift b/App/Posts/ReplyWorkspace.swift index b8cb3f328..80055cc1d 100644 --- a/App/Posts/ReplyWorkspace.swift +++ b/App/Posts/ReplyWorkspace.swift @@ -12,23 +12,14 @@ import os private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ReplyWorkspace") -/** -A place for someone to compose a reply to a thread. - -ReplyWorkspace conforms to UIStateRestoring, so it is ok to involve it in UIKit state preservation and restoration. -*/ +/// A place for someone to compose a reply to a thread. final class ReplyWorkspace: NSObject { private var cancellables: Set = [] @FoilDefaultStorage(Settings.confirmBeforeReplying) private var confirmBeforeReplying let draft: NSObject & ReplyDraft @FoilDefaultStorageOptional(Settings.userID) private var loggedInUserID - private let restorationIdentifier: String - - /** - Called when the viewController should be dismissed. - The closure can't be saved as part of UIKit state preservation, so be sure to set something after restoring state. - */ + /// Called when the viewController should be dismissed. var completion: (CompletionResult) -> Void = { _ in } enum CompletionResult { @@ -36,31 +27,32 @@ final class ReplyWorkspace: NSObject { case posted case saveDraft } - - /// Constructs a workspace for a new reply to a thread. + + /// Constructs a workspace for a new reply to a thread, loading any saved draft from disk. convenience init(thread: AwfulThread) { - let draft = NewReplyDraft(thread: thread) - self.init(draft: draft, didRestoreWithRestorationIdentifier: nil) + let draft = (DraftStore.sharedStore().loadDraft("replies/\(thread.threadID)") as? NewReplyDraft) + ?? NewReplyDraft(thread: thread) + self.init(draft: draft) } - - /// Constructs a workspace for editing a reply. + + /// Constructs a workspace for editing a reply, loading any saved edit draft from disk. convenience init(post: Post, bbcode: String) { - let draft = EditReplyDraft(post: post) - self.init(draft: draft, didRestoreWithRestorationIdentifier: nil) - bbcodeForNewlyCreatedCompositionViewController = bbcode + if let saved = DraftStore.sharedStore().loadDraft("edits/\(post.postID)") as? EditReplyDraft { + self.init(draft: saved) + } else { + self.init(draft: EditReplyDraft(post: post)) + bbcodeForNewlyCreatedCompositionViewController = bbcode + } } - - /// A nil restorationIdentifier implies that we were not created by UIKit state restoration. - fileprivate init(draft: NSObject & ReplyDraft, didRestoreWithRestorationIdentifier restorationIdentifier: String?) { + + private init(draft: NSObject & ReplyDraft) { self.draft = draft - self.restorationIdentifier = restorationIdentifier ?? UUID().uuidString super.init() - - UIApplication.registerObject(forStateRestoration: self, restorationIdentifier: self.restorationIdentifier) } - + deinit { draftTitleObserver?.invalidate() + autoSaveWorkItem?.cancel() if let textViewNotificationToken = textViewNotificationToken { NotificationCenter.default.removeObserver(textViewNotificationToken) @@ -89,11 +81,6 @@ final class ReplyWorkspace: NSObject { // compositionViewController isn't available at init time, but sometimes we already know the bbcode. private var bbcodeForNewlyCreatedCompositionViewController: String? - /* - Dealing with compositionViewController is annoyingly complicated. Ideally it'd be a constant ivar, so we could either restore state by passing it in via init() or make a new one if we're not restoring state. - Unfortunately, any compositionViewController that we preserve in encodeRestorableStateWithCoder() is not yet available in objectWithRestorationIdentifierPath(_:coder:); it only becomes available in decodeRestorableStateWithCoder(). - This didSet encompasses the junk we want to set up on the compositionViewController no matter how it's created and really belongs in init(), except we're stuck. - */ var compositionViewController: CompositionViewController! { didSet { assert(oldValue == nil, "please set compositionViewController only once") @@ -118,6 +105,7 @@ final class ReplyWorkspace: NSObject { textViewNotificationToken = NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: compositionViewController.textView, queue: OperationQueue.main) { [unowned self] note in self.rightButtonItem.isEnabled = textView.hasText + self.scheduleDraftAutoSave() } let navigationItem = compositionViewController.navigationItem @@ -173,6 +161,7 @@ final class ReplyWorkspace: NSObject { @IBAction private func didTapCancel(_ sender: UIBarButtonItem) { if compositionViewController.textView.attributedText.length == 0 { + forgetDraft() return completion(.forgetAboutIt) } @@ -194,9 +183,11 @@ final class ReplyWorkspace: NSObject { title: title, actionSheetActions: [ .destructive(title: NSLocalizedString("compose.cancel-menu.delete-draft", comment: "")) { + self.forgetDraft() self.completion(.forgetAboutIt) }, .default(title: NSLocalizedString("compose.cancel-menu.save-draft", comment: "")) { + self.flushDraftAutoSave() self.completion(.saveDraft) }, .cancel(), @@ -255,8 +246,8 @@ final class ReplyWorkspace: NSObject { self.viewController.present(alert, animated: true) } } else { - DraftStore.sharedStore().deleteDraft(self.draft) - + self.forgetDraft() + self.completion(.posted) } } @@ -287,6 +278,43 @@ final class ReplyWorkspace: NSObject { fileprivate func saveTextToDraft() { draft.text = compositionViewController.textView.attributedText } + + /// Deletes the on-disk draft and cancels any pending auto-save. Used when the user explicitly + /// discards the draft via the Cancel action sheet. + private func forgetDraft() { + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + DraftStore.sharedStore().deleteDraft(draft) + } + + private var autoSaveWorkItem: DispatchWorkItem? + + /// Debounced auto-save: copies the text view's contents into the in-memory draft and writes + /// the draft to disk so it can be recovered on next launch (or next Reply tap on the same + /// thread). If the user has cleared everything, deletes the draft instead. + private func scheduleDraftAutoSave() { + autoSaveWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in self?.performDraftAutoSave() } + autoSaveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + + /// Synchronously runs the pending auto-save. Called on dismissal paths where the 0.5 s + /// debounce might otherwise drop the user's most recent edits. + private func flushDraftAutoSave() { + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + performDraftAutoSave() + } + + private func performDraftAutoSave() { + saveTextToDraft() + if compositionViewController.textView.attributedText.length == 0 { + DraftStore.sharedStore().deleteDraft(draft) + } else { + DraftStore.sharedStore().saveDraft(draft) + } + } /// Present this view controller to let someone compose a reply. var viewController: UIViewController { @@ -297,7 +325,6 @@ final class ReplyWorkspace: NSObject { fileprivate func createCompositionViewController() { if compositionViewController == nil { compositionViewController = CompositionViewController() - compositionViewController.restorationIdentifier = "\(self.restorationIdentifier) Reply composition" if let bbcodeForNewlyCreatedCompositionViewController { compositionViewController.textView.text = bbcodeForNewlyCreatedCompositionViewController @@ -336,40 +363,6 @@ final class ReplyWorkspace: NSObject { } } -extension ReplyWorkspace: UIObjectRestoration, UIStateRestoring { - var objectRestorationClass: UIObjectRestoration.Type? { - return ReplyWorkspace.self - } - - func encodeRestorableState(with coder: NSCoder) { - saveTextToDraft() - DraftStore.sharedStore().saveDraft(draft) - coder.encode(draft.storePath, forKey: Keys.draftPath) - coder.encode(compositionViewController, forKey: Keys.compositionViewController) - } - - class func object(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIStateRestoring? { - if let path = coder.decodeObject(forKey: Keys.draftPath) as! String? { - if let draft = DraftStore.sharedStore().loadDraft(path) as! (NSObject & ReplyDraft)? { - return self.init(draft: draft, didRestoreWithRestorationIdentifier: identifierComponents.last ) - } - } - - logger.error("failing intentionally as no saved draft was found") - return nil - } - - func decodeRestorableState(with coder: NSCoder) { - // Our encoded CompositionViewController is not available any earlier (i.e. in objectWithRestorationIdentifierPath(_:coder:)). - compositionViewController = (coder.decodeObject(forKey: Keys.compositionViewController) as! CompositionViewController) - } - - fileprivate struct Keys { - static let draftPath = "draftPath" - static let compositionViewController = "compositionViewController" - } -} - @objc protocol ReplyDraft: StorableDraft, SubmittableDraft, ReplyUI { var thread: AwfulThread { get } var text: NSAttributedString? { get set } diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist index 3aecf995b..0766852fe 100644 --- a/App/Resources/Info.plist +++ b/App/Resources/Info.plist @@ -76,6 +76,23 @@ com.awfulapp.Awful.activity.listing-threads com.awfulapp.Awful.activity.reading-message + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchStoryboardName LaunchScreen UIPrerenderedIcon diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 1ebb8db95..be022746e 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -263,6 +263,17 @@ } } }, + "compose.draft-menu.private-message.title" : { + "comment" : "Title of compose draft menu when writing a private message.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're currently writing a private message" + } + } + } + }, "compose.draft-menu.replying.title" : { "comment" : "Title of compose draft menu when replying to the thread.", "localizations" : { diff --git a/App/Settings/SettingsViewController.swift b/App/Settings/SettingsViewController.swift index b8a7cad09..6e01a9794 100644 --- a/App/Settings/SettingsViewController.swift +++ b/App/Settings/SettingsViewController.swift @@ -151,3 +151,9 @@ struct SettingsContainerView: View { .themed() } } + +extension SettingsViewController: RestorableLocation { + var restorationRoute: AwfulRoute? { + .settings + } +} diff --git a/App/URLs/AwfulURLRouter.swift b/App/URLs/AwfulURLRouter.swift index 5eb7061e2..6ba608956 100644 --- a/App/URLs/AwfulURLRouter.swift +++ b/App/URLs/AwfulURLRouter.swift @@ -237,8 +237,6 @@ struct AwfulURLRouter { } private func showPostsViewController(_ postsVC: PostsPageViewController) -> Bool { - postsVC.restorationIdentifier = "Posts from URL" - // Showing a posts view controller as a result of opening a URL is not the same as simply showing a detail view controller. We want to push it on to an existing navigation stack. Which one depends on how the split view is currently configured. let targetNav: UINavigationController guard let splitVC = rootViewController.children.first as? UISplitViewController else { return false } diff --git a/App/View Controllers/AnnouncementViewController.swift b/App/View Controllers/AnnouncementViewController.swift index 0f34ed599..98e0a0400 100644 --- a/App/View Controllers/AnnouncementViewController.swift +++ b/App/View Controllers/AnnouncementViewController.swift @@ -20,7 +20,6 @@ final class AnnouncementViewController: ViewController { private var announcementObserver: ManagedObjectObserver? @FoilDefaultStorage(Settings.canSendPrivateMessages) private var canSendPrivateMessages private var clientCancellable: Task? - private var desiredFractionalContentOffsetAfterRendering: CGFloat? @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics @FoilDefaultStorageOptional(Settings.userID) private var loggedInUserID @FoilDefaultStorageOptional(Settings.username) private var loggedInUsername @@ -82,8 +81,6 @@ final class AnnouncementViewController: ViewController { hidesBottomBarWhenPushed = true - restorationClass = type(of: self) - title = !announcement.title.isEmpty ? announcement.title : LocalizedString("announcements.title") @@ -93,25 +90,6 @@ final class AnnouncementViewController: ViewController { didSet { navigationItem.titleLabel.text = title } } - private func setFractionalContentOffsetAfterRendering(fractionalContentOffset: CGFloat) { - switch state { - case .initialized, .loading, .renderingFirstTime, .rerendering: - desiredFractionalContentOffsetAfterRendering = fractionalContentOffset - - case .rendered: - scrollToFractionalOffset(fractionalContentOffset) - - case .failed: - logger.warning("ignoring attempt set fractional content offset; announcement failed to load") - } - } - - private func scrollToFractionalOffset(_ fractionalOffsetY: CGFloat) { - renderView.scrollToFractionalOffset(CGPoint(x: 0, y: fractionalOffsetY)) - - desiredFractionalContentOffsetAfterRendering = nil - } - private func updateScrollViewContentInsets() { renderView.scrollView.contentInset.bottom = view.safeAreaInsets.bottom } @@ -233,10 +211,6 @@ final class AnnouncementViewController: ViewController { hideLoadingView() - if let fractionalOffset = desiredFractionalContentOffsetAfterRendering { - scrollToFractionalOffset(fractionalOffset) - } - case (_, .failed(let error)): present(UIAlertController(networkError: error), animated: true) @@ -282,7 +256,6 @@ final class AnnouncementViewController: ViewController { let messageVC = MessageComposeViewController(recipient: user) self.messageViewController = messageVC messageVC.delegate = self - messageVC.restorationIdentifier = "New PM from announcement view" self.present(messageVC.enclosingNavigationController, animated: true) })) } @@ -316,56 +289,6 @@ final class AnnouncementViewController: ViewController { } } -extension AnnouncementViewController: UIViewControllerRestoration { - private enum StateKey { - static let announcementListIndex = "announcement list index" - static let fractionalContentOffsetY = "fractional content offset y-value" - static let messageViewController = "message view controller" - } - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(announcement.listIndex, forKey: StateKey.announcementListIndex) - coder.encode(messageViewController, forKey: StateKey.messageViewController) - coder.encode(Float(renderView.scrollView.fractionalContentOffset.y), forKey: StateKey.fractionalContentOffsetY) - } - - static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - let listIndex = coder.decodeInt32(forKey: StateKey.announcementListIndex) - let fetchRequest = Announcement.makeFetchRequest() - fetchRequest.fetchLimit = 1 - fetchRequest.predicate = NSPredicate(format: "%K = %d", #keyPath(Announcement.listIndex), listIndex) - let maybeAnnouncement: Announcement? - do { - maybeAnnouncement = try AppDelegate.instance.managedObjectContext.fetch(fetchRequest).first - } - catch { - logger.error("error attempting to fetch announcement: \(error)") - return nil - } - - guard let announcement = maybeAnnouncement else { - logger.warning("couldn't find announcement at list index \(listIndex); skipping announcement view state restoration") - return nil - } - - let announcementVC = AnnouncementViewController(announcement: announcement) - announcementVC.restorationIdentifier = identifierComponents.last - return announcementVC - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - messageViewController = coder.decodeObject(of: MessageComposeViewController.self, forKey: StateKey.messageViewController) - messageViewController?.delegate = self - - let fractionalOffset = coder.decodeFloat(forKey: StateKey.fractionalContentOffsetY) - setFractionalContentOffsetAfterRendering(fractionalContentOffset: CGFloat(fractionalOffset)) - } -} - private struct RenderModel: CustomDebugStringConvertible, Equatable, StencilContextConvertible { let authorRegdate: Date? let authorRegdateRaw: String? diff --git a/App/View Controllers/Forums/ForumsTableViewController.swift b/App/View Controllers/Forums/ForumsTableViewController.swift index 35e2edf29..e4df29609 100644 --- a/App/View Controllers/Forums/ForumsTableViewController.swift +++ b/App/View Controllers/Forums/ForumsTableViewController.swift @@ -129,8 +129,6 @@ final class ForumsTableViewController: TableViewController { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } let threadList = ThreadsTableViewController(forum: forum) - threadList.restorationClass = ThreadsTableViewController.self - threadList.restorationIdentifier = "Thread" navigationController?.pushViewController(threadList, animated: animated) } @@ -139,7 +137,6 @@ final class ForumsTableViewController: TableViewController { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } let vc = AnnouncementViewController(announcement: announcement) - vc.restorationIdentifier = "Announcement" showDetailViewController(vc, sender: self) } @@ -157,7 +154,6 @@ final class ForumsTableViewController: TableViewController { super.viewDidLoad() tableView.register(ForumListSectionHeaderView.self, forHeaderFooterViewReuseIdentifier: SectionHeader.reuseIdentifier) - tableView.restorationIdentifier = "Forums table" tableView.sectionFooterHeight = 0 tableView.separatorInset.left = tableSeparatorLeftMargin tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: tableBottomMargin)) @@ -195,7 +191,6 @@ final class ForumsTableViewController: TableViewController { @objc private func searchForums() { let searchView = SearchHostingController() - searchView.restorationIdentifier = "Search view" if traitCollection.userInterfaceIdiom == .pad { searchView.modalPresentationStyle = .pageSheet } else { @@ -338,3 +333,9 @@ private enum SectionHeader { static let height: CGFloat = 44 static let reuseIdentifier = "Header" } + +extension ForumsTableViewController: RestorableLocation { + var restorationRoute: AwfulRoute? { + .forumList + } +} diff --git a/App/View Controllers/Messages/MessageComposeViewController.swift b/App/View Controllers/Messages/MessageComposeViewController.swift index be841c833..9d637d505 100644 --- a/App/View Controllers/Messages/MessageComposeViewController.swift +++ b/App/View Controllers/Messages/MessageComposeViewController.swift @@ -15,6 +15,8 @@ final class MessageComposeViewController: ComposeTextViewController { fileprivate let regardingMessage: PrivateMessage? fileprivate let forwardingMessage: PrivateMessage? fileprivate let initialContents: String? + private let draft: PrivateMessageDraft + private var autoSaveWorkItem: DispatchWorkItem? fileprivate var threadTag: ThreadTag? { didSet { updateThreadTagButtonImage() } } @@ -37,45 +39,63 @@ final class MessageComposeViewController: ComposeTextViewController { regardingMessage = nil forwardingMessage = nil initialContents = nil + self.draft = Self.loadDraft(kind: .new) super.init(nibName: nil, bundle: nil) commonInit() } - + init(recipient: User) { self.recipient = recipient regardingMessage = nil forwardingMessage = nil initialContents = nil + self.draft = Self.loadDraft(kind: .to(recipient)) super.init(nibName: nil, bundle: nil) commonInit() } - + init(regardingMessage: PrivateMessage, initialContents: String?) { self.regardingMessage = regardingMessage recipient = nil forwardingMessage = nil self.initialContents = initialContents + self.draft = Self.loadDraft(kind: .replying(to: regardingMessage)) super.init(nibName: nil, bundle: nil) commonInit() } - + init(forwardingMessage: PrivateMessage, initialContents: String?) { self.forwardingMessage = forwardingMessage recipient = nil regardingMessage = nil self.initialContents = initialContents + self.draft = Self.loadDraft(kind: .forwarding(forwardingMessage)) super.init(nibName: nil, bundle: nil) commonInit() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + deinit { + autoSaveWorkItem?.cancel() + } + + /// Returns a fresh draft if no on-disk draft exists for the given kind, otherwise the loaded + /// draft. Resolving the on-disk path doesn't depend on the actual `PrivateMessage` instance, + /// just its message ID, so this works at init time before any properties are set. + private static func loadDraft(kind: PrivateMessageDraft.Kind) -> PrivateMessageDraft { + let placeholder = PrivateMessageDraft(kind: kind) + if let saved = DraftStore.sharedStore().loadDraft(placeholder.storePath) as? PrivateMessageDraft { + return saved + } + return placeholder + } + fileprivate func commonInit() { title = "Private Message" submitButtonItem.title = "Send" - restorationClass = type(of: self) } private var threadTagTask: ImageTask? @@ -144,6 +164,7 @@ final class MessageComposeViewController: ComposeTextViewController { relevant = .none } try await ForumsClient.shared.sendPrivateMessage(to: to, subject: subject, threadTag: threadTag, bbcode: composition, about: relevant) + deleteDraft() completion(true) } catch { completion(false) @@ -165,10 +186,54 @@ final class MessageComposeViewController: ComposeTextViewController { @objc fileprivate func toFieldDidChange() { updateSubmitButtonItem() + scheduleDraftAutoSave() } - + @objc fileprivate func subjectFieldDidChange() { updateSubmitButtonItem() + scheduleDraftAutoSave() + } + + override func bodyTextDidChange() { + scheduleDraftAutoSave() + } + + private func scheduleDraftAutoSave() { + autoSaveWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in self?.saveDraftNow() } + autoSaveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + + /// Synchronously runs any pending auto-save. Called on dismissal so the last keystrokes + /// aren't lost to the 0.5 s debounce. + private func flushDraftAutoSave() { + guard autoSaveWorkItem != nil else { return } + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + saveDraftNow() + } + + private func saveDraftNow() { + draft.to = fieldView.toField.textField.text ?? "" + draft.subject = fieldView.subjectField.textField.text ?? "" + draft.threadTag = threadTag + draft.text = textView.attributedText + if draft.to.isEmpty + && draft.subject.isEmpty + && draft.threadTag == nil + && (draft.text?.length ?? 0) == 0 + { + DraftStore.sharedStore().deleteDraft(draft) + } else { + DraftStore.sharedStore().saveDraft(draft) + } + } + + private func deleteDraft() { + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + DraftStore.sharedStore().deleteDraft(draft) } // MARK: View lifecycle @@ -193,68 +258,111 @@ final class MessageComposeViewController: ComposeTextViewController { override func viewDidLoad() { super.viewDidLoad() - + + threadTag = draft.threadTag updateThreadTagButtonImage() - - if let recipient = recipient { - if fieldView.toField.textField.text?.isEmpty ?? true { - fieldView.toField.textField.text = recipient.username - } + + // Saved-draft contents take precedence over the defaults derived from the recipient or + // the message being replied to/forwarded — the draft already incorporates any user edits. + if !draft.to.isEmpty { + fieldView.toField.textField.text = draft.to + } else if let recipient = recipient { + fieldView.toField.textField.text = recipient.username } else if let regardingMessage = regardingMessage { - if fieldView.toField.textField.text?.isEmpty ?? true { - fieldView.toField.textField.text = regardingMessage.from?.username - } - - if fieldView.subjectField.textField.text?.isEmpty ?? true { - var subject = regardingMessage.subject ?? "" - if !subject.hasPrefix("Re: ") { - subject = "Re: \(subject)" - } - fieldView.subjectField.textField.text = subject - } - - if let initialContents = initialContents , textView.text.isEmpty { - textView.text = initialContents + fieldView.toField.textField.text = regardingMessage.from?.username + } + + if !draft.subject.isEmpty { + fieldView.subjectField.textField.text = draft.subject + } else if let regardingMessage = regardingMessage { + var subject = regardingMessage.subject ?? "" + if !subject.hasPrefix("Re: ") { + subject = "Re: \(subject)" } + fieldView.subjectField.textField.text = subject } else if let forwardingMessage = forwardingMessage { - if fieldView.subjectField.textField.text?.isEmpty ?? true { - fieldView.subjectField.textField.text = "Fw: \(forwardingMessage.subject ?? "")" - } - - if let initialContents = initialContents , textView.text.isEmpty { - textView.text = initialContents - } + fieldView.subjectField.textField.text = "Fw: \(forwardingMessage.subject ?? "")" } - + + if let savedText = draft.text { + textView.attributedText = savedText + updateSubmitButtonItem() + } else if let initialContents = initialContents, textView.text.isEmpty { + textView.text = initialContents + } + updateAvailableThreadTagsIfNecessary() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + updateAvailableThreadTagsIfNecessary() } - - // MARK: State restoration - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(recipient?.objectKey, forKey: Keys.RecipientUserKey.rawValue) - coder.encode(regardingMessage?.objectKey, forKey: Keys.RegardingMessageKey.rawValue) - coder.encode(forwardingMessage?.objectKey, forKey: Keys.ForwardingMessageKey.rawValue) - coder.encode(initialContents, forKey: Keys.InitialContents.rawValue) - coder.encode(threadTag?.objectKey, forKey: Keys.ThreadTagKey.rawValue) + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Flush the debounced draft save on the way out so nothing the user typed in the last + // 0.5 s is dropped if they dismiss immediately after typing. + if isMovingFromParent || isBeingDismissed { + flushDraftAutoSave() + } } - - override func decodeRestorableState(with coder: NSCoder) { - let context = AppDelegate.instance.managedObjectContext - if let threadTagKey = coder.decodeObject(forKey: Keys.ThreadTagKey.rawValue) as? ThreadTagKey { - threadTag = ThreadTag.objectForKey(objectKey: threadTagKey, in: context) + + /// Overrides the base class to surface a Delete/Save action sheet when the user taps Cancel + /// and there's meaningful content on screen, mirroring the reply-workspace prompt. An empty + /// compose dismisses straight away (and clears any empty draft on disk). + override func cancel() { + let hasContent = !(fieldView.toField.textField.text?.isEmpty ?? true) + || !(fieldView.subjectField.textField.text?.isEmpty ?? true) + || threadTag != nil + || textView.attributedText.length > 0 + guard hasContent else { + deleteDraft() + dismissCompose(shouldKeepDraft: false) + return + } + + let actionSheet = UIAlertController( + title: NSLocalizedString("compose.draft-menu.private-message.title", comment: ""), + actionSheetActions: [ + .destructive(title: NSLocalizedString("compose.cancel-menu.delete-draft", comment: "")) { [weak self] in + guard let self = self else { return } + self.deleteDraft() + self.dismissCompose(shouldKeepDraft: false) + }, + .default(title: NSLocalizedString("compose.cancel-menu.save-draft", comment: "")) { [weak self] in + guard let self = self else { return } + self.flushDraftAutoSave() + self.dismissCompose(shouldKeepDraft: true) + }, + .cancel(), + ] + ) + if let popover = actionSheet.popoverPresentationController { + popover.barButtonItem = cancelButtonItem + } + present(actionSheet, animated: true) + } + + /// Base `ComposeTextViewController.cancel()` always reports `shouldKeepDraft: true` to its + /// delegate. Callers like `MessageListViewController` cache the compose controller and only + /// release it when the delegate callback says the draft should be dropped — so when the user + /// picks "Delete Draft" we need to notify the delegate directly with `shouldKeepDraft: false`, + /// bypassing the base implementation. + private func dismissCompose(shouldKeepDraft: Bool) { + if let delegate = delegate { + delegate.composeTextViewController( + self, + didFinishWithSuccessfulSubmission: false, + shouldKeepDraft: shouldKeepDraft + ) + } else { + dismiss(animated: true) } - - super.decodeRestorableState(with: coder) } + } extension MessageComposeViewController: ThreadTagPickerViewControllerDelegate { @@ -269,9 +377,10 @@ extension MessageComposeViewController: ThreadTagPickerViewControllerDelegate { } else { threadTag = nil } - + scheduleDraftAutoSave() + picker.dismiss() - + focusInitialFirstResponder() } @@ -284,36 +393,3 @@ extension MessageComposeViewController: ThreadTagPickerViewControllerDelegate { } } -extension MessageComposeViewController: UIViewControllerRestoration { - static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - let recipientKey = coder.decodeObject(forKey: Keys.RecipientUserKey.rawValue) as? UserKey - let regardingKey = coder.decodeObject(forKey: Keys.RegardingMessageKey.rawValue) as? PrivateMessageKey - let forwardingKey = coder.decodeObject(forKey: Keys.ForwardingMessageKey.rawValue) as? PrivateMessageKey - let initialContents = coder.decodeObject(forKey: Keys.InitialContents.rawValue) as? String - let context = AppDelegate.instance.managedObjectContext - - let composeViewController: MessageComposeViewController - if let recipientKey = recipientKey { - let recipient = User.objectForKey(objectKey: recipientKey, in: context) - composeViewController = MessageComposeViewController(recipient: recipient) - } else if let regardingKey = regardingKey { - let regardingMessage = PrivateMessage.objectForKey(objectKey: regardingKey, in: context) - composeViewController = MessageComposeViewController(regardingMessage: regardingMessage, initialContents: initialContents) - } else if let forwardingKey = forwardingKey { - let forwardingMessage = PrivateMessage.objectForKey(objectKey: forwardingKey, in: context) - composeViewController = MessageComposeViewController(forwardingMessage: forwardingMessage, initialContents: initialContents) - } else { - return nil - } - composeViewController.restorationIdentifier = identifierComponents.last - return composeViewController - } -} - -private enum Keys: String { - case RecipientUserKey - case RegardingMessageKey - case ForwardingMessageKey - case InitialContents - case ThreadTagKey -} diff --git a/App/View Controllers/Messages/MessageListViewController.swift b/App/View Controllers/Messages/MessageListViewController.swift index 02ae18f2a..4c4301ef8 100644 --- a/App/View Controllers/Messages/MessageListViewController.swift +++ b/App/View Controllers/Messages/MessageListViewController.swift @@ -71,7 +71,6 @@ final class MessageListViewController: TableViewController { } if composeViewController == nil { let compose = MessageComposeViewController() - compose.restorationIdentifier = "New message" compose.delegate = self composeViewController = compose } @@ -115,7 +114,6 @@ final class MessageListViewController: TableViewController { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } let viewController = MessageViewController(privateMessage: message) - viewController.restorationIdentifier = "Message" showDetailViewController(viewController, sender: self) } @@ -232,25 +230,14 @@ extension MessageListViewController: ComposeTextViewControllerDelegate { } } -extension MessageListViewController { - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(composeViewController, forKey: ComposeViewControllerKey) - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - composeViewController = coder.decodeObject(forKey: ComposeViewControllerKey) as! MessageComposeViewController? - composeViewController?.delegate = self - } -} - -private let ComposeViewControllerKey = "AwfulComposeViewController" - extension MessageListViewController: MessageListDataSourceDeletionDelegate { func didDeleteMessage(_ message: PrivateMessage, in dataSource: MessageListDataSource) { deleteMessage(message) } } + +extension MessageListViewController: RestorableLocation { + var restorationRoute: AwfulRoute? { + .messagesList + } +} diff --git a/App/View Controllers/Messages/MessageViewController.swift b/App/View Controllers/Messages/MessageViewController.swift index 740af151c..5ed423c12 100644 --- a/App/View Controllers/Messages/MessageViewController.swift +++ b/App/View Controllers/Messages/MessageViewController.swift @@ -24,9 +24,9 @@ final class MessageViewController: ViewController { @FoilDefaultStorage(Settings.embedTweets) private var embedTweets @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics @FoilDefaultStorage(Settings.fontScale) private var fontScale - private var fractionalContentOffsetOnLoad: CGFloat = 0 @FoilDefaultStorage(Settings.handoffEnabled) private var handoffEnabled private var loadingView: LoadingView? + private var scrollToFractionAfterLoading: CGFloat? private lazy var oEmbedFetcher: OEmbedFetcher = .init() private let privateMessage: PrivateMessage @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @@ -62,8 +62,6 @@ final class MessageViewController: ViewController { navigationItem.rightBarButtonItem = replyButtonItem hidesBottomBarWhenPushed = true - - restorationClass = type(of: self) } override var title: String? { @@ -103,7 +101,6 @@ final class MessageViewController: ViewController { let bbcode = try await ForumsClient.shared.quoteBBcodeContents(of: privateMessage) let composeVC = MessageComposeViewController(regardingMessage: privateMessage, initialContents: bbcode) composeVC.delegate = self - composeVC.restorationIdentifier = "New private message replying to private message" self.composeVC = composeVC present(composeVC.enclosingNavigationController, animated: true, completion: nil) } catch { @@ -117,7 +114,6 @@ final class MessageViewController: ViewController { let bbcode = try await ForumsClient.shared.quoteBBcodeContents(of: privateMessage) let composeVC = MessageComposeViewController(forwardingMessage: privateMessage, initialContents: bbcode) composeVC.delegate = self - composeVC.restorationIdentifier = "New private message forwarding private message" self.composeVC = composeVC present(composeVC.enclosingNavigationController, animated: true) } catch { @@ -188,7 +184,22 @@ final class MessageViewController: ViewController { } // MARK: Handoff - + + var restorationRoute: AwfulRoute? { + .message(id: privateMessage.messageID) + } + + var currentScrollFraction: CGFloat? { + guard isViewLoaded else { return nil } + return renderView.scrollView.fractionalContentOffset.y + } + + /// Stages a vertical scroll fraction to apply once the WKWebView finishes rendering. Used by + /// `SceneDelegate` to restore the user's place after iOS terminates the scene. + func prepareForRestoration(scrollFraction: CGFloat) { + scrollToFractionAfterLoading = scrollFraction + } + private func configureUserActivity() { guard handoffEnabled else { return } userActivity = NSUserActivity(activityType: Handoff.ActivityType.readingMessage) @@ -429,31 +440,6 @@ final class MessageViewController: ViewController { } } - private enum CodingKey { - static let composeViewController = "AwfulComposeViewController" - static let message = "MessageKey" - static let scrollFracton = "AwfulScrollFraction" - } - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(privateMessage.objectKey, forKey: CodingKey.message) - coder.encode(composeVC, forKey: CodingKey.composeViewController) - coder.encode(Float(renderView.scrollView.fractionalContentOffset.y), forKey: CodingKey.scrollFracton) - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - composeVC = coder.decodeObject(of: MessageComposeViewController.self, forKey: CodingKey.composeViewController) - composeVC?.delegate = self - - fractionalContentOffsetOnLoad = CGFloat(coder.decodeFloat(forKey: CodingKey.scrollFracton)) - } - - // MARK: Gunk - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -469,13 +455,16 @@ extension MessageViewController: ComposeTextViewControllerDelegate { extension MessageViewController: RenderViewDelegate { func didFinishRenderingHTML(in view: RenderView) { - if fractionalContentOffsetOnLoad > 0 { - renderView.scrollToFractionalOffset(CGPoint(x: 0, y: fractionalContentOffsetOnLoad)) - } - loadingView?.removeFromSuperview() loadingView = nil + if let fraction = scrollToFractionAfterLoading { + scrollToFractionAfterLoading = nil + var offset = renderView.scrollView.fractionalContentOffset + offset.y = fraction + renderView.scrollToFractionalOffset(offset) + } + if embedBlueskyPosts { renderView.embedBlueskyPosts() } @@ -486,11 +475,6 @@ extension MessageViewController: RenderViewDelegate { func didReceive(message: RenderViewMessage, in view: RenderView) { switch message { - case is RenderView.BuiltInMessage.DidFinishLoadingTweets: - if fractionalContentOffsetOnLoad > 0 { - renderView.scrollToFractionalOffset(CGPoint(x: 0, y: fractionalContentOffsetOnLoad)) - } - case let didTapHeader as RenderView.BuiltInMessage.DidTapAuthorHeader: showUserActions(from: didTapHeader.frame) @@ -554,20 +538,7 @@ extension MessageViewController: UIScrollViewDelegate { } } -extension MessageViewController: UIViewControllerRestoration { - static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - guard let messageKey = coder.decodeObject(of: PrivateMessageKey.self, forKey: CodingKey.message) else { - return nil - } - - let context = AppDelegate.instance.managedObjectContext - let privateMessage = PrivateMessage.objectForKey(objectKey: messageKey, in: context) - let messageVC = self.init(privateMessage: privateMessage) - messageVC.restorationIdentifier = identifierComponents.last - return messageVC - } -} - +extension MessageViewController: RestorableLocation {} private struct RenderModel: StencilContextConvertible { let context: [String: Any] diff --git a/App/View Controllers/Messages/PrivateMessageDraft.swift b/App/View Controllers/Messages/PrivateMessageDraft.swift new file mode 100644 index 000000000..904c4244a --- /dev/null +++ b/App/View Controllers/Messages/PrivateMessageDraft.swift @@ -0,0 +1,120 @@ +// PrivateMessageDraft.swift +// +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import AwfulCore +import Foundation + +/// On-disk draft for an in-progress private message, recovered when the user re-opens the +/// matching compose flow (new PM, reply to a specific PM, or forward of a specific PM). +@objc(PrivateMessageDraft) +final class PrivateMessageDraft: NSObject, NSCoding, StorableDraft { + enum Kind { + /// Blank new message with no pre-filled recipient. + case new + /// New message addressed to the given user. + case to(User) + /// Reply to the given message. + case replying(to: PrivateMessage) + /// Forward of the given message. + case forwarding(PrivateMessage) + } + + let kind: Kind + var to: String + var subject: String + var threadTag: ThreadTag? + var text: NSAttributedString? + + init(kind: Kind) { + self.kind = kind + self.to = "" + self.subject = "" + super.init() + } + + var storePath: String { + switch kind { + case .new: + return "messages/new" + case .to(let user): + return "messages/to/\(user.userID)" + case .replying(let message): + return "messages/replying/\(message.messageID)" + case .forwarding(let message): + return "messages/forwarding/\(message.messageID)" + } + } + + private enum CoderKey { + static let kind = "kind" + static let messageKey = "messageKey" + static let userKey = "userKey" + static let to = "to" + static let subject = "subject" + static let threadTagKey = "threadTagKey" + static let text = "text" + } + + private enum KindRawValue: Int { + case new = 0 + case replying = 1 + case forwarding = 2 + case to = 3 + } + + convenience init?(coder: NSCoder) { + let context = AppDelegate.instance.managedObjectContext + let rawKind = coder.decodeInteger(forKey: CoderKey.kind) + let kind: Kind + switch KindRawValue(rawValue: rawKind) { + case .new, nil: + kind = .new + case .to: + guard let key = coder.decodeObject(forKey: CoderKey.userKey) as? UserKey else { + return nil + } + kind = .to(User.objectForKey(objectKey: key, in: context)) + case .replying: + guard let key = coder.decodeObject(forKey: CoderKey.messageKey) as? PrivateMessageKey else { + return nil + } + kind = .replying(to: PrivateMessage.objectForKey(objectKey: key, in: context)) + case .forwarding: + guard let key = coder.decodeObject(forKey: CoderKey.messageKey) as? PrivateMessageKey else { + return nil + } + kind = .forwarding(PrivateMessage.objectForKey(objectKey: key, in: context)) + } + + self.init(kind: kind) + self.to = (coder.decodeObject(forKey: CoderKey.to) as? String) ?? "" + self.subject = (coder.decodeObject(forKey: CoderKey.subject) as? String) ?? "" + if let tagKey = coder.decodeObject(forKey: CoderKey.threadTagKey) as? ThreadTagKey { + self.threadTag = ThreadTag.objectForKey(objectKey: tagKey, in: context) + } + self.text = coder.decodeObject(forKey: CoderKey.text) as? NSAttributedString + } + + func encode(with coder: NSCoder) { + let rawKind: KindRawValue + switch kind { + case .new: + rawKind = .new + case .to(let user): + rawKind = .to + coder.encode(user.objectKey, forKey: CoderKey.userKey) + case .replying(let message): + rawKind = .replying + coder.encode(message.objectKey, forKey: CoderKey.messageKey) + case .forwarding(let message): + rawKind = .forwarding + coder.encode(message.objectKey, forKey: CoderKey.messageKey) + } + coder.encode(rawKind.rawValue, forKey: CoderKey.kind) + coder.encode(to, forKey: CoderKey.to) + coder.encode(subject, forKey: CoderKey.subject) + coder.encode(threadTag?.objectKey, forKey: CoderKey.threadTagKey) + coder.encode(text, forKey: CoderKey.text) + } +} diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 4ad4d48d6..d7e22d76d 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -47,8 +47,12 @@ final class PostsPageViewController: ViewController { private(set) var page: ThreadPage? @FoilDefaultStorage(Settings.pullForNext) private var pullForNext private var replyWorkspace: ReplyWorkspace? - private var restoringState = false private var scrollToFractionAfterLoading: CGFloat? + /// When true, the next `loadPage` network completion skips its usual "save current scroll + /// offset so we land in the same place after re-render" step. Set by `prepareForRestoration` + /// so a freshly restored scroll fraction isn't clobbered by the in-flight fetch that the URL + /// router kicked off before `SceneDelegate` could stage the restored value. + private var suppressNextScrollFractionPreservation = false @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @FoilDefaultStorage(Settings.loadImages) private var showImages let thread: AwfulThread @@ -115,6 +119,7 @@ final class PostsPageViewController: ViewController { private var hiddenPosts = 0 { didSet { updateUserInterface() } } + private var hiddenPostsAfterLoading: Int? private lazy var postsView: PostsPageView = { let postsView = PostsPageView() @@ -194,8 +199,6 @@ final class PostsPageViewController: ViewController { self.author = author super.init(nibName: nil, bundle: nil) - restorationClass = type(of: self) - navigationItem.rightBarButtonItem = composeItem hidesBottomBarWhenPushed = true @@ -283,9 +286,7 @@ final class PostsPageViewController: ViewController { updateUserInterface() - if !restoringState { - hiddenPosts = 0 - } + hiddenPosts = 0 refetchPosts() @@ -353,11 +354,16 @@ final class PostsPageViewController: ViewController { self.configureUserActivityIfPossible() - if self.hiddenPosts == 0, let firstUnreadPost = firstUnreadPost, firstUnreadPost > 0 { + if let pendingHidden = self.hiddenPostsAfterLoading { + self.hiddenPosts = pendingHidden + self.hiddenPostsAfterLoading = nil + } else if self.hiddenPosts == 0, let firstUnreadPost = firstUnreadPost, firstUnreadPost > 0 { self.hiddenPosts = firstUnreadPost - 1 } - if reloadingSamePage || renderedCachedPosts { + if self.suppressNextScrollFractionPreservation { + self.suppressNextScrollFractionPreservation = false + } else if reloadingSamePage || renderedCachedPosts { self.scrollToFractionAfterLoading = self.postsView.renderView.scrollView.fractionalContentOffset.y } @@ -1181,7 +1187,6 @@ final class PostsPageViewController: ViewController { let user = User.objectForKey(objectKey: userKey, in: self.thread.managedObjectContext!) let postsVC = PostsPageViewController(thread: self.thread, author: user) - postsVC.restorationIdentifier = "Just your posts" postsVC.loadPage(.first, updatingCache: true, updatingLastReadPost: true) self.navigationController?.pushViewController(postsVC, animated: true) @@ -1333,7 +1338,6 @@ final class PostsPageViewController: ViewController { self.dismiss(animated: false) { let postsVC = PostsPageViewController(thread: self.thread, author: self.selectedUser!) - postsVC.restorationIdentifier = "Just their posts" postsVC.loadPage(.first, updatingCache: true, updatingLastReadPost: true) self.navigationController?.pushViewController(postsVC, animated: true) } @@ -1348,7 +1352,6 @@ final class PostsPageViewController: ViewController { let messageVC = MessageComposeViewController(recipient: self.selectedUser!) self.messageViewController = messageVC messageVC.delegate = self - messageVC.restorationIdentifier = "New PM from posts view" self.present(messageVC.enclosingNavigationController, animated: true, completion: nil) } } @@ -1669,6 +1672,38 @@ final class PostsPageViewController: ViewController { } } + var restorationRoute: AwfulRoute? { + guard let page = page, case .specific = page else { return nil } + if let author = author { + return .threadPageSingleUser(threadID: thread.threadID, userID: author.userID, page: page, .seen) + } else { + return .threadPage(threadID: thread.threadID, page: page, .seen) + } + } + + var currentScrollFraction: CGFloat? { + guard isViewLoaded else { return nil } + return postsView.renderView.scrollView.fractionalContentOffset.y + } + + var currentHiddenPosts: Int { hiddenPosts } + + /// Stages a vertical scroll fraction and hidden-posts count to apply once the WKWebView + /// finishes rendering. Used by `SceneDelegate` to restore the user's place after iOS + /// terminates the scene. + func prepareForRestoration(scrollFraction: CGFloat?, hiddenPosts: Int?) { + if let scrollFraction = scrollFraction { + scrollToFractionAfterLoading = scrollFraction + // The URL router already kicked off `loadPage(updatingCache: true)`, which renders + // cached posts immediately and then re-renders when the network fetch completes — + // and that completion would otherwise overwrite the scroll fraction we just staged. + suppressNextScrollFractionPreservation = true + } + if let hiddenPosts = hiddenPosts { + hiddenPostsAfterLoading = hiddenPosts + } + } + private func configureUserActivityIfPossible() { guard case .specific? = page, handoffEnabled else { userActivity = nil @@ -1958,89 +1993,11 @@ final class PostsPageViewController: ViewController { userActivity = nil } - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(thread.objectKey, forKey: Keys.threadKey) - if let page = page { - coder.encode(page.nsCoderIntValue, forKey: Keys.page) - } - coder.encode(author?.objectKey, forKey: Keys.authorUserKey) - coder.encode(hiddenPosts, forKey: Keys.hiddenPosts) - coder.encode(messageViewController, forKey: Keys.messageViewController) - coder.encode(advertisementHTML, forKey: Keys.advertisementHTML) - coder.encode(Float(postsView.renderView.scrollView.fractionalContentOffset.y), forKey: Keys.scrolledFractionOfContent) - coder.encode(replyWorkspace, forKey: Keys.replyWorkspace) - } - - override func decodeRestorableState(with coder: NSCoder) { - restoringState = true - - super.decodeRestorableState(with: coder) - - messageViewController = coder.decodeObject(forKey: Keys.messageViewController) as? MessageComposeViewController - messageViewController?.delegate = self - - hiddenPosts = coder.decodeInteger(forKey: Keys.hiddenPosts) - let page: ThreadPage = { - guard - coder.containsValue(forKey: Keys.page), - let page = ThreadPage(nsCoderIntValue: coder.decodeInteger(forKey: Keys.page)) - else { return .specific(1) } - return page - }() - self.page = page - loadPage(page, updatingCache: false, updatingLastReadPost: true) - if posts.isEmpty { - loadPage(page, updatingCache: true, updatingLastReadPost: true) - } - - advertisementHTML = coder.decodeObject(forKey: Keys.advertisementHTML) as? String - scrollToFractionAfterLoading = CGFloat(coder.decodeFloat(forKey: Keys.scrolledFractionOfContent)) - - replyWorkspace = coder.decodeObject(forKey: Keys.replyWorkspace) as? ReplyWorkspace - replyWorkspace?.completion = replyCompletionBlock - } - - override func applicationFinishedRestoringState() { - super.applicationFinishedRestoringState() - - restoringState = false - } - - // MARK: Gunk - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } -private extension ThreadPage { - init?(nsCoderIntValue: Int) { - switch nsCoderIntValue { - case -2: - self = .last - case -1: - self = .nextUnread - case 1...Int.max: - self = .specific(nsCoderIntValue) - default: - return nil - } - } - - var nsCoderIntValue: Int { - switch self { - case .last: - return -2 - case .nextUnread: - return -1 - case .specific(let pageNumber): - return pageNumber - } - } -} - extension PostsPageViewController: ComposeTextViewControllerDelegate { func composeTextViewController(_ composeController: ComposeTextViewController, didFinishWithSuccessfulSubmission success: Bool, shouldKeepDraft: Bool) { dismiss(animated: true) @@ -2153,36 +2110,7 @@ extension PostsPageViewController: UIGestureRecognizerDelegate { } } -extension PostsPageViewController: UIViewControllerRestoration { - static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - let context = AppDelegate.instance.managedObjectContext - guard let threadKey = coder.decodeObject(forKey: Keys.threadKey) as? ThreadKey else { return nil } - let thread = AwfulThread.objectForKey(objectKey: threadKey, in: context) - let userKey = coder.decodeObject(forKey: Keys.authorUserKey) as? UserKey - let author: User? - if let userKey = userKey { - author = User.objectForKey(objectKey: userKey, in: context) - } else { - author = nil - } - - let postsVC = PostsPageViewController(thread: thread, author: author) - postsVC.restorationIdentifier = identifierComponents.last - return postsVC - } -} - -private struct Keys { - static let threadKey = "ThreadKey" - static let page = "AwfulCurrentPage" - static let authorUserKey = "AuthorUserKey" - static let hiddenPosts = "AwfulHiddenPosts" - static let replyViewController = "AwfulReplyViewController" - static let messageViewController = "AwfulMessageViewController" - static let advertisementHTML = "AwfulAdvertisementHTML" - static let scrolledFractionOfContent = "AwfulScrolledFractionOfContentSize" - static let replyWorkspace = "Reply workspace" -} +extension PostsPageViewController: RestorableLocation {} extension PostsPageViewController { override var keyCommands: [UIKeyCommand]? { diff --git a/App/View Controllers/Threads/BookmarksTableViewController.swift b/App/View Controllers/Threads/BookmarksTableViewController.swift index 0e4aec01e..a374a317d 100644 --- a/App/View Controllers/Threads/BookmarksTableViewController.swift +++ b/App/View Controllers/Threads/BookmarksTableViewController.swift @@ -108,7 +108,6 @@ final class BookmarksTableViewController: TableViewController { multiplexer.addDelegate(self) tableView.hideExtraneousSeparators() - tableView.restorationIdentifier = "Bookmarks table" dataSource = makeDataSource() tableView.reloadData() @@ -256,7 +255,6 @@ extension BookmarksTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let thread = dataSource!.thread(at: indexPath) let postsViewController = PostsPageViewController(thread: thread) - postsViewController.restorationIdentifier = "Posts" // SA: For an unread thread, the Forums will interpret "next unread page" to mean "last page", which is not very helpful. let targetPage = thread.beenSeen ? ThreadPage.nextUnread : .first postsViewController.loadPage(targetPage, updatingCache: true, updatingLastReadPost: true) @@ -309,3 +307,9 @@ extension BookmarksTableViewController: ThreadListDataSourceDeletionDelegate { setThread(thread, isBookmarked: false) } } + +extension BookmarksTableViewController: RestorableLocation { + var restorationRoute: AwfulRoute? { + .bookmarks + } +} diff --git a/App/View Controllers/Threads/ThreadComposeViewController.swift b/App/View Controllers/Threads/ThreadComposeViewController.swift index ef70b992c..b62e4f36f 100644 --- a/App/View Controllers/Threads/ThreadComposeViewController.swift +++ b/App/View Controllers/Threads/ThreadComposeViewController.swift @@ -29,14 +29,23 @@ final class ThreadComposeViewController: ComposeTextViewController { private var threadTagPicker: ThreadTagPickerViewController? private var formData: ForumsClient.PostNewThreadFormData? - /// - parameter forum: The forum in which the new thread is posted. + private let draft: NewThreadDraft + private var autoSaveWorkItem: DispatchWorkItem? + + /// - parameter forum: The forum in which the new thread is posted. Loads any saved draft for + /// this forum from `DraftStore` so the in-progress thread is recovered across launches. init(forum: Forum) { self.forum = forum + let saved = DraftStore.sharedStore().loadDraft("newThreads/\(forum.forumID)") as? NewThreadDraft + self.draft = saved ?? NewThreadDraft(forum: forum) super.init(nibName: nil, bundle: nil) - + title = defaultTitle submitButtonItem.title = "Preview" - restorationClass = type(of: self) + } + + deinit { + autoSaveWorkItem?.cancel() } required init?(coder: NSCoder) { @@ -59,10 +68,21 @@ final class ThreadComposeViewController: ComposeTextViewController { override func viewDidLoad() { super.viewDidLoad() - + updateTweaks() + threadTag = draft.threadTag + secondaryThreadTag = draft.secondaryThreadTag updateThreadTagButtonImage() updateAvailableThreadTagsIfNecessary() + + if !draft.subject.isEmpty { + fieldView.subjectField.textField.text = draft.subject + title = draft.subject + } + if let savedText = draft.text { + textView.attributedText = savedText + updateSubmitButtonItem() + } } override var theme: Theme { @@ -162,8 +182,58 @@ final class ThreadComposeViewController: ComposeTextViewController { } else { title = defaultTitle } - + updateSubmitButtonItem() + scheduleDraftAutoSave() + } + + override func bodyTextDidChange() { + scheduleDraftAutoSave() + } + + private func scheduleDraftAutoSave() { + autoSaveWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in self?.saveDraftNow() } + autoSaveWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + + /// Synchronously runs any pending auto-save. Called on dismissal so the last keystrokes + /// aren't lost to the 0.5 s debounce. + private func flushDraftAutoSave() { + guard autoSaveWorkItem != nil else { return } + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + saveDraftNow() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isMovingFromParent || isBeingDismissed { + flushDraftAutoSave() + } + } + + private func saveDraftNow() { + draft.subject = fieldView.subjectField.textField.text ?? "" + draft.threadTag = threadTag + draft.secondaryThreadTag = secondaryThreadTag + draft.text = textView.attributedText + if draft.subject.isEmpty + && draft.threadTag == nil + && draft.secondaryThreadTag == nil + && (draft.text?.length ?? 0) == 0 + { + DraftStore.sharedStore().deleteDraft(draft) + } else { + DraftStore.sharedStore().saveDraft(draft) + } + } + + private func deleteDraft() { + autoSaveWorkItem?.cancel() + autoSaveWorkItem = nil + DraftStore.sharedStore().deleteDraft(draft) } private func updateAvailableThreadTagsIfNecessary() { @@ -242,6 +312,7 @@ final class ThreadComposeViewController: ComposeTextViewController { bbcode: composition ) self.thread = thread + deleteDraft() completion(true) } catch { let alert = UIAlertController(title: "Network Error", error: error, handler: { @@ -252,28 +323,6 @@ final class ThreadComposeViewController: ComposeTextViewController { } } - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(forum.objectKey, forKey: Keys.ForumKey.rawValue) - coder.encode(fieldView.subjectField.textField.text, forKey: Keys.SubjectKey.rawValue) - coder.encode(threadTag?.objectKey, forKey: Keys.ThreadTagKey.rawValue) - coder.encode(secondaryThreadTag?.objectKey, forKey: Keys.SecondaryThreadTagKey.rawValue) - } - - override func decodeRestorableState(with coder: NSCoder) { - fieldView.subjectField.textField.text = coder.decodeObject(forKey: Keys.SubjectKey.rawValue) as? String - - if let tagKey = coder.decodeObject(forKey: Keys.ThreadTagKey.rawValue) as? ThreadTagKey { - threadTag = ThreadTag.objectForKey(objectKey: tagKey, in: forum.managedObjectContext!) - } - - if let secondaryTagKey = coder.decodeObject(forKey: Keys.SecondaryThreadTagKey.rawValue) as? ThreadTagKey { - secondaryThreadTag = ThreadTag.objectForKey(objectKey: secondaryTagKey, in: forum.managedObjectContext!) - } - - super.decodeRestorableState(with: coder) - } } extension ThreadComposeViewController: ThreadTagPickerViewControllerDelegate { @@ -288,7 +337,8 @@ extension ThreadComposeViewController: ThreadTagPickerViewControllerDelegate { } else { threadTag = nil } - + scheduleDraftAutoSave() + if availableSecondaryThreadTags?.isEmpty ?? true { picker.dismiss() focusInitialFirstResponder() @@ -302,6 +352,7 @@ extension ThreadComposeViewController: ThreadTagPickerViewControllerDelegate { { secondaryThreadTag = tags[i] } + scheduleDraftAutoSave() } func didDismissPicker(_ picker: ThreadTagPickerViewController) { @@ -309,24 +360,4 @@ extension ThreadComposeViewController: ThreadTagPickerViewControllerDelegate { } } -extension ThreadComposeViewController: UIViewControllerRestoration { - static func viewController( - withRestorationIdentifierPath identifierComponents: [String], - coder: NSCoder - ) -> UIViewController? { - guard let forumKey = coder.decodeObject(forKey: Keys.ForumKey.rawValue) as? ForumKey else { return nil } - let forum = Forum.objectForKey(objectKey: forumKey, in: AppDelegate.instance.managedObjectContext) - let composeViewController = ThreadComposeViewController(forum: forum) - composeViewController.restorationIdentifier = identifierComponents.last - return composeViewController - } -} - private let defaultTitle = "New Thread" - -private enum Keys: String { - case ForumKey - case SubjectKey = "AwfulSubject" - case ThreadTagKey - case SecondaryThreadTagKey -} diff --git a/App/View Controllers/Threads/ThreadsTableViewController.swift b/App/View Controllers/Threads/ThreadsTableViewController.swift index 87138cee0..70dd7a48f 100644 --- a/App/View Controllers/Threads/ThreadsTableViewController.swift +++ b/App/View Controllers/Threads/ThreadsTableViewController.swift @@ -14,7 +14,7 @@ import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ThreadsTableViewController") -final class ThreadsTableViewController: TableViewController, ComposeTextViewControllerDelegate, ThreadTagPickerViewControllerDelegate, UIViewControllerRestoration { +final class ThreadsTableViewController: TableViewController, ComposeTextViewControllerDelegate, ThreadTagPickerViewControllerDelegate { private var cancellables: Set = [] private var dataSource: ThreadListDataSource? @@ -123,8 +123,7 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont tableView.estimatedRowHeight = ThreadListCell.estimatedHeight tableView.hideExtraneousSeparators() - tableView.restorationIdentifier = "Threads table view" - + dataSource = makeDataSource() tableView.reloadData() @@ -209,7 +208,6 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont private lazy var threadComposeViewController: ThreadComposeViewController! = { [unowned self] in let composeViewController = ThreadComposeViewController(forum: self.forum) - composeViewController.restorationIdentifier = "New thread composition" composeViewController.delegate = self return composeViewController }() @@ -231,7 +229,6 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont dismiss(animated: true) { if let thread = self.threadComposeViewController.thread , success { let postsPage = PostsPageViewController(thread: thread) - postsPage.restorationIdentifier = "Posts" postsPage.loadPage(.first, updatingCache: true, updatingLastReadPost: true) self.showDetailViewController(postsPage, sender: self) } @@ -330,64 +327,6 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont logger.debug("handoff activity set: \(activity.activityType) with \(activity.userInfo ?? [:])") } - // MARK: UIViewControllerRestoration - - class func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - var forumKey = coder.decodeObject(forKey: RestorationKeys.forumKey) as! ForumKey? - if forumKey == nil { - guard let forumID = coder.decodeObject(forKey: ObsoleteRestorationKeys.forumID) as? String else { return nil } - forumKey = ForumKey(forumID: forumID) - } - let managedObjectContext = AppDelegate.instance.managedObjectContext - let forum = Forum.objectForKey(objectKey: forumKey!, in: managedObjectContext) - let viewController = self.init(forum: forum) - viewController.restorationIdentifier = identifierComponents.last - viewController.restorationClass = self - return viewController - } - - override func encodeRestorableState(with coder: NSCoder) { - super.encodeRestorableState(with: coder) - - coder.encode(forum.objectKey, forKey: RestorationKeys.forumKey) - coder.encode(threadComposeViewController, forKey: RestorationKeys.newThreadViewController) - coder.encode(filterThreadTag?.objectKey, forKey: RestorationKeys.filterThreadTagKey) - } - - override func decodeRestorableState(with coder: NSCoder) { - super.decodeRestorableState(with: coder) - - if let compose = coder.decodeObject(forKey: RestorationKeys.newThreadViewController) as? ThreadComposeViewController { - compose.delegate = self - threadComposeViewController = compose - } - - var tagKey = coder.decodeObject(forKey: RestorationKeys.filterThreadTagKey) as! ThreadTagKey? - if tagKey == nil { - if let tagID = coder.decodeObject(forKey: ObsoleteRestorationKeys.filterThreadTagID) as? String { - tagKey = ThreadTagKey(imageName: nil, threadTagID: tagID) - } - } - if let tagKey = tagKey { - filterThreadTag = ThreadTag.objectForKey(objectKey: tagKey, in: forum.managedObjectContext!) - } - - updateFilterButton() - } - - private struct RestorationKeys { - static let forumKey = "ForumKey" - static let newThreadViewController = "AwfulNewThreadViewController" - static let filterThreadTagKey = "FilterThreadTagKey" - } - - private struct ObsoleteRestorationKeys { - static let forumID = "AwfulForumID" - static let filterThreadTagID = "AwfulFilterThreadTagID" - } - - // MARK: Gunk - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -399,6 +338,12 @@ extension ThreadsTableViewController: ThreadListDataSourceDelegate { } } +extension ThreadsTableViewController: RestorableLocation { + var restorationRoute: AwfulRoute? { + .forum(id: forum.forumID) + } +} + // MARK: UITableViewDelegate extension ThreadsTableViewController { override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -411,7 +356,6 @@ extension ThreadsTableViewController { } let thread = dataSource!.thread(at: indexPath) let postsViewController = PostsPageViewController(thread: thread) - postsViewController.restorationIdentifier = "Posts" // SA: For an unread thread, the Forums will interpret "next unread page" to mean "last page", which is not very helpful. let targetPage = thread.beenSeen ? ThreadPage.nextUnread : .first postsViewController.loadPage(targetPage, updatingCache: true, updatingLastReadPost: true) diff --git a/App/View Controllers/Threads/UIContextMenuConfiguration+ThreadListItem.swift b/App/View Controllers/Threads/UIContextMenuConfiguration+ThreadListItem.swift index fcc3907ca..5d0844734 100644 --- a/App/View Controllers/Threads/UIContextMenuConfiguration+ThreadListItem.swift +++ b/App/View Controllers/Threads/UIContextMenuConfiguration+ThreadListItem.swift @@ -41,7 +41,6 @@ extension UIContextMenuConfiguration { } func jump(to page: ThreadPage) { let postsPage = PostsPageViewController(thread: thread) - postsPage.restorationIdentifier = "Posts" postsPage.loadPage(page, updatingCache: true, updatingLastReadPost: true) presenter.showDetailViewController(postsPage, sender: self) } diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index c25223b5b..a3b967c9a 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -135,6 +135,8 @@ 1C82AC4D199F5C1500CB15FE /* Selectotron.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1C82AC4C199F5C1500CB15FE /* Selectotron.xib */; }; 1C88EC9B2AE1062300FA766F /* PullToRefresh in Frameworks */ = {isa = PBXBuildFile; productRef = 1C88EC9A2AE1062300FA766F /* PullToRefresh */; }; 1C8A8CFA1A3C14DF00E4F6A4 /* ReplyWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C8A8CF91A3C14DF00E4F6A4 /* ReplyWorkspace.swift */; }; + 2D3D26012F85E80100862513 /* NewThreadDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26002F85E80100862513 /* NewThreadDraft.swift */; }; + 2D3D26032F85E80100862514 /* PrivateMessageDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */; }; 1C8D114719ACDAD9005D46CB /* RootViewControllerStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C8D114619ACDAD9005D46CB /* RootViewControllerStack.swift */; }; 1C8F67F4222105E4007E61ED /* StencilEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C8F67F3222105E4007E61ED /* StencilEnvironment.swift */; }; 1C8F67F822210DED007E61ED /* Profile.html.stencil in Resources */ = {isa = PBXBuildFile; fileRef = 1C8F67F722210DED007E61ED /* Profile.html.stencil */; }; @@ -202,7 +204,6 @@ 2D265F8C292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */; }; 2D265F8F292CB447001336ED /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 2D265F8E292CB447001336ED /* Lottie */; }; 2D327DD627F468CE00D21AB0 /* BookmarkColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */; }; - 2D571B472EC83DD00026826C /* AttachmentCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B462EC83DD00026826C /* AttachmentCardView.swift */; }; 2D3CB31E2EBF09C300BD4A12 /* five_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3102EBF09C300BD4A12 /* five_appicon.icon */; }; 2D3CB31F2EBF09C300BD4A12 /* smith_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3192EBF09C300BD4A12 /* smith_appicon.icon */; }; 2D3CB3202EBF09C300BD4A12 /* froggo_purple_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3122EBF09C300BD4A12 /* froggo_purple_appicon.icon */; }; @@ -218,6 +219,9 @@ 2D3CB32A2EBF09C300BD4A12 /* rated_five_pride_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3162EBF09C300BD4A12 /* rated_five_pride_appicon.icon */; }; 2D3CB32B2EBF09C300BD4A12 /* ghost_blue_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3132EBF09C300BD4A12 /* ghost_blue_appicon.icon */; }; 2D3CB32C2EBF09C300BD4A12 /* rated_five_trans_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3172EBF09C300BD4A12 /* rated_five_trans_appicon.icon */; }; + 2D3D25F92F85E50500862513 /* RestorableLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D25F82F85E50500862513 /* RestorableLocation.swift */; }; + 2D3D25FB2F85E50500862514 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D25FA2F85E50500862514 /* SceneDelegate.swift */; }; + 2D571B472EC83DD00026826C /* AttachmentCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B462EC83DD00026826C /* AttachmentCardView.swift */; }; 2D62DEA42EBFE93800F7121B /* LiquidGlassTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */; }; 2D62DEA62EBFE95B00F7121B /* PostsPageTopBarLiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA52EBFE95500F7121B /* PostsPageTopBarLiquidGlass.swift */; }; 2D62DEA82EBFEB2000F7121B /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA72EBFEB1D00F7121B /* GradientView.swift */; }; @@ -461,6 +465,8 @@ 1C82AC4A199F585000CB15FE /* Selectotron.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Selectotron.swift; sourceTree = ""; }; 1C82AC4C199F5C1500CB15FE /* Selectotron.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = Selectotron.xib; sourceTree = ""; }; 1C8A8CF91A3C14DF00E4F6A4 /* ReplyWorkspace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplyWorkspace.swift; sourceTree = ""; }; + 2D3D26002F85E80100862513 /* NewThreadDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewThreadDraft.swift; sourceTree = ""; }; + 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageDraft.swift; sourceTree = ""; }; 1C8D114619ACDAD9005D46CB /* RootViewControllerStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootViewControllerStack.swift; sourceTree = ""; }; 1C8F67F3222105E4007E61ED /* StencilEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StencilEnvironment.swift; sourceTree = ""; }; 1C8F67F722210DED007E61ED /* Profile.html.stencil */ = {isa = PBXFileReference; explicitFileType = text.html; fileEncoding = 4; path = Profile.html.stencil; sourceTree = ""; }; @@ -542,7 +548,6 @@ 2D19BA3829C33302009DD94F /* toot60.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = toot60.json; sourceTree = ""; }; 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetOutFrogRefreshSpinnerView.swift; sourceTree = ""; }; 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkColorPicker.swift; sourceTree = ""; }; - 2D571B462EC83DD00026826C /* AttachmentCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentCardView.swift; sourceTree = ""; }; 2D3CB30F2EBF09C300BD4A12 /* cute_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = cute_appicon.icon; sourceTree = ""; }; 2D3CB3102EBF09C300BD4A12 /* five_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = five_appicon.icon; sourceTree = ""; }; 2D3CB3112EBF09C300BD4A12 /* froggo_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = froggo_appicon.icon; sourceTree = ""; }; @@ -558,6 +563,9 @@ 2D3CB31B2EBF09C300BD4A12 /* staredog_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = staredog_appicon.icon; sourceTree = ""; }; 2D3CB31C2EBF09C300BD4A12 /* staredog_tongue_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = staredog_tongue_appicon.icon; sourceTree = ""; }; 2D3CB31D2EBF09C300BD4A12 /* v_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = v_appicon.icon; sourceTree = ""; }; + 2D3D25F82F85E50500862513 /* RestorableLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableLocation.swift; sourceTree = ""; }; + 2D3D25FA2F85E50500862514 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 2D571B462EC83DD00026826C /* AttachmentCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentCardView.swift; sourceTree = ""; }; 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTitleView.swift; sourceTree = ""; }; 2D62DEA52EBFE95500F7121B /* PostsPageTopBarLiquidGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarLiquidGlass.swift; sourceTree = ""; }; 2D62DEA72EBFEB1D00F7121B /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; @@ -660,6 +668,8 @@ 1190F71C13BE4EA900B9D271 /* Main */ = { isa = PBXGroup; children = ( + 2D3D25F82F85E50500862513 /* RestorableLocation.swift */, + 2D3D25FA2F85E50500862514 /* SceneDelegate.swift */, 1C2C1F131CEE90D900CD27DD /* AppDelegate.swift */, 1CBE1B1119CAAFA200510187 /* AwfulSplitViewController.swift */, 1C220E3C2B815AFC00DA92B0 /* Bundle+.swift */, @@ -844,6 +854,7 @@ isa = PBXGroup; children = ( 1C16FC131CCEE60C00C88BD1 /* MessageComposeViewController.swift */, + 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */, 1CF6786D201E8F45009A9640 /* MessageListCell.swift */, 1CC256B01A38526B003FA7A8 /* MessageListViewController.swift */, 1C2C1F111CE547A600CD27DD /* MessageViewController.swift */, @@ -1091,6 +1102,7 @@ 1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */, 1C16FBD41CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift */, 1C8A8CF91A3C14DF00E4F6A4 /* ReplyWorkspace.swift */, + 2D3D26002F85E80100862513 /* NewThreadDraft.swift */, ); path = Posts; sourceTree = ""; @@ -1666,6 +1678,7 @@ 2DDCCEA02F77A73E0011BCA2 /* ModernBBcodeToolbar.swift in Sources */, 2DDCCEA12F77A73E0011BCA2 /* BBcodeTagHelper.swift in Sources */, 1C16FC141CCEE60C00C88BD1 /* MessageComposeViewController.swift in Sources */, + 2D3D26032F85E80100862514 /* PrivateMessageDraft.swift in Sources */, 1C8F67F4222105E4007E61ED /* StencilEnvironment.swift in Sources */, 1C23C7051A7AB8940089BD5C /* SlopButton.swift in Sources */, 1CBE1B1219CAAFA200510187 /* AwfulSplitViewController.swift in Sources */, @@ -1692,6 +1705,8 @@ 1CC256B11A38526B003FA7A8 /* MessageListViewController.swift in Sources */, 1CC065F72D67028F002BB6A0 /* AppIconImageNames.swift in Sources */, 2D939F232EC48FDE00F3464B /* PageNumberView.swift in Sources */, + 2D3D25F92F85E50500862513 /* RestorableLocation.swift in Sources */, + 2D3D25FB2F85E50500862514 /* SceneDelegate.swift in Sources */, 1C0D80001CF9FE70003EE2D1 /* Toolbar.swift in Sources */, 1C16FC101CC6F19700C88BD1 /* ThreadPreviewViewController.swift in Sources */, 1C09BFF01A09D485007C11F5 /* InAppActionCollectionViewLayout.swift in Sources */, @@ -1708,6 +1723,7 @@ 1C4EAD5E1BC0622D0008BE54 /* AwfulCore.swift in Sources */, 1C25AC521F57784A00977D6F /* AnnouncementListRefresher.swift in Sources */, 1C8A8CFA1A3C14DF00E4F6A4 /* ReplyWorkspace.swift in Sources */, + 2D3D26012F85E80100862513 /* NewThreadDraft.swift in Sources */, 2D62DEA42EBFE93800F7121B /* LiquidGlassTitleView.swift in Sources */, 1CC256BC1A3AA82F003FA7A8 /* ShowSmilieKeyboardCommand.swift in Sources */, 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */,