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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .maestro/login.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Signs the device into the UI-test account through the app's own login deeplink —
# the same path FlipcashUITests uses (FlipcashUITests/Support/BaseUITestCase.swift).
# The access key never lives in this file; pass it in at run time:
#
# maestro test .maestro/login.yaml -e ACCESS_KEY="$(...)"
#
# Run this first when another flow needs a signed-in app.
appId: com.flipcash.app.ios
---
- launchApp
- openLink: "flipcash://login#e=${ACCESS_KEY}"

# Login pulls the account down from the server, so give it room.
- extendedWaitUntil:
visible: "Wallet"
timeout: 60000
21 changes: 21 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,27 @@ final class ConversationController {
let kind: Kind

var id: String { "\(action.rawValue)-\(kind.rawValue)" }

var title: String {
switch kind {
case .conflict: "Message Changed"
case .failure:
switch action {
case .edit: "Couldn't Edit Message"
case .delete: "Couldn't Delete Message"
case .copy, .reply: "Something Went Wrong"
}
}
}

var subtitle: String {
switch kind {
case .conflict:
"This message changed somewhere else, so your change wasn't applied. The chat now shows the latest version."
case .failure:
"Check your connection and try again."
}
}
}

/// The transcript's bounded window with the in-memory optimistic overlay applied: every confirmed
Expand Down
41 changes: 41 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
let onContactAction: () -> Void
/// Fired when the user taps the profile card in a tip DM; nil disables the card tap.
let onProfileTap: (() -> Void)?
/// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never
/// arrives here — the transcript puts the text on the pasteboard itself.
let onMessageAction: (String, MessageCapability) -> Void
let showsSendCash: Bool
let chatExists: Bool
let conversationID: ConversationID?
let symbol: String
let onSendCash: () -> Void
let conversationController: ConversationController
let barModel: ConversationBarModel
let composer: ComposerModel
/// The row an edit is open on, or nil. Passed in rather than read off `composer` inside the
/// representable so that the owning view's body depends on it — which is what gets
/// `updateUIViewController` called, and the edit backdrop taken down, when the edit ends.
let editingStableID: String?
/// Raise the keyboard when the screen first appears (post-tip open). The UIKit screen focuses
/// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the
/// keyboard across the hosting boundary.
Expand All @@ -59,6 +67,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onOpenURL = onOpenURL
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onCancelEdit = { [composer] in composer.endEditing() }
screen.update(items: items)
context.coordinator.barHost = barHost
context.coordinator.screen = screen
Expand All @@ -76,6 +86,14 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onOpenURL = onOpenURL
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onCancelEdit = { [composer] in composer.endEditing() }
// The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it
// has to claim the menu's blur before the dismissal fades it; it comes down here, whichever
// way the edit ended — cancelled, saved, or abandoned by a tap outside.
if editingStableID == nil {
screen.endEditSpotlight()
}

// Scroll only when the user's *own* message was just appended — a new trailing message id
// (skipping any trailing receipt) that is from me. Received messages and prepended history
Expand All @@ -91,6 +109,28 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
context.coordinator.lastMessageID = newLastMessageID
}

/// Wraps the action handler so the screen follows the action. The menu dismissed the keyboard
/// to present itself and the composer can't drive it back on its own — a hosted SwiftUI
/// `@FocusState` doesn't make the field first responder — so only the screen can raise it for an
/// edit, or hold it down for a delete, whose confirmation sheet it would otherwise cover. Edit
/// also claims the menu's blur here, while the menu is still up, so the two states share one
/// backdrop instead of fading one out and another in.
private func keyboardFollowing(
_ handler: @escaping (String, MessageCapability) -> Void,
screen: ChatScreenViewController
) -> (String, MessageCapability) -> Void {
{ [weak screen] stableID, action in
handler(stableID, action)
switch action {
case .edit:
screen?.beginEditSpotlight(for: stableID)
screen?.focusComposer()
case .delete: screen?.dismissKeyboard()
case .copy, .reply: break
}
}
}

private func lastMessage(of items: [ChatItem]) -> ChatItem? {
items.last { if case .message = $0 { true } else { false } }
}
Expand All @@ -108,6 +148,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
symbol: symbol,
onSendCash: onSendCash,
model: barModel,
composer: composer,
isTipDm: isTipDm
)
.environment(conversationController)
Expand Down
93 changes: 93 additions & 0 deletions Flipcash/Core/Screens/Conversation/ComposerModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// ComposerModel.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import Observation
import FlipcashCore

/// What the composer is writing, and the text it holds. Editing borrows the same field as a new
/// message, so the unsent draft is stashed while an edit is in progress and put back if the edit is
/// cancelled.
@MainActor
@Observable
final class ComposerModel {

enum Mode: Equatable {
case new
/// `stableID` is the transcript row's identity, kept alongside the message id so the screen
/// can highlight the row being edited without re-deriving it.
case editing(messageID: MessageID, stableID: String)
}

private(set) var mode: Mode = .new
var draft = ""

/// The unsent new-message draft, held while an edit occupies the field.
@ObservationIgnored private var stashedDraft = ""
/// The text the message had when the edit began, so an unchanged edit can be refused.
@ObservationIgnored private var originalText = ""

/// The trimmed text to submit, or `nil` if there is nothing worth submitting. An edit that
/// matches the original is nothing worth submitting.
var submission: String? {
let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
switch mode {
case .new:
return trimmed
case .editing:
return trimmed == originalText ? nil : trimmed
}
}

var canSubmit: Bool { submission != nil }

/// The transcript row an edit is open on, if any. The chat screen keys its edit backdrop off
/// this, so reading it is what ties the backdrop's lifetime to the composer's mode.
var editingStableID: String? {
switch mode {
case .new: nil
case .editing(_, let stableID): stableID
}
}

/// Whether the field is editing an existing message rather than writing a new one. The bar
/// swaps its leading control and its confirm glyph on this.
var isEditing: Bool {
switch mode {
case .new: false
case .editing: true
}
}

/// Switches the field to editing an existing message, stashing whatever was being written.
func beginEditing(messageID: MessageID, stableID: String, currentText: String) {
if case .new = mode {
stashedDraft = draft
}
mode = .editing(messageID: messageID, stableID: stableID)
originalText = currentText
draft = currentText
}

/// Leaves editing and restores the stashed draft.
func endEditing() {
guard case .editing = mode else { return }
mode = .new
originalText = ""
draft = stashedDraft
stashedDraft = ""
}

/// Empties the field after a successful send.
func clear() {
mode = .new
originalText = ""
stashedDraft = ""
draft = ""
}
}
Loading
Loading