diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 800446fdd..9e966b410 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 */; }; @@ -36,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 */ @@ -53,6 +58,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 +89,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 +99,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 +137,13 @@ ); target = 4C7D6FF92FC8892D0091C7A4 /* NotificationService */; }; + 7721B948AA364996A365F6E3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D1577636653C4FD18C147635 /* ShareExtension */; + }; 9AC0156E2DA579120030298E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -135,6 +156,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 +185,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 154D22319D5941AC84B73BFB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8D1766162CE94018B6DE106B /* FlipcashCore in Frameworks */, + 46809829231644E8A501AA4F /* FlipcashStore in Frameworks */, + E20CC3E1194146BCA8F87888 /* Bugsnag in Frameworks */, + 5A0E1F2B3C4D4E5F8A9B0C1D /* FlipcashUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F772D9B12E800AF478B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -257,6 +290,7 @@ 9A516F8C2D9B12EA00AF478B /* FlipcashTests */, 9A516F962D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FFB2FC8892D0091C7A4 /* NotificationService */, + 5B9C9BE3C6CD45B1A5BD6332 /* ShareExtension */, 9AED1101258BE1310088D902 /* Products */, DE3C8C53AB74589FBE36B004 /* Frameworks */, 1DE68FD2D78ED8BA7ACC542F /* NotificationContent */, @@ -270,6 +304,7 @@ 9A516F892D9B12EA00AF478B /* FlipcashTests.xctest */, 9A516F932D9B12EA00AF478B /* FlipcashUITests.xctest */, 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */, + 923C43E939D4451BA943F715 /* ShareExtension.appex */, C402C27F9E020E000EBA2936 /* NotificationContent.appex */, ); name = Products; @@ -331,6 +366,32 @@ 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 */, + EFFC929EC8849F357BE7F997 /* FlipcashUI */, + ); + 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 +427,7 @@ ); dependencies = ( 4C7D70002FC8892D0091C7A4 /* PBXTargetDependency */, + 6E1DD951BAE64096A4E5AE82 /* PBXTargetDependency */, 47A27522041981A8EE511CE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -522,6 +584,7 @@ 9A516F882D9B12EA00AF478B /* FlipcashTests */, 9A516F922D9B12EA00AF478B /* FlipcashUITests */, 4C7D6FF92FC8892D0091C7A4 /* NotificationService */, + D1577636653C4FD18C147635 /* ShareExtension */, 554904D5CE93303B1F28329F /* NotificationContent */, ); }; @@ -542,6 +605,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4A85FC8082954409BC3DD1D8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F782D9B12E800AF478B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -611,6 +681,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A066C4948A074790B85E52C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9A516F762D9B12E800AF478B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -646,6 +723,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 +782,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.FlipcashShare; + 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 +864,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.FlipcashShare; + 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 +941,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.FlipcashShare; + 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 +1013,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.FlipcashShare; + 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 +1900,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 +2088,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/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3964d77b5..f500b5880 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 @@ -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/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/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/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 + } +} 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/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 new file mode 100644 index 000000000..d8d399ae2 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift @@ -0,0 +1,33 @@ +// +// 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: 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 + /// `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/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/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.. 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 new file mode 100644 index 000000000..8afa6f088 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ShareInputTests.swift @@ -0,0 +1,190 @@ +// +// ShareInputTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +@testable import FlipcashCore + +@Suite("Share input") +@MainActor +struct ShareInputTests { + + private static func scratchDirectory() throws -> 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, 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 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, 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]) + } + + @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/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/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/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/ShareComposeBar.swift b/ShareExtension/ShareComposeBar.swift new file mode 100644 index 000000000..b943a0d52 --- /dev/null +++ b/ShareExtension/ShareComposeBar.swift @@ -0,0 +1,123 @@ +// +// ShareComposeBar.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +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. +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(Color.textAction) + } else { + Text(isRetry ? "Retry" : "Send").font(.body.weight(.semibold)) + } + } + .foregroundStyle(Color.textAction) + .frame(minWidth: 64, minHeight: 36) + .padding(.horizontal, 6) + .background(Color.textMain.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/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..7561e09e1 --- /dev/null +++ b/ShareExtension/ShareModel.swift @@ -0,0 +1,378 @@ +// +// 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 + /// 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 + 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 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 = "" + + /// 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-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 + 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]? + 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 + } + 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 ?? "" + // 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 + } + + /// 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 }) + } + + // 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() { + guard sendTask == nil, !isSending, canSend else { return } + sendTask = Task { + await performSend() + sendTask = nil + } + } + + func cancel() { + sendTask?.cancel() + input.removeFiles() + cancelRequest() + } + + /// 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 steps = ShareSendPlan.steps(imageCount: input.imageURLs.count, text: text) + guard !steps.isEmpty else { return } + + 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() + } 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 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 = sessions[chat.id] { return session } + let owner = account.keyAccount.owner + 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) + } + 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) + 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, 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[key] = $0 }, onPrepared: { _, _ in }) + uploaded[key] = 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." + } + } +} + +/// 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 new file mode 100644 index 000000000..79bd3cff9 --- /dev/null +++ b/ShareExtension/ShareRootView.swift @@ -0,0 +1,177 @@ +// +// ShareRootView.swift +// ShareExtension +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +struct ShareRootView: View { + + let model: ShareModel + + @State private var limitHit = 0 + + var body: some View { + VStack(spacing: 0) { + header + content + } + .background(Color(.systemGroupedBackground)) + .task { model.load() } + } + + private var header: some View { + ZStack { + Text("Send to").font(.headline) + HStack { + CloseButton(style: .glass) { model.cancel() } + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + ProgressView().frame(maxHeight: .infinity) + 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: + 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) + } + } + } + } + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) + } + } +} + +extension Conversation: @retroactive Identifiable {} + +private struct ShareChatRow: View { + + let name: String + let avatar: ShareAvatar + let isSelected: Bool + + var body: some View { + HStack(spacing: 12) { + ContactAvatarView(id: avatar.id, displayName: name, imageData: avatar.imageData, blurhash: avatar.blurhash, size: 40) + .accessibilityHidden(true) + Text(name) + .lineLimit(1) + Spacer(minLength: 0) + RadioButton(isSelected: isSelected) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +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) + .frame(maxHeight: .infinity) + } +} 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) + } +}