diff --git a/.well-known/release-manifest.json b/.well-known/release-manifest.json new file mode 100644 index 000000000..ece165bb0 --- /dev/null +++ b/.well-known/release-manifest.json @@ -0,0 +1,4 @@ +{ + "updated": "2026-10-14T00:00:00Z", + "production": { "version": "2026.10.2", "priority": 0 } +} diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index 182616a7f..131530f70 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -144,6 +144,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { logger.info("scenePhase → active") container.client.warmUpChannel() container.flipClient.warmUpChannel() + container.updateAvailability.refresh() sessionContainer?.session.didBecomeActive() sessionContainer?.usdcSweepOperation.start() sessionContainer?.contactSyncController.didBecomeActive() diff --git a/Flipcash/Core/Container.swift b/Flipcash/Core/Container.swift index 0fc0b18f5..59bef09b9 100644 --- a/Flipcash/Core/Container.swift +++ b/Flipcash/Core/Container.swift @@ -23,6 +23,7 @@ class Container { let databaseStore: DatabaseStore let toasts: ToastController let networkPath: NetworkPathMonitor + let updateAvailability: UpdateAvailability @ObservationIgnored lazy var sessionAuthenticator = SessionAuthenticator(container: self) @ObservationIgnored lazy var deepLinkController = DeepLinkController(sessionAuthenticator: sessionAuthenticator) @@ -46,6 +47,7 @@ class Container { self.databaseStore = DatabaseStore() self.toasts = ToastController() self.networkPath = NetworkPathMonitor() + self.updateAvailability = UpdateAvailability() _ = sessionAuthenticator } @@ -62,6 +64,7 @@ class Container { .environment(notificationController) .environment(toasts) .environment(networkPath) + .environment(updateAvailability) } static func configureFirebase() { diff --git a/Flipcash/Core/Controllers/UpdateAvailability.swift b/Flipcash/Core/Controllers/UpdateAvailability.swift new file mode 100644 index 000000000..3de991a8b --- /dev/null +++ b/Flipcash/Core/Controllers/UpdateAvailability.swift @@ -0,0 +1,271 @@ +// +// UpdateAvailability.swift +// Flipcash +// + +import Foundation +import StoreKit +import UIKit +import FlipcashCore +import FlipcashUI + +nonisolated private let logger = Logger(label: "flipcash.update-availability") + +/// Whether a newer App Store version exists, and whether the one-time "New version available" toast +/// may show for it. +/// +/// Checks the App Store lookup and the repo's release manifest when the app becomes active, at most +/// once per ``UpdatePolicy/checkInterval``, and keeps the last result across launches. Debug, +/// TestFlight and other non-store builds never check, so they never report an update. +@MainActor +@Observable +final class UpdateAvailability { + + /// The newer store version, or `nil` when the installed build is current or nothing is known. + var newerVersion: AppVersion? { + UpdateEligibility.newerVersion(installed: installedVersion, release: result?.release) + } + + /// Whether the toast may show now: a newer version is priority or stale, and it hasn't shown yet. + var isToastEligible: Bool { + UpdateEligibility.isToastEligible( + installed: installedVersion, + result: result, + lastShownVersion: lastShownVersion, + now: now() + ) + } + + private var result: UpdateCheckResult? + private var lastShownVersion: AppVersion? + + @ObservationIgnored private let installedVersion: AppVersion? + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private let fetch: @Sendable (URL) async throws -> Data + @ObservationIgnored private let isStoreBuild: () async -> Bool + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var storeDelegate: StoreProductDelegate? + @ObservationIgnored private var shownToastID: ToastController.Toast.ID? + + // MARK: - Init - + + init( + installedVersion: String = AppMeta.version, + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + fetch: @escaping @Sendable (URL) async throws -> Data = UpdateAvailability.fetchFromNetwork, + isStoreBuild: @escaping () async -> Bool = UpdateAvailability.isRunningStoreBuild + ) { + self.installedVersion = AppVersion(installedVersion) + self.defaults = defaults + self.now = now + self.fetch = fetch + self.isStoreBuild = isStoreBuild + self.result = Self.load(UpdateCheckResult.self, .updateCheckResult, from: defaults) + self.lastShownVersion = Self.load(AppVersion.self, .updateToastShownVersion, from: defaults) + #if DEBUG + applyDebugOverride() + #endif + } + + // MARK: - Check - + + /// Starts a store check unless one ran within ``UpdatePolicy/checkInterval`` or one is under way. + func refresh() { + guard refreshTask == nil else { return } + #if DEBUG + if Self.debugOverride != nil { return } + #endif + if let result, now().timeIntervalSince(result.checkedAt) < UpdatePolicy.checkInterval { return } + refreshTask = Task { + await check() + refreshTask = nil + } + } + + /// Runs one store check and records what it found. A failed lookup reads as no update. + func check() async { + guard await isStoreBuild() else { + result = nil + return + } + let checkedAt = now() + let country = Locale.current.region?.identifier ?? "us" + + async let lookupData = fetch(StoreRelease.lookupURL(country: country)) + async let manifestData = fetch(ReleaseManifest.url) + + let release: StoreRelease? + do { + release = StoreRelease.parseLookup(try await lookupData) + } catch { + logger.warning("App Store lookup failed", metadata: ["error": "\(error)"]) + release = nil + } + + var manifest: ReleaseManifest? + do { + manifest = ReleaseManifest.parse(try await manifestData) + } catch { + logger.warning("Release manifest fetch failed", metadata: ["error": "\(error)"]) + } + + guard let release else { + result = nil + save(UpdateCheckResult?.none, .updateCheckResult) + return + } + let checked = UpdateCheckResult( + release: release, + priority: manifest?.priority(for: release.version) ?? 0, + checkedAt: checkedAt + ) + result = checked + save(checked, .updateCheckResult) + logger.info("Checked for update", metadata: [ + "storeVersion": "\(release.version)", + "priority": "\(checked.priority)", + "isNewer": "\(newerVersion != nil)", + ]) + } + + // MARK: - Toast - + + /// Shows the update toast on `toasts` if it is eligible, and records its version as seen once it is + /// on screen. Returns whether it showed. + @discardableResult + func showToastIfEligible(on toasts: ToastController, timeout: Duration) -> Bool { + guard isToastEligible, let version = newerVersion else { return false } + let toast = ToastController.Toast( + "New version available", + messageIdentifier: "update-available-toast", + action: .init("Update", accessibilityIdentifier: "update-available-toast-action") { [weak self] in + self?.openStore() + }, + duration: timeout + ) + toasts.show(toast) + // The controller drops a toast while something covers the app; only one that went up counts. + guard toasts.current?.id == toast.id else { return false } + lastShownVersion = version + shownToastID = toast.id + save(version, .updateToastShownVersion) + logger.info("Showed update toast", metadata: ["version": "\(version)"]) + return true + } + + /// Dismisses the update toast from `toasts` if it is still showing. It already counts as seen. + func dismissToast(on toasts: ToastController) { + guard let shownToastID else { return } + toasts.dismiss(shownToastID) + self.shownToastID = nil + } + + // MARK: - Store - + + /// Opens the app's App Store page in the app, falling back to the App Store app when the page can't load. + func openStore() { + guard let presenter = Self.topViewController else { + URL.appStoreApplicationHome.openWithApplication() + return + } + let store = SKStoreProductViewController() + let delegate = StoreProductDelegate { [weak self] in self?.storeDelegate = nil } + store.delegate = delegate + storeDelegate = delegate + presenter.present(store, animated: true) + store.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: Self.appStoreID]) { loaded, error in + guard !loaded else { return } + Task { @MainActor in + logger.warning("App Store product failed to load", metadata: ["error": "\(String(describing: error))"]) + store.dismiss(animated: true) { + URL.appStoreApplicationHome.openWithApplication() + } + } + } + } + + private static let appStoreID = 6758636374 + + private static var topViewController: UIViewController? { + var top = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + while let presented = top?.presentedViewController { top = presented } + return top + } + + // MARK: - Defaults - + + /// Fetches `url` over the shared session. + nonisolated static func fetchFromNetwork(_ url: URL) async throws -> Data { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return data + } + + /// Whether this build came from the App Store. Debug builds never do; TestFlight reports sandbox and a + /// build run from Xcode reports `xcode`. + static func isRunningStoreBuild() async -> Bool { + #if DEBUG + return false + #else + guard let transaction = try? await AppTransaction.shared.payloadValue else { return false } + return transaction.environment == .production + #endif + } + + // MARK: - Persistence - + + private static func load(_ type: T.Type, _ key: DefaultsKey, from defaults: UserDefaults) -> T? { + guard let data = defaults.data(forKey: key.rawValue) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func save(_ value: T?, _ key: DefaultsKey) { + guard let value, let data = try? JSONEncoder().encode(value) else { + defaults.removeObject(forKey: key.rawValue) + return + } + defaults.set(data, forKey: key.rawValue) + } + + // MARK: - Debug - + + #if DEBUG + /// The store version a debug launch pretends to find, set by the `-UpdateAvailableOverride ` + /// launch argument. It reads as a priority update and ignores the seen-once record. + private static var debugOverride: AppVersion? { + UserDefaults.standard.string(forKey: "UpdateAvailableOverride").flatMap(AppVersion.init) + } + + private func applyDebugOverride() { + guard let version = Self.debugOverride else { return } + result = UpdateCheckResult( + release: StoreRelease(version: version, releasedAt: now()), + priority: UpdatePolicy.priorityThreshold, + checkedAt: now() + ) + lastShownVersion = nil + } + #endif +} + +/// Clears the presenter's hold on the store sheet once the user closes it. +private final class StoreProductDelegate: NSObject, SKStoreProductViewControllerDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) { + viewController.dismiss(animated: true) + onFinish() + } +} diff --git a/Flipcash/Core/Controllers/UpdateCheck.swift b/Flipcash/Core/Controllers/UpdateCheck.swift new file mode 100644 index 000000000..c77bee9f5 --- /dev/null +++ b/Flipcash/Core/Controllers/UpdateCheck.swift @@ -0,0 +1,166 @@ +// +// UpdateCheck.swift +// Flipcash +// + +import Foundation + +/// The thresholds that decide when the update toast shows, kept together so both triggers and the +/// eligibility check read the same numbers. +nonisolated enum UpdatePolicy { + /// The release manifest priority, on Play's 0–5 scale, from which an update counts as priority. + static let priorityThreshold = 4 + /// How long a newer store version has been out before an update counts without priority. + static let stalenessThreshold: TimeInterval = 7 * 24 * 60 * 60 + /// The shortest gap between two store checks. + static let checkInterval: TimeInterval = 6 * 60 * 60 + /// The pause after the user comes back to a tab home before the toast shows. + static let returnSettle: Duration = .seconds(1) + /// How long the user stays on a tab home before the toast shows. + static let homeDwell: Duration = .seconds(3) + /// The pause after a chat opens before the toast shows. + static let chatSettle: Duration = .seconds(1) + /// How long the toast stays on a tab home. + static let homeTimeout: Duration = .seconds(10) + /// How long the toast stays in a chat, where it covers the newest messages. + static let chatTimeout: Duration = .seconds(6) +} + +/// A dotted marketing version, such as `2026.10.4`, compared numerically per component. +nonisolated struct AppVersion: Comparable, Codable, Sendable, CustomStringConvertible { + + /// The numeric components, most significant first. + let components: [Int] + + /// The version `string` names, or `nil` when it is empty or any component is not a number. + init?(_ string: String) { + let parts = string.trimmingCharacters(in: .whitespaces).split(separator: ".", omittingEmptySubsequences: false) + let numbers = parts.compactMap { Int($0) } + guard !numbers.isEmpty, numbers.count == parts.count, numbers.allSatisfy({ $0 >= 0 }) else { return nil } + components = numbers + } + + var description: String { + components.map(String.init).joined(separator: ".") + } + + // A missing trailing component reads as zero, so `2026.10` equals `2026.10.0`. + static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + for index in 0.. Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + init(from decoder: any Decoder) throws { + let string = try decoder.singleValueContainer().decode(String.self) + guard let version = AppVersion(string) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Not a version")) + } + self = version + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} + +/// The newest version the App Store offers, and when it was released. +nonisolated struct StoreRelease: Codable, Equatable, Sendable { + let version: AppVersion + let releasedAt: Date + + /// The App Store lookup endpoint for the app in `country`, an ISO 3166 alpha-2 code. + static func lookupURL(country: String) -> URL { + var components = URLComponents(string: "https://itunes.apple.com/lookup")! + components.queryItems = [ + URLQueryItem(name: "id", value: "6758636374"), + URLQueryItem(name: "country", value: country.lowercased()), + ] + return components.url! + } + + /// The release the lookup response `data` describes, or `nil` when it lists no usable result. + static func parseLookup(_ data: Data) -> StoreRelease? { + struct Response: Decodable { + struct Result: Decodable { + let version: String + let currentVersionReleaseDate: String + } + let results: [Result] + } + guard let result = (try? JSONDecoder().decode(Response.self, from: data))?.results.first, + let version = AppVersion(result.version), + let releasedAt = ISO8601DateFormatter().date(from: result.currentVersionReleaseDate) + else { return nil } + return StoreRelease(version: version, releasedAt: releasedAt) + } +} + +/// The release manifest published from the repo, which marks how urgent the production version is. +nonisolated struct ReleaseManifest: Equatable, Sendable { + let productionVersion: AppVersion + let priority: Int + + /// Where the manifest is read from. + static let url = URL(string: "https://raw.githubusercontent.com/code-payments/code-ios-app/main/.well-known/release-manifest.json")! + + /// The manifest `data` holds, or `nil` when it doesn't parse. + static func parse(_ data: Data) -> ReleaseManifest? { + struct Body: Decodable { + struct Production: Decodable { + let version: String + let priority: Int + } + let production: Production + } + guard let body = try? JSONDecoder().decode(Body.self, from: data), + let version = AppVersion(body.production.version) + else { return nil } + return ReleaseManifest(productionVersion: version, priority: body.production.priority) + } + + /// The priority this manifest gives `version`: its own priority when it names that version, else 0. + func priority(for version: AppVersion) -> Int { + productionVersion == version ? priority : 0 + } +} + +/// What the last store check found, cached between launches. +nonisolated struct UpdateCheckResult: Codable, Equatable, Sendable { + let release: StoreRelease + let priority: Int + let checkedAt: Date +} + +/// Decides, from a store check, whether a newer version exists and whether it merits the toast. +nonisolated enum UpdateEligibility { + + /// The store version when it is newer than `installed`, else `nil`. + static func newerVersion(installed: AppVersion?, release: StoreRelease?) -> AppVersion? { + guard let installed, let release, release.version > installed else { return nil } + return release.version + } + + /// Whether the toast may show for `result`: the store build is newer, the update is priority or has + /// been out for the staleness threshold, and the toast hasn't shown for that version yet. + static func isToastEligible( + installed: AppVersion?, + result: UpdateCheckResult?, + lastShownVersion: AppVersion?, + now: Date + ) -> Bool { + guard let result, let newer = newerVersion(installed: installed, release: result.release) else { return false } + guard lastShownVersion != newer else { return false } + let isPriority = result.priority >= UpdatePolicy.priorityThreshold + let isStale = now.timeIntervalSince(result.release.releasedAt) >= UpdatePolicy.stalenessThreshold + return isPriority || isStale + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 4d259e9e4..06f4c962a 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -44,6 +44,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let linkCardSource: any LinkCardSource /// Whether web cards ask for their pages as they draw (DMs, members) or wait for the chip. let webPreviewMode: WebLinkPreviewMode + /// The "Update Flipcash" capsule an `.updateApp` bubble draws, or nil while no newer version is known. + let updateAction: ChatBubbleAction? + /// The app's toast, raised over the bar and narrowed to its width while the chat is open. Passed in: + /// the bar's hosting controller doesn't inherit the environment. + let toasts: ToastController /// Fired when the user taps the "Encrypted" marker above a DM's first encrypted message. let onEncryptionMarkerTap: () -> Void /// Fired when the user taps an author's face in a group's gutter, with that author's user id. @@ -165,6 +170,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode + screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil screen.onImagesPasted = onImagesPasted @@ -224,6 +230,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode + screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil screen.onImagesPasted = onImagesPasted @@ -347,6 +354,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } ) .environment(conversationController) + .toastClearance(toasts, horizontalInset: BarMetrics.compactInset, overlap: BarMetrics.toastOverlap) .modifier( MeasuredBarHeight { height, accessories in // After the update that measured it, not inside it. Resized inside an animated diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 1e60896bf..65e9250b9 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -250,6 +250,11 @@ enum BarMetrics { static func trailingControlCentreInset(isCompact: Bool) -> CGFloat { controlInset(isCompact: isCompact) + contentHeight / 2 } + + /// How far a toast comes back down into the compact bar's measured height, so it rests 8pt above + /// the controls: the bar's top margin, its drop into the safe area, and the 4pt the toast host's + /// 12pt padding exceeds that gap. + static let toastOverlap: CGFloat = contentPadding + compactDrop + 4 } /// What stands outside the message field, beside it. diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 7ae0ddf73..03b0609f1 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -98,6 +98,8 @@ struct ConversationScreen: View { /// The join-requests anchor's measured height, reserved at the transcript's top; 0 while hidden. @State private var anchorHeight: CGFloat = 0 + /// Whether the keyboard is up, which holds the update toast back. + @State private var isKeyboardUp = false /// This conversation's id when the viewer hosts it as a private group; the anchor is host-only. private var hostedConversationID: ConversationID? { @@ -217,6 +219,13 @@ struct ConversationScreen: View { /// requirement — or a rate that finally loads — opens the chat without a reopen. /// DMs and members fetch web previews as they draw; anyone else, or a chat whose record has not /// loaded yet, waits for the chip. + /// The bubble's "Update Flipcash" action, offered whenever the store has a newer version. + private var updateAction: ChatBubbleAction? { + let updates = container.updateAvailability + guard updates.newerVersion != nil else { return nil } + return ChatBubbleAction(title: "Update Flipcash") { updates.openStore() } + } + private var webPreviewMode: WebLinkPreviewMode { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID), @@ -403,6 +412,8 @@ struct ConversationScreen: View { onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, webPreviewMode: webPreviewMode, + updateAction: updateAction, + toasts: container.toasts, onEncryptionMarkerTap: { isShowingEncryptionInfo = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, @@ -689,6 +700,20 @@ struct ConversationScreen: View { /// The fetches the transcript needs: gate token names, sender names, and avatars. private func loads(_ content: some View) -> some View { content + // The update toast, once per version: a beat after the chat opens, never over a raised keyboard. + .task { + guard !openKeyboard else { return } + try? await Task.sleep(for: UpdatePolicy.chatSettle) + guard !Task.isCancelled, !isKeyboardUp else { return } + container.updateAvailability.showToastIfEligible(on: container.toasts, timeout: UpdatePolicy.chatTimeout) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + isKeyboardUp = true + container.updateAvailability.dismissToast(on: container.toasts) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + isKeyboardUp = false + } .task { await sessionContainer.linkCardMemo.awaitLoaded() waitedForLinkPreviews = true diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 85b002c6d..7f46238f9 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -70,11 +70,31 @@ struct HomeTabView: View { /// Sheets already cover the bar, so only in-tab pushes need handling here. @State private var cardExpansion = WalletCardExpansion() + /// Set when the user comes back to a tab home from a screen pushed on it, so the update toast waits + /// only for the navigation to settle rather than the full dwell. + @State private var returnedToHome = false + + private var hasPushedScreen: Bool { + selection.pushStack.map { !router[$0].isEmpty } ?? false + } + + /// When the update toast may show on this view: the Chats or Wallet home, with nothing pushed or + /// covering it. + private var updateToastMoment: UpdateToastMoment? { + guard selection == .chat || selection == .wallet, + !hasPushedScreen, + !container.toasts.isCovered, + !sessionContainer.session.isShowingBill, + container.updateAvailability.isToastEligible + else { return nil } + return UpdateToastMoment(tab: selection, isReturn: returnedToHome) + } + private var isTabBarHidden: Bool { selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, - hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false + hasPushedScreen: hasPushedScreen ) } @@ -105,6 +125,17 @@ struct HomeTabView: View { .onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in container.toasts.isCovered = covered } + .onChange(of: hasPushedScreen) { wasPushed, isPushed in + if wasPushed && !isPushed { returnedToHome = true } + } + .onChange(of: selection) { _, _ in returnedToHome = false } + .task(id: updateToastMoment) { + guard let moment = updateToastMoment else { return } + try? await Task.sleep(for: moment.isReturn ? UpdatePolicy.returnSettle : UpdatePolicy.homeDwell) + guard !Task.isCancelled else { return } + returnedToHome = false + container.updateAvailability.showToastIfEligible(on: container.toasts, timeout: UpdatePolicy.homeTimeout) + } .onChange(of: sessionContainer.session.profile?.username, initial: true) { _, username in sessionContainer.chatArchive.setViewerUsername(username) } @@ -343,6 +374,12 @@ struct HomeTabView: View { } } +/// A tab home the update toast can show on, and whether the user just came back to it. +private struct UpdateToastMoment: Equatable { + let tab: HomeTab + let isReturn: Bool +} + extension HomeTab { /// The router stack this tab pushes onto, published to `AppRouter` as the /// active push target. `nil` for tabs that only present sheets (Scan) or diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index 197b7237e..015b2ed2a 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -27,6 +27,9 @@ enum DefaultsKey: String { case contactsConnected = "com.flipcash.contacts.connected" case trustedWebsites = "com.flipcash.links.trustedWebsites" + + case updateCheckResult = "com.flipcash.update.checkResult" + case updateToastShownVersion = "com.flipcash.update.toastShownVersion" } private let defaultsEncoder = JSONEncoder() diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 101c28c22..9a23604ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -26,8 +26,8 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// A deleted message's placeholder copy, already resolved for the viewer — "You deleted this /// message" or "This message was deleted". The mapper decides which; the view just draws it. case deleted(String) - /// A message this client cannot display — today, an encrypted one it cannot decrypt. Drawn - /// as a dashed "This message can't be displayed" bubble with `hint` beneath it. + /// A message this client cannot display: one it cannot decrypt, or a type it does not know. + /// `hint` decides its copy, and whether the bubble draws dashed. case unavailable(UnavailableHint) /// A shared profile, drawn as a card with a Share button. The card carries only the /// handle's link; the name and picture are looked up by the view, as a link card's are. @@ -36,19 +36,20 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { case media(ChatMediaContent) } - /// The line under an unavailable message's bubble, chosen by why it can't be shown. + /// Why an unavailable message can't be shown, which picks the bubble's copy. public enum UnavailableHint: Hashable, Sendable, Codable { - /// A newer client can read it: "Update Flipcash to see it". + /// A newer client can read it. Drawn as its own bubble, titled ``ChatMessage/updateAppTitle`` + /// with this hint's `text` as the body. case updateApp /// It failed to decrypt and only a resend can fix it: "Ask {first name} to send it again". case askToResend(firstName: String) /// The viewer's own message failed to decrypt: "Try sending it again". case resend - /// The hint's copy. + /// The hint's copy: the body inside the bubble for `.updateApp`, the line under it otherwise. public var text: String { switch self { - case .updateApp: "Update Flipcash to see it" + case .updateApp: "Update Flipcash to see messages like this. This one stays hidden." case .askToResend(let firstName): "Ask \(firstName) to send it again" case .resend: "Try sending it again" } @@ -72,7 +73,10 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { } } - /// The bubble copy for an unavailable message. + /// The title of the bubble for a message a newer client can read. + public static let updateAppTitle = "This Message Needs a Newer Version of Flipcash" + + /// The bubble copy for an unavailable message whose hint hangs beneath it. public static let unavailableCopy = "This message can\u{2019}t be displayed" public let id: String diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 690a1f13f..0acaac823 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -68,8 +68,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// media and reply, so a widget decrypted out of `EncryptedContent` is still unsupported. public enum Widget: Hashable, Sendable { case shareProfile(ShareProfileWidget) - /// A variant this client doesn't recognize, or one missing what it needs (a malformed - /// username). Drawn as an unsupported message. + /// A variant this client doesn't recognize, one missing what it needs (a malformed + /// username), or a message content type this client doesn't know. Drawn as an unsupported + /// message. case unrecognized } @@ -310,7 +311,9 @@ extension ConversationMessage { extension ConversationMessage { /// Builds a message from its proto, returning nil for content the client - /// can't represent (unknown type, or a cash amount that fails to parse). A + /// can't represent (no content, a system message, or a cash amount that + /// fails to parse). A content type this build doesn't know is kept as an + /// unrecognized widget so it draws as unsupported. A /// deleted message materializes as a `.deleted` tombstone — never nil — so /// it converges the same way across the stream, `GetMessages`, and `GetDelta` /// and can't leave the pre-delete content on screen. @@ -383,7 +386,13 @@ extension ConversationMessage { self.cashAction = nil repliedTo = nil case .none: - return nil + // A present entry with no known type is a variant added after this build. It reuses the + // unrecognized widget, whose stored shape (kind 4, no text) already reads back as + // unsupported, so keeping it needs no schema change. + guard !proto.content.isEmpty else { return nil } + self.content = .widget(.unrecognized) + self.cashAction = nil + repliedTo = nil } self.id = MessageID(proto.messageID) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift index e0ec458c6..5335862cb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift @@ -75,7 +75,7 @@ struct ChatPreviewDecryptionTests { @Test("The hint's copy") func hintCopy() { - #expect(ChatMessage.UnavailableHint.updateApp.text == "Update Flipcash to see it") + #expect(ChatMessage.UnavailableHint.updateApp.text == "Update Flipcash to see messages like this. This one stays hidden.") #expect(ChatMessage.UnavailableHint.askToResend(firstName: "Ada").text == "Ask Ada to send it again") #expect(ChatMessage.UnavailableHint.resend.text == "Try sending it again") } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 520f27eb1..c8928d621 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -852,6 +852,26 @@ struct ConversationMessageContentEncodingTests { #expect(message.content == .widget(.unrecognized)) } + @Test("A content type this build doesn't know is kept as an unrecognized widget, not dropped") + func unknownContentTypeIsUnrecognized() throws { + // Field 99 is not a member of `Content.type` in this build, so it decodes into + // `unknownFields` and leaves the oneof unset, as a newer content type would. + let content = try Flipcash_Messaging_V1_Content(serializedBytes: Data([0x98, 0x06, 0x01])) + #expect(content.type == nil) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [content] + } + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .widget(.unrecognized)) + } + + @Test("A message with no content entry is still dropped") + func emptyContentIsDropped() { + let proto = Flipcash_Messaging_V1_Message.with { $0.messageID = .with { $0.value = 9 } } + #expect(ConversationMessage(proto) == nil) + } + @Test("A widget can't be sent through the content encoder") func widgetIsNotEncodable() throws { let widget = ConversationMessage.Content.widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))) diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 1205a8427..31214007d 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -113,6 +113,19 @@ struct ChatMessageMappingTests { #expect(hint(me, .authentication) == [.unavailable(.resend)]) } + @Test("an unrecognized widget draws as the update bubble") + func unrecognizedWidgetIsUpdateApp() { + let widget = ConversationMessage( + id: MessageID(value: 1), + senderID: them, + content: .widget(.unrecognized), + date: base, + unreadSeq: 1 + ) + let items = ChatItem.from([widget], selfUserID: me, counterpartName: "Ada Lovelace") + #expect(messageRows(items).map(\.content) == [.unavailable(.updateApp)]) + } + @Test("a group's resend hint names the message's author, not the empty DM counterpart") func decryptFailureHintNamesGroupAuthor() { let items = ChatItem.from( diff --git a/FlipcashTests/UpdateAvailabilityTests.swift b/FlipcashTests/UpdateAvailabilityTests.swift new file mode 100644 index 000000000..4d486eea1 --- /dev/null +++ b/FlipcashTests/UpdateAvailabilityTests.swift @@ -0,0 +1,226 @@ +// +// UpdateAvailabilityTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashUI +@testable import Flipcash + +@Suite struct AppVersionTests { + + @Test func comparesEachComponentNumerically() { + #expect(AppVersion("2026.10.10")! > AppVersion("2026.10.9")!) + #expect(AppVersion("2026.9.30")! < AppVersion("2026.10.1")!) + #expect(AppVersion("2027.1.0")! > AppVersion("2026.12.31")!) + } + + @Test func missingTrailingComponentsReadAsZero() { + #expect(AppVersion("2026.10")! == AppVersion("2026.10.0")!) + #expect(AppVersion("2026.10.1")! > AppVersion("2026.10")!) + } + + @Test(arguments: ["", "2026.x.1", "2026..1", "1.-2", "v2026.10.1"]) + func rejectsMalformed(_ string: String) { + #expect(AppVersion(string) == nil) + } +} + +@Suite struct UpdateCheckParsingTests { + + @Test func lookupReadsTheFirstResult() { + let data = Data(""" + {"resultCount":1,"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-08T17:02:11Z"}]} + """.utf8) + let release = StoreRelease.parseLookup(data) + #expect(release?.version == AppVersion("2026.10.4")) + #expect(release?.releasedAt == ISO8601DateFormatter().date(from: "2026-10-08T17:02:11Z")) + } + + @Test func lookupWithNoResultsIsNil() { + #expect(StoreRelease.parseLookup(Data(#"{"resultCount":0,"results":[]}"#.utf8)) == nil) + #expect(StoreRelease.parseLookup(Data("not json".utf8)) == nil) + } + + @Test func lookupURLNamesTheAppAndStorefront() { + let url = StoreRelease.lookupURL(country: "CA").absoluteString + #expect(url == "https://itunes.apple.com/lookup?id=6758636374&country=ca") + } + + @Test func manifestPriorityAppliesOnlyToItsVersion() { + let data = Data(""" + {"updated":"2026-10-14T00:00:00Z","production":{"version":"2026.10.4","priority":5}} + """.utf8) + let manifest = ReleaseManifest.parse(data) + #expect(manifest?.priority(for: AppVersion("2026.10.4")!) == 5) + #expect(manifest?.priority(for: AppVersion("2026.10.5")!) == 0) + } + + @Test func malformedManifestIsNil() { + #expect(ReleaseManifest.parse(Data(#"{"production":{"version":"x","priority":1}}"#.utf8)) == nil) + } +} + +@Suite struct UpdateEligibilityTests { + + private let now = Date(timeIntervalSince1970: 1_000_000_000) + private let installed = AppVersion("2026.10.3") + + private func result(version: String = "2026.10.4", daysOut: Double, priority: Int = 0) -> UpdateCheckResult { + UpdateCheckResult( + release: StoreRelease(version: AppVersion(version)!, releasedAt: now.addingTimeInterval(-daysOut * 86_400)), + priority: priority, + checkedAt: now + ) + } + + private func eligible(_ result: UpdateCheckResult?, shown: String? = nil) -> Bool { + UpdateEligibility.isToastEligible( + installed: installed, + result: result, + lastShownVersion: shown.flatMap(AppVersion.init), + now: now + ) + } + + @Test func freshOptionalUpdateWaits() { + #expect(!eligible(result(daysOut: 6.9))) + } + + @Test func updateOutForSevenDaysIsEligible() { + #expect(eligible(result(daysOut: 7))) + } + + @Test func priorityUpdateIsEligibleAtOnce() { + #expect(eligible(result(daysOut: 0, priority: 4))) + #expect(!eligible(result(daysOut: 0, priority: 3))) + } + + @Test func showsOncePerVersion() { + #expect(!eligible(result(daysOut: 30), shown: "2026.10.4")) + #expect(eligible(result(version: "2026.10.5", daysOut: 30), shown: "2026.10.4")) + } + + @Test func sameOrOlderStoreVersionIsNoUpdate() { + #expect(!eligible(result(version: "2026.10.3", daysOut: 30, priority: 5))) + #expect(!eligible(result(version: "2026.10.2", daysOut: 30, priority: 5))) + #expect(UpdateEligibility.newerVersion(installed: installed, release: result(version: "2026.10.2", daysOut: 1).release) == nil) + } + + @Test func noResultIsNoUpdate() { + #expect(!eligible(nil)) + } +} + +@MainActor +@Suite struct UpdateAvailabilityTests { + + private let defaults = UserDefaults(suiteName: "update-availability-\(UUID())")! + private let now = ISO8601DateFormatter().date(from: "2026-10-14T12:00:00Z")! + + private static let staleLookup = Data(""" + {"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-01T00:00:00Z"}]} + """.utf8) + + private func sut( + isStoreBuild: Bool = true, + lookup: Data = staleLookup, + manifest: Data? = nil, + at date: Date? = nil + ) -> UpdateAvailability { + let time = date ?? now + return UpdateAvailability( + installedVersion: "2026.10.3", + defaults: defaults, + now: { time }, + fetch: { url in + if url == ReleaseManifest.url { + guard let manifest else { throw URLError(.fileDoesNotExist) } + return manifest + } + return lookup + }, + isStoreBuild: { isStoreBuild } + ) + } + + @Test func storeBuildFindsTheNewerVersion() async { + let updates = sut() + await updates.check() + #expect(updates.newerVersion == AppVersion("2026.10.4")) + #expect(updates.isToastEligible) + } + + @Test func nonStoreBuildNeverReportsAnUpdate() async { + let updates = sut(isStoreBuild: false) + await updates.check() + #expect(updates.newerVersion == nil) + #expect(!updates.isToastEligible) + } + + @Test func failedLookupIsNoUpdate() async { + let updates = UpdateAvailability( + installedVersion: "2026.10.3", + defaults: defaults, + now: { now }, + fetch: { _ in throw URLError(.notConnectedToInternet) }, + isStoreBuild: { true } + ) + await updates.check() + #expect(updates.newerVersion == nil) + } + + @Test func manifestPriorityMakesAFreshUpdateEligible() async { + let fresh = Data(""" + {"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-13T00:00:00Z"}]} + """.utf8) + let manifest = Data(#"{"production":{"version":"2026.10.4","priority":4}}"#.utf8) + let plain = sut(lookup: fresh) + await plain.check() + #expect(plain.newerVersion != nil) + #expect(!plain.isToastEligible) + + let urgent = sut(lookup: fresh, manifest: manifest) + await urgent.check() + #expect(urgent.isToastEligible) + } + + @Test func resultSurvivesRelaunch() async { + await sut().check() + #expect(sut().newerVersion == AppVersion("2026.10.4")) + } + + @Test func showingTheToastMarksItsVersionSeen() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + #expect(updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + #expect(toasts.current != nil) + #expect(!updates.isToastEligible) + #expect(!sut().isToastEligible) + #expect(!updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + } + + @Test func toastDroppedWhileCoveredIsNotSeen() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + toasts.isCovered = true + #expect(!updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + #expect(updates.isToastEligible) + } + + @Test func dismissRemovesOnlyTheUpdateToast() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + updates.showToastIfEligible(on: toasts, timeout: .seconds(10)) + updates.dismissToast(on: toasts) + #expect(toasts.current == nil) + + toasts.show(.init("Copied")) + updates.dismissToast(on: toasts) + #expect(toasts.current != nil) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 265dfc244..6176ab01b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -10,6 +10,17 @@ import UIKit import SwiftUI import FlipcashCore +/// A button drawn inside a chat bubble: its label and what a tap does. +public struct ChatBubbleAction { + public let title: String + public let handler: @MainActor () -> Void + + public init(title: String, handler: @escaping @MainActor () -> Void) { + self.title = title + self.handler = handler + } +} + /// A single chat bubble: a multiline label over the shared `BubbleBackgroundView`, styled to /// match the app's conversation design (white-opacity fill, hairline border, app font, flattened /// inner corners on a bubble run). Dumb — hand it a `ChatMessage` and it draws. @@ -20,6 +31,11 @@ public final class ChatBubbleView: UIView { private let editedLabel = EditedMarker.makeLabel() /// The line under an unavailable message's bubble; hidden for every other row. private let hintLabel = UILabel() + /// The update bubble's optional capsule; hidden for every other row and when no action is given. + private let actionButton = UIButton(type: .system) + /// The update bubble's body, under the title the main label carries; empty on every other row. + private let detailLabel = UILabel() + private var action: ChatBubbleAction? private(set) var quotePanel = ChatQuotePanelView() /// Forwarded from the panel: the stable id of the message to jump to. @@ -42,6 +58,12 @@ public final class ChatBubbleView: UIView { private var backgroundBottomToBounds: NSLayoutConstraint! private var backgroundBottomToLabel: NSLayoutConstraint! private var hintBottom: NSLayoutConstraint! + /// The update bubble's body closes the bubble when it has no capsule under it. + private var detailBottom: NSLayoutConstraint! + /// The action capsule sits under the body and takes over the body's bottom inset. + private var actionTop: NSLayoutConstraint! + private var actionBottom: NSLayoutConstraint! + private var actionLeading: NSLayoutConstraint! /// Whether the row currently draws bare, so `maskingPath` can decline to clip a lift preview to /// a bubble that is not drawn. private var isBare = false @@ -50,6 +72,11 @@ public final class ChatBubbleView: UIView { private static let bodyPadding: CGFloat = 9 private static let hintTopSpacing: CGFloat = 8 private static let hintLeadingInset: CGFloat = 4 + /// The update bubble's insets and spacing, from Figma component 11420:13397. It stacks a title, + /// a body and a capsule, so it takes the roomier card padding rather than a text bubble's. + private static let updateInsetVertical: CGFloat = 12 + private static let updateInsetHorizontal: CGFloat = 14 + private static let updateSpacing: CGFloat = 10 /// A bare row's padding. Smaller than a bubble's because the emoji carries its own margin /// inside its line box, and the transcript's rhythm is what is being matched, not the bubble's. private static let barePadding: CGFloat = 4 @@ -88,6 +115,29 @@ public final class ChatBubbleView: UIView { hintLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(hintLabel) + var buttonConfig = UIButton.Configuration.filled() + buttonConfig.baseBackgroundColor = .white + buttonConfig.baseForegroundColor = .black + buttonConfig.cornerStyle = .capsule + buttonConfig.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14) + buttonConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = UIFont.appTextSmall + return attributes + } + detailLabel.font = .appTextCaption + detailLabel.textColor = UIColor.white.withAlphaComponent(0.55) + detailLabel.numberOfLines = 0 + detailLabel.isHidden = true + detailLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(detailLabel) + + actionButton.configuration = buttonConfig + actionButton.isHidden = true + actionButton.translatesAutoresizingMaskIntoConstraints = false + actionButton.addAction(UIAction { [weak self] _ in self?.action?.handler() }, for: .primaryActionTriggered) + addSubview(actionButton) + quotePanel.translatesAutoresizingMaskIntoConstraints = false addSubview(quotePanel) @@ -112,6 +162,10 @@ public final class ChatBubbleView: UIView { hintBottom = hintLabel.bottomAnchor.constraint(equalTo: bottomAnchor) labelLeading = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.bodyInset) labelTrailing = label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.bodyInset) + detailBottom = detailLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.updateInsetVertical) + actionTop = actionButton.topAnchor.constraint(equalTo: detailLabel.bottomAnchor, constant: Self.updateSpacing) + actionBottom = actionButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.updateInsetVertical) + actionLeading = actionButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.updateInsetHorizontal) NSLayoutConstraint.activate(quoteCollapse + [ background.topAnchor.constraint(equalTo: topAnchor), @@ -130,6 +184,13 @@ public final class ChatBubbleView: UIView { hintLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.hintLeadingInset), hintLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + detailLabel.topAnchor.constraint(equalTo: label.bottomAnchor, constant: Self.updateSpacing), + detailLabel.leadingAnchor.constraint(equalTo: label.leadingAnchor), + detailLabel.trailingAnchor.constraint(equalTo: label.trailingAnchor), + + actionLeading, + actionButton.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Self.updateInsetHorizontal), + // Bottom-trailing corner: the body's reservation run keeps the space clear, so the // marker lands on the last line where it fits and on the wrapped line where it doesn't. editedLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -EditedMarker.trailingInset), @@ -148,35 +209,59 @@ public final class ChatBubbleView: UIView { /// Whether this bubble is currently flashing. var isFlashingAttention: Bool { background.isFlashingAttention } - /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from. - public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = nil) { + /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from, and + /// `updateAction` is the capsule an `.updateApp` bubble draws, or nil for none. + public func configure( + with message: ChatMessage, + quoteThumbnail: ChatMediaLocation? = nil, + updateAction: ChatBubbleAction? = nil + ) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji - // Deactivate before activating, as with the quote's top constraints below. let hint: String? + let isUpdateBubble: Bool switch message.content { - case .unavailable(let unavailableHint): hint = unavailableHint.text - case .text, .cash, .deleted, .shareProfile, .media: hint = nil + case .unavailable(.updateApp): + hint = nil + isUpdateBubble = true + case .unavailable(let unavailableHint): + hint = unavailableHint.text + isUpdateBubble = false + case .text, .cash, .deleted, .shareProfile, .media: + hint = nil + isUpdateBubble = false } + action = isUpdateBubble ? updateAction : nil + let showsAction = action != nil + actionButton.configuration?.title = action?.title + actionButton.isHidden = !showsAction + + // Deactivate before activating, as with the quote's top constraints below. hintLabel.text = hint hintLabel.isHidden = hint == nil + // Cleared rather than only hidden: a hidden label's text would still widen the bubble. + detailLabel.text = isUpdateBubble ? ChatMessage.UnavailableHint.updateApp.text : nil + detailLabel.isHidden = !isUpdateBubble + NSLayoutConstraint.deactivate([ + labelBottom, hintBottom, detailBottom, actionTop, actionBottom, backgroundBottomToLabel, backgroundBottomToBounds, + ]) if hint != nil { - labelBottom.isActive = false - backgroundBottomToBounds.isActive = false - backgroundBottomToLabel.isActive = true - hintBottom.isActive = true + NSLayoutConstraint.activate([backgroundBottomToLabel, hintBottom]) + } else if showsAction { + NSLayoutConstraint.activate([backgroundBottomToBounds, actionTop, actionBottom]) + } else if isUpdateBubble { + NSLayoutConstraint.activate([backgroundBottomToBounds, detailBottom]) } else { - hintBottom.isActive = false - backgroundBottomToLabel.isActive = false - backgroundBottomToBounds.isActive = true - labelBottom.isActive = true + NSLayoutConstraint.activate([backgroundBottomToBounds, labelBottom]) } - labelTopToBubble.constant = isBare ? Self.barePadding : Self.bodyPadding - labelBottom.constant = isBare ? -Self.barePadding : -Self.bodyPadding - labelLeading.constant = isBare ? 0 : Self.bodyInset - labelTrailing.constant = isBare ? 0 : -Self.bodyInset + let vertical = isBare ? Self.barePadding : isUpdateBubble ? Self.updateInsetVertical : Self.bodyPadding + let horizontal = isBare ? 0 : isUpdateBubble ? Self.updateInsetHorizontal : Self.bodyInset + labelTopToBubble.constant = vertical + labelBottom.constant = -vertical + labelLeading.constant = horizontal + labelTrailing.constant = -horizontal // Deactivate before activating: with both top constraints live the layout is // unsatisfiable, and UIKit resolves that by breaking one at random. @@ -204,7 +289,7 @@ public final class ChatBubbleView: UIView { groupedBelow: message.joinsBubbleBelow ), bare: isBare, - dashedBorder: Self.isUnavailable(message), + dashedBorder: Self.drawsDashedBorder(message), identity: message.id ) } @@ -220,9 +305,12 @@ public final class ChatBubbleView: UIView { } } - private static func isUnavailable(_ message: ChatMessage) -> Bool { + /// The hinted unavailable bubbles draw dashed; the update bubble draws as a plain received + /// bubble, as Figma component 11420:13397 has no outline. + private static func drawsDashedBorder(_ message: ChatMessage) -> Bool { switch message.content { - case .unavailable: true + case .unavailable(.updateApp): false + case .unavailable(.askToResend), .unavailable(.resend): true case .text, .cash, .deleted, .shareProfile, .media: false } } @@ -250,6 +338,17 @@ public final class ChatBubbleView: UIView { return result } + /// The update bubble's title; `detailLabel` carries the body beneath it. + private static func updateAppText() -> NSAttributedString { + NSAttributedString( + string: ChatMessage.updateAppTitle, + attributes: [ + .font: UIFont.appTextMedium, + .foregroundColor: UIColor.white.withAlphaComponent(0.9), + ] + ) + } + /// The bubble's rendered text: the body, a muted italic placeholder for a tombstone, and the /// "Edited" marker's reservation where the sender has revised the message — the marker itself is /// a separate label pinned to the bubble's corner. `nil` for cash rows, which use a dedicated @@ -264,7 +363,9 @@ public final class ChatBubbleView: UIView { case .deleted(let placeholder): body = placeholder isPlaceholder = true - case .unavailable: + case .unavailable(.updateApp): + return updateAppText() + case .unavailable(.askToResend), .unavailable(.resend): return unavailableText() case .cash, .shareProfile, .media: return nil @@ -320,4 +421,33 @@ public final class ChatBubbleView: UIView { ]) return container } + +#Preview("Unavailable bubbles") { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 12 + stack.alignment = .leading + stack.translatesAutoresizingMaskIntoConstraints = false + + let samples: [(ChatMessage, ChatBubbleAction?)] = [ + (ChatMessage(id: "1", content: .unavailable(.updateApp), sender: .other), nil), + (ChatMessage(id: "2", content: .unavailable(.updateApp), sender: .other), ChatBubbleAction(title: "Update Flipcash") {}), + (ChatMessage(id: "3", content: .unavailable(.askToResend(firstName: "Ada")), sender: .other), nil), + ] + for (message, action) in samples { + let bubble = ChatBubbleView() + bubble.configure(with: message, updateAction: action) + bubble.widthAnchor.constraint(lessThanOrEqualToConstant: 280).isActive = true + stack.addArrangedSubview(bubble) + } + + let container = UIView() + container.backgroundColor = UIColor(Color.backgroundMain) + container.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + ]) + return container +} #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index d02896b06..97b18e98e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -67,13 +67,15 @@ public final class ChatMessageCell: ChatColumnCell { /// - maxWidth: the widest the bubble may grow before its text wraps, in points. The owner /// derives it from the collection view's width. /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + /// - updateAction: the capsule an `.updateApp` bubble draws, or nil for none. public func configure( with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil, - quoteThumbnail: ChatMediaLocation? = nil + quoteThumbnail: ChatMediaLocation? = nil, + updateAction: ChatBubbleAction? = nil ) { - bubble.configure(with: message, quoteThumbnail: quoteThumbnail) + bubble.configure(with: message, quoteThumbnail: quoteThumbnail, updateAction: updateAction) maxWidthConstraint.constant = maxWidth reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index d2fa3bdf1..927bb63b1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -231,6 +231,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.webPreviewMode = newValue } } + /// ``ChatViewController/updateAction``. + public var updateAction: ChatBubbleAction? { + get { transcript.updateAction } + set { transcript.updateAction = newValue } + } + /// Where a photo row gets its download URL — see ``ChatViewController/mediaURLResolver``. public var mediaURLResolver: ChatMediaURLResolver? { get { transcript.mediaURLResolver } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b32599472..511ed0aed 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -96,6 +96,21 @@ public final class ChatViewController: UICollectionViewController { } } + /// The capsule an `.updateApp` bubble draws, or nil while no newer version is known. A change + /// redraws the rows on screen. + public var updateAction: ChatBubbleAction? { + didSet { + guard (updateAction == nil) != (oldValue == nil), isViewLoaded else { return } + for cell in collectionView.visibleCells where cell is ChatMessageCell { + guard let indexPath = collectionView.indexPath(for: cell), + items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item] else { continue } + configure(cell, with: message) + } + collectionView.collectionViewLayout.invalidateLayout() + } + } + /// The web previews this transcript's viewer asked for through the chip, shared by every row. private let webPreviewRequests = WebPreviewRequests() @@ -690,7 +705,8 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, authorImageData: authorImageData, - quoteThumbnail: quoteThumbnail(for: message) + quoteThumbnail: quoteThumbnail(for: message), + updateAction: updateAction ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onQuoteTap = { [weak self] id in self?.onQuoteTap?(id) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index 86779f72b..9eca9b233 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -60,6 +60,10 @@ public final class ToastController { /// through ``View/toastClearance(_:)``. public var bottomClearance: CGFloat = 0 + /// The toast's inset from each screen edge in place of the floating tab bar's, for a screen whose + /// pinned bar is narrower than the tab bar. Set through ``View/toastClearance(_:horizontalInset:)``. + public var horizontalInset: CGFloat? + /// A controller with no toast showing. public init() {} @@ -114,30 +118,41 @@ extension View { extension View { /// Raises `controller`'s toast above this view, measured, for as long as it is on screen. Put it on - /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. - public func toastClearance(_ controller: ToastController) -> some View { - modifier(ToastClearance(controller: controller)) + /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. A `horizontalInset` sets + /// the toast's inset from each screen edge, to match the bar's width, for the same span. An `overlap` + /// lowers the toast that far back into the bar's measured height, for a bar with clear margin above + /// its controls. + public func toastClearance( + _ controller: ToastController, + horizontalInset: CGFloat? = nil, + overlap: CGFloat = 0 + ) -> some View { + modifier(ToastClearance(controller: controller, horizontalInset: horizontalInset, overlap: overlap)) } } private struct ToastClearance: ViewModifier { let controller: ToastController + let horizontalInset: CGFloat? + let overlap: CGFloat @State private var height: CGFloat = 0 @State private var isVisible = false func body(content: Content) -> some View { content .onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in - height = newValue - if isVisible { controller.bottomClearance = newValue } + height = max(0, newValue - overlap) + if isVisible { controller.bottomClearance = height } } .onAppear { isVisible = true controller.bottomClearance = height + controller.horizontalInset = horizontalInset } .onDisappear { isVisible = false controller.bottomClearance = 0 + controller.horizontalInset = nil } } } @@ -162,6 +177,9 @@ private struct ToastHost: View { width: toast.width, onDismiss: toast.action == nil ? nil : { controller.dismiss(toast.id) } ) + .transformEnvironment(\.floatingTabBar) { tabBar in + if let inset = controller.horizontalInset { tabBar = FloatingTabBar(horizontalInset: inset) } + } .allowsHitTesting(toast.action != nil) .padding(.bottom, bottomPadding + controller.bottomClearance) .floatingToastTransition()