From f39a2a6f7771df458044ced53702ddc94808f9fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 16:05:13 -0400 Subject: [PATCH 1/9] feat(share): add Share Extension to send images and text into a chat Accepts images, text and web links from the share sheet, lets the user pick a chat, and sends from the extension with the shared upload/seal code. Images decode one at a time and are capped at 2560px so the extension stays under its memory limit. Also moves the plaintext/sealed photo send switch into FlipClient.sendMediaMessage(photo:) so the app and extension share it. --- Code.xcodeproj/project.pbxproj | 257 +++++++++++++++++ .../Controllers/EncryptedChatClient.swift | 13 +- .../FlipcashCore/Blob/ChatPhotoSend.swift | 31 ++ .../Share/ShareChatSelection.swift | 31 ++ .../FlipcashCore/Share/ShareInput.swift | 127 +++++++++ .../FlipcashCore/Share/ShareSendPlan.swift | 31 ++ .../FlipcashCoreTests/ShareInputTests.swift | 182 ++++++++++++ ShareExtension/ExtensionReporting.swift | 74 +++++ ShareExtension/Info.plist | 34 +++ ShareExtension/ShareComposeView.swift | 97 +++++++ ShareExtension/ShareExtension.entitlements | 14 + ShareExtension/ShareModel.swift | 268 ++++++++++++++++++ ShareExtension/ShareRootView.swift | 102 +++++++ ShareExtension/ShareViewController.swift | 45 +++ 14 files changed, 1294 insertions(+), 12 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Share/ShareInput.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Share/ShareSendPlan.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift create mode 100644 ShareExtension/ExtensionReporting.swift create mode 100644 ShareExtension/Info.plist create mode 100644 ShareExtension/ShareComposeView.swift create mode 100644 ShareExtension/ShareExtension.entitlements create mode 100644 ShareExtension/ShareModel.swift create mode 100644 ShareExtension/ShareRootView.swift create mode 100644 ShareExtension/ShareViewController.swift diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 800446fdd..393da198b 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EC0A077F6E7EE1EB0853134B /* Foundation.framework */; }; 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B526B7F9A40A479F80FDC7FE /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 923C43E939D4451BA943F715 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8D1766162CE94018B6DE106B /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 358ED8813F044795BC79ADAE /* FlipcashCore */; }; + 46809829231644E8A501AA4F /* FlipcashStore in Frameworks */ = {isa = PBXBuildFile; productRef = 31E89F3BEC50418CAE99662C /* FlipcashStore */; }; + E20CC3E1194146BCA8F87888 /* Bugsnag in Frameworks */ = {isa = PBXBuildFile; productRef = 13ACBA3BA12440C8B3036028 /* Bugsnag */; }; 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; 4CA1B0022FD00002000AA002 /* FlipcashStore in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA1B0012FD00001000AA001 /* FlipcashStore */; }; 4CA1B0032FD00003000AA003 /* FlipcashStore in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA1B0012FD00001000AA001 /* FlipcashStore */; }; @@ -53,6 +57,13 @@ remoteGlobalIDString = 4C7D6FF92FC8892D0091C7A4; remoteInfo = NotificationService; }; + C6B4C895A4224756AEDCBDE8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9AED10F8258BE1310088D902 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D1577636653C4FD18C147635; + remoteInfo = ShareExtension; + }; 9A516F8A2D9B12EA00AF478B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9AED10F8258BE1310088D902 /* Project object */; @@ -77,6 +88,7 @@ dstSubfolderSpec = 13; files = ( 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */, + B526B7F9A40A479F80FDC7FE /* ShareExtension.appex in Embed Foundation Extensions */, 7531D04D33DB0C02B873BF36 /* NotificationContent.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -86,6 +98,7 @@ /* Begin PBXFileReference section */ 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 923C43E939D4451BA943F715 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4E679C9CB11F511DF4EE5B34 /* NotificationViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 88FB56DF2EE0AB2F0014EC5F /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 88FB56E12EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "2025-11-27-swap-rpc-analysis.md"; sourceTree = ""; }; @@ -123,6 +136,13 @@ ); target = 4C7D6FF92FC8892D0091C7A4 /* NotificationService */; }; + 7721B948AA364996A365F6E3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D1577636653C4FD18C147635 /* ShareExtension */; + }; 9AC0156E2DA579120030298E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -135,6 +155,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4C7D70082FC8892D0091C7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationService; sourceTree = ""; }; + 5B9C9BE3C6CD45B1A5BD6332 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7721B948AA364996A365F6E3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = ""; }; 9A516F7B2D9B12E900AF478B /* Flipcash */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9AC0156E2DA579120030298E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Flipcash; sourceTree = ""; }; 9A516F8C2D9B12EA00AF478B /* FlipcashTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FlipcashTests; sourceTree = ""; }; 9A516F962D9B12EA00AF478B /* FlipcashUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4C025B362F69DC1F00914087 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FlipcashUITests; sourceTree = ""; }; @@ -163,6 +184,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 154D22319D5941AC84B73BFB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D1766162CE94018B6DE106B /* FlipcashCore in Frameworks */, + 46809829231644E8A501AA4F /* FlipcashStore in Frameworks */, + E20CC3E1194146BCA8F87888 /* Bugsnag in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F772D9B12E800AF478B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -257,6 +288,7 @@ 9A516F8C2D9B12EA00AF478B /* FlipcashTests */, 9A516F962D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */, + 5B9C9BE3C6CD45B1A5BD6332 /* ShareExtension */, 9AED1101258BE1310088D902 /* Products */, DE3C8C53AB74589FBE36B004 /* Frameworks */, 1DE68FD2D78ED8BA7ACC542F /* NotificationContent */, @@ -270,6 +302,7 @@ 9A516F892D9B12EA00AF478B /* FlipcashTests.xctest */, 9A516F932D9B12EA00AF478B /* FlipcashUITests.xctest */, 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */, + 923C43E939D4451BA943F715 /* ShareExtension.appex */, C402C27F9E020E000EBA2936 /* NotificationContent.appex */, ); name = Products; @@ -331,6 +364,31 @@ productReference = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; + D1577636653C4FD18C147635 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2DEBF046882A462B9A2E9DFA /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + A066C4948A074790B85E52C4 /* Sources */, + 154D22319D5941AC84B73BFB /* Frameworks */, + 4A85FC8082954409BC3DD1D8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5B9C9BE3C6CD45B1A5BD6332 /* ShareExtension */, + ); + name = ShareExtension; + packageProductDependencies = ( + 358ED8813F044795BC79ADAE /* FlipcashCore */, + 31E89F3BEC50418CAE99662C /* FlipcashStore */, + 13ACBA3BA12440C8B3036028 /* Bugsnag */, + ); + productName = ShareExtension; + productReference = 923C43E939D4451BA943F715 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 554904D5CE93303B1F28329F /* NotificationContent */ = { isa = PBXNativeTarget; buildConfigurationList = 371A55E2554524D8CDBA32B3 /* Build configuration list for PBXNativeTarget "NotificationContent" */; @@ -366,6 +424,7 @@ ); dependencies = ( 4C7D70002FC8892D0091C7A4 /* PBXTargetDependency */, + 6E1DD951BAE64096A4E5AE82 /* PBXTargetDependency */, 47A27522041981A8EE511CE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -522,6 +581,7 @@ 9A516F882D9B12EA00AF478B /* FlipcashTests */, 9A516F922D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FF92FC8892D0091C7A4 /* NotificationService */, + D1577636653C4FD18C147635 /* ShareExtension */, 554904D5CE93303B1F28329F /* NotificationContent */, ); }; @@ -542,6 +602,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4A85FC8082954409BC3DD1D8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F782D9B12E800AF478B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -611,6 +678,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A066C4948A074790B85E52C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F762D9B12E800AF478B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -646,6 +720,11 @@ target = 4C7D6FF92FC8892D0091C7A4 /* NotificationService */; targetProxy = 4C7D6FFF2FC8892D0091C7A4 /* PBXContainerItemProxy */; }; + 6E1DD951BAE64096A4E5AE82 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D1577636653C4FD18C147635 /* ShareExtension */; + targetProxy = C6B4C895A4224756AEDCBDE8 /* PBXContainerItemProxy */; + }; 9A516F8B2D9B12EA00AF478B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 9A516F792D9B12E800AF478B /* Flipcash */; @@ -700,6 +779,47 @@ }; name = Development; }; + CA552A00B43B4CA9B4C8C6A0 /* Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Flipcash; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 2026.10.3; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Development; + }; 4C7D70042FC8892D0091C7A4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -741,6 +861,47 @@ }; name = Debug; }; + 725CF6403E244464B751A68F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Flipcash; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 2026.10.3; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; 4C7D70052FC8892D0091C7A4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -777,6 +938,42 @@ }; name = Release; }; + F88B0A175E9A4DC7A52766DE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Flipcash; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 2026.10.3; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; 4C7D70062FC8892D0091C7A4 /* Release Development */ = { isa = XCBuildConfiguration; buildSettings = { @@ -813,6 +1010,42 @@ }; name = "Release Development"; }; + 50F7941CD151479D97CA7D63 /* Release Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 272; + DEVELOPMENT_TEAM = SC677X4LH6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Flipcash; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 2026.10.3; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = "Release Development"; + }; 4CBD160721BD7B5207D62D56 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1664,6 +1897,17 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 2DEBF046882A462B9A2E9DFA /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA552A00B43B4CA9B4C8C6A0 /* Development */, + 725CF6403E244464B751A68F /* Debug */, + F88B0A175E9A4DC7A52766DE /* Release */, + 50F7941CD151479D97CA7D63 /* Release Development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 9A516F9B2D9B12EA00AF478B /* Build configuration list for PBXNativeTarget "Flipcash" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1841,6 +2085,19 @@ isa = XCSwiftPackageProductDependency; productName = FlipcashUI; }; + 358ED8813F044795BC79ADAE /* FlipcashCore */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashCore; + }; + 31E89F3BEC50418CAE99662C /* FlipcashStore */ = { + isa = XCSwiftPackageProductDependency; + productName = FlipcashStore; + }; + 13ACBA3BA12440C8B3036028 /* Bugsnag */ = { + isa = XCSwiftPackageProductDependency; + package = 9A476D482978884700C5B5CE /* XCRemoteSwiftPackageReference "bugsnag-cocoa" */; + productName = Bugsnag; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 9AED10F8258BE1310088D902 /* Project object */; diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 4b0b909c1..700e7d2ca 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -148,18 +148,7 @@ extension EncryptedChatClient: ConversationMessaging { func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { try await refetchingOnRefusal(conversationID) { let seal = try await sealForSending(conversationID, owner: owner) - switch ChatPhotoSend.matching(photo, seal: seal, conversationID: conversationID) { - case .plain(let blobID): - return try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) - case .sealed(let sealed, let seal): - return try await client.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: sealed, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) - case .mismatch: - logger.error("Photo was uploaded for a different encryption choice", metadata: [ - "conversationID": "\(conversationID)", - "sealed": "\(seal != nil)", - ]) - throw ErrorSendMessage.encryptionFailed - } + return try await client.sendMediaMessage(owner: owner, conversationID: conversationID, photo: photo, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift index 268c95b21..bdfe8abd1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift @@ -38,3 +38,34 @@ public enum ChatPhotoSend: Equatable { } } } + +private let logger = Logger(label: "flipcash.chat-photo-send") + +extension FlipClient { + + /// Sends a photo the way it was uploaded, after checking the chat still decides the same way: a + /// plaintext blob never goes into a chat that encrypts, nor a sealed one into a chat that doesn't. + /// `seal` is the chat's current seal, nil when it sends in plaintext. + public func sendMediaMessage( + owner: KeyPair, + conversationID: ConversationID, + photo: UploadedPhoto, + caption: String?, + repliedTo: MessageID?, + seal: ChatSeal?, + clientMessageID: UUID + ) async throws -> ConversationMessage { + switch ChatPhotoSend.matching(photo, seal: seal, conversationID: conversationID) { + case .plain(let blobID): + return try await sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + case .sealed(let sealed, let seal): + return try await sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: sealed, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) + case .mismatch: + logger.error("Photo was uploaded for a different encryption choice", metadata: [ + "conversationID": "\(conversationID)", + "sealed": "\(seal != nil)", + ]) + throw ErrorSendMessage.encryptionFailed + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift b/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift new file mode 100644 index 000000000..21d8dfc59 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift @@ -0,0 +1,31 @@ +// +// ShareChatSelection.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Which chats the share extension offers, and which one a share was aimed at. +public enum ShareChatSelection { + + /// The chats a share can go into, most recent first. Hidden chats are the ones the user removed + /// from the list, so they stay out of the picker too. + public static func candidates(from conversations: [Conversation]) -> [Conversation] { + conversations + .filter { !$0.isHidden } + .sorted { $0.lastActivity > $1.lastActivity } + } + + /// The chat named by an `INSendMessageIntent.conversationIdentifier`, when it is one of + /// `conversations`. The identifier is a chat's link form: the dashed UUID of a group or the + /// base64url of a DM, which is what `ConversationID.linkPathComponent` produces. Anything else, or + /// a chat the picker does not offer, selects nothing. + public static func preselected(conversationIdentifier: String?, in conversations: [Conversation]) -> ConversationID? { + guard let conversationIdentifier, + let id = ConversationID(uuidString: conversationIdentifier) ?? ConversationID(base64URLEncoded: conversationIdentifier) + else { return nil } + return conversations.contains { $0.id == id } ? id : nil + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Share/ShareInput.swift b/FlipcashCore/Sources/FlipcashCore/Share/ShareInput.swift new file mode 100644 index 000000000..97156bf9a --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareInput.swift @@ -0,0 +1,127 @@ +// +// ShareInput.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import UniformTypeIdentifiers + +/// What a share sheet hands the share extension, sorted into the images to send and the text to +/// prefill the message with. +public struct ShareInput: Equatable, Sendable { + + /// Copies of the shared images in the extension's temporary directory, in share order. Files, not + /// bitmaps: the extension decodes one at a time so a 10-photo share never holds 10 decodes. + public var imageURLs: [URL] + + /// The text to prefill the message with: the shared link when there is one, otherwise the shared + /// text. A web page arrives as a URL plus a plain-text title, and the link is the part worth sending. + public var text: String? + + /// Images past the attachment limit that were left out. + public var droppedImageCount: Int + + public init(imageURLs: [URL] = [], text: String? = nil, droppedImageCount: Int = 0) { + self.imageURLs = imageURLs + self.text = text + self.droppedImageCount = droppedImageCount + } + + public var isEmpty: Bool { imageURLs.isEmpty && (text?.isEmpty ?? true) } + + /// The most photos one send can carry; matches the composer's limit in the app. + public static let maxImages = 10 + + /// Sorts `providers` into images and text. A provider counts as an image first, a web link + /// second, text last, so a provider that offers several representations is read once. File + /// URLs are never a link: a file shared from Files arrives as one and is not message text. + @MainActor + public static func parse( + _ providers: [NSItemProvider], + maxImages: Int = ShareInput.maxImages, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) async -> ShareInput { + var input = ShareInput() + var link: String? + var text: String? + + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + guard input.imageURLs.count < maxImages else { + input.droppedImageCount += 1 + continue + } + if let url = await copyImage(from: provider, into: temporaryDirectory) { + input.imageURLs.append(url) + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if link == nil, let url = await loadWebURL(from: provider) { + link = url.absoluteString + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + if text == nil, let string = await loadText(from: provider) { + text = string + } + } + } + + let prefill = (link ?? text)?.trimmingCharacters(in: .whitespacesAndNewlines) + input.text = prefill?.isEmpty == false ? prefill : nil + return input + } + + /// Removes the temporary image copies. + public func removeFiles(fileManager: FileManager = .default) { + for url in imageURLs { + try? fileManager.removeItem(at: url) + } + } + + // MARK: - Loading - + + /// The URL `loadFileRepresentation` hands over is deleted when its handler returns, so the copy + /// has to happen inside it. + @MainActor + private static func copyImage(from provider: NSItemProvider, into directory: URL) async -> URL? { + await withCheckedContinuation { continuation in + _ = provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, _ in + guard let url else { + continuation.resume(returning: nil) + return + } + let copy = directory.appendingPathComponent("share-\(UUID().uuidString)").appendingPathExtension(url.pathExtension) + do { + try FileManager.default.copyItem(at: url, to: copy) + continuation.resume(returning: copy) + } catch { + continuation.resume(returning: nil) + } + } + } + } + + @MainActor + private static func loadWebURL(from provider: NSItemProvider) async -> URL? { + let item = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier) + let url: URL? = switch item { + case let url as URL: url + case let url as NSURL: url as URL + case let string as String: URL(string: string) + default: nil + } + guard let url, !url.isFileURL else { return nil } + return url + } + + @MainActor + private static func loadText(from provider: NSItemProvider) async -> String? { + let item = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) + return switch item { + case let string as String: string + case let data as Data: String(data: data, encoding: .utf8) + default: nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Share/ShareSendPlan.swift b/FlipcashCore/Sources/FlipcashCore/Share/ShareSendPlan.swift new file mode 100644 index 000000000..700cbb0bf --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareSendPlan.swift @@ -0,0 +1,31 @@ +// +// ShareSendPlan.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The messages one share becomes, in send order. +public enum ShareSendPlan { + + public enum Step: Equatable, Sendable { + /// The photo at `index` in the shared images, with `caption` under it. + case photo(index: Int, caption: String?) + case text(String) + } + + /// One message per image with the text as the caption of the last, the way the composer sends + /// staged photos; a text message when there are no images; nothing when there is neither. + public static func steps(imageCount: Int, text: String?) -> [Step] { + let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) + let caption = text?.isEmpty == false ? text : nil + guard imageCount > 0 else { + return caption.map { [.text($0)] } ?? [] + } + return (0.. URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("share-input-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private static func pngFile(in directory: URL) throws -> URL { + let url = directory.appendingPathComponent("\(UUID().uuidString).png") + let context = CGContext(data: nil, width: 4, height: 4, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, context.makeImage()!, nil) + CGImageDestinationFinalize(destination) + return url + } + + private static func text(_ string: String) -> NSItemProvider { + NSItemProvider(item: string as NSString, typeIdentifier: UTType.plainText.identifier) + } + + private static func link(_ string: String) -> NSItemProvider { + NSItemProvider(item: URL(string: string)! as NSURL, typeIdentifier: UTType.url.identifier) + } + + @Test("Shared text prefills the message") + func sharedText() async throws { + let input = await ShareInput.parse([Self.text(" hello there \n")]) + #expect(input.text == "hello there") + #expect(input.imageURLs.isEmpty) + } + + @Test("A web link prefills the message") + func sharedLink() async { + let input = await ShareInput.parse([Self.link("https://example.com/a?b=1")]) + #expect(input.text == "https://example.com/a?b=1") + } + + @Test("A link wins over the page title Safari sends beside it") + func linkWinsOverTitle() async { + let input = await ShareInput.parse([Self.text("Example page"), Self.link("https://example.com")]) + #expect(input.text == "https://example.com") + } + + @Test("A file URL is not message text") + func fileURLIsNotALink() async { + let input = await ShareInput.parse([NSItemProvider(item: URL(fileURLWithPath: "/tmp/x.pdf") as NSURL, typeIdentifier: UTType.url.identifier)]) + #expect(input.text == nil) + #expect(input.isEmpty) + } + + @Test("Blank text prefills nothing") + func blankText() async { + let input = await ShareInput.parse([Self.text(" \n ")]) + #expect(input.text == nil) + } + + @Test("Images are copied out of the provider and keep share order") + func images() async throws { + let source = try Self.scratchDirectory() + let destination = try Self.scratchDirectory() + defer { try? FileManager.default.removeItem(at: source); try? FileManager.default.removeItem(at: destination) } + let first = try Self.pngFile(in: source) + let second = try Self.pngFile(in: source) + let providers = [first, second].map { NSItemProvider(contentsOf: $0)! } + + let input = await ShareInput.parse(providers, temporaryDirectory: destination) + + #expect(input.imageURLs.count == 2) + for url in input.imageURLs { + #expect(url.deletingLastPathComponent().standardizedFileURL == destination.standardizedFileURL) + #expect(FileManager.default.fileExists(atPath: url.path)) + } + input.removeFiles() + #expect(input.imageURLs.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) + } + + @Test("Images past the limit are counted, not kept") + func imageLimit() async throws { + let source = try Self.scratchDirectory() + let destination = try Self.scratchDirectory() + defer { try? FileManager.default.removeItem(at: source); try? FileManager.default.removeItem(at: destination) } + let providers = try (0..<3).map { _ in NSItemProvider(contentsOf: try Self.pngFile(in: source))! } + + let input = await ShareInput.parse(providers, maxImages: 2, temporaryDirectory: destination) + + #expect(input.imageURLs.count == 2) + #expect(input.droppedImageCount == 1) + } +} + +@Suite("Share chat selection") +struct ShareChatSelectionTests { + + private static func conversation(_ byte: UInt8, size: Int = 32, activity: TimeInterval, hidden: Bool = false) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: size)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSinceReferenceDate: activity), + isHidden: hidden + ) + } + + @Test("Candidates are most recent first and leave out hidden chats") + func candidates() { + let old = Self.conversation(1, activity: 10) + let new = Self.conversation(2, activity: 30) + let hidden = Self.conversation(3, activity: 40, hidden: true) + let middle = Self.conversation(4, activity: 20) + + let result = ShareChatSelection.candidates(from: [old, hidden, new, middle]) + + #expect(result.map(\.id) == [new.id, middle.id, old.id]) + } + + @Test("A DM is preselected by its base64url identifier") + func preselectsDM() { + let dm = Self.conversation(1, activity: 1) + let other = Self.conversation(2, activity: 2) + #expect(ShareChatSelection.preselected(conversationIdentifier: dm.id.linkPathComponent, in: [dm, other]) == dm.id) + } + + @Test("A group is preselected by its UUID identifier") + func preselectsGroup() { + let group = Self.conversation(7, size: 16, activity: 1) + #expect(ShareChatSelection.preselected(conversationIdentifier: group.id.linkPathComponent, in: [group]) == group.id) + } + + @Test("A missing, malformed or unknown identifier selects nothing") + func preselectsNothing() { + let dm = Self.conversation(1, activity: 1) + let unknown = Self.conversation(9, activity: 1) + #expect(ShareChatSelection.preselected(conversationIdentifier: nil, in: [dm]) == nil) + #expect(ShareChatSelection.preselected(conversationIdentifier: "not an id", in: [dm]) == nil) + #expect(ShareChatSelection.preselected(conversationIdentifier: unknown.id.linkPathComponent, in: [dm]) == nil) + } +} + +@Suite("Share send plan") +struct ShareSendPlanTests { + + @Test("Text alone is one text message") + func textOnly() { + #expect(ShareSendPlan.steps(imageCount: 0, text: " hi ") == [.text("hi")]) + } + + @Test("Nothing to send plans nothing") + func empty() { + #expect(ShareSendPlan.steps(imageCount: 0, text: " ").isEmpty) + #expect(ShareSendPlan.steps(imageCount: 0, text: nil).isEmpty) + } + + @Test("The caption goes under the last photo only") + func captionOnLast() { + #expect(ShareSendPlan.steps(imageCount: 3, text: "look") == [ + .photo(index: 0, caption: nil), + .photo(index: 1, caption: nil), + .photo(index: 2, caption: "look"), + ]) + } + + @Test("Photos with no text carry no caption") + func noCaption() { + #expect(ShareSendPlan.steps(imageCount: 1, text: nil) == [.photo(index: 0, caption: nil)]) + } +} diff --git a/ShareExtension/ExtensionReporting.swift b/ShareExtension/ExtensionReporting.swift new file mode 100644 index 000000000..d7d123632 --- /dev/null +++ b/ShareExtension/ExtensionReporting.swift @@ -0,0 +1,74 @@ +// +// ExtensionReporting.swift +// ShareExtension +// + +import Foundation +import Bugsnag + +/// Crash and error reporting for the share extension process. +/// +/// Deliberately separate from the app's `ErrorReporting`: that type lives in the +/// app target, and hoisting it into `FlipcashCore` would make the whole core +/// package — including the macOS vector test plans — depend on Bugsnag. +/// +/// Note what this cannot see. A jetsam kill for exceeding the extension's memory +/// limit terminates the process without an exception, so it produces no report. +/// Memory headroom has to be measured, not inferred from silence here. +enum ExtensionReporting { + + private static let lock = NSLock() + // Guarded by `lock`, not by isolation — the extension calls this from ordinary + // dispatch queues, not actors, so a lock is the synchronization mechanism here. + private static nonisolated(unsafe) var _isStarted = false + + private static var isStarted: Bool { + lock.lock() + defer { lock.unlock() } + return _isStarted + } + + /// Starts Bugsnag once per extension process. Safe to call on every launch: + /// the extension is torn down and relaunched often, and each new process + /// needs its own start. + static func startIfNeeded() { + lock.lock() + defer { lock.unlock() } + guard !_isStarted else { return } + + let config = BugsnagConfiguration.loadConfig() + config.maxStringValueLength = 50_000 + // Distinguishes extension events from the app's in the Bugsnag dashboard; + // without it both processes report under the same app id and the NSE's + // crashes are indistinguishable from the app's. + config.addMetadata("share-extension", key: "process", section: "app") + Bugsnag.start(with: config) + _isStarted = true + } + + /// Reports a non-fatal from the extension. No-op before `startIfNeeded`. + static func capture(_ error: Swift.Error, reason: String, metadata: [String: String] = [:]) { + guard isStarted else { return } + Bugsnag.notifyError(error as NSError) { event in + event.severity = .error + if !event.errors.isEmpty { + event.errors[0].errorClass = reason + event.errors[0].errorMessage = "\(error)" + } + metadata.forEach { key, value in + event.addMetadata(value, key: key, section: "extension") + } + event.groupingHash = reason + return true + } + } + + /// Records a named checkpoint so a later crash report shows how far the + /// extension got. A `0xdead10cc` termination arrives with no Swift error + /// attached, so the breadcrumb trail is the only evidence of where the + /// process was when it died. + static func breadcrumb(_ message: String, metadata: [String: String] = [:]) { + guard isStarted else { return } + Bugsnag.leaveBreadcrumb(message, metadata: metadata, type: .process) + } +} diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist new file mode 100644 index 000000000..915e1bae7 --- /dev/null +++ b/ShareExtension/Info.plist @@ -0,0 +1,34 @@ + + + + + bugsnag + + apiKey + $(FLIPCASH_BUGSNAG) + + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ShareExtension/ShareComposeView.swift b/ShareExtension/ShareComposeView.swift new file mode 100644 index 000000000..934d75c95 --- /dev/null +++ b/ShareExtension/ShareComposeView.swift @@ -0,0 +1,97 @@ +// +// ShareComposeView.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +struct ShareComposeView: View { + + let model: ShareModel + let chat: Conversation + + @Environment(\.dismiss) private var dismiss + + var body: some View { + @Bindable var model = model + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + if !model.thumbnails.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.thumbnails) { thumbnail in + Image(decorative: thumbnail.image, scale: 1) + .resizable() + .scaledToFill() + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + .scrollClipDisabled() + if model.input.droppedImageCount > 0 { + Text("Only the first \(ShareInput.maxImages) images are sent.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + TextField(model.thumbnails.isEmpty ? "Message" : "Add a caption", text: $model.text, axis: .vertical) + .lineLimit(1...6) + .textFieldStyle(.roundedBorder) + .disabled(model.isSending) + + status + + Spacer(minLength: 0) + } + .padding(16) + .navigationTitle(model.displayName(for: chat)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back") { dismiss() } + .disabled(model.isSending) + } + ToolbarItem(placement: .confirmationAction) { + Button(isRetry ? "Retry" : "Send") { model.send(to: chat) } + .disabled(!canSend) + } + } + } + .interactiveDismissDisabled(model.isSending) + .presentationDetents([.medium, .large]) + } + + private var isRetry: Bool { + if case .failed = model.sendState { true } else { false } + } + + private var canSend: Bool { + if model.isSending { return false } + if case .failed(_, let canRetry) = model.sendState, !canRetry { return false } + return !model.thumbnails.isEmpty || !model.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @ViewBuilder + private var status: some View { + switch model.sendState { + case .idle: + EmptyView() + case .sending(let step, let total): + HStack(spacing: 8) { + ProgressView() + Text(total > 1 ? "Sending \(step) of \(total)" : "Sending") + .font(.subheadline) + .foregroundStyle(.secondary) + } + case .failed(let message, _): + Text(message) + .font(.subheadline) + .foregroundStyle(.red) + } + } +} diff --git a/ShareExtension/ShareExtension.entitlements b/ShareExtension/ShareExtension.entitlements new file mode 100644 index 000000000..a9aae3051 --- /dev/null +++ b/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.flipcash.shared + + keychain-access-groups + + $(AppIdentifierPrefix)com.flipcash.shared + + + diff --git a/ShareExtension/ShareModel.swift b/ShareExtension/ShareModel.swift new file mode 100644 index 000000000..c64dc23f2 --- /dev/null +++ b/ShareExtension/ShareModel.swift @@ -0,0 +1,268 @@ +// +// ShareModel.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics +import Foundation +import ImageIO +import Observation +import FlipcashCore +import FlipcashStore + +/// A small, already-downsampled preview of one shared image. +struct ShareThumbnail: Identifiable { + let id: Int + let image: CGImage +} + +/// State and send logic for the share extension. +/// +/// The extension is killed at roughly 120MB, so images stay on disk as files and are decoded one at +/// a time, capped at ``maxUploadEdge``, only while they upload. What the view holds are thumbnails. +@MainActor +@Observable +final class ShareModel { + + enum Phase { + case loading + /// No owner key in the shared keychain; the user has to sign in from the app. + case signedOut + /// The shared store is missing, busy or from another schema. The app rebuilds it on launch. + case chatsUnavailable + case ready + } + + enum SendState: Equatable { + case idle + case sending(step: Int, of: Int) + case failed(message: String, canRetry: Bool) + } + + private(set) var phase: Phase = .loading + private(set) var chats: [Conversation] = [] + private(set) var thumbnails: [ShareThumbnail] = [] + private(set) var input = ShareInput() + private(set) var sendState: SendState = .idle + + /// The chat the share was aimed at by an intent, or nil. + private(set) var preselectedID: ConversationID? + + /// The message text, prefilled from the shared text or link. + var text = "" + + /// The longest edge an image is decoded at for upload. A 12MP decode is about 48MB, and the encoder + /// holds more than one copy while it works; 2560 keeps the peak well under the extension's limit. + nonisolated static let maxUploadEdge = 2560 + nonisolated static let thumbnailEdge = 240 + + private let providers: [NSItemProvider] + private let intentConversationIdentifier: String? + private let finish: () -> Void + private let cancelRequest: () -> Void + + private var account: UserAccount? + private var loadTask: Task? + private var sendTask: Task? + + // Per-send progress that survives a retry: what already went out is never sent twice. + private var clientMessageIDs: [Int: UUID] = [:] + private var uploaded: [Int: UploadedPhoto] = [:] + private var completedSteps: Set = [] + private var session: Session? + + private struct Session { + let flipClient: FlipClient + let seal: ChatSeal? + let uploader: ChatMediaUploader + } + + init( + providers: [NSItemProvider], + intentConversationIdentifier: String?, + finish: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.providers = providers + self.intentConversationIdentifier = intentConversationIdentifier + self.finish = finish + self.cancelRequest = cancel + } + + var isSending: Bool { + if case .sending = sendState { true } else { false } + } + + // MARK: - Loading - + + func load() { + guard loadTask == nil else { return } + loadTask = Task { await performLoad() } + } + + private func performLoad() async { + guard let account = OwnerKeyStore.loadOwnerAccount() else { + phase = .signedOut + return + } + self.account = account + + var conversations: [Conversation]? + let outcome = ExtensionStore.perform(owner: account.keyAccount.ownerPublicKey) { database in + conversations = try database.getConversations() + } + guard outcome == .wrote, let conversations else { + ExtensionReporting.breadcrumb("chat list unavailable: \(outcome)") + phase = .chatsUnavailable + return + } + chats = ShareChatSelection.candidates(from: conversations) + preselectedID = ShareChatSelection.preselected(conversationIdentifier: intentConversationIdentifier, in: chats) + + input = await ShareInput.parse(providers) + text = input.text ?? "" + // One decode at a time, each at thumbnail size. + var loaded: [ShareThumbnail] = [] + for (index, url) in input.imageURLs.enumerated() { + if let image = await ChatPhotoDecoder.decode(contentsOf: url, maxPixelSize: Self.thumbnailEdge) { + loaded.append(ShareThumbnail(id: index, image: image)) + } + } + thumbnails = loaded + phase = .ready + } + + func displayName(for chat: Conversation) -> String { + // The extension has no contacts access, so the feed's name stands in for the address-book name. + chat.displayName(selfUserID: account?.userID, contactName: { _ in nil }) + } + + // MARK: - Sending - + + func send(to chat: Conversation) { + guard sendTask == nil, !isSending else { return } + sendTask = Task { + await performSend(to: chat) + sendTask = nil + } + } + + func cancel() { + sendTask?.cancel() + input.removeFiles() + cancelRequest() + } + + private func performSend(to chat: Conversation) async { + guard let account else { return } + let owner = account.keyAccount.owner + let steps = ShareSendPlan.steps(imageCount: input.imageURLs.count, text: text) + guard !steps.isEmpty else { return } + + do { + let session = try await resolveSession(for: chat, account: account) + for (position, step) in steps.enumerated() where !completedSteps.contains(position) { + try Task.checkCancellation() + sendState = .sending(step: position + 1, of: steps.count) + let clientMessageID = clientMessageIDs[position] ?? UUID() + clientMessageIDs[position] = clientMessageID + + let message: ConversationMessage + switch step { + case .text(let body): + message = try await session.flipClient.sendMessage(owner: owner, conversationID: chat.id, text: body, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) + case .photo(let index, let caption): + let photo = try await uploadedPhoto(at: index, session: session) + message = try await session.flipClient.sendMediaMessage(owner: owner, conversationID: chat.id, photo: photo, caption: caption, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) + } + completedSteps.insert(position) + persist(message, in: chat.id, account: account) + } + input.removeFiles() + finish() + } catch is CancellationError { + sendState = .idle + } catch { + ExtensionReporting.capture(error, reason: "Share send failed") + sendState = .failed(message: Self.message(for: error), canRetry: Self.canRetry(error)) + } + } + + /// The client, the chat's seal and the uploader, built once per send and kept across retries so a + /// retry doesn't repeat the chat fetch. Only a fully built session is kept. + private func resolveSession(for chat: Conversation, account: UserAccount) async throws -> Session { + if let session { return session } + let owner = account.keyAccount.owner + let flipClient = try FlipClient(network: .mainNet) + let keyring = ChatKeyring(owner: owner, selfUserID: account.userID) { userID in + try await flipClient.resolveUserID(userID, owner: owner) + } + let conversation = try await flipClient.getChat(owner: owner, conversationID: chat.id) + let seal = try await keyring.sealForSending(in: conversation) + let uploader = ChatMediaUploader( + blob: FlipClientChatMediaBlobStore(flipClient: flipClient, owner: owner), + seal: { seal } + ) + let session = Session(flipClient: flipClient, seal: seal, uploader: uploader) + self.session = session + return session + } + + /// Decodes, uploads and releases one image. The decoded bitmap lives only inside this call, and a + /// retry after a failed send reuses the stored upload instead of decoding again. + private func uploadedPhoto(at index: Int, session: Session) async throws -> UploadedPhoto { + if let photo = uploaded[index] { return photo } + guard let image = await ChatPhotoDecoder.decode(contentsOf: input.imageURLs[index], maxPixelSize: Self.maxUploadEdge) else { + throw ShareError.unreadableImage + } + let photo = try await session.uploader.upload(image, orientation: .up, onStored: { uploaded[index] = $0 }, onPrepared: { _, _ in }) + uploaded[index] = photo + return photo + } + + /// Writes the confirmed message into the shared store so the app has it on next launch, and tells a + /// running app to re-read. Best effort: the app also catches up on sync, so a missing or busy store + /// loses nothing. + private func persist(_ message: ConversationMessage, in conversationID: ConversationID, account: UserAccount) { + let outcome = ExtensionStore.perform(owner: account.keyAccount.ownerPublicKey) { database in + try database.persistMessages([message], cursor: 0, conversationID: conversationID) + } + switch outcome { + case .wrote: + ChatStoreWriteNotification.post() + case .noStore, .busy, .versionMismatch: + ExtensionReporting.breadcrumb("share store write skipped: \(outcome)") + case .failed(let description): + ExtensionReporting.breadcrumb("share store write failed: \(description)") + } + } + + // MARK: - Errors - + + enum ShareError: Error { + case unreadableImage + } + + private static func canRetry(_ error: Error) -> Bool { + switch error { + case let error as ChatMediaUploadError: !error.isTerminal + case ShareError.unreadableImage: false + default: true + } + } + + private static func message(for error: Error) -> String { + switch error { + case ShareError.unreadableImage: + "One of the images couldn't be read." + case ChatMediaUploadError.rejected(.moderation): + "This image can't be sent." + case ChatMediaUploadError.rejected, ChatMediaUploadError.encodingFailed, ChatMediaUploadError.noMatchingConstraint: + "This image couldn't be prepared for sending." + default: + "Couldn't send. Check your connection and try again." + } + } +} diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift new file mode 100644 index 000000000..8470383df --- /dev/null +++ b/ShareExtension/ShareRootView.swift @@ -0,0 +1,102 @@ +// +// ShareRootView.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +struct ShareRootView: View { + + let model: ShareModel + + @State private var selected: Conversation? + + var body: some View { + NavigationStack { + content + .navigationTitle("Share to Flipcash") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { model.cancel() } + } + } + } + .task { model.load() } + .sheet(item: $selected) { chat in + ShareComposeView(model: model, chat: chat) + } + .onChange(of: model.preselectedID) { _, id in + guard selected == nil, let id, let chat = model.chats.first(where: { $0.id == id }) else { return } + selected = chat + } + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + ProgressView() + case .signedOut: + ShareMessageView(title: "Open Flipcash to sign in", detail: "Sign in to the app, then share again.") + case .chatsUnavailable: + ShareMessageView(title: "Open Flipcash to refresh", detail: "Your chats aren't ready yet. Open the app once, then share again.") + case .ready where model.chats.isEmpty: + ShareMessageView(title: "No chats yet", detail: "Start a chat in Flipcash, then share again.") + case .ready: + List(model.chats) { chat in + Button { selected = chat } label: { + ShareChatRow(name: model.displayName(for: chat)) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + } + } +} + +extension Conversation: @retroactive Identifiable {} + +private struct ShareChatRow: View { + + let name: String + + var body: some View { + HStack(spacing: 12) { + Text(initials) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 40, height: 40) + .background(.quaternary, in: Circle()) + .accessibilityHidden(true) + Text(name) + .lineLimit(1) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + + private var initials: String { + String(name.split(separator: " ").prefix(2).compactMap(\.first)).uppercased() + } +} + +struct ShareMessageView: View { + + let title: String + let detail: String + + var body: some View { + VStack(spacing: 8) { + Text(title).font(.headline) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(32) + } +} diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift new file mode 100644 index 000000000..febbd35f5 --- /dev/null +++ b/ShareExtension/ShareViewController.swift @@ -0,0 +1,45 @@ +// +// ShareViewController.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Intents +import SwiftUI +import UIKit + +/// Hosts the SwiftUI share flow. The principal class of a share extension has to be a view +/// controller, so this only wires the extension context into ``ShareModel`` and embeds the view. +final class ShareViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + ExtensionReporting.startIfNeeded() + + let context = extensionContext + let providers = (context?.inputItems as? [NSExtensionItem])? + .flatMap { $0.attachments ?? [] } ?? [] + // Set when the share sheet was opened from a suggested contact. Donations that populate it are + // a separate change; until then this is nil and no chat is preselected. + let intentIdentifier = (context?.intent as? INSendMessageIntent)?.conversationIdentifier + + let model = ShareModel( + providers: providers, + intentConversationIdentifier: intentIdentifier, + finish: { [weak context] in + context?.completeRequest(returningItems: []) + }, + cancel: { [weak context] in + context?.cancelRequest(withError: NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + ) + + let host = UIHostingController(rootView: ShareRootView(model: model)) + addChild(host) + host.view.frame = view.bounds + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(host.view) + host.didMove(toParent: self) + } +} From 80f80e81d221418549528cd495435a95ec4e66b7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 23:36:48 -0400 Subject: [PATCH 2/9] chore(share): use the FlipcashShare bundle ID feat/scan-from-shared-image already claims com.flipcash.app.ios.FlipcashShare for its share extension. Both branches use one ID so it only gets registered once and the two extensions can merge into one target. --- Code.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 393da198b..8f7470898 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -807,7 +807,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2026.10.3; - PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.FlipcashShare; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -889,7 +889,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2026.10.3; - PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.FlipcashShare; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -962,7 +962,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2026.10.3; - PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.FlipcashShare; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -1034,7 +1034,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 2026.10.3; - PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.ShareExtension; + PRODUCT_BUNDLE_IDENTIFIER = com.flipcash.app.ios.FlipcashShare; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; From 89d1406d2cf5d184f033ab3078648bf48b73f49c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 17:44:28 -0400 Subject: [PATCH 3/9] fix(share): offer the same chats as the Chats tab The picker listed every non-hidden conversation, which included contact DMs, unjoined groups and archived chats. Extract the Chats tab's selection into ChatListSelection and use it from both ConversationController and the extension, which now also reads group memberships and the archive file. --- .../Controllers/ConversationController.swift | 2 +- .../Conversation/ChatListSelection.swift | 45 ++++++++++++++ .../Share/ShareChatSelection.swift | 14 +++-- .../ChatListSelectionTests.swift | 58 +++++++++++++++++++ .../FlipcashCoreTests/ShareInputTests.swift | 18 ++++-- ShareExtension/ShareModel.swift | 5 +- 6 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListSelection.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatListSelectionTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3964d77b5..1d114f078 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -88,7 +88,7 @@ final class ConversationController { /// The Chats tab's source rows before archive is applied: tip DMs and joined groups. Already /// without hidden conversations, so everything downstream inherits that exclusion. private var chatListSource: [Conversation] { - conversations(of: .tipDm) + joinedGroups + ChatListSelection.source(from: conversations, isMember: { store.isMember(of: $0) }) } /// The one place `Conversation` becomes the list rules' input. The list, the chips, the Archived diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListSelection.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListSelection.swift new file mode 100644 index 000000000..dc726707c --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListSelection.swift @@ -0,0 +1,45 @@ +// +// ChatListSelection.swift +// FlipcashCore +// + +import Foundation + +/// Which conversations the Chats tab lists. The app and the Share Extension both call it, so the +/// extension's picker cannot drift from the tab. +public enum ChatListSelection { + + /// The Chats tab's source rows before archive is applied: tip DMs, then groups the user has + /// joined, without hidden conversations. Contact DMs and groups reached by link but not joined + /// never appear. + public static func source( + from conversations: [Conversation], + isMember: (Conversation) -> Bool + ) -> [Conversation] { + let visible = conversations.filter { !$0.isHidden } + return visible.filter { $0.type == .tipDm } + + visible.filter { $0.type == .group && isMember($0) } + } + + /// The Chats tab's main list: ``source(from:isMember:)`` minus archived chats, newest activity first. + public static func mainList( + from conversations: [Conversation], + joinedGroupIDs: Set, + archivedIDs: Set + ) -> [Conversation] { + let rows = source(from: conversations, isMember: { joinedGroupIDs.contains($0.id) }) + let entries = rows.map { conversation in + ChatListEntry( + id: conversation.id, + type: conversation.type, + lastActivity: conversation.lastActivity, + isArchived: archivedIDs.contains(conversation.id), + isMuted: false, + isHidden: conversation.isHidden, + unread: .count(0) + ) + } + let byID = Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ChatListProjection.project(entries).main.compactMap { byID[$0] } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift b/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift index 21d8dfc59..d8d399ae2 100644 --- a/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift @@ -10,12 +10,14 @@ import Foundation /// Which chats the share extension offers, and which one a share was aimed at. public enum ShareChatSelection { - /// The chats a share can go into, most recent first. Hidden chats are the ones the user removed - /// from the list, so they stay out of the picker too. - public static func candidates(from conversations: [Conversation]) -> [Conversation] { - conversations - .filter { !$0.isHidden } - .sorted { $0.lastActivity > $1.lastActivity } + /// The chats a share can go into: exactly the Chats tab's main list, so the picker matches what + /// the user sees in the app. Hidden and archived chats, contact DMs and unjoined groups stay out. + public static func candidates( + from conversations: [Conversation], + joinedGroupIDs: Set, + archivedIDs: Set + ) -> [Conversation] { + ChatListSelection.mainList(from: conversations, joinedGroupIDs: joinedGroupIDs, archivedIDs: archivedIDs) } /// The chat named by an `INSendMessageIntent.conversationIdentifier`, when it is one of diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatListSelectionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatListSelectionTests.swift new file mode 100644 index 000000000..cee1129c8 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatListSelectionTests.swift @@ -0,0 +1,58 @@ +// +// ChatListSelectionTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("Chat list selection") +struct ChatListSelectionTests { + + private static func conversation(_ byte: UInt8, type: ConversationType, activity: TimeInterval = 0, hidden: Bool = false) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: 32)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSinceReferenceDate: activity), + type: type, + isHidden: hidden + ) + } + + @Test("Source keeps tip DMs and joined groups, tip DMs first") + func source() { + let group = Self.conversation(1, type: .group) + let tip = Self.conversation(2, type: .tipDm) + let contact = Self.conversation(3, type: .contactDm) + let unjoined = Self.conversation(4, type: .group) + let hidden = Self.conversation(5, type: .tipDm, hidden: true) + + let result = ChatListSelection.source(from: [group, tip, contact, unjoined, hidden], isMember: { $0.id == group.id }) + + #expect(result.map(\.id) == [tip.id, group.id]) + } + + @Test("Main list matches the projection's order and drops archived chats") + func mainList() { + let a = Self.conversation(1, type: .tipDm, activity: 10) + let b = Self.conversation(2, type: .group, activity: 30) + let c = Self.conversation(3, type: .tipDm, activity: 20) + let archived = Self.conversation(4, type: .tipDm, activity: 99) + + let result = ChatListSelection.mainList(from: [a, b, c, archived], joinedGroupIDs: [b.id], archivedIDs: [archived.id]) + + #expect(result.map(\.id) == [b.id, c.id, a.id]) + } + + @Test("Equal activity keeps source order") + func tiebreak() { + let tip = Self.conversation(1, type: .tipDm, activity: 5) + let group = Self.conversation(2, type: .group, activity: 5) + + let result = ChatListSelection.mainList(from: [group, tip], joinedGroupIDs: [group.id], archivedIDs: []) + + #expect(result.map(\.id) == [tip.id, group.id]) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift index 812f3b7da..8afa6f088 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift @@ -107,24 +107,32 @@ struct ShareInputTests { @Suite("Share chat selection") struct ShareChatSelectionTests { - private static func conversation(_ byte: UInt8, size: Int = 32, activity: TimeInterval, hidden: Bool = false) -> Conversation { + private static func conversation(_ byte: UInt8, size: Int = 32, activity: TimeInterval, type: ConversationType = .tipDm, hidden: Bool = false) -> Conversation { Conversation( id: ConversationID(data: Data(repeating: byte, count: size)), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSinceReferenceDate: activity), + type: type, isHidden: hidden ) } - @Test("Candidates are most recent first and leave out hidden chats") + @Test("Candidates are the Chats tab's main list: newest first, no hidden, archived, contact or unjoined chats") func candidates() { let old = Self.conversation(1, activity: 10) let new = Self.conversation(2, activity: 30) let hidden = Self.conversation(3, activity: 40, hidden: true) - let middle = Self.conversation(4, activity: 20) - - let result = ShareChatSelection.candidates(from: [old, hidden, new, middle]) + let middle = Self.conversation(4, activity: 20, type: .group) + let archived = Self.conversation(5, activity: 50) + let contact = Self.conversation(6, activity: 60, type: .contactDm) + let unjoined = Self.conversation(7, activity: 70, type: .group) + + let result = ShareChatSelection.candidates( + from: [old, hidden, new, middle, archived, contact, unjoined], + joinedGroupIDs: [middle.id], + archivedIDs: [archived.id] + ) #expect(result.map(\.id) == [new.id, middle.id, old.id]) } diff --git a/ShareExtension/ShareModel.swift b/ShareExtension/ShareModel.swift index c64dc23f2..fac6c13a8 100644 --- a/ShareExtension/ShareModel.swift +++ b/ShareExtension/ShareModel.swift @@ -110,15 +110,18 @@ final class ShareModel { self.account = account var conversations: [Conversation]? + var joinedGroupIDs: Set = [] let outcome = ExtensionStore.perform(owner: account.keyAccount.ownerPublicKey) { database in conversations = try database.getConversations() + joinedGroupIDs = try database.getGroupMemberships() } guard outcome == .wrote, let conversations else { ExtensionReporting.breadcrumb("chat list unavailable: \(outcome)") phase = .chatsUnavailable return } - chats = ShareChatSelection.candidates(from: conversations) + let archivedIDs = ChatArchiveReader.load(owner: account.keyAccount.ownerPublicKey)?.archivedIDs ?? [] + chats = ShareChatSelection.candidates(from: conversations, joinedGroupIDs: joinedGroupIDs, archivedIDs: archivedIDs) preselectedID = ShareChatSelection.preselected(conversationIdentifier: intentConversationIdentifier, in: chats) input = await ShareInput.parse(providers) From 2551a11e1d24384bb006e88a5947087c9a237390 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 17:45:51 -0400 Subject: [PATCH 4/9] feat(share): draw chat avatars in the picker Render ContactAvatarView with the same picture, cached bytes and blurhash the Chats tab row uses, instead of initials. Links FlipcashUI into the extension. --- Code.xcodeproj/project.pbxproj | 3 +++ ShareExtension/ShareModel.swift | 24 ++++++++++++++++++++++++ ShareExtension/ShareRootView.swift | 14 ++++---------- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 8f7470898..9e966b410 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ A3BE11B009F4E2EF7ED93F05 /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = EFFC929EC8849F357BE7F997 /* FlipcashUI */; }; B4D66444ACFD12F6C0FE8FA2 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D542CC6E03E2DF4F10D1BFE7 /* UserNotifications.framework */; }; FACE000000000000000000A2 /* NotificationTranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE000000000000000000A1 /* NotificationTranscriptView.swift */; }; + 5A0E1F2B3C4D4E5F8A9B0C1D /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = EFFC929EC8849F357BE7F997 /* FlipcashUI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -191,6 +192,7 @@ 8D1766162CE94018B6DE106B /* FlipcashCore in Frameworks */, 46809829231644E8A501AA4F /* FlipcashStore in Frameworks */, E20CC3E1194146BCA8F87888 /* Bugsnag in Frameworks */, + 5A0E1F2B3C4D4E5F8A9B0C1D /* FlipcashUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -384,6 +386,7 @@ 358ED8813F044795BC79ADAE /* FlipcashCore */, 31E89F3BEC50418CAE99662C /* FlipcashStore */, 13ACBA3BA12440C8B3036028 /* Bugsnag */, + EFFC929EC8849F357BE7F997 /* FlipcashUI */, ); productName = ShareExtension; productReference = 923C43E939D4451BA943F715 /* ShareExtension.appex */; diff --git a/ShareExtension/ShareModel.swift b/ShareExtension/ShareModel.swift index fac6c13a8..47c0545a7 100644 --- a/ShareExtension/ShareModel.swift +++ b/ShareExtension/ShareModel.swift @@ -137,6 +137,23 @@ final class ShareModel { phase = .ready } + /// What a row's avatar draws, from the same picture the Chats tab uses: the chat's own for a + /// group, the counterpart's for a DM. Bytes come from the App Group cache the app fills, so a + /// picture the app has not fetched yet falls back to its blurhash. + func avatar(for chat: Conversation) -> ShareAvatar { + let picture = chat.type == .group + ? chat.picture + : chat.counterpart(excluding: account?.userID)?.profilePicture + let id = chat.type == .group + ? chat.id.description + : (chat.counterpart(excluding: account?.userID)?.userID?.uuidString ?? chat.id.description) + return ShareAvatar( + id: id, + imageData: picture.flatMap { BlobCache.profilePictures.data(for: $0.thumbnailBlobID) }, + blurhash: picture?.thumbnailBlurhash + ) + } + func displayName(for chat: Conversation) -> String { // The extension has no contacts access, so the feed's name stands in for the address-book name. chat.displayName(selfUserID: account?.userID, contactName: { _ in nil }) @@ -269,3 +286,10 @@ final class ShareModel { } } } + +/// The inputs of a `ContactAvatarView` for one picker row. +struct ShareAvatar { + let id: String + let imageData: Data? + let blurhash: String? +} diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift index 8470383df..393a5f59b 100644 --- a/ShareExtension/ShareRootView.swift +++ b/ShareExtension/ShareRootView.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashUI struct ShareRootView: View { @@ -49,7 +50,7 @@ struct ShareRootView: View { case .ready: List(model.chats) { chat in Button { selected = chat } label: { - ShareChatRow(name: model.displayName(for: chat)) + ShareChatRow(name: model.displayName(for: chat), avatar: model.avatar(for: chat)) } .buttonStyle(.plain) } @@ -63,14 +64,11 @@ extension Conversation: @retroactive Identifiable {} private struct ShareChatRow: View { let name: String + let avatar: ShareAvatar var body: some View { HStack(spacing: 12) { - Text(initials) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 40, height: 40) - .background(.quaternary, in: Circle()) + ContactAvatarView(id: avatar.id, displayName: name, imageData: avatar.imageData, blurhash: avatar.blurhash, size: 40) .accessibilityHidden(true) Text(name) .lineLimit(1) @@ -78,10 +76,6 @@ private struct ShareChatRow: View { } .contentShape(Rectangle()) } - - private var initials: String { - String(name.split(separator: " ").prefix(2).compactMap(\.first)).uppercased() - } } struct ShareMessageView: View { From 68c421ae8e0c8efb7bdf0fce1ab7e384ed1d0be8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 17:47:35 -0400 Subject: [PATCH 5/9] feat(share): donate an outgoing intent so sent-to chats are Share sheet suggestions Donate an INSendMessageIntent after a successful text or photo send, with the chat's link-form id as conversationIdentifier, the format the Share Extension preselects on. --- .../Controllers/ConversationController.swift | 11 +++- Flipcash/Core/Spotlight/ChatShareDonor.swift | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Spotlight/ChatShareDonor.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 1d114f078..f500b5880 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -2022,7 +2022,15 @@ final class ConversationController { if let draft { failedSends?.willSend(draft, clientMessageID: clientMessageID, in: conversationID) } - return await deliver(clientMessageID: clientMessageID, text: text, repliedTo: repliedTo, to: conversationID) + let delivered = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: repliedTo, to: conversationID) + if delivered { donateShareSuggestion(for: conversationID) } + return delivered + } + + /// Tells the system the user just wrote into this chat, so it is offered in the Share sheet. + private func donateShareSuggestion(for conversationID: ConversationID) { + guard let conversation = conversation(withID: conversationID) else { return } + ChatShareDonor.donate(conversation, displayName: displayName(for: conversation), selfUserID: selfUserID) } /// Posts `text` the way ``send(_:to:repliedTo:restoringOnFailure:)`` does, except that a @@ -2193,6 +2201,7 @@ final class ConversationController { ) deliveredAll = deliveredAll && delivered } + if deliveredAll { donateShareSuggestion(for: conversationID) } return deliveredAll } diff --git a/Flipcash/Core/Spotlight/ChatShareDonor.swift b/Flipcash/Core/Spotlight/ChatShareDonor.swift new file mode 100644 index 000000000..9ce9d91b6 --- /dev/null +++ b/Flipcash/Core/Spotlight/ChatShareDonor.swift @@ -0,0 +1,62 @@ +// +// ChatShareDonor.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Intents +import FlipcashCore + +/// Donates an outgoing `INSendMessageIntent` after the user sends into a chat, which is what makes +/// that chat a suggestion in the system Share sheet. The identifier is the chat's link form, the +/// shape `ShareChatSelection.preselected` parses in the Share Extension. +nonisolated enum ChatShareDonor { + + static func donate(_ conversation: Conversation, displayName: String, selfUserID: UserID?) { + let intent = makeIntent(for: conversation, displayName: displayName, selfUserID: selfUserID) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate(completion: nil) + } + + static func makeIntent(for conversation: Conversation, displayName: String, selfUserID: UserID?) -> INSendMessageIntent { + let picture = conversation.type == .group + ? conversation.picture + : conversation.counterpart(excluding: selfUserID)?.profilePicture + let image = picture + .flatMap { BlobCache.profilePictures.data(for: $0.thumbnailBlobID) } + .map { INImage(imageData: $0) } + + var recipients: [INPerson]? + var groupName: INSpeakableString? + if conversation.type == .group { + groupName = INSpeakableString(spokenPhrase: displayName) + } else { + let customIdentifier = conversation.counterpart(excluding: selfUserID)?.userID?.uuidString + recipients = [INPerson( + personHandle: INPersonHandle(value: customIdentifier, type: .unknown), + nameComponents: nil, + displayName: displayName, + image: image, + contactIdentifier: nil, + customIdentifier: customIdentifier + )] + } + + let intent = INSendMessageIntent( + recipients: recipients, + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: groupName, + conversationIdentifier: conversation.id.linkPathComponent, + serviceName: nil, + sender: nil, + attachments: nil + ) + if let image, groupName != nil { + intent.setImage(image, forParameterNamed: \.speakableGroupName) + } + return intent + } +} From dee633fdf05722162224ade6e24d1cadf87d222e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 17:58:23 -0400 Subject: [PATCH 6/9] feat(share): redesign the picker as a single Send to screen with multi-select --- .../FlipcashCore/Share/ShareRecipients.swift | 136 ++++++++++++++ .../ShareRecipientsTests.swift | 109 +++++++++++ ShareExtension/ShareComposeBar.swift | 122 +++++++++++++ ShareExtension/ShareComposeView.swift | 97 ---------- ShareExtension/ShareModel.swift | 169 +++++++++++++----- ShareExtension/ShareRootView.swift | 132 +++++++++++--- 6 files changed, 604 insertions(+), 161 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Share/ShareRecipients.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ShareRecipientsTests.swift create mode 100644 ShareExtension/ShareComposeBar.swift delete mode 100644 ShareExtension/ShareComposeView.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Share/ShareRecipients.swift b/FlipcashCore/Sources/FlipcashCore/Share/ShareRecipients.swift new file mode 100644 index 000000000..8f55068b7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareRecipients.swift @@ -0,0 +1,136 @@ +// +// ShareRecipients.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The chats a share is addressed to, in the order they were picked, capped at ``limit``. +public struct ShareRecipientSelection: Equatable, Sendable { + + public enum ToggleResult: Equatable, Sendable { + case selected + case deselected + /// The selection is full; nothing changed. + case limitReached + } + + public static let limit = 5 + + public private(set) var ids: [ConversationID] = [] + + public init() {} + + public var isEmpty: Bool { ids.isEmpty } + public var isFull: Bool { ids.count >= Self.limit } + + public func contains(_ id: ConversationID) -> Bool { ids.contains(id) } + + /// Selects `id`, or deselects it when already selected. + @discardableResult + public mutating func toggle(_ id: ConversationID) -> ToggleResult { + if let index = ids.firstIndex(of: id) { + ids.remove(at: index) + return .deselected + } + guard !isFull else { return .limitReached } + ids.append(id) + return .selected + } + + /// Selects `id` if there is room and it is not already selected. + public mutating func select(_ id: ConversationID) { + guard !contains(id) else { return } + toggle(id) + } + + /// Drops any selected id not in `available`. + public mutating func retain(_ available: Set) { + ids.removeAll { !available.contains($0) } + } +} + +/// Searching and sectioning of the picker. +public enum ShareChatList { + + public struct Sections: Equatable, Sendable { + /// Chats the Share sheet suggested; empty when there is no suggestion. + public var suggested: [ConversationID] + public var recent: [ConversationID] + /// True when a search is active: `recent` then holds every match in one list. + public var isSearching: Bool + } + + /// Chats whose name contains `query`, ignoring case and diacritics, in their original order. A blank + /// query matches everything. + public static func filter(_ chats: [Conversation], query: String, name: (Conversation) -> String) -> [Conversation] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return chats } + return chats.filter { + name($0).range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + } + + /// The suggested chat first and the rest under "Recent chats", or, while searching, one flat list of + /// matches. The suggested chat is not repeated in the recent list. + public static func sections( + _ chats: [Conversation], + query: String, + suggested: ConversationID?, + name: (Conversation) -> String + ) -> Sections { + let matches = filter(chats, query: query, name: name).map(\.id) + if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return Sections(suggested: [], recent: matches, isSearching: true) + } + guard let suggested, matches.contains(suggested) else { + return Sections(suggested: [], recent: matches, isSearching: false) + } + return Sections(suggested: [suggested], recent: matches.filter { $0 != suggested }, isSearching: false) + } +} + +/// Tracks which chats a share has reached, so a retry sends only to the rest. +public struct ShareSendProgress: Equatable, Sendable { + + public enum Status: Equatable, Sendable { + case pending + case sent + case failed(message: String, canRetry: Bool) + } + + public private(set) var statuses: [ConversationID: Status] + public let order: [ConversationID] + + public init(recipients: [ConversationID]) { + order = recipients + statuses = Dictionary(uniqueKeysWithValues: recipients.map { ($0, .pending) }) + } + + public mutating func markSent(_ id: ConversationID) { statuses[id] = .sent } + + public mutating func markFailed(_ id: ConversationID, message: String, canRetry: Bool) { + statuses[id] = .failed(message: message, canRetry: canRetry) + } + + /// Recipients still to send to: pending, or failed with a retryable error. + public var remaining: [ConversationID] { + order.filter { + switch statuses[$0] { + case .pending, .failed(_, canRetry: true): true + case .sent, .failed(_, canRetry: false), nil: false + } + } + } + + public var failed: [ConversationID] { + order.filter { if case .failed = statuses[$0] { true } else { false } } + } + + public var isComplete: Bool { order.allSatisfy { statuses[$0] == .sent } } + + /// True when something failed and nothing retryable is left to try. + public var isStuck: Bool { !isComplete && remaining.isEmpty } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ShareRecipientsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ShareRecipientsTests.swift new file mode 100644 index 000000000..71dd433e3 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ShareRecipientsTests.swift @@ -0,0 +1,109 @@ +// +// ShareRecipientsTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("Share recipients") +struct ShareRecipientsTests { + + private static func id(_ n: UInt8) -> ConversationID { + ConversationID(data: Data(repeating: n, count: 32)) + } + + @Test("Toggling selects, then deselects") + func toggle() { + var selection = ShareRecipientSelection() + #expect(selection.toggle(Self.id(1)) == .selected) + #expect(selection.contains(Self.id(1))) + #expect(selection.toggle(Self.id(1)) == .deselected) + #expect(selection.isEmpty) + } + + @Test("Selection keeps pick order and stops at the limit") + func limit() { + var selection = ShareRecipientSelection() + for n in 1...UInt8(ShareRecipientSelection.limit) { selection.toggle(Self.id(n)) } + #expect(selection.isFull) + #expect(selection.toggle(Self.id(99)) == .limitReached) + #expect(selection.ids.count == ShareRecipientSelection.limit) + #expect(selection.ids.first == Self.id(1)) + #expect(selection.toggle(Self.id(2)) == .deselected) + #expect(selection.toggle(Self.id(99)) == .selected) + } + + @Test("Select is idempotent and retain drops unavailable ids") + func selectAndRetain() { + var selection = ShareRecipientSelection() + selection.select(Self.id(1)) + selection.select(Self.id(1)) + selection.select(Self.id(2)) + #expect(selection.ids == [Self.id(1), Self.id(2)]) + selection.retain([Self.id(2)]) + #expect(selection.ids == [Self.id(2)]) + } + + @Test("Progress retries only what has not been sent") + func progress() { + let ids = [Self.id(1), Self.id(2), Self.id(3)] + var progress = ShareSendProgress(recipients: ids) + #expect(progress.remaining == ids) + progress.markSent(ids[0]) + progress.markFailed(ids[1], message: "x", canRetry: true) + progress.markFailed(ids[2], message: "y", canRetry: false) + #expect(progress.remaining == [ids[1]]) + #expect(progress.failed == [ids[1], ids[2]]) + #expect(!progress.isComplete) + #expect(!progress.isStuck) + progress.markSent(ids[1]) + #expect(progress.isStuck) + #expect(progress.remaining.isEmpty) + } + + @Test("Progress is complete when every chat is sent") + func complete() { + var progress = ShareSendProgress(recipients: [Self.id(1)]) + progress.markSent(Self.id(1)) + #expect(progress.isComplete) + #expect(!progress.isStuck) + } + + private static func chat(_ n: UInt8, _ title: String) -> Conversation { + Conversation(id: id(n), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 0), type: .group, title: title) + } + + private static let chats = [chat(1, "Zoë Park"), chat(2, "Book club"), chat(3, "ZOLA")] + private static func name(_ c: Conversation) -> String { c.title ?? "" } + + @Test("Search ignores case and diacritics and keeps order") + func search() { + let hits = ShareChatList.filter(Self.chats, query: " zo ", name: Self.name).map(\.id) + #expect(hits == [Self.id(1), Self.id(3)]) + #expect(ShareChatList.filter(Self.chats, query: " ", name: Self.name).count == 3) + #expect(ShareChatList.filter(Self.chats, query: "nope", name: Self.name).isEmpty) + } + + @Test("Sections lead with the suggestion and do not repeat it") + func sections() { + let sections = ShareChatList.sections(Self.chats, query: "", suggested: Self.id(2), name: Self.name) + #expect(sections.suggested == [Self.id(2)]) + #expect(sections.recent == [Self.id(1), Self.id(3)]) + #expect(!sections.isSearching) + } + + @Test("No suggestion omits the section; searching flattens it") + func sectionsWithoutSuggestionOrWhileSearching() { + let none = ShareChatList.sections(Self.chats, query: "", suggested: nil, name: Self.name) + #expect(none.suggested.isEmpty) + #expect(none.recent.count == 3) + let searching = ShareChatList.sections(Self.chats, query: "book", suggested: Self.id(2), name: Self.name) + #expect(searching.suggested.isEmpty) + #expect(searching.recent == [Self.id(2)]) + #expect(searching.isSearching) + } +} diff --git a/ShareExtension/ShareComposeBar.swift b/ShareExtension/ShareComposeBar.swift new file mode 100644 index 000000000..83fbe4125 --- /dev/null +++ b/ShareExtension/ShareComposeBar.swift @@ -0,0 +1,122 @@ +// +// ShareComposeBar.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +/// The bar that slides up once a chat is picked: the shared images, the message field, who it goes +/// to, and Send. +struct ShareComposeBar: View { + + let model: ShareModel + + var body: some View { + @Bindable var model = model + VStack(alignment: .leading, spacing: 10) { + failures + + HStack(alignment: .top, spacing: 10) { + if !model.thumbnails.isEmpty { + thumbnails + } + TextField("Add a message…", text: $model.text, axis: .vertical) + .lineLimit(1...5) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color(.secondarySystemFill), in: RoundedRectangle(cornerRadius: 20)) + .disabled(model.isSending) + } + + HStack(spacing: 12) { + Text(model.recipientSummary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + sendButton + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + .background(.bar) + } + + private var thumbnails: some View { + ZStack { + ForEach(Array(model.thumbnails.prefix(3).enumerated().reversed()), id: \.element.id) { offset, thumbnail in + Image(decorative: thumbnail.image, scale: 1) + .resizable() + .scaledToFill() + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(.systemBackground), lineWidth: 1.5)) + .offset(x: CGFloat(offset) * 5, y: CGFloat(offset) * -3) + } + if model.thumbnails.count > 1 { + Text("\(model.thumbnails.count)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .background(.black.opacity(0.6), in: Capsule()) + .offset(x: 16, y: 16) + } + } + .frame(width: 54, height: 48) + .padding(.top, 2) + .accessibilityLabel("\(model.thumbnails.count) attached") + } + + private var sendButton: some View { + Button { + model.send() + } label: { + Group { + if model.isSending { + ProgressView().tint(.white) + } else { + Text(isRetry ? "Retry" : "Send").font(.body.weight(.semibold)) + } + } + .foregroundStyle(.white) + .frame(minWidth: 64, minHeight: 36) + .padding(.horizontal, 6) + .background(Color.green.opacity(model.canSend || model.isSending ? 1 : 0.4), in: Capsule()) + } + .disabled(!model.canSend) + } + + private var isRetry: Bool { + if case .failed = model.sendState { true } else { false } + } + + @ViewBuilder + private var failures: some View { + if model.input.droppedImageCount > 0 { + Text("Only the first \(ShareInput.maxImages) images are sent.") + .font(.footnote) + .foregroundStyle(.secondary) + } + if case .sending(let chat, let total) = model.sendState, total > 1 { + Text("Sending \(chat) of \(total)") + .font(.footnote) + .foregroundStyle(.secondary) + } + if let progress = model.progress, case .failed = model.sendState { + VStack(alignment: .leading, spacing: 2) { + ForEach(progress.failed, id: \.self) { id in + if let chat = model.chat(id), case .failed(let message, _) = progress.statuses[id] { + Text("\(model.displayName(for: chat)): \(message)") + .font(.footnote) + .foregroundStyle(.red) + } + } + } + } + } +} diff --git a/ShareExtension/ShareComposeView.swift b/ShareExtension/ShareComposeView.swift deleted file mode 100644 index 934d75c95..000000000 --- a/ShareExtension/ShareComposeView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// ShareComposeView.swift -// ShareExtension -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import SwiftUI -import FlipcashCore - -struct ShareComposeView: View { - - let model: ShareModel - let chat: Conversation - - @Environment(\.dismiss) private var dismiss - - var body: some View { - @Bindable var model = model - NavigationStack { - VStack(alignment: .leading, spacing: 16) { - if !model.thumbnails.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(model.thumbnails) { thumbnail in - Image(decorative: thumbnail.image, scale: 1) - .resizable() - .scaledToFill() - .frame(width: 88, height: 88) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - } - } - .scrollClipDisabled() - if model.input.droppedImageCount > 0 { - Text("Only the first \(ShareInput.maxImages) images are sent.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - TextField(model.thumbnails.isEmpty ? "Message" : "Add a caption", text: $model.text, axis: .vertical) - .lineLimit(1...6) - .textFieldStyle(.roundedBorder) - .disabled(model.isSending) - - status - - Spacer(minLength: 0) - } - .padding(16) - .navigationTitle(model.displayName(for: chat)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Back") { dismiss() } - .disabled(model.isSending) - } - ToolbarItem(placement: .confirmationAction) { - Button(isRetry ? "Retry" : "Send") { model.send(to: chat) } - .disabled(!canSend) - } - } - } - .interactiveDismissDisabled(model.isSending) - .presentationDetents([.medium, .large]) - } - - private var isRetry: Bool { - if case .failed = model.sendState { true } else { false } - } - - private var canSend: Bool { - if model.isSending { return false } - if case .failed(_, let canRetry) = model.sendState, !canRetry { return false } - return !model.thumbnails.isEmpty || !model.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - @ViewBuilder - private var status: some View { - switch model.sendState { - case .idle: - EmptyView() - case .sending(let step, let total): - HStack(spacing: 8) { - ProgressView() - Text(total > 1 ? "Sending \(step) of \(total)" : "Sending") - .font(.subheadline) - .foregroundStyle(.secondary) - } - case .failed(let message, _): - Text(message) - .font(.subheadline) - .foregroundStyle(.red) - } - } -} diff --git a/ShareExtension/ShareModel.swift b/ShareExtension/ShareModel.swift index 47c0545a7..7561e09e1 100644 --- a/ShareExtension/ShareModel.swift +++ b/ShareExtension/ShareModel.swift @@ -37,8 +37,10 @@ final class ShareModel { enum SendState: Equatable { case idle - case sending(step: Int, of: Int) - case failed(message: String, canRetry: Bool) + /// Sending to the chat at `chat` of `total`, one message at a time. + case sending(chat: Int, of: Int) + /// At least one chat didn't get the share; `progress` says which. + case failed } private(set) var phase: Phase = .loading @@ -50,6 +52,15 @@ final class ShareModel { /// The chat the share was aimed at by an intent, or nil. private(set) var preselectedID: ConversationID? + /// The chats the share will go to, in pick order. + private(set) var recipients = ShareRecipientSelection() + + /// The search field's text. + var query = "" + + /// Where each chosen chat stands once a send has started; nil before the first send. + private(set) var progress: ShareSendProgress? + /// The message text, prefilled from the shared text or link. var text = "" @@ -67,11 +78,24 @@ final class ShareModel { private var loadTask: Task? private var sendTask: Task? - // Per-send progress that survives a retry: what already went out is never sent twice. - private var clientMessageIDs: [Int: UUID] = [:] - private var uploaded: [Int: UploadedPhoto] = [:] - private var completedSteps: Set = [] - private var session: Session? + // Per-chat progress that survives a retry: what already went out is never sent twice. + private var clientMessageIDs: [StepKey: UUID] = [:] + private var completedSteps: Set = [] + private var sessions: [ConversationID: Session] = [:] + private var flipClient: FlipClient? + /// Uploads by image. A photo sealed for one chat is that chat's alone, so `chat` is nil only for + /// plaintext uploads, which every chat can reference. + private var uploaded: [UploadKey: UploadedPhoto] = [:] + + private struct StepKey: Hashable { + let chat: ConversationID + let step: Int + } + + private struct UploadKey: Hashable { + let chat: ConversationID? + let index: Int + } private struct Session { let flipClient: FlipClient @@ -123,6 +147,7 @@ final class ShareModel { let archivedIDs = ChatArchiveReader.load(owner: account.keyAccount.ownerPublicKey)?.archivedIDs ?? [] chats = ShareChatSelection.candidates(from: conversations, joinedGroupIDs: joinedGroupIDs, archivedIDs: archivedIDs) preselectedID = ShareChatSelection.preselected(conversationIdentifier: intentConversationIdentifier, in: chats) + if let preselectedID { recipients.select(preselectedID) } input = await ShareInput.parse(providers) text = input.text ?? "" @@ -159,12 +184,45 @@ final class ShareModel { chat.displayName(selfUserID: account?.userID, contactName: { _ in nil }) } + // MARK: - Selection - + + var sections: ShareChatList.Sections { + ShareChatList.sections(chats, query: query, suggested: preselectedID, name: displayName(for:)) + } + + func chat(_ id: ConversationID) -> Conversation? { + chats.first { $0.id == id } + } + + /// The chosen chats' names, comma-joined, in pick order. + var recipientSummary: String { + recipients.ids.compactMap { chat($0) }.map(displayName(for:)).joined(separator: ", ") + } + + @discardableResult + func toggle(_ id: ConversationID) -> ShareRecipientSelection.ToggleResult { + guard !isSending else { return .deselected } + let result = recipients.toggle(id) + if result != .limitReached, case .failed = sendState { + // The set changed under a failed send; start the next send from the new set. + progress = nil + sendState = .idle + } + return result + } + + var canSend: Bool { + if isSending || recipients.isEmpty { return false } + if let progress, progress.isStuck { return false } + return !thumbnails.isEmpty || !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + // MARK: - Sending - - func send(to chat: Conversation) { - guard sendTask == nil, !isSending else { return } + func send() { + guard sendTask == nil, !isSending, canSend else { return } sendTask = Task { - await performSend(to: chat) + await performSend() sendTask = nil } } @@ -175,47 +233,69 @@ final class ShareModel { cancelRequest() } - private func performSend(to chat: Conversation) async { + /// Sends to every chat that hasn't received the share yet, one at a time, and finishes the request + /// once all have. A chat that fails doesn't stop the others. + private func performSend() async { guard let account else { return } - let owner = account.keyAccount.owner let steps = ShareSendPlan.steps(imageCount: input.imageURLs.count, text: text) guard !steps.isEmpty else { return } - do { - let session = try await resolveSession(for: chat, account: account) - for (position, step) in steps.enumerated() where !completedSteps.contains(position) { - try Task.checkCancellation() - sendState = .sending(step: position + 1, of: steps.count) - let clientMessageID = clientMessageIDs[position] ?? UUID() - clientMessageIDs[position] = clientMessageID - - let message: ConversationMessage - switch step { - case .text(let body): - message = try await session.flipClient.sendMessage(owner: owner, conversationID: chat.id, text: body, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) - case .photo(let index, let caption): - let photo = try await uploadedPhoto(at: index, session: session) - message = try await session.flipClient.sendMediaMessage(owner: owner, conversationID: chat.id, photo: photo, caption: caption, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) - } - completedSteps.insert(position) - persist(message, in: chat.id, account: account) + var progress = self.progress ?? ShareSendProgress(recipients: recipients.ids) + let targets = progress.remaining + for (position, id) in targets.enumerated() { + guard let chat = chat(id) else { continue } + sendState = .sending(chat: position + 1, of: targets.count) + do { + try await send(steps, to: chat, account: account) + progress.markSent(id) + } catch is CancellationError { + self.progress = progress + sendState = .idle + return + } catch { + ExtensionReporting.capture(error, reason: "Share send failed") + progress.markFailed(id, message: Self.message(for: error), canRetry: Self.canRetry(error)) } + self.progress = progress + } + + if progress.isComplete { input.removeFiles() finish() - } catch is CancellationError { - sendState = .idle - } catch { - ExtensionReporting.capture(error, reason: "Share send failed") - sendState = .failed(message: Self.message(for: error), canRetry: Self.canRetry(error)) + } else { + sendState = .failed + } + } + + private func send(_ steps: [ShareSendPlan.Step], to chat: Conversation, account: UserAccount) async throws { + let owner = account.keyAccount.owner + let session = try await resolveSession(for: chat, account: account) + for (position, step) in steps.enumerated() where !completedSteps.contains(StepKey(chat: chat.id, step: position)) { + try Task.checkCancellation() + let key = StepKey(chat: chat.id, step: position) + let clientMessageID = clientMessageIDs[key] ?? UUID() + clientMessageIDs[key] = clientMessageID + + let message: ConversationMessage + switch step { + case .text(let body): + message = try await session.flipClient.sendMessage(owner: owner, conversationID: chat.id, text: body, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) + case .photo(let index, let caption): + let photo = try await uploadedPhoto(at: index, chat: chat.id, session: session) + message = try await session.flipClient.sendMediaMessage(owner: owner, conversationID: chat.id, photo: photo, caption: caption, repliedTo: nil, seal: session.seal, clientMessageID: clientMessageID) + } + completedSteps.insert(key) + persist(message, in: chat.id, account: account) } } - /// The client, the chat's seal and the uploader, built once per send and kept across retries so a + /// The client, the chat's seal and the uploader, built once per chat and kept across retries so a /// retry doesn't repeat the chat fetch. Only a fully built session is kept. private func resolveSession(for chat: Conversation, account: UserAccount) async throws -> Session { - if let session { return session } + if let session = sessions[chat.id] { return session } let owner = account.keyAccount.owner - let flipClient = try FlipClient(network: .mainNet) + let flipClient = try self.flipClient ?? FlipClient(network: .mainNet) + self.flipClient = flipClient let keyring = ChatKeyring(owner: owner, selfUserID: account.userID) { userID in try await flipClient.resolveUserID(userID, owner: owner) } @@ -226,19 +306,22 @@ final class ShareModel { seal: { seal } ) let session = Session(flipClient: flipClient, seal: seal, uploader: uploader) - self.session = session + sessions[chat.id] = session return session } /// Decodes, uploads and releases one image. The decoded bitmap lives only inside this call, and a /// retry after a failed send reuses the stored upload instead of decoding again. - private func uploadedPhoto(at index: Int, session: Session) async throws -> UploadedPhoto { - if let photo = uploaded[index] { return photo } + private func uploadedPhoto(at index: Int, chat: ConversationID, session: Session) async throws -> UploadedPhoto { + let sealedKey = UploadKey(chat: chat, index: index) + let plainKey = UploadKey(chat: nil, index: index) + if let photo = uploaded[sealedKey] ?? uploaded[plainKey] { return photo } + let key = session.seal == nil ? plainKey : sealedKey guard let image = await ChatPhotoDecoder.decode(contentsOf: input.imageURLs[index], maxPixelSize: Self.maxUploadEdge) else { throw ShareError.unreadableImage } - let photo = try await session.uploader.upload(image, orientation: .up, onStored: { uploaded[index] = $0 }, onPrepared: { _, _ in }) - uploaded[index] = photo + let photo = try await session.uploader.upload(image, orientation: .up, onStored: { uploaded[key] = $0 }, onPrepared: { _, _ in }) + uploaded[key] = photo return photo } diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift index 393a5f59b..23c9ac54f 100644 --- a/ShareExtension/ShareRootView.swift +++ b/ShareExtension/ShareRootView.swift @@ -13,34 +13,41 @@ struct ShareRootView: View { let model: ShareModel - @State private var selected: Conversation? + @State private var limitHit = 0 var body: some View { - NavigationStack { + VStack(spacing: 0) { + header content - .navigationTitle("Share to Flipcash") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { model.cancel() } - } - } } + .background(Color(.systemGroupedBackground)) .task { model.load() } - .sheet(item: $selected) { chat in - ShareComposeView(model: model, chat: chat) - } - .onChange(of: model.preselectedID) { _, id in - guard selected == nil, let id, let chat = model.chats.first(where: { $0.id == id }) else { return } - selected = chat + } + + private var header: some View { + ZStack { + Text("Send to").font(.headline) + HStack { + Button { model.cancel() } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .background(Color(.secondarySystemFill), in: Circle()) + } + .accessibilityLabel("Cancel") + Spacer() + } } + .padding(.horizontal, 16) + .padding(.vertical, 12) } @ViewBuilder private var content: some View { switch model.phase { case .loading: - ProgressView() + ProgressView().frame(maxHeight: .infinity) case .signedOut: ShareMessageView(title: "Open Flipcash to sign in", detail: "Sign in to the app, then share again.") case .chatsUnavailable: @@ -48,13 +55,88 @@ struct ShareRootView: View { case .ready where model.chats.isEmpty: ShareMessageView(title: "No chats yet", detail: "Start a chat in Flipcash, then share again.") case .ready: - List(model.chats) { chat in - Button { selected = chat } label: { - ShareChatRow(name: model.displayName(for: chat), avatar: model.avatar(for: chat)) + picker + } + } + + private var picker: some View { + @Bindable var model = model + let sections = model.sections + return ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + searchField + if !sections.suggested.isEmpty { + section("Suggested", ids: sections.suggested) + } + if sections.isSearching && sections.recent.isEmpty { + Text("No chats match \u{201C}\(model.query)\u{201D}") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if !sections.recent.isEmpty { + section(sections.isSearching ? "Chats" : "Recent chats", ids: sections.recent) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .scrollDismissesKeyboard(.interactively) + .sensoryFeedback(.warning, trigger: limitHit) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !model.recipients.isEmpty { + ShareComposeBar(model: model) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.smooth(duration: 0.25), value: model.recipients.isEmpty) + } + + private var searchField: some View { + @Bindable var model = model + return HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search", text: $model.query) + .autocorrectionDisabled() + .submitLabel(.search) + if !model.query.isEmpty { + Button { model.query = "" } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.secondarySystemFill), in: RoundedRectangle(cornerRadius: 12)) + } + + private func section(_ title: String, ids: [ConversationID]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.leading, 4) + VStack(spacing: 0) { + ForEach(ids, id: \.self) { id in + if let chat = model.chat(id) { + Button { + if model.toggle(id) == .limitReached { limitHit += 1 } + } label: { + ShareChatRow( + name: model.displayName(for: chat), + avatar: model.avatar(for: chat), + isSelected: model.recipients.contains(id) + ) + } + .buttonStyle(.plain) + if id != ids.last { + Divider().padding(.leading, 68) + } + } } - .buttonStyle(.plain) } - .listStyle(.plain) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) } } } @@ -65,6 +147,7 @@ private struct ShareChatRow: View { let name: String let avatar: ShareAvatar + let isSelected: Bool var body: some View { HStack(spacing: 12) { @@ -73,8 +156,14 @@ private struct ShareChatRow: View { Text(name) .lineLimit(1) Spacer(minLength: 0) + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 24)) + .foregroundStyle(isSelected ? Color.success : Color(.tertiaryLabel)) } + .padding(.horizontal, 16) + .padding(.vertical, 10) .contentShape(Rectangle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -92,5 +181,6 @@ struct ShareMessageView: View { .multilineTextAlignment(.center) } .padding(32) + .frame(maxHeight: .infinity) } } From cac326ef13d804d22488cdf4a4d94958ac983d1f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 20:50:25 -0400 Subject: [PATCH 7/9] fix(share): use the app's close button and a white Send pill --- ShareExtension/ShareComposeBar.swift | 7 ++++--- ShareExtension/ShareRootView.swift | 9 +-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/ShareExtension/ShareComposeBar.swift b/ShareExtension/ShareComposeBar.swift index 83fbe4125..b943a0d52 100644 --- a/ShareExtension/ShareComposeBar.swift +++ b/ShareExtension/ShareComposeBar.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashCore +import FlipcashUI /// The bar that slides up once a chat is picked: the shared images, the message field, who it goes /// to, and Send. @@ -78,15 +79,15 @@ struct ShareComposeBar: View { } label: { Group { if model.isSending { - ProgressView().tint(.white) + ProgressView().tint(Color.textAction) } else { Text(isRetry ? "Retry" : "Send").font(.body.weight(.semibold)) } } - .foregroundStyle(.white) + .foregroundStyle(Color.textAction) .frame(minWidth: 64, minHeight: 36) .padding(.horizontal, 6) - .background(Color.green.opacity(model.canSend || model.isSending ? 1 : 0.4), in: Capsule()) + .background(Color.textMain.opacity(model.canSend || model.isSending ? 1 : 0.4), in: Capsule()) } .disabled(!model.canSend) } diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift index 23c9ac54f..a230e5aff 100644 --- a/ShareExtension/ShareRootView.swift +++ b/ShareExtension/ShareRootView.swift @@ -28,14 +28,7 @@ struct ShareRootView: View { ZStack { Text("Send to").font(.headline) HStack { - Button { model.cancel() } label: { - Image(systemName: "xmark") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.primary) - .frame(width: 32, height: 32) - .background(Color(.secondarySystemFill), in: Circle()) - } - .accessibilityLabel("Cancel") + CloseButton(style: .glass) { model.cancel() } Spacer() } } From 7cfa3c1a2dc977795f93f62ace91f3d3c8378592 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 21:58:07 -0400 Subject: [PATCH 8/9] fix(share): draw the selection with the app's CheckView Color.success resolves a colour asset from the main app's catalog, which the extension bundle doesn't have, so the selected checkmark drew clear. --- ShareExtension/ShareRootView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift index a230e5aff..4c484272c 100644 --- a/ShareExtension/ShareRootView.swift +++ b/ShareExtension/ShareRootView.swift @@ -149,9 +149,7 @@ private struct ShareChatRow: View { Text(name) .lineLimit(1) Spacer(minLength: 0) - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 24)) - .foregroundStyle(isSelected ? Color.success : Color(.tertiaryLabel)) + CheckView(active: isSelected) } .padding(.horizontal, 16) .padding(.vertical, 10) From dce022bc3e84bbb138ba0d78ca9fc560d69fc192 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 10 Oct 2026 22:12:54 -0400 Subject: [PATCH 9/9] refactor(ui): consolidate the selection indicators into RadioButton The app drew row selection two ways: CheckView (a white ring that fills with a checkmark) on the Select Region, currency, featured groups, account and Share to Chats screens, and an inline largecircle.fill.circle symbol in the report flow. Rename CheckView to RadioButton, use it in all of them and in the share picker, and drop the report flow's one-off symbol. --- .../Conversation/ReportFlowScreen.swift | 4 +- .../CurrencySelectionScreen.swift | 2 +- .../Profile/EditFeaturedGroupsScreen.swift | 2 +- .../Screens/Main/SelectCurrencyScreen.swift | 2 +- .../Tips/New Group/AllCurrenciesCard.swift | 2 +- .../Tips/New Group/ShareToChatsSheet.swift | 2 +- .../Onboarding/AccountSelectionScreen.swift | 2 +- .../Sources/FlipcashUI/Views/CheckView.swift | 47 ------------------ .../FlipcashUI/Views/RadioButton.swift | 48 +++++++++++++++++++ ShareExtension/ShareRootView.swift | 2 +- 10 files changed, 56 insertions(+), 57 deletions(-) delete mode 100644 FlipcashUI/Sources/FlipcashUI/Views/CheckView.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/RadioButton.swift diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift index 253899773..4e563b751 100644 --- a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -223,9 +223,7 @@ private struct ReportReasonRow: View { var body: some View { Button(action: onSelect) { HStack(alignment: .top, spacing: 12) { - Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") - .font(.system(size: 20)) - .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) + RadioButton(isSelected: isSelected) VStack(alignment: .leading, spacing: 4) { Text(reason.title) diff --git a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift index ed7f1c052..be30c1669 100644 --- a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionScreen.swift @@ -122,7 +122,7 @@ private struct CurrencyRowContent: View { .layoutPriority(10) } Spacer() - CheckView(active: viewModel.isCurrencyActive(description.currency)) + RadioButton(isSelected: viewModel.isCurrencyActive(description.currency)) } .padding(.vertical, 12) .background(Color.backgroundMain) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index 58ab3b392..d82ca7233 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -110,7 +110,7 @@ struct EditFeaturedGroupsScreen: View { model.toggle(group.id) } label: { FeaturedGroupRow(group: group) { - CheckView(active: model.isSelected(group.id)) + RadioButton(isSelected: model.isSelected(group.id)) } .contentShape(Rectangle()) } diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index d8b8e7565..a737012ad 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -274,7 +274,7 @@ private struct AccessoryView: View { Image.system(.chevronRight) .foregroundStyle(Color.textSecondary) case .check(let isSelected): - CheckView(active: isSelected) + RadioButton(isSelected: isSelected) case .loader: ProgressView() .progressViewStyle(.circular) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift b/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift index bec09a5c5..a7f31ac3b 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift @@ -37,7 +37,7 @@ struct AllCurrenciesCard: View { } .frame(maxWidth: .infinity, alignment: .leading) - CheckView(active: isSelected) + RadioButton(isSelected: isSelected) .accessibilityHidden(true) } .padding(.horizontal, 12) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift index ccc2854d1..70925f0b1 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift @@ -504,7 +504,7 @@ private struct InviteChatRow: View { Spacer(minLength: 12) - CheckView(active: isSelected) + RadioButton(isSelected: isSelected) } .padding(.vertical, 16) .overlay(alignment: .bottom) { diff --git a/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift b/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift index 7e555ecf5..6873333fc 100644 --- a/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift @@ -254,7 +254,7 @@ private struct AccountRow: View { var body: some View { Button(action: onTap) { HStack(alignment: .center, spacing: 15) { - CheckView(active: isSelected) + RadioButton(isSelected: isSelected) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .bottom, spacing: 10) { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/CheckView.swift b/FlipcashUI/Sources/FlipcashUI/Views/CheckView.swift deleted file mode 100644 index 9b6414b4c..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Views/CheckView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CheckView.swift -// CodeUI -// -// Created by Dima Bart. -// Copyright © 2021 Code Inc. All rights reserved. -// - -import SwiftUI - -public struct CheckView: View { - - public let active: Bool - - public init(active: Bool) { - self.active = active - } - - public var body: some View { - ZStack { - if active { - Circle() - .fill(Color.checkmarkBackground) - Image.asset(.checkmark) - } else { - Circle() - .strokeBorder(Color.white) - } - } - .frame(width: 24, height: 24) - } -} - -// MARK: - Previews - - -struct CheckView_Previews: PreviewProvider { - static var previews: some View { - Background(color: .backgroundMain) { - VStack { - CheckView(active: true) - CheckView(active: false) - } - } - .tint(.textMain) - .previewLayout(.fixed(width: 180, height: 180)) - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/RadioButton.swift b/FlipcashUI/Sources/FlipcashUI/Views/RadioButton.swift new file mode 100644 index 000000000..f3b48cc54 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/RadioButton.swift @@ -0,0 +1,48 @@ +// +// RadioButton.swift +// FlipcashUI +// +// Created by Dima Bart. +// Copyright © 2021 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The app's selection indicator for a row: a white ring, filled with a checkmark when selected. +/// +/// It only reports state. The row around it is the tap target and carries the `.isSelected` +/// accessibility trait, so the indicator is hidden from VoiceOver. +public struct RadioButton: View { + + public let isSelected: Bool + + public init(isSelected: Bool) { + self.isSelected = isSelected + } + + public var body: some View { + ZStack { + if isSelected { + Circle() + .fill(Color.checkmarkBackground) + Image.asset(.checkmark) + } else { + Circle() + .strokeBorder(Color.white) + } + } + .frame(width: 24, height: 24) + .accessibilityHidden(true) + } +} + +// MARK: - Previews - + +#Preview { + Background(color: .backgroundMain) { + VStack { + RadioButton(isSelected: true) + RadioButton(isSelected: false) + } + } +} diff --git a/ShareExtension/ShareRootView.swift b/ShareExtension/ShareRootView.swift index 4c484272c..79bd3cff9 100644 --- a/ShareExtension/ShareRootView.swift +++ b/ShareExtension/ShareRootView.swift @@ -149,7 +149,7 @@ private struct ShareChatRow: View { Text(name) .lineLimit(1) Spacer(minLength: 0) - CheckView(active: isSelected) + RadioButton(isSelected: isSelected) } .padding(.horizontal, 16) .padding(.vertical, 10)