Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bdbd708
feat: App Intents for Spotlight chat search, Send Cash, and Siri Sugg…
raulriera Jun 22, 2026
76f85ea
feat: shared chat notification category identifiers
raulriera Jun 22, 2026
fa3a9d2
feat: resolve Send Cash target from a chat notification
raulriera Jun 22, 2026
9f10082
feat: register chat notification category and route Send Cash action
raulriera Jun 22, 2026
61d9144
feat: tag chat pushes with the CHAT_MESSAGE category
raulriera Jun 22, 2026
7c65d43
feat: store the owner key in a shared keychain group with zero-logout…
raulriera Jun 22, 2026
5fe4ebb
feat: lean chat messaging client for the notification extension
raulriera Jun 22, 2026
b382301
feat: add ChatItem.preview(from:selfUserID:limit:) mapping for notifi…
raulriera Jun 22, 2026
5978d7f
feat: add OwnerKeyStore to load owner KeyPair from shared keychain group
raulriera Jun 22, 2026
a0cf523
feat: add NotificationContent extension target
raulriera Jun 22, 2026
530622a
feat: implement NotificationContent rich-chat VC
raulriera Jun 22, 2026
4cc3acc
fix: prevent stacked status labels in notification content extension
raulriera Jun 22, 2026
537daec
fix: address code review findings — keychain lock-screen access, NIO …
raulriera Jun 22, 2026
0091751
fix: render rich chat notification content on device
raulriera Jun 23, 2026
3bca159
feat: route notification Send Cash through the chat conversation
raulriera Jun 23, 2026
6c5a838
fix: open Send Cash directly over the chat, no chat slide-up
raulriera Jun 23, 2026
4295e05
fix: present Send Cash amount sheet directly from the deeplink (no chat)
raulriera Jun 23, 2026
125cd8a
feat: render the chat notification preview with the real chat transcript
raulriera Jun 24, 2026
be0bf3b
feat: token name and icon on the chat notification cash card
raulriera Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 336 additions & 10 deletions Code.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions Flipcash/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import UIKit
import SwiftUI
import CoreSpotlight
import FlipcashUI
import FlipcashCore

Expand Down Expand Up @@ -40,6 +41,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
])
self.container = Container()
super.init()

// Point App Intents (which run outside the SwiftUI environment) at the
// live session so the Send shortcut can reach contacts and the router.
AppIntentContext.sessionAuthenticator = container.sessionAuthenticator
}

// MARK: - Launch -
Expand Down Expand Up @@ -170,6 +175,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
handleOpenURL(url: url)
}

/// Routes a continued `NSUserActivity` — a Spotlight chat tap or a Handoff /
/// Siri-suggestion of an opened chat — into the deep-link pipeline by
/// rebuilding the `flipcash://chat/{id}` URL the activity carries.
func handleContinue(_ activity: NSUserActivity) {
let chatID: String? = switch activity.activityType {
case CSSearchableItemActionType:
activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
case AppUserActivity.openChat:
activity.userInfo?[AppUserActivity.chatIDKey] as? String
default:
nil
}

guard let chatID, let url = URL(string: "flipcash://chat/\(chatID)") else {
logger.warning("User activity continuation missing chat id", metadata: ["type": "\(activity.activityType)"])
return
}

handleOpenURL(url: url)
}

// MARK: - Push Notifications -

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Expand Down
51 changes: 51 additions & 0 deletions Flipcash/Core/AppIntents/AppIntentContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// AppIntentContext.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

/// Main-actor-confined bridge from App Intents (which run outside the SwiftUI
/// environment) to the live session. `AppDelegate` points it at the
/// `SessionAuthenticator` at launch; intents and entity queries read through it.
///
/// Everything degrades to "no session": a logged-out or send-disabled state
/// yields no contacts and a `false` `canSend`, so the Send shortcut stays inert
/// until the feature is available.
@MainActor
enum AppIntentContext {

static var sessionAuthenticator: SessionAuthenticator?

private static var loggedInContainer: SessionContainer? {
guard case .loggedIn(let container) = sessionAuthenticator?.state else { return nil }
return container
}

/// Whether the signed-in user can send — gates both the contact query and
/// the intent. Mirrors `Session.canSend` (beta flag OR server flag).
static var canSend: Bool {
loggedInContainer?.session.canSend ?? false
}

/// On-Flipcash contacts the user can send to, or `[]` when logged out or
/// send is unavailable. Invite-only (non-Flipcash) contacts are excluded —
/// they can't receive cash.
static func sendableContacts() async -> [ResolvedContact] {
guard canSend, let container = loggedInContainer else { return [] }
return await RecipientLoader.load(database: container.database).onFlipcash
}

static func contact(withID id: String) async -> ResolvedContact? {
await sendableContacts().first { $0.id == id }
}

/// Foregrounds the app on the Send Cash amount entry for `contact`, presented
/// directly as a sheet (one animation, no chat behind).
static func openSendFlow(to contact: ResolvedContact) {
loggedInContainer?.appRouter.present(.sendAmount(contact))
}
}
65 changes: 65 additions & 0 deletions Flipcash/Core/AppIntents/ContactEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// ContactEntity.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents
import FlipcashCore

/// A sendable Flipcash contact, exposed to Siri and the Shortcuts app as the
/// recipient parameter of ``SendCashIntent``. Lean by design: the live
/// `ResolvedContact` (with its image and chat id) is re-resolved by `id` at
/// `perform()` time so the entity payload stays small and never goes stale.
struct ContactEntity: AppEntity {

static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact")
static let defaultQuery = ContactEntityQuery()

/// The `ResolvedContact.id` composite (`contactId|e164`).
let id: String
let displayName: String
let nationalPhone: String

var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(displayName)", subtitle: "\(nationalPhone)")
}
}

extension ContactEntity {
init(_ contact: ResolvedContact) {
self.id = contact.id
self.displayName = contact.displayName
self.nationalPhone = contact.nationalPhone
}
}

/// Resolves ``ContactEntity`` values from the live sendable-contact list.
/// Returns nothing when the user is logged out or can't send, so the parameter
/// offers no suggestions and the shortcut stays inert.
struct ContactEntityQuery: EntityQuery {

func entities(for identifiers: [String]) async throws -> [ContactEntity] {
let wanted = Set(identifiers)
return await AppIntentContext.sendableContacts()
.filter { wanted.contains($0.id) }
.map(ContactEntity.init)
}

func suggestedEntities() async throws -> [ContactEntity] {
await AppIntentContext.sendableContacts().map(ContactEntity.init)
}
}

extension ContactEntityQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [ContactEntity] {
let lowered = string.lowercased()
return await AppIntentContext.sendableContacts()
.filter {
$0.displayName.lowercased().contains(lowered)
|| $0.nationalPhone.contains(string)
}
.map(ContactEntity.init)
}
}
25 changes: 25 additions & 0 deletions Flipcash/Core/AppIntents/FlipcashShortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// FlipcashShortcuts.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents

/// Surfaces the app's intents to Siri and Spotlight without user setup. Auto-
/// discovered by the system — no Info.plist entry needed.
struct FlipcashShortcuts: AppShortcutsProvider {

static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SendCashIntent(),
phrases: [
"Send cash with \(.applicationName)",
"Send money with \(.applicationName)",
],
shortTitle: "Send Cash",
systemImageName: "paperplane.circle.fill"
)
}
}
54 changes: 54 additions & 0 deletions Flipcash/Core/AppIntents/SendCashIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// SendCashIntent.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents

/// Opens the app at the send amount-entry screen for a chosen contact. Launches
/// the app rather than completing the payment headlessly — entering an amount
/// and confirming always happens in-app, matching Apple's money-handling
/// guidance and the verified-state invariant a headless send would break.
///
/// `contact` is required so Siri/Spotlight prompt "who?" using the on-Flipcash
/// contacts the query offers, then proceed once one is chosen.
struct SendCashIntent: AppIntent {

static let title: LocalizedStringResource = "Send Cash"
static let description = IntentDescription("Start sending cash to one of your Flipcash contacts.")

/// Bring the app to the foreground so the user finishes the send in-app.
static let openAppWhenRun = true

@Parameter(title: "To")
var contact: ContactEntity

func perform() async throws -> some IntentResult {
guard await AppIntentContext.canSend else {
throw SendCashIntentError.sendUnavailable
}
guard let resolved = await AppIntentContext.contact(withID: contact.id) else {
throw SendCashIntentError.recipientUnavailable
}
await AppIntentContext.openSendFlow(to: resolved)
return .result()
}

static var parameterSummary: some ParameterSummary {
Summary("Send cash to \(\.$contact)")
}
}

enum SendCashIntentError: Error, CustomLocalizedStringResourceConvertible {
case sendUnavailable
case recipientUnavailable

var localizedStringResource: LocalizedStringResource {
switch self {
case .sendUnavailable: "Sending isn’t available yet."
case .recipientUnavailable: "That contact isn’t available to send to."
}
}
}
9 changes: 8 additions & 1 deletion Flipcash/Core/Controllers/ContactDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extension ResolvedContact {
/// works before the person is a synced contact. `nil` when no number is on
/// file. The phone resolves the recipient and `dmChatID` carries the chat
/// payment metadata; the display fields aren't read by the send flow.
init?(counterpart member: ConversationMember, dmChatID: Data?) {
nonisolated init?(counterpart member: ConversationMember, dmChatID: Data?) {
guard let phoneE164 = member.phoneE164, !phoneE164.isEmpty else { return nil }
let national = member.formattedPhoneNumber ?? phoneE164
self.init(
Expand All @@ -60,6 +60,13 @@ extension ResolvedContact {
dmChatID: dmChatID
)
}

/// The Send Cash target for a DM `conversation`: its counterpart resolved by phone number,
/// carrying `dmChatID`. `nil` when there's no counterpart or no number on file.
nonisolated static func sendTarget(in conversation: Conversation?, dmChatID: Data?, selfUserID: UserID) -> ResolvedContact? {
guard let counterpart = conversation?.counterpart(excluding: selfUserID) else { return nil }
return ResolvedContact(counterpart: counterpart, dmChatID: dmChatID)
}
}

nonisolated struct ResolvedContacts: Equatable, Sendable {
Expand Down
38 changes: 33 additions & 5 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ final class DeepLinkController {
case .chat(let conversationID):
return actionForChat(conversationID: conversationID)

case .chatSendCash(let conversationID):
return actionForChatSendCash(conversationID: conversationID)

case .give:
return actionForOpenSheet(.give)

Expand Down Expand Up @@ -157,6 +160,13 @@ final class DeepLinkController {
)
}

private func actionForChatSendCash(conversationID: ConversationID) -> DeepLinkAction {
DeepLinkAction(
kind: .chatSendCash(conversationID),
sessionAuthenticator: sessionAuthenticator
)
}

private func actionForOpenSheet(_ sheet: AppRouter.SheetPresentation) -> DeepLinkAction {
DeepLinkAction(
kind: .openSheet(sheet),
Expand Down Expand Up @@ -223,6 +233,22 @@ struct DeepLinkAction {
container.appRouter.present(.conversation(.existing(conversationID)))
}

case .chatSendCash(let conversationID):
if case .loggedIn(let container) = sessionAuthenticator.state {
let conversation = container.conversationController.conversation(withID: conversationID)
if let target = ResolvedContact.sendTarget(
in: conversation,
dmChatID: conversationID.data,
selfUserID: container.session.userID
), container.session.canSend {
Analytics.deeplinkRouted(kind: kind)
// Open the Send Cash amount entry directly as the sheet — one
// animation, no chat behind it. Dismissing returns to where the
// user was (the chat itself is reachable via the chat deeplink).
container.appRouter.present(.sendAmount(target))
}
}

case .openSheet(let sheet):
if case .loggedIn(let container) = sessionAuthenticator.state {
Analytics.deeplinkRouted(kind: kind)
Expand Down Expand Up @@ -253,18 +279,20 @@ extension DeepLinkAction {
case verifyEmail(VerificationDescription)
case currencyInfo(PublicKey)
case chat(ConversationID)
case chatSendCash(ConversationID)
case openSheet(AppRouter.SheetPresentation)
}
}

extension DeepLinkAction.Kind {
var analyticsName: String {
switch self {
case .accessKey: "Login"
case .receiveCashLink: "CashLink"
case .verifyEmail: "EmailVerification"
case .currencyInfo: "TokenInfo"
case .chat: "Chat"
case .accessKey: "Login"
case .receiveCashLink: "CashLink"
case .verifyEmail: "EmailVerification"
case .currencyInfo: "TokenInfo"
case .chat: "Chat"
case .chatSendCash: "ChatSendCash"
case .openSheet(let sheet): "Sheet:\(sheet)"
}
}
Expand Down
4 changes: 4 additions & 0 deletions Flipcash/Core/Controllers/Deep Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ nonisolated extension Route {
case verifyEmail
case token(PublicKey)
case chat(ConversationID)
case chatSendCash(ConversationID)
case give
case balance
case discover
Expand Down Expand Up @@ -120,6 +121,9 @@ nonisolated extension Route {
guard components.count > 1, let id = ConversationID(base64URLEncoded: components[1]) else {
return nil
}
if components.count > 2, components[2] == "send" {
return .chatSendCash(id)
}
return .chat(id)
case "give":
return .give
Expand Down
Loading