diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ca52e943d..13a85d080 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -111,14 +111,6 @@ extension AppRouter { /// trace shows "post-tip, keyboard up" distinctly, and so the ordinary /// tip-list / push-notification opens stay keyboard-closed untouched. case tipConversationWithKeyboard(ConversationID) - /// A tip DM named by its counterpart rather than its chat id — the - /// username lookup's destination. The chat is created by the first tip, - /// so before then there is no id to push; the screen derives one. - case tipConversationForUser(UserID) - /// Same chat as `tipConversationForUser`, opened with Send Cash already started — the - /// profile's Send Cash button. A sibling case for the same reason as - /// `tipConversationWithKeyboard`: the trace shows the send-first open distinctly. - case tipConversationForUserSendingCash(UserID) /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) @@ -156,8 +148,7 @@ extension AppRouter { .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, - .tipConversation, .tipConversationWithKeyboard, .tipConversationForUser, - .tipConversationForUserSendingCash, .userProfile, .chatProfile, + .tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .archivedChats: return .tips } @@ -209,8 +200,6 @@ extension AppRouter { case .newPublicGroup: "newPublicGroup" case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" - case .tipConversationForUser: "tipConversationForUser" - case .tipConversationForUserSendingCash: "tipConversationForUserSendingCash" case .userProfile: "userProfile" case .chatProfile: "chatProfile" case .editGroup: "editGroup" @@ -244,9 +233,7 @@ extension AppRouter { .editGroupName(let conversationID), .editGroupPicture(let conversationID): return conversationID.description - case .userProfile(let userID, _), - .tipConversationForUser(let userID), - .tipConversationForUserSendingCash(let userID): + case .userProfile(let userID, _): return userID.uuidString case .username(let username): return username?.value diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 69d74a4f1..f35f2359c 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -210,17 +210,6 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), openKeyboard: true) .id(conversationID) - case .tipConversationForUser(let userID): - // Opened before the chat exists, so the screen is given the person - // and derives the chat id itself. `.id` forces fresh view identity. - ConversationScreen(context: .tipDM(counterpart: userID)) - .id(userID) - - case .tipConversationForUserSendingCash(let userID): - // The profile's Send Cash: the same chat, with the send started on open. - ConversationScreen(context: .tipDM(counterpart: userID), startSendCash: true) - .id(userID) - case .userProfile(let userID, let origin): UserProfileScreen(userID: userID, origin: origin) .id(userID) diff --git a/Flipcash/Core/Navigation/DMRoute.swift b/Flipcash/Core/Navigation/DMRoute.swift new file mode 100644 index 000000000..7b8d8bd81 --- /dev/null +++ b/Flipcash/Core/Navigation/DMRoute.swift @@ -0,0 +1,22 @@ +// +// DMRoute.swift +// Flipcash +// + +import FlipcashCore + +/// Where opening a person lands: their DM once it exists, their profile until then. +nonisolated enum DMRoute { + + /// The destination for opening `userID`, given the DM with them if there is one. + static func destination( + for userID: UserID, + dmID: ConversationID?, + origin: UserProfileOrigin + ) -> AppRouter.Destination { + if let dmID { + return .tipConversation(dmID) + } + return .userProfile(userID, origin: origin) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index d040c30c8..87158330f 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -80,7 +80,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the long-press strip's "+" is tapped, with the row's stable id. let onReactionStripAdd: (String) -> Void let showsSendCash: Bool - let chatExists: Bool let conversationID: ConversationID? let symbol: String let onSendCash: () -> Void @@ -95,10 +94,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the /// keyboard across the hosting boundary. let focusOnAppear: Bool - /// Whether this is a tip DM, whose Send Cash reads Start Chatting until the chat exists. - let isTipDm: Bool - /// The floor the first tip has to clear to open this chat, named on the CTA. - let startChattingFee: FiatAmount? /// Whether the chat's participation rules leave this user anything to type, and whether they /// may read at all. Drives the gate panel in place of the bar and the blur over the transcript. let gate: ConversationGatePresentation @@ -150,7 +145,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder - screen.barRestingDrop = chatExists ? BarMetrics.compactDrop : 0 + screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -206,7 +201,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator) screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder - screen.barRestingDrop = chatExists ? BarMetrics.compactDrop : 0 + screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -313,14 +308,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { return AnyView( ConversationBottomBar( showsSendCash: showsSendCash, - chatExists: chatExists, conversationID: conversationID, symbol: symbol, onSendCash: onSendCash, model: barModel, composer: composer, - isTipDm: isTipDm, - startChattingFee: startChattingFee, gate: gate, gateMintName: gateMintName, onGateAddFunds: onGateAddFunds, diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 88e09ed97..2799f600b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -140,8 +140,7 @@ struct BarOverflowReporting: ViewModifier { } } -/// Single spring driving the whole bar: the button morph, the composer's -/// appearance when the chat materializes, and the send-arrow pop. +/// Single spring driving the bar's morphs: the focus-driven resize and the send-arrow pop. private let barMorphSpring = ChatMotion.swap.animation /// The curve the bar grows and shrinks on around the reply strip. @@ -182,22 +181,20 @@ enum BarMetrics { enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit - /// The full-width Send Cash call to action, alone in the bar before the chat exists. - case sendCash - /// The round `$` beside the field once the chat exists, shown while the draft is empty. + /// The round `$` beside the field, shown while the draft is empty. case cash /// Nothing beside the field. case none - /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to - /// action before the chat exists and as the round `$` after. - init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { + /// Returns the control for the bar's state. An edit wins; otherwise the round `$` when Send Cash + /// is offered. + init(isEditing: Bool, showsSendCash: Bool) { if isEditing { self = .cancelEdit } else if !showsSendCash { self = .none } else { - self = chatExists ? .cash : .sendCash + self = .cash } } } @@ -214,10 +211,10 @@ struct ConversationBarBottomRow: Equatable { /// Whether there is nothing for `+` to open. var isEmpty: Bool { plusItems.isEmpty } - /// Returns the row for the bar's state: empty during an edit and before the chat exists; - /// otherwise `+` while it has a row to open. - init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { - guard !isEditing, chatExists else { + /// Returns the row for the bar's state: empty during an edit; otherwise `+` while it has a row + /// to open. + init(isEditing: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing else { self.init(plusItems: []) return } @@ -230,21 +227,14 @@ struct ConversationBarBottomRow: Equatable { } /// The unified bottom bar: the attach menu beside the message field. -/// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { let showsSendCash: Bool - let chatExists: Bool let conversationID: ConversationID? let symbol: String let onSendCash: () -> Void let model: ConversationBarModel let composer: ComposerModel - /// Whether this is a tip DM, whose Send Cash reads Start Chatting until the chat exists. - var isTipDm: Bool = false - /// What the first tip has to clear to open this chat, named on the CTA. - /// Nil once the chat exists, and when no floor has resolved yet. - var startChattingFee: FiatAmount? = nil /// Whether the chat's participation rules leave this user anything to type. Anything but /// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel. var gate: ConversationGatePresentation = .open @@ -279,7 +269,7 @@ struct ConversationBottomBar: View { switch leadingControl { case .cash: cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 - case .cancelEdit, .sendCash, .none: + case .cancelEdit, .none: 0 } } @@ -314,9 +304,8 @@ struct ConversationBottomBar: View { static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens - /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat - /// CTA keeps its full-width button. - private var isCompact: Bool { chatExists && !model.isComposing } + /// to the full edge inset as the keyboard comes up. + private var isCompact: Bool { !model.isComposing } /// How much further in than ``BarMetrics/edgeInset`` the controls and the reply quote sit. private var compactExtraInset: CGFloat { isCompact ? BarMetrics.compactInset - BarMetrics.edgeInset : 0 } @@ -364,7 +353,7 @@ struct ConversationBottomBar: View { .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) } - case .sendCash, .none: + case .none: EmptyView() } Color.clear @@ -381,8 +370,8 @@ struct ConversationBottomBar: View { private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft - // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and - // `isEditing`, neither of which moves during a send — so it snapped while the bar's height + // gained or lost. Nothing animates that travel — the bar's springs key on `isEditing`, + // which doesn't move during a send — so it snapped while the bar's height // sprang underneath it. let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { @@ -392,22 +381,6 @@ struct ConversationBottomBar: View { switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - case .sendCash: - SendCashMorphButton( - symbol: symbol, - // Minimized by a reply as well as by focus. Starting a reply from the context - // menu raises the keyboard, and focus — and so `isComposing` — arrives a - // transaction later than the reply target, on its own bouncy spring: the button - // collapsed after the bar had already grown, jolting the field beside it. - // Reading the target directly puts the morph in the reply's own transaction, so - // the two move together and the later focus change finds nothing left to do. - composing: model.isComposing || composer.replyTarget != nil, - // Before the first tip there is no composer to sit beside: the design draws - // the full-width "Start Chatting" CTA (node 10074:18891). - standalone: true, - expandedTitle: isTipDm ? startChattingTitle : nil, - action: onSendCash - ) case .cash: if cashIsShown { ComposerCashButton(symbol: symbol, action: onSendCash) @@ -416,26 +389,21 @@ struct ConversationBottomBar: View { case .none: EmptyView() } - if chatExists { - ConversationComposer( - conversationID: conversationID, - model: model, - composer: composer, - bottomRow: bottomRow, - hidesPlus: showsCard, - // Swapped out in one frame for the surface, which is drawn as `+` where it stands. - plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, - onAttachOpen: onAttachOpen - ) - // Over the row's other controls, which the attach panel floats across. - .zIndex(1) - .transition(.opacity) - } + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, + // Swapped out in one frame for the surface, which is drawn as `+` where it stands. + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen + ) + // Over the row's other controls, which the attach panel floats across. + .zIndex(1) } .background { - if chatExists { - composerGlass - } + composerGlass } // A draft already there when the chat opens hides `$` without animating it out. .onAppear { cashIsShown = composer.draft.isEmpty } @@ -468,7 +436,6 @@ struct ConversationBottomBar: View { .animation(Self.widthSpring, value: isCompact) .padding(.top, BarMetrics.contentPadding) .padding(.bottom, BarMetrics.contentPadding) - .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) // The strip arriving with its first chip and leaving with its last, on the chip spring unless // a capture's animation is already carrying it. @@ -652,7 +619,6 @@ struct ConversationBottomBar: View { private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, - chatExists: chatExists, showsSendCash: showsSendCash ) } @@ -660,19 +626,10 @@ struct ConversationBottomBar: View { private var bottomRow: ConversationBarBottomRow { ConversationBarBottomRow( isEditing: composer.isEditing, - chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: composer.chips.count ) } - - /// The tip CTA's title. Names the amount that opens the chat when a floor - /// has resolved; the fee is what the recipient charges for the - /// conversation, so stating it is the whole point of the button. - private var startChattingTitle: String { - guard let startChattingFee else { return "Start Chatting" } - return "Send \(startChattingFee.formatted()) to Start Chatting" - } } /// A card's arrival and departure above the composer row — the reply strip or the mention list: @@ -1394,112 +1351,3 @@ private struct CancelEditButton: View { .accessibilityIdentifier("cancel-edit-button") } } - -/// The Send Cash button, rendered as a white "Send €" pill at rest and a -/// compact glass "€" square while composing (or always, in a tip chat). Alone -/// in the bar it takes the standard filled-button size; beside the composer -/// it's field-sized. -// One persistent view: the morph animates its properties (prefix text, fill, -// width, color) in lockstep — splitting the two states into separate views -// would crossfade instead of morphing. -struct SendCashMorphButton: View { - - let symbol: String - let composing: Bool - /// Whether the button is the bar's only control (no chat yet): it spans - /// the bar at the standard filled-button size instead of field-sized. - let standalone: Bool - /// Forces the compact symbol-only presentation regardless of composing. - var alwaysMinimized: Bool = false - /// Replaces "Send " while expanded. A tip chat names the tip - /// instead of the currency, because the amount is chosen on the next screen. - var expandedTitle: String? - let action: () -> Void - - /// The compact glass "€" presentation: while composing, or always once a - /// chat exists. The whole morph (label, fill, width, color) keys off this. - private var minimized: Bool { composing || alwaysMinimized } - - private var height: CGFloat { - standalone ? Metrics.buttonHeight : BarMetrics.contentHeight - } - - private var cornerRadius: CGFloat { - standalone ? Metrics.buttonRadius : BarMetrics.cornerRadius - } - - var body: some View { - Button(action: action) { - HStack(spacing: 4) { - if !minimized { - Text(expandedTitle ?? "Send") - .font(.appTextMedium) - .transition(.opacity) - } - // Suppressed while a custom title is showing: "Send a Tip $" - // isn't a label. It returns when the button minimizes. - if minimized || expandedTitle == nil { - Text(symbol) - // Same persistent Text — .interpolate animates the glyph - // between sizes; swapping views would crossfade. - .font(minimized ? .appTextXL : .appTextMedium) - .contentTransition(.interpolate) - } - } - .foregroundStyle(minimized ? Color.textMain : Color.textAction) - // The label must never reflow to "Se…" mid-morph; overflow is - // clipped by the shape instead. - .fixedSize() - .padding(.horizontal, minimized ? 0 : 20) - .frame(minWidth: BarMetrics.contentHeight) - .frame(maxWidth: standalone && !minimized ? .infinity : nil) - .frame(height: height) - // The label is the only drawn content and the fill is a background on the button, not - // on the label, so with `.plain` only the text was the target: alone in the bar the - // pill spans the width but "Send a Tip" answered a tap on its centre and nothing else. - .contentShape(RoundedRectangle(cornerRadius: cornerRadius)) - } - .buttonStyle(.plain) - // White fill above the glass base: fading it out is the white → glass - // change, without ever swapping views. - .background { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.action) - .opacity(minimized ? 0 : 1) - } - .glassBackground(cornerRadius: cornerRadius) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .accessibilityLabel(expandedTitle ?? "Send Cash") - .accessibilityIdentifier("send-cash-button") - } -} - -#Preview("Morph") { - @Previewable @State var composing = false - ZStack { - Color.backgroundMain.ignoresSafeArea() - VStack { - Spacer() - HStack(spacing: 10) { - SendCashMorphButton(symbol: "€", composing: composing, standalone: false) { - withAnimation(barMorphSpring) { composing.toggle() } - } - RoundedRectangle(cornerRadius: BarMetrics.cornerRadius) - .fill(.white.opacity(0.1)) - .frame(height: BarMetrics.contentHeight) - } - .padding(12) - } - } -} - -#Preview("Standalone") { - ZStack { - Color.backgroundMain.ignoresSafeArea() - VStack { - Spacer() - SendCashMorphButton(symbol: "€", composing: false, standalone: true) {} - .padding(12) - } - } -} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2ba694e49..f9967f06d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -16,15 +16,10 @@ import FlipcashUI // foreground-banner suppression gate on — so a missed buzz or an unsuppressed banner is traceable. private let logger = Logger(label: "flipcash.conversation") -/// How a conversation is reached: an existing DM chat (only tip DMs are -/// surfaced now — contact/phone DMs were retired with the Send tab), or a tip -/// DM named by its counterpart before the chat exists server-side. +/// How a conversation is reached: an existing chat, by its id. A tip DM that doesn't exist yet +/// is reached through the counterpart's profile, not here. nonisolated enum ConversationContext: Hashable { case existing(ConversationID) - /// A tip DM opened from the username lookup. The chat is created by the - /// first tip, so until then there is no record to reach it by — only the - /// counterpart, whose id derives the chat's own id locally. - case tipDM(counterpart: UserID) /// Resolves the counterpart's synced contact from the directory — the one /// rule the nav title, transcript profile card, and profile page share. @@ -32,19 +27,13 @@ nonisolated enum ConversationContext: Hashable { switch self { case .existing(let conversationID): directory.first { $0.dmChatID == conversationID.data } - case .tipDM: - // A tip DM's counterpart is known by profile, never by address book. - nil } } } /// A DM conversation: an iMessage-style transcript over a unified bottom bar /// (Send Cash beside the composer). Reads live messages from -/// `ConversationController`, which owns the single event stream. For a -/// contact without a chat the transcript stays empty and only Send Cash -/// shows; once the first payment creates the chat, the chat ID resolves live -/// from the synced directory and the composer appears. +/// `ConversationController`, which owns the single event stream. struct ConversationScreen: View { let context: ConversationContext @@ -53,10 +42,6 @@ struct ConversationScreen: View { /// post-tip navigation; every other entry point opens keyboard-closed. var openKeyboard: Bool = false - /// Start Send Cash once on open, as though its button had been tapped. Set only by the - /// profile's Send Cash; every other entry point waits for the tap. - var startSendCash: Bool = false - @Environment(ConversationController.self) private var conversationController @Environment(ContactSyncController.self) private var contactSyncController @Environment(AppRouter.self) private var router @@ -79,7 +64,6 @@ struct ConversationScreen: View { /// below only corrects it. @State private var navBarWidth: CGFloat = UIApplication.shared.firstWindowScene?.coordinateSpace.bounds.width ?? 0 @State private var presentedCard: ContactCard? - @State private var startChattingRequest: StartChattingRequest? @State private var coordinator: ConversationLoadCoordinator? /// Tickers for the mints the gate's copy names, resolved from mint metadata. Empty until they /// land, and for every ungated chat. @@ -95,9 +79,6 @@ struct ConversationScreen: View { /// Whether the encryption explainer opened from the transcript's marker is showing. @State private var isShowingEncryptionInfo = false @State private var messageReport: MessageReportRequest? - /// Whether `startSendCash` has been acted on, so a re-render or a return from the sheet it - /// opened doesn't start it again. - @State private var didStartSendCash = false /// Whether `Group: Gate Shown` has gone out for this visit. The destination is keyed by /// conversation ID, so a new push is a new visit; a return from a pushed screen is not. @State private var didReportGate = false @@ -131,31 +112,17 @@ struct ConversationScreen: View { switch context { case .existing(let conversationID): return conversationID - case .tipDM(let counterpart): - // The same derivation the server uses, so the id is known before - // the chat is — and matches the one the first tip creates. - return .tipDm(between: conversationController.selfUserID, and: counterpart) - } - } - - /// The counterpart this screen was opened for, when it was opened by - /// person rather than by chat. - private var counterpartUserID: UserID? { - switch context { - case .existing: nil - case .tipDM(let counterpart): counterpart } } - /// The tip DM counterpart, when this conversation is a tip DM. Falls back - /// to the cached profile before the first tip creates the chat. + /// The tip DM counterpart, when this conversation is a tip DM. private var tipCounterpart: ConversationMember? { if let conversationID, let conversation = conversationController.conversation(withID: conversationID), conversation.type == .tipDm { return conversation.counterpart(excluding: conversationController.selfUserID) } - return Self.cachedCounterpart(counterpartUserID, session: session) + return nil } /// Who Send Cash pays: the tip counterpart in a tip DM, the chat itself in @@ -167,53 +134,13 @@ struct ConversationScreen: View { return .contact(contact) } guard let conversationID else { return nil } - if let target = SendTarget( + return SendTarget( conversation: conversationController.conversation(withID: conversationID), dmChatID: conversationID.data, selfUserID: conversationController.selfUserID - ) { - return target - } - // No conversation record to read the counterpart from yet, so the - // cached profile is what the tip is addressed to. - guard let member = tipCounterpart, let userID = member.userID else { return nil } - return .tip(TipRecipient( - userID: userID, - displayName: member.displayName, - username: member.username, - origin: .chat - )) - } - - /// What the first tip has to clear to open this chat — the fee the - /// counterpart charges for the conversation, falling back to the regional - /// tip minimum when they charge nothing. Nil once the chat exists, since a - /// send into an open thread carries no floor. - /// - /// Derived from the same inputs as `SendAmountViewModel.tipFloor(in:)` so - /// the amount the CTA names is the one the amount screen enforces. - private var startChattingFee: FiatAmount? { - guard !chatExists, let userID = tipCounterpart?.userID else { return nil } - return StartChattingFee.amount( - for: session.cachedUserProfile(for: userID), - session: session, - ratesController: ratesController ) } - /// Whether a chat exists to hold a transcript. An `existing` conversation - /// was reached by its chat id, so it does by construction; a tip DM opened - /// by counterpart does not until the first tip creates it server-side. - private var chatExists: Bool { - guard let conversationID else { return false } - switch context { - case .existing: - return true - case .tipDM: - return conversationController.conversation(withID: conversationID) != nil - } - } - /// For a tip DM, all counterpart taps open the profile screen — even when /// the counterpart is also an address-book contact. A group has no counterpart, so the same /// taps open the chat's own profile instead. @@ -256,11 +183,6 @@ struct ConversationScreen: View { } private var title: String { - // Without a chat there is no conversation record to name, so the - // counterpart's cached profile is the only source for the title. - if !chatExists, let name = tipCounterpart?.displayName { - return name - } if let conversationID { return conversationController.displayName(forConversationID: conversationID) } @@ -295,12 +217,7 @@ struct ConversationScreen: View { /// is what creates it. private var awaitingMetadata: Bool { guard let conversationID else { return false } - switch context { - case .tipDM: - return false - case .existing: - return conversationController.conversation(withID: conversationID) == nil - } + return conversationController.conversation(withID: conversationID) == nil } /// The rule verdicts behind ``gate``, kept separately because the head card states the chat's @@ -510,7 +427,6 @@ struct ConversationScreen: View { onReactionStripSelect: toggleReaction, onReactionStripAdd: openReactionPicker, showsSendCash: sendTarget != nil, - chatExists: chatExists, conversationID: conversationID, symbol: ratesController.balanceCurrency.compactSymbol, onSendCash: sendCash, @@ -519,8 +435,6 @@ struct ConversationScreen: View { composer: composer, editingStableID: composer.editingStableID, focusOnAppear: openKeyboard, - isTipDm: tipCounterpart != nil, - startChattingFee: startChattingFee, gate: gate, // A viewer the gate refuses is refused the read too, so a blocked chat they have no // history of has nothing under its blur. The shapes stand in for what they are not @@ -529,7 +443,7 @@ struct ConversationScreen: View { // whose rules haven't landed yet is blurred without yet refusing anything. // Also stands in while an empty transcript's first load is out, so a chat with nothing // cached opens on the placeholder and paints once with its history. - showsGatePlaceholder: (gate.withholdsTranscript || (chatExists && !didInitialRead)) && (coordinator?.items.isEmpty ?? true), + showsGatePlaceholder: (gate.withholdsTranscript || !didInitialRead) && (coordinator?.items.isEmpty ?? true), gateMintName: gateMintName, onGateAddFunds: addFunds, onGateJoin: joinChat, @@ -621,7 +535,7 @@ struct ConversationScreen: View { // `onReachTop` on every scroll frame it spends near the top, and reading `gate` re-evaluates // the chat's rules against the balance and the rate table each time. let gate = self.gate - let pagesHistory = chatExists && !gate.obscuresTranscript + let pagesHistory = !gate.obscuresTranscript // Stages, rather than one chain. A getter is a single type-check budget however many // statements it holds, and this chain is more than the compiler will finish inside one — // it gives up on CI, where the budget is tighter than on a dev machine. A function each @@ -816,9 +730,6 @@ struct ConversationScreen: View { ContactCardView(card: card) .ignoresSafeArea() } - .sheet(item: $startChattingRequest) { request in - StartChattingSheet(target: request.target, fee: request.fee) - } .sheet(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } @@ -865,8 +776,8 @@ struct ConversationScreen: View { /// Takes `gate` rather than reading it, so these closures capture the value `body` resolved. private func lifecycle(_ content: some View, gate: ConversationGatePresentation) -> some View { content - .task(id: chatExists ? conversationID : nil) { - guard chatExists, let conversationID else { return } + .task(id: conversationID) { + guard let conversationID else { return } // Ensure the conversation metadata is in the store before the title, tip styling, and Send // Cash target rely on it. The post-tip open (and any push/link that lands here before the // feed or stream has the freshly-created chat) would otherwise render the unresolved @@ -886,7 +797,7 @@ struct ConversationScreen: View { // The opening task read the gate it started with, which for a chat reached by link or // push is `.undetermined`: a non-member the rules admit, or a balance that crosses the // requirement mid-screen, lifts the blur without re-running it. A join loads for itself. - guard !obscures, !didInitialRead, !isJoiningChat, chatExists, let conversationID else { return } + guard !obscures, !didInitialRead, !isJoiningChat, let conversationID else { return } Task { await loadTranscript(for: conversationID) } } // Buzz on a live message from the other side while this conversation is on screen. `old != nil` @@ -912,11 +823,6 @@ struct ConversationScreen: View { syncCoordinator(id) restoreDraft(id) } - .onChange(of: isReadyToStartSendCash, initial: true) { _, ready in - guard ready else { return } - didStartSendCash = true - sendCash() - } .onChange(of: composer.draft) { _, _ in saveDraft() } // Mode rather than `replyTarget` alone: it also covers the edit transitions, where what is // worth saving swaps between the field and the draft the edit displaced. @@ -953,7 +859,7 @@ struct ConversationScreen: View { // Donate the open chat for Siri prediction, Handoff, and Spotlight. // Only an existing chat carries an id worth resuming; a contact without // a chat yet has nothing to hand off to. - .userActivity(AppUserActivity.openChat, isActive: chatExists && conversationID != nil) { activity in + .userActivity(AppUserActivity.openChat, isActive: conversationID != nil) { activity in guard let conversationID else { return } activity.title = title activity.userInfo = [AppUserActivity.chatIDKey: conversationID.base64URLEncoded] @@ -1210,13 +1116,6 @@ struct ConversationScreen: View { } } - /// Whether the open-time Send Cash can run and take the branch a tap would. Before the chat - /// exists that waits on the fee: `sendCash()` falls through to the amount screen without it. - private var isReadyToStartSendCash: Bool { - startSendCash && !didStartSendCash && sendTarget != nil - && (chatExists || startChattingFee != nil) - } - private func sendCash() { guard let sendTarget else { return } let context: AddMoneyContext = switch sendTarget { @@ -1228,26 +1127,9 @@ struct ConversationScreen: View { session.dialogItem = dialog return } - // The payment that opens a tip DM can only be the fee, and the bar has - // already named it — so it's confirmed rather than entered. Everything - // else, this one included when the fee hasn't resolved yet, opens the - // amount screen. - if case .tip = sendTarget, let fee = startChattingFee { - startChattingRequest = StartChattingRequest(target: sendTarget, fee: fee) - return - } router.presentSendAmount(sendTarget) } - /// What the start-chatting sheet is opened for, snapshotted at the tap: once - /// the send lands the chat exists and `startChattingFee` goes nil. - private struct StartChattingRequest: Identifiable { - let target: SendTarget - let fee: FiatAmount - - var id: SendTarget { target } - } - /// Re-send a failed message tapped in the transcript. The id is the row's stable id, which for a /// failed message is its client message id. private func retry(messageID: String) { @@ -1494,7 +1376,7 @@ struct ConversationScreen: View { knownAuthors: sessionContainer.knownAuthors, // `profileAvatars` is captured directly so the coordinator retains // one small store, not the whole session container. - profileCard: { [context, contactSyncController, conversationController, session, counterpartUserID, profileAvatars = sessionContainer.profileAvatars] in + profileCard: { [context, contactSyncController, conversationController, session, profileAvatars = sessionContainer.profileAvatars] in // A group cards itself — the address book has nothing to say about a chat, and // the unknown-contact fallback below would flag one as an unknown person. guard conversationController.conversation(withID: id)?.type != .group else { @@ -1505,10 +1387,7 @@ struct ConversationScreen: View { conversationID: id, directory: contactSyncController.resolvedContacts.onFlipcash, controller: conversationController, - profileAvatars: profileAvatars, - // Resolved inside the closure, not captured: the card - // must pick up the conversation the first tip creates. - fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session) + profileAvatars: profileAvatars ) } ) @@ -1523,28 +1402,6 @@ struct ConversationScreen: View { return .handle(username) } - /// The counterpart of a tip DM that has no chat yet, built from a fetched - /// profile. Gives the title, card, and Send Cash target the same member - /// shape a synced conversation would supply. - static func counterpart(userID: UserID, profile: Profile) -> ConversationMember { - // A name-less account can still be tipped, and the chat has to be - // titled either way. The server sends such a name as "", not nil. - let name = profile.displayName.flatMap { $0.isEmpty ? nil : $0 } - return ConversationMember( - userID: userID, - displayName: name ?? profile.username?.handle ?? ConversationController.fallbackCounterpartName, - profilePicture: profile.profilePicture, - username: profile.username - ) - } - - /// ``counterpart(userID:profile:)`` against the profile cache the username - /// lookup writes on its way here. - private static func cachedCounterpart(_ userID: UserID?, session: Session) -> ConversationMember? { - guard let userID, let profile = session.cachedUserProfile(for: userID) else { return nil } - return counterpart(userID: userID, profile: profile) - } - /// The transcript's profile card for the counterpart, resolved live from the directory the /// same way the nav title is: the synced contact when there is one, the profile-only tip /// counterpart for a tip DM, otherwise the counterpart's formatted number flagged as an @@ -1554,8 +1411,7 @@ struct ConversationScreen: View { conversationID: ConversationID, directory: [ResolvedContact], controller: ConversationController, - profileAvatars: ProfileAvatarStore, - fallbackCounterpart: ConversationMember? = nil + profileAvatars: ProfileAvatarStore ) -> ChatProfileCard { if let conversation = controller.conversation(withID: conversationID), conversation.type == .tipDm { @@ -1568,16 +1424,6 @@ struct ConversationScreen: View { counterpart: Self.tipDMCounterpart(counterpart) ) } - // No conversation record yet — the same card, from the cached profile. - if let counterpart = fallbackCounterpart { - return ChatProfileCard( - name: counterpart.displayName, - avatarID: counterpart.userID?.uuidString ?? conversationID.description, - imageData: profileAvatars.data(for: counterpart.userID), - blurhash: counterpart.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart) - ) - } if let contact = context.resolvedContact(in: directory) { return ChatProfileCard( name: contact.displayName, diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index cfaad557d..67c70bb6e 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -71,15 +71,16 @@ struct TransactionDetailsScreen: View { idCard if let userID = activity.counterparty?.userID, details.canViewInChat { - Button("View in Chat") { + let dmID = sessionContainer.conversationController.tipDMID(withUserID: userID) + Button(dmID == nil ? "View Profile" : "View in Chat") { // Pushed onto the stack this screen is already // on, not routed to the Chat tab: a cross-stack // jump swaps the tab out from under the // transition, so the bar and the conversation - // list both show before the chat lands. Pushed, - // the chat arrives from the entry it belongs to - // and back returns here. - router.push(.tipConversationForUser(userID)) + // list both show before the screen lands. + // Pushed, it arrives from the entry it belongs + // to and back returns here. + router.push(DMRoute.destination(for: userID, dmID: dmID, origin: .transaction)) } .buttonStyle(.filled05) } diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index 6f7790feb..9c0f01e48 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -11,8 +11,7 @@ private let logger = Logger(label: "flipcash.tip-flow") /// Session-scoped orchestrator for opening someone's tipcard. Entered from a /// scanned tipcode or a legacy `/tip/` link, it gates the entry (profile), resolves -/// the recipient, shows the card, and hands over to their chat — where the -/// amount is chosen and sent (node 10074:18893). +/// the recipient, shows the card, and hands over to their profile. @Observable final class TipFlow { @@ -22,7 +21,7 @@ final class TipFlow { @ObservationIgnored private var prepTask: Task? - /// The hold between the card landing and the chat opening. Held so a + /// The hold between the card landing and the profile opening. Held so a /// dismissed card cancels the hand-off, and so a second entry can't start /// while one is mid-flight. @ObservationIgnored private var routeTask: Task? @@ -53,11 +52,9 @@ final class TipFlow { /// (routed to the user's own profile), then a tippable profile (held + /// profile creation presented). /// - /// No balance gate: this flow no longer moves money, it opens a chat. The - /// giveable-balance check belongs to the send the chat makes, and - /// `ConversationScreen.sendCash()` already applies it there — gating the - /// card as well would stop an empty-balance user from reaching a - /// conversation they can read and reply in. + /// No balance gate: this flow no longer moves money, it opens a profile. + /// The giveable-balance check belongs to the payment that starts the chat, + /// which the profile applies. func begin(userID: UserID) { // Tipping yourself is a payment no-op, so there's no flow to start from // your own code — show the user their own profile rather than swallow @@ -154,9 +151,8 @@ final class TipFlow { } guard !Task.isCancelled else { return } - // The chat is created by the first tip, so until then the - // conversation's only source for the counterpart's name, - // picture, and handle is the profile this resolve fetched. + // The profile screen reads this instead of fetching again; see + // `UserProfileOrigin.arrivesFetched`. session.cacheUserProfile(profile, for: userID) present(userID: userID, profile: profile) await loadAvatar(userID: userID, picture: profile.profilePicture) @@ -182,12 +178,11 @@ final class TipFlow { } /// Shows the resolved card, holds it long enough to read whose it is, then - /// pops it away as the chat with them pushes in underneath. + /// pops it away as their profile pushes in underneath. /// /// The card is the confirmation that the right code was scanned, not a - /// place to compose from: the amount is chosen in the chat, behind the - /// "Start Chatting" CTA, which is where the username lookup already lands - /// (node 10074:18893). + /// place to compose from. The profile is where the chat starts, and it is + /// where a scan lands even once the DM exists, offering Open Chat there. private func present(userID: UserID, profile: Profile) { // The card is resolved and about to show, whether reached from a scan or // a deep link — the second step of the Scanned → Presented → Sent Tip funnel. @@ -199,9 +194,9 @@ final class TipFlow { keyboard.suppress() // A tip deep link can beat the app's foreground stream refresh, so kick the - // rate stream to reconnect now, while the card animates in. The chat's CTA - // names the fee in the display currency and its amount screen priced in it, - // so both want a rate the moment they appear. + // rate stream to reconnect now, while the card animates in. The profile's + // pinned button names the fee in the display currency, so it wants a rate + // the moment it appears. ratesController.ensureStreamConnected() session.billState = BillState(bill: .tipcard( @@ -213,11 +208,11 @@ final class TipFlow { session.presentationState = .visible(.pop) // The card's pop is given 750ms to settle and be read, then it leaves - // and the chat arrives together: the dismissal and the route happen in - // the same tick, so the card's 100ms exit plays over the chat pushing + // and the profile arrives together: the dismissal and the route happen in + // the same tick, so the card's 100ms exit plays over the profile pushing // in rather than handing back to the camera in between. Cancelled by // `cancel()`, so a card the user drags away during the hold never drops - // them into a chat they backed out of. + // them onto a profile they backed out of. routeTask = Task { [weak self] in defer { self?.routeTask = nil } try? await Task.delay(milliseconds: 750) @@ -228,10 +223,11 @@ final class TipFlow { } // `navigate` rather than `push`: the card is an app-root overlay - // raised over whichever tab the scan or link arrived on, and a tip - // DM belongs on the Tips stack. This brings that tab forward with - // the chat as its only entry, so Back lands on the chat list. - router.navigate(to: .tipConversationForUser(userID)) + // raised over whichever tab the scan or link arrived on, and the DM + // the profile leads to belongs on the Tips stack. This brings that + // tab forward with the profile as its only entry, so Back lands on + // the chat list. + router.navigate(to: .userProfile(userID, origin: .scan)) } } diff --git a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift index 033a5cb91..cf7df413a 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift @@ -141,16 +141,15 @@ struct UsernameLookupScreen: View { buttonState = .success try await Task.delay(milliseconds: 500) - // Your own handle has no chat to open — one tipcard is what - // scanning your own code already shows. + // Your own handle has no chat to open — scanning your own code + // already shows your own profile. guard userID != session.userID else { sessionContainer.tipFlow.begin(userID: userID) return } - // The chat is created by the first tip, so until then the - // screen's only source for the counterpart's name, picture, and - // handle is the profile this lookup just fetched. + // The profile screen reads this instead of fetching again, and a + // chat with no record yet reads its counterpart from it. session.cacheUserProfile(profile, for: userID) // Detached before the push: covering this screen fires the @@ -158,16 +157,22 @@ struct UsernameLookupScreen: View { // that has to run anyway. lookupTask = nil - let chat = AppRouter.Destination.tipConversationForUser(userID) + // The DM once it exists, the profile until then: the profile is + // where the chat starts. + let destination = DMRoute.destination( + for: userID, + dmID: sessionContainer.conversationController.tipDMID(withUserID: userID), + origin: .usernameLookup + ) // 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) + let depthWithDestination = router[.tips].count + 1 + router.push(destination) - // Well inside the push transition, so the remounted chat settles + // Well inside the push transition, so the remounted screen settles // behind the animation rather than in front of the user. Late // enough that SwiftUI has committed the push as its own update: // rewriting in the same tick coalesces the two into a leaf swap, @@ -175,17 +180,17 @@ struct UsernameLookupScreen: View { try await Task.delay(milliseconds: 150) // 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 == depthWithChat else { return } + // them, and rewriting the path would drag the screen back. + guard router[.tips].count == depthWithDestination else { return } // Unanimated, because nothing is arriving — only the entry // underneath is leaving. Animated, SwiftUI stages the remount as - // a second push: the same chat sliding in over the one already + // a second push: the same screen sliding in over the one already // on screen. var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { - router.setPath([chat], on: .tips) + router.setPath([destination], on: .tips) } } catch { diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index e337c7390..6d06b8a82 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -361,32 +361,47 @@ nonisolated enum UserProfileOrigin: Hashable { case mention /// A `flipcash.com/` or `flipcash.com/` link opened into the app. case deeplink + /// Their code scanned with the camera, or their profile QR link opened, after the card shows. + case scan + /// A username search from New Chat, with no DM yet. + case usernameLookup + /// A transaction's details, with no DM yet. + case transaction /// Whether Open Chat returns to the DM the profile was opened from rather than pushing a /// second copy of it. var returnsToExistingDM: Bool { switch self { - case .directMessage: return true - case .groupMember, .mention, .deeplink: return false + case .directMessage: + return true + case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction: + return false } } /// Whether the profile was fetched and cached just before the screen opened, so the screen - /// reads the cache instead of fetching again. A link is looked up before it navigates. + /// reads the cache instead of fetching again. A link, a scan, and a username search all look the + /// person up before they navigate. var arrivesFetched: Bool { switch self { - case .deeplink: return true - case .directMessage, .groupMember, .mention: return false + case .deeplink, .scan, .usernameLookup: + return true + case .directMessage, .groupMember, .mention, .transaction: + return false } } /// Whether blocking closes just the profile rather than resetting its stack. From a chat the /// stack beneath can hold the blocked person's DM, so it resets; a link opened the profile over - /// whatever the user was on, and that is where blocking returns them. + /// whatever the user was on, and that is where blocking returns them, as does a transaction's + /// details. A scan or a username search opens the profile as the Chats tab's only entry, so a + /// reset lands on the chat list either way. var blockReturnsToOpener: Bool { switch self { - case .deeplink: return true - case .directMessage, .groupMember, .mention: return false + case .deeplink, .transaction: + return true + case .directMessage, .groupMember, .mention, .scan, .usernameLookup: + return false } } } diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index ee0350e51..6bfef51b7 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -75,8 +75,7 @@ final class SendAmountViewModel { /// False for a contact send and for a thread that already exists. var opensTipDM: Bool { guard case .tip(let recipient) = target else { return false } - // The same rule `ConversationScreen.chatExists` draws: a tip DM's id is - // derived locally, so the feed holding it is what says the chat is real. + // A tip DM's id is derived locally, so the feed holding it is what says the chat is real. return conversationController.conversation( withID: .tipDm(between: session.userID, and: recipient.userID) ) == nil diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 4533ee991..53fd0ea5b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -129,7 +129,7 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { /// nothing else. public let canCancel: Bool - /// Whether the counterparty's conversation can be opened from here. + /// Whether the counterparty can be opened from here: their DM, or their profile until it exists. public let canViewInChat: Bool /// The line the header actually renders. @@ -177,10 +177,9 @@ extension TransactionDetails { self.fee = swap?.fee self.received = swap?.toFiat self.canCancel = activity.cancellableCashLinkMetadata != nil - // Opening the conversation needs somebody to open it with, and only a - // user id identifies one — a phone-number counterparty has no chat. The - // display name is not required: the chat screen derives its own header - // from the id (see `AppRouter.Destination.tipConversationForUser`). + // Opening the person needs somebody to open, and only a user id + // identifies one — a phone-number counterparty has no chat or profile. + // The display name is not required: the profile fetches its own. self.canViewInChat = activity.counterparty?.userID != nil } diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index 9a54eb6f4..7498e77c4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -124,7 +124,6 @@ private struct HostedBar: View { var body: some View { ConversationBottomBar( showsSendCash: true, - chatExists: true, conversationID: .test(1), symbol: "$", onSendCash: {}, diff --git a/FlipcashTests/Chat/ChatAttributionNameTests.swift b/FlipcashTests/Chat/ChatAttributionNameTests.swift index b43d66079..c907080a2 100644 --- a/FlipcashTests/Chat/ChatAttributionNameTests.swift +++ b/FlipcashTests/Chat/ChatAttributionNameTests.swift @@ -29,17 +29,4 @@ struct ChatAttributionNameTests { let member = ConversationMember(userID: UserID(), displayName: "") #expect(ConversationLoadCoordinator.attributionName(for: member) == ConversationController.fallbackCounterpartName) } - - @Test("A tip DM with no chat yet titles a nameless profile by its handle, not a blank") - @MainActor func dmCounterpart_emptyName_usesHandle() throws { - let username = try #require(Username("grace")) - let profile = Profile(displayName: "", phone: Phone?.none, email: nil, username: username) - #expect(ConversationScreen.counterpart(userID: UserID(), profile: profile).displayName == username.handle) - } - - @Test("A tip DM with no chat yet titles a nameless profile without a handle as Flipcash User") - @MainActor func dmCounterpart_emptyNameNoHandle_usesFallback() { - let profile = Profile(displayName: "", phone: Phone?.none, email: nil) - #expect(ConversationScreen.counterpart(userID: UserID(), profile: profile).displayName == ConversationController.fallbackCounterpartName) - } } diff --git a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift index c166c3969..e8bfda284 100644 --- a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift +++ b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift @@ -14,35 +14,24 @@ struct ConversationBarLeadingControlTests { private func control( isEditing: Bool = false, - chatExists: Bool = true, showsSendCash: Bool = true ) -> ConversationBarLeadingControl { - ConversationBarLeadingControl(isEditing: isEditing, chatExists: chatExists, showsSendCash: showsSendCash) + ConversationBarLeadingControl(isEditing: isEditing, showsSendCash: showsSendCash) } - @Test("Before the chat exists, Send Cash stays the full-width call to action") - func callToActionBeforeChatExists() { - #expect(control(chatExists: false) == .sendCash) - } - - @Test("Before the chat exists with nothing to send, nothing stands outside the field") - func emptyBeforeChatExistsWithoutCash() { - #expect(control(chatExists: false, showsSendCash: false) == .none) - } - - @Test("Once the chat exists, Send Cash is the round `$` beside the field") - func cashOnceChatExists() { + @Test("Send Cash is the round `$` beside the field") + func cash() { #expect(control() == .cash) } - @Test("Once the chat exists with nothing to send, nothing stands beside the field") - func noneOnceChatExistsWithoutCash() { + @Test("With nothing to send, nothing stands beside the field") + func noneWithoutCash() { #expect(control(showsSendCash: false) == .none) } @Test("An edit takes the slot whatever else is true", arguments: [true, false]) - func cancelWhileEditing(chatExists: Bool) { - #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + func cancelWhileEditing(showsSendCash: Bool) { + #expect(control(isEditing: true, showsSendCash: showsSendCash) == .cancelEdit) } } @@ -52,13 +41,11 @@ struct ConversationBarBottomRowTests { private func row( isEditing: Bool = false, - chatExists: Bool = true, acceptsMedia: Bool = true, attachedCount: Int = 0 ) -> ConversationBarBottomRow { ConversationBarBottomRow( isEditing: isEditing, - chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: attachedCount ) @@ -88,9 +75,4 @@ struct ConversationBarBottomRowTests { func hiddenWhileEditing() { #expect(row(isEditing: true).isEmpty) } - - @Test("Before the chat exists there is no bottom row") - func hiddenBeforeChatExists() { - #expect(row(chatExists: false).isEmpty) - } } diff --git a/FlipcashTests/UserProfileChatActionsTests.swift b/FlipcashTests/UserProfileChatActionsTests.swift index b0c52c384..bd847359b 100644 --- a/FlipcashTests/UserProfileChatActionsTests.swift +++ b/FlipcashTests/UserProfileChatActionsTests.swift @@ -13,25 +13,31 @@ import FlipcashCore @Suite("User profile chat actions") struct UserProfileChatActionsTests { - @Test("Blocking from a link-opened profile returns to where the link was followed") - func deeplink_blockReturnsToOpener() { - #expect(UserProfileOrigin.deeplink.blockReturnsToOpener) + @Test( + "Blocking from a link or a transaction returns to the screen the profile opened over", + arguments: [UserProfileOrigin.deeplink, .transaction] + ) + func openerOrigins_blockReturnsToOpener(_ origin: UserProfileOrigin) { + #expect(origin.blockReturnsToOpener) } @Test( "Blocking from a chat-opened profile resets the stack, which can hold the blocked DM", - arguments: [UserProfileOrigin.directMessage, .groupMember, .mention] + arguments: [UserProfileOrigin.directMessage, .groupMember, .mention, .scan, .usernameLookup] ) func chatOrigins_blockResetsStack(_ origin: UserProfileOrigin) { #expect(!origin.blockReturnsToOpener) } - @Test("Only a link-opened profile arrives already fetched") - func arrivesFetched_onlyFromDeeplink() { + @Test("A link, a scan, and a username search arrive already fetched; the rest fetch") + func arrivesFetched_onlyAfterALookup() { #expect(UserProfileOrigin.deeplink.arrivesFetched) + #expect(UserProfileOrigin.scan.arrivesFetched) + #expect(UserProfileOrigin.usernameLookup.arrivesFetched) #expect(!UserProfileOrigin.directMessage.arrivesFetched) #expect(!UserProfileOrigin.groupMember.arrivesFetched) #expect(!UserProfileOrigin.mention.arrivesFetched) + #expect(!UserProfileOrigin.transaction.arrivesFetched) } @Test("Open Chat returns to the DM only when the profile was opened from that DM") @@ -40,6 +46,9 @@ struct UserProfileChatActionsTests { #expect(!UserProfileOrigin.groupMember.returnsToExistingDM) #expect(!UserProfileOrigin.mention.returnsToExistingDM) #expect(!UserProfileOrigin.deeplink.returnsToExistingDM) + #expect(!UserProfileOrigin.scan.returnsToExistingDM) + #expect(!UserProfileOrigin.usernameLookup.returnsToExistingDM) + #expect(!UserProfileOrigin.transaction.returnsToExistingDM) } // MARK: - Destinations - @@ -55,14 +64,4 @@ struct UserProfileChatActionsTests { #expect(fromDM.payload == userID.uuidString) #expect(fromDM.owningStack == .tips) } - - @Test("Send Cash's chat destination logs apart from the plain chat, keyed by the counterpart") - func sendingCash_destination() { - let userID = UUID() - let destination = AppRouter.Destination.tipConversationForUserSendingCash(userID) - #expect(destination.description == "tipConversationForUserSendingCash") - #expect(destination.payload == userID.uuidString) - #expect(destination.owningStack == .tips) - #expect(destination != .tipConversationForUser(userID)) - } } diff --git a/FlipcashTests/UsernameLookupRoutingTests.swift b/FlipcashTests/UsernameLookupRoutingTests.swift index 8397dddee..01dc4dba6 100644 --- a/FlipcashTests/UsernameLookupRoutingTests.swift +++ b/FlipcashTests/UsernameLookupRoutingTests.swift @@ -8,44 +8,28 @@ import Testing import FlipcashCore @testable import Flipcash -/// The username lookup opens the counterpart's chat (node 9443:8928), which -/// does not exist server-side until the first tip. These cover what the screen -/// has to resolve from the fetched profile alone in the meantime. +/// The username lookup opens the counterpart's profile until a DM with them +/// exists, and the DM after that. @MainActor @Suite("Username lookup routing") struct UsernameLookupRoutingTests { - private static func profile(displayName: String?, username: String? = nil) -> Profile { - Profile( - displayName: displayName, - phone: Optional.none, - email: nil, - username: username.flatMap { Username($0) } - ) - } - // MARK: - Destination - - @Test("The chat destination belongs to the tips stack") - func destination_ownedByTipsStack() { - #expect(AppRouter.Destination.tipConversationForUser(UUID()).owningStack == .tips) - } - - @Test("The chat destination logs the counterpart as its payload") - func destination_payloadIsCounterpart() { + @Test("Without a DM, the lookup lands on the profile, owned by the tips stack, keyed by the user") + func destination_withoutDMIsTheProfile() { let userID = UUID() - let destination = AppRouter.Destination.tipConversationForUser(userID) - #expect(destination.description == "tipConversationForUser") + let destination = DMRoute.destination(for: userID, dmID: nil, origin: .usernameLookup) + #expect(destination == .userProfile(userID, origin: .usernameLookup)) + #expect(destination.owningStack == .tips) #expect(destination.payload == userID.uuidString) } - @Test("Two lookups of the same person are the same destination") - func destination_isStablePerCounterpart() { - let userID = UUID() - #expect( - AppRouter.Destination.tipConversationForUser(userID) - == AppRouter.Destination.tipConversationForUser(userID) - ) + @Test("With a DM, the lookup lands on the chat") + func destination_withDMIsTheChat() { + let (me, them) = (UUID(), UUID()) + let dmID = ConversationID.tipDm(between: me, and: them) + #expect(DMRoute.destination(for: them, dmID: dmID, origin: .usernameLookup) == .tipConversation(dmID)) } // MARK: - Context - @@ -58,27 +42,6 @@ struct UsernameLookupRoutingTests { #expect(ConversationID.tipDm(between: me, and: them) == .tipDm(between: them, and: me)) } - @Test("A tip DM counterpart is never matched to an address-book contact") - func context_neverResolvesAContact() { - // Tip DMs identify people by profile. A directory entry that happens to - // carry the derived chat id must not retitle the screen or redirect the - // send to a phone number. - let (me, them) = (UUID(), UUID()) - let chatID = ConversationID.tipDm(between: me, and: them) - let contact = ResolvedContact( - contactId: "abc", - displayName: "Fred From My Phone", - phoneE164: "+15551234567", - nationalPhone: "(555) 123-4567", - imageData: nil, - dmChatID: chatID.data - ) - let context = ConversationContext.tipDM(counterpart: them) - #expect(context.resolvedContact(in: [contact]) == nil) - // The same directory does resolve for a chat reached by its id. - #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") @@ -95,64 +58,26 @@ struct UsernameLookupRoutingTests { #expect(router[.tips].count == 2) } - @Test("Back from a chat opened by handle lands on the chat list") - func backStack_rewriteLeavesOnlyTheChat() { + @Test( + "Back from a profile or chat opened by handle lands on the chat list", + arguments: [false, true] + ) + func backStack_rewriteLeavesOnlyTheDestination(hasDM: Bool) { 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) + let them = UUID() + let dmID = hasDM ? ConversationID.tipDm(between: UUID(), and: them) : nil + let destination = DMRoute.destination(for: them, dmID: dmID, origin: .usernameLookup) + let depthWithDestination = router[.tips].count + 1 + router.push(destination) + #expect(router[.tips].count == depthWithDestination) // 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) + router.setPath([destination], on: .tips) #expect(router[.tips].count == 1) } - - // MARK: - Counterpart - - - @Test("The fetched profile supplies the name and handle the chat shows") - func counterpart_carriesNameAndHandle() { - let userID = UUID() - let member = ConversationScreen.counterpart( - userID: userID, - profile: Self.profile(displayName: "Fred Wilson", username: "fred_wilson") - ) - #expect(member.userID == userID) - #expect(member.displayName == "Fred Wilson") - #expect(member.username == Username("fred_wilson")) - } - - @Test("A name-less account is titled by its handle") - func counterpart_fallsBackToTheHandleForANamelessAccount() { - // Claiming a handle doesn't require a display name, so this is a real - // account, not a malformed response — the chat needs a title regardless. - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: nil, username: "fred_wilson") - ) - #expect(member.displayName == "@fred_wilson") - } - - @Test("An account with neither a name nor a handle gets the fallback title") - func counterpart_fallsBackForAnAccountWithNoNameOrHandle() { - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: nil) - ) - #expect(member.displayName == ConversationController.fallbackCounterpartName) - } - - @Test("A counterpart with no handle renders the name-only card") - func counterpart_withoutAHandleShowsNoHandleLine() { - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: "Fred Wilson") - ) - #expect(ConversationScreen.tipDMCounterpart(member) == ChatProfileCard.Counterpart.none) - } } diff --git a/FlipcashUITests/Smoke/TipsSmokeTests.swift b/FlipcashUITests/Smoke/TipsSmokeTests.swift index c13007867..bd1f0e95d 100644 --- a/FlipcashUITests/Smoke/TipsSmokeTests.swift +++ b/FlipcashUITests/Smoke/TipsSmokeTests.swift @@ -5,7 +5,7 @@ import XCTest -/// Rides a tipcard link from the link to the chat it opens, without committing +/// Rides a tipcard link from the link to the profile it opens, without committing /// a transfer. The tipcard deeplink stands in for scanning, which a simulator /// camera can't do. /// @@ -33,19 +33,18 @@ final class TipsSmokeTests: BaseUITestCase { executionTimeAllowance = 600 } - /// A tipcard link shows the recipient's card and then lands the sender in - /// the chat with them, where the amount is chosen. The chat doesn't exist - /// until the first tip, so what proves the landing is the send control the - /// empty thread offers — no message list, no transfer. - func testTipDeeplink_opensTheChat() throws { + /// A tipcard link shows the recipient's card and then opens their + /// profile, whose pinned button starts or opens the chat. The profile + /// proves the landing; no transfer is made. + func testTipDeeplink_opensTheProfile() throws { assertMainScreenReached() app.open(URL(string: "flipcash://tip/\(Self.recipientID)")!) - let sendButton = app.buttons["send-cash-button"] + let pinnedAction = app.buttons["profile-pinned-action"] XCTAssertTrue( - sendButton.waitForExistence(timeout: 180), - "The tip chat never appeared. On screen: [\(visibleText())]" + pinnedAction.waitForExistence(timeout: 180), + "The recipient's profile never appeared. On screen: [\(visibleText())]" ) } }