commit 8cae06e6e2eb7a92851d9403539ab6cbfc0613d3
parent 7ad4815ea00185d58b0d70649924fa629d88a505
Author: triesap <tyson@radroots.org>
Date: Wed, 9 Sep 2026 16:51:55 +0000
today: make context transitions presentation atomic
- Reconcile runtime context snapshots and retain only valid selected choices.
- Clear scoped cards and supporting stores before publishing a new scope.
- Reset detail and sheet navigation on account or context presentation changes.
- Verify seven context regressions with unchanged artifacts and quality ceilings.
Diffstat:
11 files changed, 293 insertions(+), 40 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -54,6 +54,7 @@ let package = Package(
"App/TeraRemoteQualification.swift",
"App/TeraRemoteQualificationEvidence.swift",
"App/TeraRootShell.swift",
+ "App/TeraTodayNavigation.swift",
"Runtime/TeraAddMediaCoordinator.swift",
"Runtime/TeraBackgroundUploadRequest.swift",
"Runtime/TeraCheckedTime.swift",
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -37,6 +37,7 @@
852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */; };
912C8743116D929BB6A02778 /* TeraRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */; };
9384AAECB22885E2D05C54A8 /* TeraScopedMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */; };
+ 9995BC15D67E1C9C29B4A7DC /* TeraTodayContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */; };
9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */; };
9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */; };
A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */; };
@@ -136,6 +137,7 @@
E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; };
E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedObservationTests.swift; sourceTree = "<group>"; };
EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedMediaTests.swift; sourceTree = "<group>"; };
+ EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayContextTests.swift; sourceTree = "<group>"; };
F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceFixtures.swift; sourceTree = "<group>"; };
FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeClientTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -241,6 +243,7 @@
BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */,
C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */,
53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */,
+ EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */,
99BE248D1450D76476255626 /* TeraTodayPresentationTests.swift */,
794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */,
46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */,
@@ -492,6 +495,7 @@
30EDD3EB0C09D864FC1DD3D4 /* TeraStoreRecoveryTests.swift in Sources */,
2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */,
E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */,
+ 9995BC15D67E1C9C29B4A7DC /* TeraTodayContextTests.swift in Sources */,
AA7C3FC1F46B6723A39B1D09 /* TeraTodayPresentationTests.swift in Sources */,
64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */,
4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */,
diff --git a/Tera/App/TeraProductStores.swift b/Tera/App/TeraProductStores.swift
@@ -21,6 +21,13 @@ final class TeraProductStores {
me = TeraMeStore(runtimeClient: runtimeClient)
settings = TeraSettingsStore(runtimeClient: runtimeClient)
media = TeraMediaStore(runtimeClient: runtimeClient)
+ today.scopeWillChange = { [search, me, media] context in
+ search.stop()
+ search.configure(context: context)
+ me.stop()
+ me.configure(context: context)
+ media.reset()
+ }
}
deinit {
diff --git a/Tera/App/TeraRootShell.swift b/Tera/App/TeraRootShell.swift
@@ -43,31 +43,13 @@ struct TeraRootShell: View {
var body: some View {
TabView(selection: selection) {
- NavigationStack {
+ Group {
if let stores {
- TeraTodayView(
- snapshot: snapshot,
- store: stores.today,
- searchStore: stores.search,
- meStore: stores.me,
- addStore: stores.add,
- settingsStore: stores.settings,
- mediaStore: stores.media,
- revise: { card in
- Task {
- await stores.add.retractAndRevise(card)
- storedSelection = TeraRootTab.add.rawValue
- }
- },
- retract: { card in
- Task {
- await stores.add.retract(card)
- storedSelection = TeraRootTab.add.rawValue
- }
- }
- )
+ TeraTodayNavigation(snapshot: snapshot, stores: stores) {
+ storedSelection = TeraRootTab.add.rawValue
+ }
} else {
- TeraTodayLanding(snapshot: snapshot)
+ NavigationStack { TeraTodayLanding(snapshot: snapshot) }
}
}
.tabItem { Label("Today", systemImage: "sun.max.fill") }
diff --git a/Tera/App/TeraTodayNavigation.swift b/Tera/App/TeraTodayNavigation.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+/// A scope change also ends the lifetime of its details and supporting sheets.
+struct TeraTodayNavigation: View {
+ let snapshot: TeraRuntimeSnapshot
+ let stores: TeraProductStores
+ let selectAdd: () -> Void
+ @ObservedObject private var today: TeraTodayStore
+
+ init(snapshot: TeraRuntimeSnapshot, stores: TeraProductStores, selectAdd: @escaping () -> Void) {
+ self.snapshot = snapshot
+ self.stores = stores
+ self.selectAdd = selectAdd
+ today = stores.today
+ }
+
+ var body: some View {
+ NavigationStack {
+ TeraTodayView(
+ snapshot: snapshot,
+ store: today,
+ searchStore: stores.search,
+ meStore: stores.me,
+ addStore: stores.add,
+ settingsStore: stores.settings,
+ mediaStore: stores.media,
+ revise: { card in
+ Task {
+ await stores.add.retractAndRevise(card)
+ selectAdd()
+ }
+ },
+ retract: { card in
+ Task {
+ await stores.add.retract(card)
+ selectAdd()
+ }
+ }
+ )
+ }
+ .id(today.scopeGeneration)
+ }
+}
diff --git a/Tera/State/TeraStoreObservation.swift b/Tera/State/TeraStoreObservation.swift
@@ -3,11 +3,13 @@ import Foundation
/// Stable configuration inputs, excluding changing service observation evidence.
struct TeraPresentationConfiguration: Equatable {
let publicKey: String
+ let relayProfile: String?
let context: TeraLocalNetwork
let blossom: TeraBlossomConfigurationStatus?
init(snapshot: TeraRuntimeSnapshot) {
publicKey = snapshot.identity.publicKeyHex
+ relayProfile = snapshot.relay?.profile
context = .defaultContext(snapshot: snapshot)
blossom = snapshot.blossomConfiguration
}
diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift
@@ -8,6 +8,8 @@ final class TeraTodayStore: ObservableObject {
@Published private(set) var presentation = TeraTodayPresentation()
@Published private(set) var isLoadingNextPage = false
@Published private(set) var observationState: TeraRuntimeObservationState = .inactive
+ @Published private(set) var scopeGeneration = TeraSessionGeneration.initial
+ var scopeWillChange: (TeraLocalNetwork?) -> Void = { _ in }
private let runtimeClient: TeraRuntimeClient
private let pageSize: UInt16
@@ -58,14 +60,14 @@ final class TeraTodayStore: ObservableObject {
func configure(snapshot: TeraRuntimeSnapshot) {
let updated = TeraPresentationConfiguration(snapshot: snapshot)
guard configuration != updated else { return }
- let previous = configuration
let reload = observation.isActive
- invalidatePresentation()
+ invalidatePresentation(for: updated.context)
configuration = updated
- if previous != nil || contexts.isEmpty {
- contexts = [updated.context]
- selectedContextID = updated.context.id
- }
+ // The runtime snapshot currently supplies one default local network.
+ // Reconcile even the first configuration; injected choices are not an
+ // authority for a different account or runtime profile.
+ contexts = [updated.context]
+ selectedContextID = updated.context.id
if reload {
scheduleReload()
}
@@ -89,7 +91,7 @@ final class TeraTodayStore: ObservableObject {
presentation.stop()
}
- private func invalidatePresentation() {
+ private func invalidatePresentation(for context: TeraLocalNetwork?) {
requestGeneration = requestGeneration.invalidated()
reloadTask?.cancel()
reloadTask = nil
@@ -98,6 +100,8 @@ final class TeraTodayStore: ObservableObject {
nextCursor = nil
isLoadingNextPage = false
presentation = TeraTodayPresentation()
+ scopeWillChange(context)
+ scopeGeneration = scopeGeneration.invalidated()
if observation.isActive {
observation.stop()
startObservation()
@@ -106,23 +110,24 @@ final class TeraTodayStore: ObservableObject {
func selectContext(id: String) {
guard id != selectedContextID,
- contexts.contains(where: { $0.id == id })
+ let context = contexts.first(where: { $0.id == id })
else {
return
}
- invalidatePresentation()
+ invalidatePresentation(for: context)
selectedContextID = id
scheduleReload()
}
func replaceContexts(_ updatedContexts: [TeraLocalNetwork], selectedID: String?) {
let updatedContexts = Self.unique(updatedContexts)
- invalidatePresentation()
+ let selected = [selectedID, selectedContextID].compactMap(\.self).first { requested in
+ updatedContexts.contains(where: { $0.id == requested })
+ } ?? updatedContexts.first?.id
+ guard contexts != updatedContexts || selectedContextID != selected else { return }
+ invalidatePresentation(for: updatedContexts.first(where: { $0.id == selected }))
contexts = updatedContexts
- selectedContextID =
- selectedID.flatMap { requested in
- updatedContexts.contains(where: { $0.id == requested }) ? requested : nil
- } ?? updatedContexts.first?.id
+ selectedContextID = selected
scheduleReload()
}
diff --git a/TeraTests/TeraScopeFixtures.swift b/TeraTests/TeraScopeFixtures.swift
@@ -4,12 +4,13 @@ import XCTest
enum TeraScopeFixtures {
static func snapshot(
- account: String = "a", relay: String = "first", evidence: TeraBlossomEvidence? = nil
+ account: String = "a", relay: String = "first", evidence: TeraBlossomEvidence? = nil,
+ profile: String = "simulator"
) -> TeraRuntimeSnapshot {
TeraRuntimeSnapshot(
identity: TeraRuntimeIdentity(publicKeyHex: String(repeating: account, count: 64), hostSignerConfigured: true),
relay: TeraRelayStatus(
- profile: "simulator", state: "configured", readAvailability: "unobserved", writeAvailability: "unobserved",
+ profile: profile, state: "configured", readAvailability: "unobserved", writeAvailability: "unobserved",
relays: [TeraRelayEndpointStatus(
url: "wss://\(relay).example", access: .readWrite, readState: "unobserved", writeState: "unobserved",
readLastAttemptUnixMilliseconds: nil, writeLastAttemptUnixMilliseconds: nil,
diff --git a/TeraTests/TeraTodayContextTests.swift b/TeraTests/TeraTodayContextTests.swift
@@ -0,0 +1,207 @@
+import Combine
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraTodayContextTests: XCTestCase {
+ func testReplacementPreservesOnlyPresentSelectionAndIgnoresUnknownChoice() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let first = context("first"), second = context("second")
+ let store = TeraTodayStore(runtimeClient: client, contexts: [first, second], selectedContextID: second.id)
+ await store.reload(refreshProjection: false)
+ let original = store.scopeGeneration
+ store.replaceContexts([first, second, second], selectedID: nil)
+ store.selectContext(id: "absent")
+ XCTAssertEqual(store.scopeGeneration, original)
+ XCTAssertEqual(store.cards.map(\.id), second.relayURLs)
+ store.replaceContexts([second, first], selectedID: "absent")
+ XCTAssertEqual(store.selectedContext, second)
+ store.replaceContexts([first], selectedID: second.id)
+ XCTAssertEqual(store.selectedContext, first)
+ XCTAssertTrue(store.cards.isEmpty)
+ store.replaceContexts([], selectedID: nil)
+ XCTAssertNil(store.selectedContext)
+ XCTAssertFalse(store.canLoadNextPage)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testFirstConfigurationReconcilesInjectedChoicesWithRuntimeSnapshot() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let stale = context("old")
+ let store = TeraTodayStore(runtimeClient: client, contexts: [stale])
+ await store.reload(refreshProjection: false)
+ let snapshot = TeraScopeFixtures.snapshot()
+ store.configure(snapshot: snapshot)
+ XCTAssertTrue(store.cards.isEmpty)
+ XCTAssertEqual(store.contexts, [.defaultContext(snapshot: snapshot)])
+ XCTAssertEqual(store.selectedContext?.id, "default")
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testAccountAndProfileChangesInvalidateSameNamedScopeButEvidenceDoesNot() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraTodayStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.reload(refreshProjection: false)
+ var generation = store.scopeGeneration
+ store.configure(snapshot: TeraScopeFixtures.snapshot(evidence: TeraScopeFixtures.evidence(observedAt: 9)))
+ XCTAssertEqual(store.scopeGeneration, generation)
+ XCTAssertFalse(store.cards.isEmpty)
+ for snapshot in [TeraScopeFixtures.snapshot(profile: "public"), TeraScopeFixtures.snapshot(account: "b", profile: "public")] {
+ let paused = await backend.pause(.page)
+ let old = Task { await store.reload(refreshProjection: false) }
+ await paused.entered.wait()
+ store.configure(snapshot: snapshot)
+ XCTAssertNotEqual(store.scopeGeneration, generation)
+ XCTAssertTrue(store.cards.isEmpty)
+ XCTAssertEqual(store.selectedContext?.id, "default")
+ await paused.resume.open()
+ await old.value
+ XCTAssertTrue(store.cards.isEmpty)
+ generation = store.scopeGeneration
+ await store.reload(refreshProjection: false)
+ XCTAssertEqual(store.scopeGeneration, generation)
+ }
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testFirstPageSwitchClearsBeforeLabelForCachedAndEmptyDestinations() async throws {
+ for cached in [false, true] {
+ try await assertSwitch(nextPage: false, cached: cached)
+ }
+ }
+
+ func testSameIDReplacementClearsBeforePublishingChangedLabelAndDefinition() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let original = context("first")
+ let store = TeraTodayStore(runtimeClient: client, contexts: [original])
+ await store.reload(refreshProjection: false)
+ let paused = await backend.pause(.page)
+ let old = Task { await store.reload(refreshProjection: false) }
+ await paused.entered.wait()
+ let updated = TeraLocalNetwork(
+ schemaVersion: 1, id: original.id, label: "Updated network", relayURLs: ["wss://second.example"],
+ locality: nil, followedAuthors: [], generation: 2
+ )
+ var published = false
+ let subscription = store.$contexts.dropFirst().sink { values in
+ published = true
+ XCTAssertEqual(values, [updated])
+ XCTAssertTrue(store.cards.isEmpty)
+ XCTAssertEqual(store.presentation.content, .notLoaded)
+ }
+ store.replaceContexts([updated], selectedID: nil)
+ XCTAssertTrue(published)
+ XCTAssertEqual(store.selectedContext, updated)
+ await TeraScopeFixtures.eventually { store.cards.map(\.id) == updated.relayURLs }
+ await paused.resume.open()
+ await old.value
+ XCTAssertEqual(store.cards.map(\.id), updated.relayURLs)
+ subscription.cancel()
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testNextPageSwitchClearsBeforeLabelForCachedAndEmptyDestinations() async throws {
+ for cached in [false, true] {
+ try await assertSwitch(nextPage: true, cached: cached)
+ }
+ }
+
+ func testContextSwitchClearsSupportingStoresBeforeLabelAndRejectsLateResults() async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let stores = TeraProductStores(runtimeClient: client)
+ stores.configure(snapshot: TeraScopeFixtures.snapshot())
+ stores.search.updateQuery("old")
+ await stores.search.search()
+ await stores.me.reload()
+ XCTAssertFalse(stores.search.results.isEmpty)
+ XCTAssertNotNil(stores.me.snapshot)
+ let searchPause = await backend.pause(.search), mePause = await backend.pause(.me)
+ let search = Task { await stores.search.search() }, me = Task { await stores.me.reload() }
+ await searchPause.entered.wait()
+ await mePause.entered.wait()
+ var published = false
+ let subscription = stores.today.$selectedContextID.dropFirst().sink { selected in
+ published = true
+ XCTAssertEqual(selected, "second")
+ XCTAssertTrue(stores.search.results.isEmpty)
+ XCTAssertNil(stores.me.snapshot)
+ XCTAssertEqual(stores.me.observationState, .stopped)
+ }
+ stores.today.replaceContexts([context("second")], selectedID: nil)
+ XCTAssertTrue(published)
+ await searchPause.resume.open()
+ await mePause.resume.open()
+ await search.value
+ await me.value
+ XCTAssertTrue(stores.search.results.isEmpty)
+ XCTAssertNil(stores.me.snapshot)
+ await stores.me.reload()
+ let actual = await backend.lastMeContext
+ XCTAssertEqual(actual, stores.today.selectedContext)
+ subscription.cancel()
+ stores.stop()
+ _ = try await client.stop()
+ }
+
+ private func assertSwitch(nextPage: Bool, cached: Bool) async throws {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let first = context("first"), second = context("second")
+ let store = TeraTodayStore(runtimeClient: client, contexts: [first, second])
+ await backend.setPage(page(["old"], cursor: "next"))
+ await backend.setPage(page(["old-next"]), cursor: "next")
+ await store.reload(refreshProjection: false)
+ let paused = await backend.pause(.page)
+ let old = Task {
+ if nextPage {
+ await store.loadNextPage()
+ } else {
+ await store.reload(refreshProjection: false)
+ }
+ }
+ await paused.entered.wait()
+ let expected = cached ? ["new"] : []
+ await backend.setPage(page(expected))
+ var changes = 0
+ let subscription = store.$selectedContextID.dropFirst().sink { selected in
+ changes += 1
+ XCTAssertEqual(selected, second.id)
+ XCTAssertTrue(store.cards.isEmpty)
+ XCTAssertEqual(store.presentation.content, .notLoaded)
+ XCTAssertFalse(store.isLoadingNextPage)
+ XCTAssertFalse(store.canLoadNextPage)
+ }
+ let generation = store.scopeGeneration
+ store.selectContext(id: second.id)
+ XCTAssertNotEqual(store.scopeGeneration, generation)
+ XCTAssertEqual(changes, 1)
+ XCTAssertEqual(store.selectedContext?.label, "second")
+ await TeraScopeFixtures.eventually { store.presentation.content == (cached ? .available : .empty) }
+ await paused.resume.open()
+ await old.value
+ XCTAssertEqual(store.cards.map(\.id), expected)
+ XCTAssertEqual(store.selectedContext, second)
+ subscription.cancel()
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ private func context(_ id: String) -> TeraLocalNetwork {
+ TeraLocalNetwork(schemaVersion: 1, id: id, label: id, relayURLs: ["wss://\(id).example"],
+ locality: nil, followedAuthors: [], generation: 1)
+ }
+
+ private func page(_ ids: [String], cursor: String? = nil) -> TeraTodayPage {
+ TeraTodayPage(asOfUnixSeconds: 1, items: ids.map(TeraScopeFixtures.card), nextCursor: cursor)
+ }
+}
diff --git a/project.yml b/project.yml
@@ -63,6 +63,7 @@ targets:
- path: TeraTests/TeraTodayStoreTests.swift
- path: TeraTests/TeraTodayPresentationTests.swift
- path: TeraTests/TeraTodayCacheFirstTests.swift
+ - path: TeraTests/TeraTodayContextTests.swift
- path: TeraTests/TeraProductStartupTests.swift
- path: TeraTests/TeraAddSchemaFixtures.swift
- path: TeraTests/TeraScopeFixtures.swift
diff --git a/release/provenance.json b/release/provenance.json
@@ -6,7 +6,7 @@
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410",
- "xcode_project_sha256": "06f5970c0e75efb3ef97ad2bf0b3f13aad54b0b99787f24aa44076606fbb778a"
+ "xcode_project_sha256": "5738745e0ad43c494bb7127ddb0a56479cccaae839634b4c0bb9b4c278efe386"
},
"disposition": "unsigned",
"platforms": [