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
11 changes: 8 additions & 3 deletions .maestro/you-tab-download.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Drives the You tab's export path end to end, once per format: card page ->
# Download -> pick a format -> the system share sheet holding the written file.
# Drives the You tab's export path end to end, once per format: Share menu ->
# Show Profile Card -> Download -> pick a format -> the system share sheet holding the written file.
appId: com.flipcash.app.ios
---
- launchApp
- tapOn: "You"
- assertVisible: "Full Screen"
- tapOn:
id: "you-share"
- tapOn: "Show Profile Card"

# --- SVG ---
- tapOn:
Expand All @@ -23,6 +25,9 @@ appId: com.flipcash.app.ios
# Relaunching is the cheapest way to get the system sheet off screen.
- launchApp
- tapOn: "You"
- tapOn:
id: "you-share"
- tapOn: "Show Profile Card"
- tapOn:
id: "you-download-button"
- tapOn:
Expand Down
6 changes: 3 additions & 3 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ struct DeepLinkAction {
}

/// Whether a person link names the viewer, answerable without a lookup only when the id or the
/// handle is already known locally. A link to the viewer keeps opening their own tip card, as it
/// did before person links opened a profile.
/// handle is already known locally. A link to the viewer opens their own profile, the same as
/// scanning their own code.
static func isOwnLink(
_ identifier: ProfileIdentifier,
selfUserID: UserID,
Expand Down Expand Up @@ -425,7 +425,7 @@ struct DeepLinkAction {
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// `begin` owns the own-id case: a self link lands on the user's
// own tip card rather than starting a tip.
// own profile rather than starting a tip.
container.tipFlow.begin(userID: userID)
}

Expand Down
34 changes: 30 additions & 4 deletions Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ final class ProfileAvatarStore {
enum AvatarSubject: Hashable, Sendable {
case user(UserID)
case chat(ConversationID)
/// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob.
case cover(UserID)

/// The context this subject's blobs authorize through.
var accessContext: BlobAccessContext {
switch self {
case .user(let userID): .userProfile(userID)
case .chat(let conversationID): .chatProfile(conversationID)
case .cover(let userID): .userProfile(userID)
}
}

/// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original.
func blobID(for picture: ProfilePicture) -> BlobID {
switch self {
case .user, .chat: picture.thumbnailBlobID
case .cover: picture.blobID
}
}
}
Expand All @@ -43,6 +54,7 @@ final class ProfileAvatarStore {
/// merely turned away could not learn that the fetch it skipped had failed, and nothing retried.
@ObservationIgnored private var inFlight: [AvatarSubject: Task<Void, Never>] = [:]
@ObservationIgnored private let cache: BlobCache
@ObservationIgnored private let coverCache: BlobCache
@ObservationIgnored private let mintURL: (BlobID, AvatarSubject) async throws -> URL?
@ObservationIgnored private let fetch: (URL) async throws -> Data

Expand All @@ -52,19 +64,27 @@ final class ProfileAvatarStore {
/// - fetch: downloads the bytes at a URL.
init(
cache: BlobCache = .profilePictures,
coverCache: BlobCache = .profileCovers,
mintURL: @escaping (BlobID, AvatarSubject) async throws -> URL?,
fetch: @escaping (URL) async throws -> Data = { url in
try await URLSession.shared.data(from: url).0
}
) {
self.cache = cache
self.coverCache = coverCache
self.mintURL = mintURL
self.fetch = fetch
}

convenience init(flipClient: FlipClient, owner: KeyPair, cache: BlobCache = .profilePictures) {
convenience init(
flipClient: FlipClient,
owner: KeyPair,
cache: BlobCache = .profilePictures,
coverCache: BlobCache = .profileCovers
) {
self.init(
cache: cache,
coverCache: coverCache,
mintURL: { blobID, subject in
try await flipClient.blobDownloadURL(
blobID: blobID,
Expand All @@ -91,16 +111,17 @@ final class ProfileAvatarStore {
await load(.user(userID), picture: picture)
}

/// Makes a subject's current thumbnail available to ``data(for:)``.
/// Makes a subject's current picture available to ``data(for:)``.
///
/// Returns without a round trip when the bytes are already in memory or on disk for that exact
/// blob, and joins the fetch already running for that subject rather than starting a second one.
func load(_ subject: AvatarSubject, picture: ProfilePicture?) async {
guard let blobID = picture?.thumbnailBlobID else { return }
guard let blobID = picture.map(subject.blobID(for:)) else { return }

// A different blob under the same id is a changed picture, not a cache hit.
if blobBySubject[subject] == blobID, dataBySubject[subject] != nil { return }

let cache = cache(for: subject)
if let cached = cache.data(for: blobID) {
store(cached, subject: subject, blobID: blobID)
return
Expand All @@ -122,13 +143,18 @@ final class ProfileAvatarStore {
await task.value
}

/// Covers hold the full-size blob, so they get their own budget rather than crowding out thumbnails.
private func cache(for subject: AvatarSubject) -> BlobCache {
if case .cover = subject { coverCache } else { cache }
}

/// Download URLs expire, so one is minted per fetch and never stored.
private func download(blobID: BlobID, subject: AvatarSubject) async {
do {
guard let url = try await mintURL(blobID, subject) else { return }

let data = try await fetch(url)
cache.write(data, for: blobID)
cache(for: subject).write(data, for: blobID)
store(data, subject: subject, blobID: blobID)
} catch {
guard !Task.isCancelled else { return }
Expand Down
22 changes: 18 additions & 4 deletions Flipcash/Core/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,16 @@ final class AppRouter {
/// is showing; the topmost-stack mutators (`push`, `popTopmost`, …) fall
/// back to this so in-tab navigation lands on the right stack.
/// `HomeTabView` keeps it in sync with the selected tab.
var activeTabStack: Stack?
var activeTabStack: Stack? {
didSet {
if activeTabStack != oldValue { isShowingProfileCard = false }
}
}

/// Whether the You tab's profile card is up. It is a cover local to `YouScreen`, which the
/// sheet stack cannot see, so the router closes it itself whenever sheets are dismissed, a
/// tab is requested or the selected tab changes — otherwise a deep link would land beneath it.
var isShowingProfileCard = false

/// A token the wallet should open in its expanded card state, rather than
/// as a pushed screen. Deep links use this so following a link lands where
Expand All @@ -86,7 +95,11 @@ final class AppRouter {
/// A tab the router wants brought forward, named by the stack it owns.
/// `HomeTabView` selects it and clears this. Deep-link routing only; nothing
/// else drives tab selection.
var requestedTabStack: Stack?
var requestedTabStack: Stack? {
didSet {
if requestedTabStack != nil { isShowingProfileCard = false }
}
}

/// The stack that `push`/`pop`-style calls target: the presented sheet's
/// stack, or — when no sheet is up and a tab is the active surface — the
Expand Down Expand Up @@ -458,6 +471,7 @@ final class AppRouter {
/// re-open instead.
func dismissSheet() {
guard let dismissing = presentedSheets.popLast() else { return }
isShowingProfileCard = false
dismissedStacks.insert(dismissing.stack)
logger.info("Dismissed sheet", metadata: [
"sheet": "\(dismissing)",
Expand Down Expand Up @@ -577,10 +591,10 @@ final class AppRouter {
logger.info("Routed to wallet", metadata: ["stack": "\(Stack.balance)"])
}

/// Surfaces the user's own tip card: the You tab at its root.
/// Surfaces the user's own profile: the You tab at its root.
///
/// Not expressible as `navigate(to:)` — the You tab's stack is entered by
/// tab selection and has no destination that names the card.
/// tab selection and has no destination that names the profile.
///
/// Idempotent, like `navigate(to:)`: the scanner decodes a tipcode every
/// frame until its camera tears down, so arriving is not allowed to re-fire
Expand Down
12 changes: 5 additions & 7 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,11 @@ struct ConversationScreen: View {
/// 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 }
let currency = ratesController.balanceCurrency
return TipFloor.toOpenDM(
recipientFee: session.cachedUserProfile(for: userID)?.minDmChatInitFee,
presets: session.userFlags?.tipPresets(for: currency),
in: currency,
rates: ratesController.cachedRates
)?.displayed
return StartChattingFee.amount(
for: session.cachedUserProfile(for: userID),
session: session,
ratesController: ratesController
)
}

/// Whether a chat exists to hold a transcript. An `existing` conversation
Expand Down
11 changes: 2 additions & 9 deletions Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,13 @@ struct HomeTabView: View {
/// Hide the tab bar whenever the active tab has pushed a screen (e.g. Wallet →
/// Currency Info → Give) so that screen owns the full height, while a
/// bill/tipcard is up (the app-root overlay owns the screen then, as the v1
/// bill replaced the bottom bar), and while the You tab's card is expanded.
/// bill replaced the bottom bar).
/// Sheets already cover the bar, so only in-tab pushes need handling here.
@State private var cardExpansion = WalletCardExpansion()
@State private var tipCardPresentation = TipCardPresentation()

private var isTabBarHidden: Bool {
selection.hidesTabBar(
isWalletCardExpanded: cardExpansion.isExpanded,
isTipCardExpanded: tipCardPresentation.isExpanded,
isShowingBill: sessionContainer.session.isShowingBill,
hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false
)
Expand Down Expand Up @@ -102,8 +100,6 @@ struct HomeTabView: View {
.onChange(of: selection) { _, tab in
router.activeTabStack = tab.pushStack
if tab == .chat { hasOpenedChat = true }
// Leaving the tab puts the card back (and the brightness with it).
tipCardPresentation.collapse()
}
.onDisappear { router.activeTabStack = nil }
.onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in
Expand Down Expand Up @@ -330,7 +326,6 @@ struct HomeTabView: View {
}
case .tipCard:
TipCardTab()
.environment(tipCardPresentation)
}
}
}
Expand Down Expand Up @@ -358,15 +353,13 @@ extension HomeTab {
/// hiding on it everywhere left that tab with no bar and no way back.
func hidesTabBar(
isWalletCardExpanded: Bool,
isTipCardExpanded: Bool,
isShowingBill: Bool,
hasPushedScreen: Bool
) -> Bool {
if isShowingBill || hasPushedScreen { return true }
switch self {
case .wallet: return isWalletCardExpanded
case .tipCard: return isTipCardExpanded
case .scan, .chat: return false
case .tipCard, .scan, .chat: return false
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/Tips/TipFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ final class TipFlow {
// MARK: - Entry -

/// Handles a scanned or deeplinked tipcode. Gates in order: own-id codes
/// (routed to the user's own tip card), then a tippable profile (held +
/// (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
Expand All @@ -60,7 +60,7 @@ final class TipFlow {
/// conversation they can read and reply in.
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 tip card rather than swallow
// your own code — show the user their own profile rather than swallow
// the scan or tap. `showOwnTipCard()` absorbs the repeat calls the
// per-frame scanner makes until the camera tears down.
guard userID != session.userID else {
Expand Down
Loading
Loading