From 586da460088cc2c4883f45c19335fcb530e1e69a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 10:06:40 -0400 Subject: [PATCH] fix(chat): return to the chat list from a chat opened by handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The username lookup replaces its route with the chat it pushes, so Back from that chat lands on the chat list rather than on the field that found the person. It guarded that rewrite on the tips stack being two deep, which is what it was in #661, when the lookup was pushed straight off the list. #790 put the New Chat picker in between. The chat now lands three deep, the guard never matches, and the rewrite is skipped — so Back goes to the username field. Measure the stack before the push instead of assuming its depth, so the guard tracks whatever route reaches this screen. --- .../Main/Username/UsernameLookupScreen.swift | 19 +++++++---- .../UsernameLookupRoutingTests.swift | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift index 3a0ef491c..b4eada42f 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift @@ -24,11 +24,12 @@ private let logger = Logger(label: "flipcash.username-lookup") /// verification screens use (`PhoneVerificationViewModel.swift:320-329`), and so /// is the push. /// -/// This screen then drops out from under the chat, since a chat's Back belongs -/// on the chat list. It goes while the push is still animating: the removal is a -/// whole-path rewrite, which remounts the chat, and a freshly-mounted chat -/// settles its layout a beat after it appears. Under the transition that settle -/// is hidden — the same way it is on every other push into a chat. +/// The route in then drops out from under the chat — this screen and the New +/// Chat picker above the list — since a chat's Back belongs on the chat list. It +/// goes while the push is still animating: the removal is a whole-path rewrite, +/// which remounts the chat, and a freshly-mounted chat settles its layout a beat +/// after it appears. Under the transition that settle is hidden — the same way +/// it is on every other push into a chat. struct UsernameLookupScreen: View { @Environment(Container.self) private var container @@ -158,6 +159,12 @@ struct UsernameLookupScreen: View { lookupTask = nil let chat = AppRouter.Destination.tipConversationForUser(userID) + + // Measured rather than assumed: this screen is reached through + // the New Chat picker today and was reached straight off the + // chat list before it, so a fixed depth silently stops matching + // the moment a screen is added to or removed from the route. + let depthWithChat = router[.tips].count + 1 router.push(chat) // Well inside the push transition, so the remounted chat settles @@ -169,7 +176,7 @@ struct UsernameLookupScreen: View { // Unless they went back while it landed: the lookup left with // them, and rewriting the path would drag the chat back. - guard router[.tips].count == 2 else { return } + guard router[.tips].count == depthWithChat else { return } // Unanimated, because nothing is arriving — only the entry // underneath is leaving. Animated, SwiftUI stages the remount as diff --git a/FlipcashTests/UsernameLookupRoutingTests.swift b/FlipcashTests/UsernameLookupRoutingTests.swift index a102c268f..bef528285 100644 --- a/FlipcashTests/UsernameLookupRoutingTests.swift +++ b/FlipcashTests/UsernameLookupRoutingTests.swift @@ -79,6 +79,40 @@ struct UsernameLookupRoutingTests { #expect(ConversationContext.existing(chatID).resolvedContact(in: [contact]) != nil) } + // MARK: - Back stack - + + @Test("The chat list is not a fixed number of screens below the lookup") + func backStack_routeInHasNoFixedDepth() { + // The lookup was pushed straight off the chat list until the New Chat + // picker went in between (#790). A screen that assumed the depth it had + // then is a screen that stops unwinding the moment the route changes. + let router = AppRouter() + router.activeTabStack = .tips + + router.push(.newChat) + router.push(.usernameLookup) + + #expect(router[.tips].count == 2) + } + + @Test("Back from a chat opened by handle lands on the chat list") + func backStack_rewriteLeavesOnlyTheChat() { + let router = AppRouter() + router.activeTabStack = .tips + router.push(.newChat) + router.push(.usernameLookup) + + let chat = AppRouter.Destination.tipConversationForUser(UUID()) + let depthWithChat = router[.tips].count + 1 + router.push(chat) + #expect(router[.tips].count == depthWithChat) + + // What the screen does once the push has started: neither the picker + // nor the lookup is somewhere Back belongs, and the list is the root. + router.setPath([chat], on: .tips) + #expect(router[.tips].count == 1) + } + // MARK: - Counterpart - @Test("The fetched profile supplies the name and handle the chat shows")