From 47524fc6274443d23b79719351636deb6a6b39f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 14:19:08 -0400 Subject: [PATCH] refactor(chat): build the attach actions once for both hosts The menu and card callbacks were written out three times: once for the overlay over the keyboard, once as the bar's six parameters, and again when the bar repackaged those into AttachOverlayActions. The copies had already drifted on onAllPhotos, and the overlay kept the value it was handed when it opened, so it never saw a later render's closures. The representable now builds one AttachActions per render and gives it to the bar as a single parameter. The overlay reads it through a closure each time its root renders, and sets onAllPhotos itself, since only a card drawn in the keyboard's window has to hand All Photos to the bar. handOffToBar is private to the overlay as a result. --- .../Conversation/AttachKeyboardOverlay.swift | 31 ++++++-------- .../Screens/Conversation/AttachSurface.swift | 17 +++++++- .../ChatScreenRepresentable.swift | 41 +++++++------------ .../Conversation/ConversationBottomBar.swift | 25 ++--------- 4 files changed, 45 insertions(+), 69 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift index f36afa06a..aaa04af40 100644 --- a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift +++ b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift @@ -7,24 +7,11 @@ import SwiftUI import UIKit -import PhotosUI import FlipcashCore import FlipcashUI private let logger = Logger(label: "flipcash.attach-overlay") -/// What the panel and cards drawn over the keyboard hand back to the screen. -struct AttachOverlayActions { - var onCash: () -> Void - var onCamera: () -> Void - var onPhotos: () -> Void - var onCameraCapture: (ChatCameraCapture) -> Void - var onCameraCancel: () -> Void - var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void - var onPhotosBack: () -> Void - var onAllPhotos: (() -> Void)? = nil -} - /// Draws the attach panel and its cards over the keyboard while the composer keeps focus, as /// ChatGPT does: in the keyboard's own window when ``KeyboardOverlayHost`` finds it, else over a /// keyboard-coloured input view swapped in under the field. @@ -59,7 +46,7 @@ final class AttachKeyboardOverlay { screen: ChatScreenViewController, items: [AttachMenuItem], composer: ComposerModel, - actions: AttachOverlayActions + actions: @escaping () -> AttachActions ) -> AttachOverlayMode { if let mode { return mode } let plan = AttachOverlayPlan( @@ -149,7 +136,7 @@ final class AttachKeyboardOverlay { /// Hands the photo card to the bar, with the keyboard down and the full picker opening: a sheet /// can't be presented from the keyboard's window. - func handOffToBar() { + private func handOffToBar() { guard mode != nil else { return } model.overKeyboard.opensLibraryInBar = true end() @@ -159,7 +146,7 @@ final class AttachKeyboardOverlay { private func makeContainer( appWindow: UIWindow, composer: ComposerModel, - actions: AttachOverlayActions + actions: @escaping () -> AttachActions ) -> AttachOverlayContainer { let container = AttachOverlayContainer() container.appWindow = appWindow @@ -177,7 +164,12 @@ final class AttachKeyboardOverlay { let root = AttachOverlayRoot( model: model, composer: composer, - actions: actions, + // A card here can't present a sheet, so All Photos hands the card to the bar instead. + actions: { [weak self] in + var actions = actions() + actions.onAllPhotos = { self?.handOffToBar() } + return actions + }, report: { [weak container] region, rect in container?.hitRects[region] = rect }, onFinished: { [weak self] in self?.end() }, onAbort: { [weak self] in self?.abort() } @@ -321,7 +313,8 @@ private struct AttachOverlayRoot: View { let model: ConversationBarModel let composer: ComposerModel - let actions: AttachOverlayActions + /// Returns the screen's actions as of its latest render. + let actions: () -> AttachActions let report: (AttachOverlayRegion, CGRect?) -> Void let onFinished: () -> Void let onAbort: () -> Void @@ -340,7 +333,7 @@ private struct AttachOverlayRoot: View { landing: model.attachCard.landingChipFrame, menuPlacement: .straddlesPlus, selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), - actions: actions, + actions: actions(), report: report ) } diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 8e68d7234..c30180f1f 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -6,6 +6,7 @@ // import SwiftUI +import PhotosUI import FlipcashUI /// What the attach surface is showing. @@ -147,6 +148,20 @@ enum AttachSurfaceLayout { } } +/// What the attach menu and its cards hand back to the screen, built once and drawn by both the bar +/// and the overlay over the keyboard. +struct AttachActions { + var onCash: () -> Void = {} + var onCamera: () -> Void = {} + var onPhotos: () -> Void = {} + var onCameraCapture: (ChatCameraCapture) -> Void = { _ in } + var onCameraCancel: () -> Void = {} + var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void = { _, _ in } + var onPhotosBack: () -> Void = {} + /// Replaces All Photos presenting the full picker from the card; nil presents it in place. + var onAllPhotos: (() -> Void)? = nil +} + /// The attach flow's one surface: a single glass shape that grows out of `+` into the menu, springs /// between the menu and the camera or photo card, and shrinks onto the chip a photo is staged as. /// @@ -164,7 +179,7 @@ struct AttachSurface: View { let landing: CGRect? let menuPlacement: AttachMenuPlacement let selectionLimit: Int - let actions: AttachOverlayActions + let actions: AttachActions /// Whether the photo card opens the full picker as it mounts, for one All Photos was handed to. var opensLibraryOnAppear = false /// Receives the regions that take touches as they change, and none once the surface is gone. diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index b2e5e6ceb..d40692ca3 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -298,16 +298,17 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } private func bar(coordinator: Coordinator) -> AnyView { - coordinator.overlayActions = AttachOverlayActions( + let attachActions = AttachActions( onCash: onSendCash, onCamera: { [weak coordinator] in coordinator?.openCard(.camera) }, onPhotos: { [weak coordinator] in coordinator?.openCard(.photos) }, onCameraCapture: { [weak coordinator] capture in coordinator?.closeCard { onCameraCapture(capture) } }, + // Back into the menu, with the keyboard left where it was. onCameraCancel: { [model = barModel] in model.returnToMenu() }, onPhotosAdd: { [weak coordinator] items, preloader in coordinator?.closeCard { onPhotosAdded(items, preloader) } }, - onPhotosBack: { [model = barModel] in model.returnToMenu() }, - onAllPhotos: { [weak coordinator] in coordinator?.overlay.handOffToBar() } + onPhotosBack: { [model = barModel] in model.returnToMenu() } ) + coordinator.attachActions = attachActions return AnyView( ConversationBottomBar( showsSendCash: showsSendCash, @@ -327,27 +328,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onAttachOpen: { [weak coordinator] items in coordinator?.openAttach(items: items) }, - onCamera: { [weak coordinator] in - coordinator?.openCard(.camera) - }, - onCameraCapture: { [weak coordinator] capture in - coordinator?.closeCard { onCameraCapture(capture) } - }, - onCameraCancel: { [model = barModel] in - // Back into the panel, with the keyboard left down. - model.returnToMenu() - }, - onPhotos: { [weak coordinator] in - coordinator?.openCard(.photos) - }, - onPhotosAdd: { [weak coordinator] items, preloader in - coordinator?.closeCard { onPhotosAdded(items, preloader) } - }, + attachActions: attachActions, onImagesDropped: onImagesDropped, - onPhotosBack: { [model = barModel] in - // Back into the panel, with the keyboard left down. - model.returnToMenu() - }, // Only a member can aim a reply, so the viewer may see what it quotes. quoteThumbnailLocation: { [resolver = coordinator.mediaURLResolver] kind in await resolver.thumbnailLocation(for: kind, canReact: true) @@ -384,8 +366,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let composer: ComposerModel /// Draws the attach panel and cards over the keyboard while the composer keeps focus. let overlay: AttachKeyboardOverlay - /// What the panel and cards drawn over the keyboard hand back, as of the latest render. - var overlayActions: AttachOverlayActions? + /// What the attach menu and its cards hand back, as of the latest render. + var attachActions = AttachActions() init(model: ConversationBarModel, composer: ComposerModel) { self.model = model @@ -396,10 +378,15 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// `+` is opening the panel with `items` as its rows: over the keyboard while it is up and its /// window or the field's input view can carry the panel, else with the keyboard taken down. func openAttach(items: [AttachMenuItem]) { - guard let screen, let overlayActions else { return } + guard let screen else { return } // Sized now, so the card's content is laid out at its final size while the menu is up. model.attachCard.measure(screenHeight: screen.view.bounds.height) - let mode = overlay.begin(screen: screen, items: items, composer: composer, actions: overlayActions) + let mode = overlay.begin( + screen: screen, + items: items, + composer: composer, + actions: { [weak self] in self?.attachActions ?? AttachActions() } + ) switch mode { case .keyboardWindow, .inputView: break diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 5ef5918c4..c74c55490 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -294,22 +294,11 @@ struct ConversationBottomBar: View { /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, /// or the keyboard goes down under it. var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } - /// Opens the camera from the attach menu. - var onCamera: () -> Void = {} - /// Receives a photo the inline camera took. - var onCameraCapture: (ChatCameraCapture) -> Void = { _ in } - /// Fired by the camera card's back chevron. - var onCameraCancel: () -> Void = {} - /// Opens the photo card from the attach menu. - var onPhotos: () -> Void = {} - /// Receives the photos added from the photo card, in the order they were selected, with the - /// loader already reading them. - var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void = { _, _ in } + /// What the attach menu and its cards hand back, the same value the overlay over the keyboard draws with. + var attachActions = AttachActions() /// Receives images dropped on the bar, such as the system screenshot thumbnail or an image /// from another app in split view. Only fired while the bar takes photos. var onImagesDropped: ([NSItemProvider]) -> Void = { _ in } - /// Fired by the photo card's back chevron and escape gesture. - var onPhotosBack: () -> Void = {} /// Where the reply strip's quoted photo loads its thumbnail from. var quoteThumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ in nil } @@ -658,15 +647,7 @@ struct ConversationBottomBar: View { landing: model.attachCard.landingChipFrame.map(toRow), menuPlacement: .standsOnPlus, selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), - actions: AttachOverlayActions( - onCash: onSendCash, - onCamera: onCamera, - onPhotos: onPhotos, - onCameraCapture: onCameraCapture, - onCameraCancel: onCameraCancel, - onPhotosAdd: onPhotosAdd, - onPhotosBack: onPhotosBack - ), + actions: attachActions, opensLibraryOnAppear: model.overKeyboard.opensLibraryInBar ) { region, rect in if region == .card, let rect {