Skip to content
Merged
30 changes: 6 additions & 24 deletions App/Composition/ComposeTextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 0 additions & 22 deletions App/Composition/CompositionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -61,7 +60,6 @@ final class CompositionViewController: ViewController {
view = containerView

_textView = CompositionTextView()
_textView.restorationIdentifier = "Composition text view"
_textView.translatesAutoresizingMaskIntoConstraints = false

attachmentPreviewView.translatesAutoresizingMaskIntoConstraints = false
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions App/Extensions/UIKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
100 changes: 16 additions & 84 deletions App/Main/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -135,56 +125,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {

return true
}

func applicationWillResignActive(_ application: UIApplication) {
SmilieKeyboardSetIsAwfulAppActive(false)

updateShortcutItems()
}

func applicationDidBecomeActive(_ application: UIApplication) {
SmilieKeyboardSetIsAwfulAppActive(true)

// Screen brightness may have changed while the app wasn't paying attention.
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
Expand Down Expand Up @@ -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 }
Expand All @@ -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: {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions App/Main/RestorableLocation.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading