Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; };
6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */; };
7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; };
AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000002 /* ThemePreference.swift */; };
73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; };
745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; };
79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; };
Expand Down Expand Up @@ -135,6 +136,7 @@
ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */; };
B1C1D1E1F1A10001 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10002 /* DataCache.swift */; };
B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; };
AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */; };
B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; };
B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10008 /* FeedSkeletonView.swift */; };
B1C1D1E1F1A10009 /* ListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000A /* ListSkeletonView.swift */; };
Expand Down Expand Up @@ -396,6 +398,8 @@
A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaEditorView.swift; sourceTree = "<group>"; };
A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaDraft.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20042 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = "<group>"; };
BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = "<group>"; };
AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreferenceTests.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20044 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20046 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20048 /* FollowRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestsView.swift; sourceTree = "<group>"; };
Expand All @@ -420,7 +424,6 @@
B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredDocumentSheet.swift; sourceTree = "<group>"; };
B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = "<group>"; };
B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FindPeopleView.swift; sourceTree = "<group>"; };
BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = "<group>"; };
BADBF836F0B5BD6524776FE7 /* SharingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharingView.swift; sourceTree = "<group>"; };
BD05B653363E3A794B1B1EE7 /* ShareLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLink.swift; sourceTree = "<group>"; };
BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -476,6 +479,7 @@
F7FD22FBB140088C4C19A054 /* ListContributorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListContributorsView.swift; sourceTree = "<group>"; };
F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GitHubMetadata.swift; sourceTree = "<group>"; };
FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; };
AB80F1A1C0DE000000000002 /* ThemePreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreference.swift; sourceTree = "<group>"; };
FCCD5BA8427B40000116FC43 /* APIClientDocumentPresenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentPresenceTests.swift; sourceTree = "<group>"; };
FC8B6FBC22A2B1CC69B72ED0 /* ViewPreferences.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewPreferences.swift; sourceTree = "<group>"; };
T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -638,6 +642,7 @@
D0A1D0A1D0A10002 /* OAuthCoordinator.swift */,
1F84433332486544DA15C7BA /* APIClient+Documents.swift */,
FC5914D730B4355F02E89D81 /* ILColor.swift */,
AB80F1A1C0DE000000000002 /* ThemePreference.swift */,
8B79D26B822DCA1188DF37E9 /* PushService.swift */,
5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */,
7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */,
Expand Down Expand Up @@ -858,6 +863,7 @@
78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */,
D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */,
BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */,
AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */,
8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */,
D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */,
126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */,
Expand Down Expand Up @@ -1031,6 +1037,7 @@
CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */,
D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */,
7201B78F48B60376E06DB08E /* ILColor.swift in Sources */,
AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */,
47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */,
C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */,
1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */,
Expand Down Expand Up @@ -1166,6 +1173,7 @@
T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */,
23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */,
1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */,
AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */,
6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */,
E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */,
64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */,
Expand Down
33 changes: 33 additions & 0 deletions InterlinedList/Assets.xcassets/Logo.imageset/Contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,48 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Logo-Dark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Logo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Logo-Dark@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Logo@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Logo-Dark@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion InterlinedList/InterlinedListApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ struct InterlinedListApp: App {
}
}
.sheet(item: $router.pendingDeepLink) { link in
deepLinkSheet(for: link)
// A sheet inherits the color-scheme override from RootView (it is a
// window-level trait) but not its tint, so brand the sheet's own
// controls here or they render iOS system blue.
deepLinkSheet(for: link).tint(ILColor.link)
}
}
}
Expand Down
13 changes: 11 additions & 2 deletions InterlinedList/Services/AuthState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@ import Foundation

@MainActor
final class AuthState: ObservableObject {
@Published private(set) var user: User?
@Published private(set) var user: User? {
didSet {
// Every path that loads or updates the account funnels through here, so
// this is the one place the theme mirror is written. Logout (user == nil)
// deliberately leaves it alone: the login screen keeps the last choice.
if let user { themeStore.save(user.theme) }
}
}
@Published private(set) var isRestoring = true
@Published private(set) var hasToken: Bool = false
/// Provider types (e.g. "github", "mastodon") linked to this account, or nil
/// until first loaded. Populated lazily via `loadLinkedProvidersIfNeeded()`.
@Published private(set) var linkedProviders: Set<String>?

private let api = APIClient.shared
private let themeStore: ThemePreferenceStore

init() {
init(themeStore: ThemePreferenceStore = ThemePreferenceStore()) {
self.themeStore = themeStore
if let token = KeychainService.loadToken() {
api.setBearerToken(token)
hasToken = true
Expand Down
58 changes: 58 additions & 0 deletions InterlinedList/Services/ThemePreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// ThemePreference.swift
// InterlinedList
//

import Foundation
import SwiftUI

/// The account's display-theme choice, as the server stores it ("system" / "light" / "dark").
enum ThemePreference: String, CaseIterable {
case system
case light
case dark

/// `UserDefaults` key for the mirror of the server value. A display preference,
/// not a credential, so the Keychain-only rule for tokens does not apply here.
static let storageKey = "themePreference"

/// Anything the server (or the mirror) can't be read as a known theme means
/// "follow the OS" — the same fallback `RootView` applied before.
init(stored value: String?) {
self = ThemePreference(rawValue: value?.lowercased() ?? "") ?? .system
}

var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}

/// The account value is authoritative once `GET /api/user` has answered; until
/// then — the login screen, a cold launch, an offline start — the mirror stands in.
static func resolve(serverTheme: String?, mirrored: String?) -> ThemePreference {
if let serverTheme {
return ThemePreference(stored: serverTheme)
}
return ThemePreference(stored: mirrored)
}
}

/// Writes the theme mirror that `RootView` reads at first render.
struct ThemePreferenceStore {
private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

var current: ThemePreference {
ThemePreference(stored: defaults.string(forKey: ThemePreference.storageKey))
}

func save(_ theme: String?) {
defaults.set(ThemePreference(stored: theme).rawValue, forKey: ThemePreference.storageKey)
}
}
13 changes: 6 additions & 7 deletions InterlinedList/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import SwiftUI

struct RootView: View {
@EnvironmentObject var authState: AuthState
/// Read synchronously at first render, so the saved theme is in force before
/// `GET /api/user` answers — no flash of the OS appearance, and it survives
/// an offline cold start.
@AppStorage(ThemePreference.storageKey) private var mirroredTheme = ThemePreference.system.rawValue

var body: some View {
Group {
Expand All @@ -17,20 +21,15 @@ struct RootView: View {
}
}
.preferredColorScheme(preferredScheme)
.tint(ILColor.link)
// The server resizes every image upload to its own cap regardless of
// what the client sends, so read the caps once at launch and let
// ImageUploadProcessor size to them. Public route — no token needed,
// and a failure just leaves the documented fallbacks in place.
.task { await ServerLimitsStore.shared.refresh() }
}

/// Honor the user's saved theme preference ("light" / "dark"); "system" or
/// missing leaves the OS appearance in control.
private var preferredScheme: ColorScheme? {
switch authState.user?.theme {
case "light": return .light
case "dark": return .dark
default: return nil
}
ThemePreference.resolve(serverTheme: authState.user?.theme, mirrored: mirroredTheme).colorScheme
}
}
98 changes: 98 additions & 0 deletions InterlinedListTests/ServiceTests/ThemePreferenceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import XCTest
import SwiftUI
@testable import InterlinedList

final class ThemePreferenceTests: XCTestCase {
private var suiteName = ""
private var defaults = UserDefaults.standard

override func setUpWithError() throws {
try super.setUpWithError()
suiteName = "ThemePreferenceTests.\(UUID().uuidString)"
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
}

override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}

// MARK: - Parsing

func test_init_stored_knownValues_parse() {
XCTAssertEqual(ThemePreference(stored: "system"), .system)
XCTAssertEqual(ThemePreference(stored: "light"), .light)
XCTAssertEqual(ThemePreference(stored: "dark"), .dark)
}

func test_init_stored_mixedCase_parses() {
XCTAssertEqual(ThemePreference(stored: "Dark"), .dark)
}

func test_init_stored_nil_fallsBackToSystem() {
XCTAssertEqual(ThemePreference(stored: nil), .system)
}

func test_init_stored_unknownValue_fallsBackToSystem() {
XCTAssertEqual(ThemePreference(stored: "sepia"), .system)
XCTAssertEqual(ThemePreference(stored: ""), .system)
}

func test_colorScheme_mapsEachCase() {
XCTAssertNil(ThemePreference.system.colorScheme)
XCTAssertEqual(ThemePreference.light.colorScheme, ColorScheme.light)
XCTAssertEqual(ThemePreference.dark.colorScheme, ColorScheme.dark)
}

// MARK: - Resolution (server value vs. mirror)

func test_resolve_serverThemePresent_winsOverMirror() {
XCTAssertEqual(ThemePreference.resolve(serverTheme: "light", mirrored: "dark"), .light)
}

func test_resolve_serverThemeMissing_usesMirror() {
// The login screen and every cold launch before GET /api/user answers.
XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: "dark"), .dark)
}

func test_resolve_serverThemeUnknown_fallsBackToSystemNotMirror() {
XCTAssertEqual(ThemePreference.resolve(serverTheme: "chartreuse", mirrored: "dark"), .system)
}

func test_resolve_bothMissing_isSystem() {
XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: nil), .system)
}

// MARK: - Mirror persistence

func test_current_nothingStored_isSystem() {
XCTAssertEqual(ThemePreferenceStore(defaults: defaults).current, .system)
}

func test_save_thenFreshStore_readsStoredValue() throws {
ThemePreferenceStore(defaults: defaults).save("dark")

let reopened = try XCTUnwrap(UserDefaults(suiteName: suiteName))
XCTAssertEqual(ThemePreferenceStore(defaults: reopened).current, .dark)
}

func test_save_unknownValue_normalizesToSystem() {
let store = ThemePreferenceStore(defaults: defaults)
store.save("dark")
store.save("sepia")
XCTAssertEqual(store.current, .system)
}

func test_save_nil_normalizesToSystem() {
let store = ThemePreferenceStore(defaults: defaults)
store.save("dark")
store.save(nil)
XCTAssertEqual(store.current, .system)
}

func test_save_writesRawValueUnderSharedKey() {
// RootView reads the same key through @AppStorage, so the raw string matters.
ThemePreferenceStore(defaults: defaults).save("light")
XCTAssertEqual(defaults.string(forKey: ThemePreference.storageKey), "light")
}
}
Loading