Skip to content
Merged
260 changes: 260 additions & 0 deletions Code.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2193,6 +2201,7 @@ final class ConversationController {
)
deliveredAll = deliveredAll && delivered
}
if deliveredAll { donateShareSuggestion(for: conversationID) }
return deliveredAll
}

Expand Down
13 changes: 1 addition & 12 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
4 changes: 1 addition & 3 deletions Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct AllCurrenciesCard: View {
}
.frame(maxWidth: .infinity, alignment: .leading)

CheckView(active: isSelected)
RadioButton(isSelected: isSelected)
.accessibilityHidden(true)
}
.padding(.horizontal, 12)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ private struct InviteChatRow: View {

Spacer(minLength: 12)

CheckView(active: isSelected)
RadioButton(isSelected: isSelected)
}
.padding(.vertical, 16)
.overlay(alignment: .bottom) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
62 changes: 62 additions & 0 deletions Flipcash/Core/Spotlight/ChatShareDonor.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 31 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Blob/ChatPhotoSend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ConversationID>,
archivedIDs: Set<ConversationID>
) -> [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] }
}
}
33 changes: 33 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Share/ShareChatSelection.swift
Original file line number Diff line number Diff line change
@@ -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<ConversationID>,
archivedIDs: Set<ConversationID>
) -> [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
}
}
Loading
Loading