From a624c954270c90867c887b7eda33a7c94d1d6988 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 31 Jul 2026 09:56:35 -0400 Subject: [PATCH 1/5] feat(profile): re-vendor Core protos for join_ts and surface Profile.joinedAt --- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ .../Core/Generated/profile_v1_model.pb.swift | 18 +++++++++++++++++- .../Core/proto/profile/v1/model.proto | 4 ++++ .../Sources/FlipcashCore/Models/Profile.swift | 17 ++++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 61bb2ecf3..42d34be35 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -239,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { @@ -266,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { @@ -284,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", - "version" : "1.6.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift index 59107bcd1..c09f0e151 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/Generated/profile_v1_model.pb.swift @@ -71,6 +71,16 @@ public struct Flipcash_Profile_V1_UserProfile: Sendable { /// Clears the value of `profilePicture`. Subsequent reads from it will return its default value. public mutating func clearProfilePicture() {self._profilePicture = nil} + /// Timestamp the user joined Flipcash + public var joinTs: SwiftProtobuf.Google_Protobuf_Timestamp { + get {return _joinTs ?? SwiftProtobuf.Google_Protobuf_Timestamp()} + set {_joinTs = newValue} + } + /// Returns true if `joinTs` has been explicitly set. + public var hasJoinTs: Bool {return self._joinTs != nil} + /// Clears the value of `joinTs`. Subsequent reads from it will return its default value. + public mutating func clearJoinTs() {self._joinTs = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -78,6 +88,7 @@ public struct Flipcash_Profile_V1_UserProfile: Sendable { fileprivate var _phoneNumber: Flipcash_Phone_V1_PhoneNumber? = nil fileprivate var _emailAddress: Flipcash_Email_V1_EmailAddress? = nil fileprivate var _profilePicture: Flipcash_Blob_V1_Media? = nil + fileprivate var _joinTs: SwiftProtobuf.Google_Protobuf_Timestamp? = nil } public struct Flipcash_Profile_V1_SocialProfile: Sendable { @@ -184,7 +195,7 @@ fileprivate let _protobuf_package = "flipcash.profile.v1" extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".UserProfile" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}display_name\0\u{3}social_profiles\0\u{3}phone_number\0\u{3}email_address\0\u{3}profile_picture\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}display_name\0\u{3}social_profiles\0\u{3}phone_number\0\u{3}email_address\0\u{3}profile_picture\0\u{3}join_ts\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -197,6 +208,7 @@ extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf. case 3: try { try decoder.decodeSingularMessageField(value: &self._phoneNumber) }() case 4: try { try decoder.decodeSingularMessageField(value: &self._emailAddress) }() case 5: try { try decoder.decodeSingularMessageField(value: &self._profilePicture) }() + case 6: try { try decoder.decodeSingularMessageField(value: &self._joinTs) }() default: break } } @@ -222,6 +234,9 @@ extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf. try { if let v = self._profilePicture { try visitor.visitSingularMessageField(value: v, fieldNumber: 5) } }() + try { if let v = self._joinTs { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -231,6 +246,7 @@ extension Flipcash_Profile_V1_UserProfile: SwiftProtobuf.Message, SwiftProtobuf. if lhs._phoneNumber != rhs._phoneNumber {return false} if lhs._emailAddress != rhs._emailAddress {return false} if lhs._profilePicture != rhs._profilePicture {return false} + if lhs._joinTs != rhs._joinTs {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto index fb9da21b4..47e89f9f3 100644 --- a/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto +++ b/FlipcashAPI/Sources/FlipcashAPI/Core/proto/profile/v1/model.proto @@ -9,6 +9,7 @@ option objc_class_prefix = "FPBProfileV1"; import "blob/v1/model.proto"; import "email/v1/model.proto"; import "phone/v1/model.proto"; +import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; message UserProfile { @@ -43,6 +44,9 @@ message UserProfile { // these blobs, so a GetBlobs call must carry a blob.v1.AccessContext whose // `profile` scope names this user. A caller reading its own needs none. blob.v1.Media profile_picture = 5; + + // Timestamp the user joined Flipcash + google.protobuf.Timestamp join_ts = 6 [(validate.rules).timestamp.required = true]; } message SocialProfile { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift index c019def33..b71a463ff 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift @@ -13,7 +13,8 @@ public struct Profile: Codable, Equatable, Sendable { public static let empty = Profile( displayName: nil, phone: Optional.none, - email: nil + email: nil, + joinedAt: nil ) public let displayName: String? @@ -21,6 +22,9 @@ public struct Profile: Codable, Equatable, Sendable { public let email: String? public let profilePicture: ProfilePicture? + /// The date the user joined Flipcash, or `nil` when the server did not supply one. + public let joinedAt: Date? + public var isPhoneVerified: Bool { phone != nil } @@ -36,7 +40,7 @@ public struct Profile: Codable, Equatable, Sendable { phone != nil && phone?.e164 != previous?.phone?.e164 } - public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil) throws { + public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil) throws { // Only parse phone if it's not empty var parsedPhone: Phone? @@ -56,15 +60,17 @@ public struct Profile: Codable, Equatable, Sendable { displayName: displayName, phone: parsedPhone, email: normalizedEmail, - profilePicture: profilePicture + profilePicture: profilePicture, + joinedAt: joinedAt ) } - public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil) { + public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil) { self.displayName = displayName self.phone = phone self.email = email self.profilePicture = profilePicture + self.joinedAt = joinedAt } } @@ -82,7 +88,8 @@ extension Profile { displayName: proto.displayName, phone: proto.phoneNumber.value, email: proto.emailAddress.value, - profilePicture: proto.hasProfilePicture ? ProfilePicture(proto.profilePicture) : nil + profilePicture: proto.hasProfilePicture ? ProfilePicture(proto.profilePicture) : nil, + joinedAt: proto.hasJoinTs ? proto.joinTs.date : nil ) } } From d3d0480f6460b3fb3797bdab5b1aba3376230bc4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 31 Jul 2026 09:56:36 -0400 Subject: [PATCH 2/5] feat(blocklist): wire the Blocklist gRPC service into FlipClient --- .../Flip API/FlipClient+Blocklist.swift | 41 +++++ .../Clients/Flip API/FlipClient.swift | 2 + .../Flip API/Services/BlocklistService.swift | 140 ++++++++++++++++++ .../TransportClassificationTests.swift | 1 + 4 files changed, 184 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blocklist.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlocklistService.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blocklist.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blocklist.swift new file mode 100644 index 000000000..715caaa62 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blocklist.swift @@ -0,0 +1,41 @@ +// +// FlipClient+Blocklist.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +extension FlipClient { + + /// Adds a user to the caller's blocklist. + public func blockUser(userID: UserID, owner: KeyPair) async throws { + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + blocklistService.blockUser(userID: userID, owner: owner) { c.resume(with: $0) } + } + } + + /// Removes a user from the caller's blocklist. + public func unblockUser(userID: UserID, owner: KeyPair) async throws { + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + blocklistService.unblockUser(userID: userID, owner: owner) { c.resume(with: $0) } + } + } + + /// Pages the blocklist to exhaustion, most-recently-blocked first. + public func getBlocklist(owner: KeyPair) async throws -> [BlockedUserEntry] { + var all: [BlockedUserEntry] = [] + var pagingToken: Data? + // Loop until the server signals no further pages via hasMore/pagingToken. + while true { + let page = try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + blocklistService.getBlocklist(owner: owner, pagingToken: pagingToken) { c.resume(with: $0) } + } + all.append(contentsOf: page.blockedUsers) + if !page.hasMore { break } + pagingToken = page.pagingToken + } + return all + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift index 362e935b4..a651295c6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -37,6 +37,7 @@ public class FlipClient: ObservableObject { internal let resolverService: ResolverService internal let chatService: ChatService internal let chatMessagingService: ChatMessagingService + internal let blocklistService: BlocklistService /// The single per-user event stream. Started on login, stopped on logout. public let eventStreamer: EventStreamer @@ -83,6 +84,7 @@ public class FlipClient: ObservableObject { self.resolverService = ResolverService(client: client) self.chatService = ChatService(client: client) self.chatMessagingService = ChatMessagingService(client: client) + self.blocklistService = BlocklistService(client: client) self.eventStreamer = EventStreamer(service: EventStreamingService(client: client)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlocklistService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlocklistService.swift new file mode 100644 index 000000000..6d4b7d691 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlocklistService.swift @@ -0,0 +1,140 @@ +// +// BlocklistService.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import GRPCCore + +private let logger = Logger(label: "flipcash.blocklist-service") + +// MARK: - Public types - + +/// One blocked user as returned by the server: identity + when they were blocked. +public struct BlockedUserEntry: Sendable { + public let userID: UserID + public let blockedAt: Date +} + +/// A single page of blocked users from the server, with the cursor for the next page. +public struct BlocklistPage: Sendable { + public let blockedUsers: [BlockedUserEntry] + public let pagingToken: Data + public let hasMore: Bool +} + +// MARK: - + +final class BlocklistService: Sendable { + + private let service: Flipcash_Blocklist_V1_Blocklist.Client + + init(client: GRPCClient) { + self.service = Flipcash_Blocklist_V1_Blocklist.Client(wrapping: client) + } + + func blockUser(userID: UserID, owner: KeyPair, completion: @Sendable @escaping (Result) -> Void) { + logger.info("Blocking user", metadata: ["userId": "\(userID)"]) + let request = Flipcash_Blocklist_V1_BlockUserRequest.with { + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + Task { + do { + let response = try await service.blockUser(request, options: .unaryDefault) + let error = ErrorBlocklist(rawValue: response.result.rawValue) ?? .unknown + await MainActor.run { completion(error == .ok ? .success(()) : .failure(error)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func unblockUser(userID: UserID, owner: KeyPair, completion: @Sendable @escaping (Result) -> Void) { + logger.info("Unblocking user", metadata: ["userId": "\(userID)"]) + let request = Flipcash_Blocklist_V1_UnblockUserRequest.with { + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + Task { + do { + let response = try await service.unblockUser(request, options: .unaryDefault) + let error = ErrorBlocklist(rawValue: response.result.rawValue) ?? .unknown + await MainActor.run { completion(error == .ok ? .success(()) : .failure(error)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func getBlocklist(owner: KeyPair, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Blocklist_V1_GetBlocklistRequest.with { + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + Task { + do { + let response = try await service.getBlocklist(request, options: .unaryDefault) + let error = ErrorBlocklist(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + await MainActor.run { completion(.failure(error)) } + return + } + let users: [BlockedUserEntry] = response.blockedUsers.compactMap { proto in + guard let userID = try? UUID(data: proto.userID.value) else { return nil } + return BlockedUserEntry(userID: userID, blockedAt: proto.blockedAt.date) + } + let page = BlocklistPage( + blockedUsers: users, + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } +} + +// MARK: - Errors - + +/// One error type covers all four blocklist RPCs: their result enums are subsets +/// of these cases (`BlockUser` uses `.denied`/`.userNotFound`/`.cannotBlockSelf`; +/// the others only `.denied`). The raw values line up with every response's +/// `Result` (ok=0, denied=1, userNotFound=2, cannotBlockSelf=3). +public enum ErrorBlocklist: Int, Error { + case ok + case denied + case userNotFound + case cannotBlockSelf + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorBlocklist: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .userNotFound, .cannotBlockSelf: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index f3c46bf39..9f66e3028 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -55,6 +55,7 @@ struct TransportClassificationTests { @Test func errorCheckEmailCode() { assertClassifies(ErrorCheckEmailCode.self) } @Test func errorUnlinkEmail() { assertClassifies(ErrorUnlinkEmail.self) } @Test func errorFetchProfile() { assertClassifies(ErrorFetchProfile.self) } + @Test func errorBlocklist() { assertClassifies(ErrorBlocklist.self) } @Test func errorRateHistory() { assertClassifies(ErrorRateHistory.self) } @Test func errorGetSwap() { assertClassifies(ErrorGetSwap.self) } @Test func errorVoidGiftCard() { assertClassifies(ErrorVoidGiftCard.self) } From 8baee4bfcd595792f7634fc8e1bfa4bd5a36755d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 31 Jul 2026 09:56:36 -0400 Subject: [PATCH 3/5] feat(blocklist): add blocklist controller, persistent cache, and blocked-conversation hiding --- .../Controllers/BlocklistController.swift | 112 ++++++++++++++++++ .../Controllers/ConversationController.swift | 40 ++++++- .../Database/Database+Blocklist.swift | 61 ++++++++++ .../Database/Database+Conversations.swift | 4 +- .../Core/Controllers/Database/Schema.swift | 25 ++++ .../Core/Spotlight/ChatSpotlightIndexer.swift | 4 +- Flipcash/Supporting Files/Info.plist | 2 +- .../Models/BlockedUserProfile.swift | 26 ++++ .../Models/Conversation/Conversation.swift | 6 +- .../Conversation/ConversationStore.swift | 6 + .../BlockedUserProfileTests.swift | 18 +++ .../ConversationHiddenTests.swift | 35 ++++++ FlipcashTests/BlocklistControllerTests.swift | 84 +++++++++++++ 13 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 Flipcash/Core/Controllers/BlocklistController.swift create mode 100644 Flipcash/Core/Controllers/Database/Database+Blocklist.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/BlockedUserProfile.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/BlockedUserProfileTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ConversationHiddenTests.swift create mode 100644 FlipcashTests/BlocklistControllerTests.swift diff --git a/Flipcash/Core/Controllers/BlocklistController.swift b/Flipcash/Core/Controllers/BlocklistController.swift new file mode 100644 index 000000000..44df812a2 --- /dev/null +++ b/Flipcash/Core/Controllers/BlocklistController.swift @@ -0,0 +1,112 @@ +// +// BlocklistController.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +nonisolated private let logger = Logger(label: "flipcash.blocklist-controller") + +/// The backend the controller talks to — abstracted so tests inject a fake. +@MainActor +protocol BlocklistFetching: Sendable { + func getBlockedUserProfiles() async throws -> [BlockedUserProfile] + func block(userID: UserID) async throws + func unblock(userID: UserID) async throws +} + +/// Owns the user's blocklist: an in-memory list backing the Blocked screen and a +/// persistent cache, refreshed from the server on foreground. Exposes `isBlocked` +/// and optimistic `block`/`unblock`. +@MainActor +@Observable +final class BlocklistController { + + /// Blocked users, most-recently-blocked first. + private(set) var blockedUsers: [BlockedUserProfile] = [] + + @ObservationIgnored private let fetching: any BlocklistFetching + @ObservationIgnored private let database: Database + + /// Invoked after any change to the blocklist so the conversation feed can + /// reconcile which chats are hidden. + @ObservationIgnored var onBlocklistChanged: () -> Void = {} + + init(fetching: any BlocklistFetching, database: Database) { + self.fetching = fetching + self.database = database + blockedUsers = (try? database.getBlockedUsers()) ?? [] + } + + /// Returns whether `userID` is currently in the blocklist. + func isBlocked(_ userID: UserID) -> Bool { + blockedUsers.contains { $0.userID == userID } + } + + /// Pull the authoritative blocklist, resolve display profiles, and atomically + /// replace both memory and the cache. Best-effort — keeps the cached list on failure. + func refresh() async { + do { + let users = try await fetching.getBlockedUserProfiles() + blockedUsers = users + onBlocklistChanged() + try database.replaceBlocklist(users) + } catch { + logger.error("Failed to refresh blocklist", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to refresh blocklist") + } + } + + /// Block a user, then optimistically add them so the list + `isBlocked` update + /// without waiting for a refresh. + func block(userID: UserID, displayName: String, avatarBlurhash: String?) async throws { + try await fetching.block(userID: userID) + // Note: Date() is an approximate local timestamp; it is corrected to the server's blockedAt on the next refresh(), which replaces the whole list. + let entry = BlockedUserProfile(userID: userID, blockedAt: Date(), displayName: displayName, avatarBlurhash: avatarBlurhash) + if !isBlocked(userID) { + blockedUsers.insert(entry, at: 0) + } + try? database.upsertBlockedUser(entry) + onBlocklistChanged() + } + + /// Unblock a user, then optimistically remove them. + func unblock(userID: UserID) async throws { + try await fetching.unblock(userID: userID) + blockedUsers.removeAll { $0.userID == userID } + try? database.deleteBlockedUser(userID: userID) + onBlocklistChanged() + } +} + +// MARK: - Production backend + +/// Resolves the server blocklist (userID + blockedAt) into display profiles by +/// fetching each user's profile, and forwards block/unblock to `FlipClient`. +@MainActor +struct FlipBlocklisting: BlocklistFetching { + let flipClient: FlipClient + let owner: KeyPair + + func getBlockedUserProfiles() async throws -> [BlockedUserProfile] { + let blocked = try await flipClient.getBlocklist(owner: owner) + var profiles: [BlockedUserProfile] = [] + for user in blocked { + let profile = try? await flipClient.fetchProfile(userID: user.userID, owner: owner) + let name = profile?.displayName.flatMap { $0.isEmpty ? nil : $0 } ?? ConversationController.fallbackCounterpartName + profiles.append(BlockedUserProfile( + userID: user.userID, + blockedAt: user.blockedAt, + displayName: name, + avatarBlurhash: profile?.profilePicture?.thumbnailBlurhash + )) + } + return profiles + } + + func block(userID: UserID) async throws { try await flipClient.blockUser(userID: userID, owner: owner) } + func unblock(userID: UserID) async throws { try await flipClient.unblockUser(userID: userID, owner: owner) } +} diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index e68c7cb33..b107ce9ce 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -41,9 +41,25 @@ final class ConversationController { conversations.first { $0.id == id } } - /// DM conversations of one type, most-recent activity first. + /// The visible feed, excluding hidden (blocked-counterpart) conversations. + var visibleConversations: [Conversation] { conversations.filter { !$0.isHidden } } + + /// DM conversations of `type`, most-recent activity first, excluding hidden conversations. func conversations(of type: ConversationType) -> [Conversation] { - conversations.filter { $0.type == type } + conversations.filter { $0.type == type && !$0.isHidden } + } + + /// Reconciles every conversation's hidden flag against the authoritative + /// blocklist so a blocked counterpart's chat drops from the feed and an + /// unblocked one returns; re-run after each feed load and on any blocklist change. + func reconcileHidden() { + let blocked = blockedUserIDs() + for conversation in store.conversations { + let hidden = conversation.counterpart(excluding: selfUserID)?.userID.map(blocked.contains) ?? false + if conversation.isHidden != hidden { + store.setHidden(hidden, in: conversation.id) + } + } } /// Number of conversations of `type` with unread messages for the @@ -86,6 +102,10 @@ final class ConversationController { private var store = ConversationStore() + /// The current blocklist (wired to `BlocklistController`), used to reconcile + /// which conversations are hidden from the feed. + @ObservationIgnored var blockedUserIDs: () -> Set = { [] } + @ObservationIgnored private let fetching: any ConversationFetching @ObservationIgnored private let messaging: any ConversationMessaging @ObservationIgnored private let streaming: any ConversationEventStreaming @@ -455,6 +475,7 @@ final class ConversationController { do { let conversations = try await fetching.getDmChatFeed(owner: owner, type: type) store.setFeed(conversations, type: type) + reconcileHidden() persist(operation: "replace-feed") { try database.replaceConversationFeed(conversations, type: type) } } catch { logger.error("Failed to load conversation feed", metadata: [ @@ -661,6 +682,14 @@ final class ConversationController { return contactName(for: conversationID) ?? Self.fallbackCounterpartName } + /// Seed values for the profile screen while the live profile loads: the + /// counterpart's current name and avatar blurhash from the open conversation. + func counterpartSeed(forUserID userID: UserID) -> CounterpartSeed { + let member = conversations.flatMap(\.members).first { $0.userID == userID } + let name = member.flatMap { $0.displayName.isEmpty ? nil : $0.displayName } ?? Self.fallbackCounterpartName + return CounterpartSeed(displayName: name, imageData: nil, blurhash: member?.profilePicture?.thumbnailBlurhash) + } + private func contactName(for conversationID: ConversationID) -> String? { guard let name = contactNaming.contactDisplayName(forDMChat: conversationID), !name.isEmpty else { @@ -921,3 +950,10 @@ final class ConversationController { typing.stopSelfTyping(in: conversationID) } } + +/// Seed data for the profile screen before the live profile fetch returns. +struct CounterpartSeed: Sendable { + let displayName: String + let imageData: Data? + let blurhash: String? +} diff --git a/Flipcash/Core/Controllers/Database/Database+Blocklist.swift b/Flipcash/Core/Controllers/Database/Database+Blocklist.swift new file mode 100644 index 000000000..21a558c16 --- /dev/null +++ b/Flipcash/Core/Controllers/Database/Database+Blocklist.swift @@ -0,0 +1,61 @@ +// +// Database+Blocklist.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import SQLite + +nonisolated extension Database { + + /// The cached blocklist, most-recently-blocked first. + func getBlockedUsers() throws -> [BlockedUserProfile] { + let b = BlocklistTable() + let rows = try reader.prepareRowIterator(b.table.order(b.blockedAt.desc)) + return try rows.map { row in + BlockedUserProfile( + userID: row[b.userID], + blockedAt: Date(timeIntervalSinceReferenceDate: row[b.blockedAt]), + displayName: row[b.displayName], + avatarBlurhash: row[b.avatarBlurhash] + ) + } + } + + /// Atomically replace the entire cached blocklist with `users`. + func replaceBlocklist(_ users: [BlockedUserProfile]) throws { + let b = BlocklistTable() + try writer.transaction { + try writer.run(b.table.delete()) + for user in users { + try writer.run(b.table.insert( + b.userID <- user.userID, + b.blockedAt <- user.blockedAt.timeIntervalSinceReferenceDate, + b.displayName <- user.displayName, + b.avatarBlurhash <- user.avatarBlurhash + )) + } + } + } + + /// Insert or replace one blocked user (optimistic block). + func upsertBlockedUser(_ user: BlockedUserProfile) throws { + let b = BlocklistTable() + try writer.run(b.table.upsert( + b.userID <- user.userID, + b.blockedAt <- user.blockedAt.timeIntervalSinceReferenceDate, + b.displayName <- user.displayName, + b.avatarBlurhash <- user.avatarBlurhash, + onConflictOf: b.userID + )) + } + + /// Remove one blocked user (optimistic unblock). + func deleteBlockedUser(userID: UserID) throws { + let b = BlocklistTable() + try writer.run(b.table.filter(b.userID == userID).delete()) + } +} diff --git a/Flipcash/Core/Controllers/Database/Database+Conversations.swift b/Flipcash/Core/Controllers/Database/Database+Conversations.swift index 36772acec..72ff8489a 100644 --- a/Flipcash/Core/Controllers/Database/Database+Conversations.swift +++ b/Flipcash/Core/Controllers/Database/Database+Conversations.swift @@ -80,7 +80,8 @@ nonisolated extension Database { members: membersByConversation[id] ?? [], lastMessage: try latestMessage(conversationId: id), lastActivity: Date(timeIntervalSinceReferenceDate: row[c.lastActivity]), - type: ConversationType(rawValue: row[c.type]) ?? .contactDm + type: ConversationType(rawValue: row[c.type]) ?? .contactDm, + isHidden: row[c.isHidden] ) } } @@ -285,6 +286,7 @@ nonisolated extension Database { c.id <- conversation.id.data, c.lastActivity <- conversation.lastActivity.timeIntervalSinceReferenceDate, c.type <- conversation.type.rawValue, + c.isHidden <- conversation.isHidden, onConflictOf: c.id ) ) diff --git a/Flipcash/Core/Controllers/Database/Schema.swift b/Flipcash/Core/Controllers/Database/Schema.swift index 582dffa53..988a2bf01 100644 --- a/Flipcash/Core/Controllers/Database/Schema.swift +++ b/Flipcash/Core/Controllers/Database/Schema.swift @@ -119,6 +119,16 @@ nonisolated struct UserFlagsTable: Sendable { let data = Expression ("data") } +nonisolated struct BlocklistTable: Sendable { + static let name = "blocklist" + + let table = Table(Self.name) + let userID = Expression ("userID") // PK + let blockedAt = Expression ("blockedAt") // timeIntervalSinceReferenceDate + let displayName = Expression ("displayName") + let avatarBlurhash = Expression ("avatarBlurhash") +} + // Verified reserve-state proofs, one per mint. nonisolated struct VerifiedReserveTable: Sendable { static let name = "verified_reserve" @@ -174,6 +184,9 @@ nonisolated struct ConversationTable: Sendable { let catchupCursor = Expression ("catchupCursor") // ConversationType raw value; scopes feed replaces and the Tips surfaces. let type = Expression ("type") + // Server-set: the counterpart is on the owner's blocklist. Retained so an + // unblock restores the conversation; filtered from the displayed feed. + let isHidden = Expression ("isHidden") } nonisolated struct ConversationMemberTable: Sendable { @@ -392,6 +405,7 @@ nonisolated extension Database { t.column(conversationTable.lastActivity) t.column(conversationTable.catchupCursor) t.column(conversationTable.type, defaultValue: ConversationType.contactDm.rawValue) + t.column(conversationTable.isHidden, defaultValue: false) }) } @@ -436,6 +450,17 @@ nonisolated extension Database { }) } + let blocklistTable = BlocklistTable() + + try writer.transaction { + try writer.run(blocklistTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(blocklistTable.userID, primaryKey: true) + t.column(blocklistTable.blockedAt) + t.column(blocklistTable.displayName) + t.column(blocklistTable.avatarBlurhash) + }) + } + } } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift b/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift index 8de0213fc..6ae29e3e8 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightIndexer.swift @@ -72,7 +72,7 @@ final class ChatSpotlightIndexer { // its phone-number fallback with no avatar. No image rendering here — // that's reindex()'s job, off the debounce. _ = withObservationTracking { - controller.conversations.map { controller.displayName(for: $0) } + controller.visibleConversations.map { controller.displayName(for: $0) } } onChange: { [weak self] in Task { @MainActor in guard let self else { return } @@ -93,7 +93,7 @@ final class ChatSpotlightIndexer { } private func reindex() { - let items = controller.conversations.map { conversation -> ChatSpotlightItem in + let items = controller.visibleConversations.map { conversation -> ChatSpotlightItem in let contact = counterpartContact(for: conversation) let displayName = controller.displayName(for: conversation) return ChatSpotlightItem( diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index d21d689d0..bc0394ddc 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -23,7 +23,7 @@ com.flipcash.app.openChat SQLiteVersion - 27 + 29 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/FlipcashCore/Sources/FlipcashCore/Models/BlockedUserProfile.swift b/FlipcashCore/Sources/FlipcashCore/Models/BlockedUserProfile.swift new file mode 100644 index 000000000..93234a0ca --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/BlockedUserProfile.swift @@ -0,0 +1,26 @@ +// +// BlockedUserProfile.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// A blocked user resolved to the fields the Blocked list shows: identity, when +/// they were blocked, their display name, and their avatar BlurHash. +public struct BlockedUserProfile: Identifiable, Hashable, Sendable { + public let userID: UserID + public let blockedAt: Date + public var displayName: String + public var avatarBlurhash: String? + + public var id: UserID { userID } + + public init(userID: UserID, blockedAt: Date, displayName: String, avatarBlurhash: String?) { + self.userID = userID + self.blockedAt = blockedAt + self.displayName = displayName + self.avatarBlurhash = avatarBlurhash + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 2fd7a57d5..373e508b7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -18,13 +18,16 @@ public struct Conversation: Identifiable, Hashable, Sendable { public var lastMessage: ConversationMessage? public var lastActivity: Date public let type: ConversationType + /// Whether the server has hidden this conversation from the feed because the counterpart is on the owner's blocklist. + public var isHidden: Bool - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm) { + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false) { self.id = id self.members = members self.lastMessage = lastMessage self.lastActivity = lastActivity self.type = type + self.isHidden = isHidden } } @@ -62,6 +65,7 @@ extension Conversation { self.lastMessage = proto.hasLastMessage ? ConversationMessage(proto.lastMessage) : nil self.lastActivity = proto.hasLastActivity ? proto.lastActivity.date : .distantPast self.type = ConversationType(proto.type) + self.isHidden = proto.isHidden } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index bdaf6db39..c072b4ab3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -202,6 +202,12 @@ public struct ConversationStore: Sendable { conversations[convoIndex].members[memberIndex].readPointerTimestamp = date } + /// Flips the hidden flag for a conversation in place, retaining it in the store; no-ops for a conversation not in the feed. + public mutating func setHidden(_ hidden: Bool, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].isHidden = hidden + } + /// Bump a conversation's last activity and re-sort the feed. No-ops for a conversation not in the /// feed. public mutating func advanceLastActivity(to date: Date, in conversationID: ConversationID) { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlockedUserProfileTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlockedUserProfileTests.swift new file mode 100644 index 000000000..b20fb7170 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlockedUserProfileTests.swift @@ -0,0 +1,18 @@ +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("BlockedUserProfile") +struct BlockedUserProfileTests { + + @Test("Holds identity, block time, and minimal display fields") + func fields() { + let id = UUID() + let at = Date(timeIntervalSince1970: 1_700_000_000) + let p = BlockedUserProfile(userID: id, blockedAt: at, displayName: "Fred Wilson", avatarBlurhash: "L6") + #expect(p.userID == id) + #expect(p.blockedAt == at) + #expect(p.displayName == "Fred Wilson") + #expect(p.avatarBlurhash == "L6") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationHiddenTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationHiddenTests.swift new file mode 100644 index 000000000..4f696abc4 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationHiddenTests.swift @@ -0,0 +1,35 @@ +// +// ConversationHiddenTests.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Conversation isHidden") +struct ConversationHiddenTests { + + private func convo(_ id: UInt8, hidden: Bool) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: id, count: 32)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: TimeInterval(id)), + type: .tipDm, + isHidden: hidden + ) + } + + @Test("setHidden flips the flag but retains the conversation") + func setHiddenRetains() { + var store = ConversationStore() + let c = convo(1, hidden: false) + store.setFeed([c]) + store.setHidden(true, in: c.id) + #expect(store.conversations.count == 1) // retained + #expect(store.conversations.first?.isHidden == true) // flagged + } +} diff --git a/FlipcashTests/BlocklistControllerTests.swift b/FlipcashTests/BlocklistControllerTests.swift new file mode 100644 index 000000000..0caca030c --- /dev/null +++ b/FlipcashTests/BlocklistControllerTests.swift @@ -0,0 +1,84 @@ +// +// BlocklistControllerTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@Suite("BlocklistController") +@MainActor +struct BlocklistControllerTests { + + /// A fake blocklist backend capturing calls and returning canned pages. + final class FakeBlocklisting: BlocklistFetching { + var page: [BlockedUserProfile] = [] + var blocked: [UserID] = [] + var unblocked: [UserID] = [] + /// When set, `getBlockedUserProfiles()` throws this instead of returning `page`. + var error: Error? + func getBlockedUserProfiles() async throws -> [BlockedUserProfile] { + if let error { throw error } + return page + } + func block(userID: UserID) async throws { blocked.append(userID) } + func unblock(userID: UserID) async throws { unblocked.append(userID) } + } + + private func makeController(_ fake: FakeBlocklisting) -> BlocklistController { + BlocklistController(fetching: fake, database: .mock) + } + + @Test("refresh loads the blocklist into memory + cache") + func refreshPopulates() async { + let fake = FakeBlocklisting() + let id = UUID() + fake.page = [BlockedUserProfile(userID: id, blockedAt: .init(timeIntervalSince1970: 1), displayName: "Fred", avatarBlurhash: nil)] + let controller = makeController(fake) + await controller.refresh() + #expect(controller.blockedUsers.map(\.userID) == [id]) + #expect(controller.isBlocked(id)) + } + + @Test("block calls the backend and inserts optimistically") + func blockOptimistic() async throws { + let fake = FakeBlocklisting() + let controller = makeController(fake) + let id = UUID() + try await controller.block(userID: id, displayName: "Fred", avatarBlurhash: "L6") + #expect(fake.blocked == [id]) + #expect(controller.isBlocked(id)) + } + + @Test("unblock calls the backend and removes optimistically") + func unblockOptimistic() async throws { + let fake = FakeBlocklisting() + let id = UUID() + fake.page = [BlockedUserProfile(userID: id, blockedAt: .init(timeIntervalSince1970: 1), displayName: "Fred", avatarBlurhash: nil)] + let controller = makeController(fake) + await controller.refresh() + try await controller.unblock(userID: id) + #expect(fake.unblocked == [id]) + #expect(!controller.isBlocked(id)) + } + + @Test("refresh failure keeps the existing list") + func refreshFailureKeepsExistingList() async { + let fake = FakeBlocklisting() + let id = UUID() + fake.page = [BlockedUserProfile(userID: id, blockedAt: .init(timeIntervalSince1970: 1), displayName: "Fred", avatarBlurhash: nil)] + let controller = makeController(fake) + await controller.refresh() + #expect(controller.isBlocked(id)) + + // Make the next refresh fail — the existing list must survive unchanged. + fake.error = URLError(.notConnectedToInternet) + await controller.refresh() + #expect(controller.blockedUsers.map(\.userID) == [id]) + #expect(controller.isBlocked(id)) + } +} From 5391d2b7e59d70a9d36ae308ce0faaab37dd9ccf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 31 Jul 2026 09:56:36 -0400 Subject: [PATCH 4/5] feat(blocklist): add block/unblock UI, navigation, and beta gating --- Flipcash/Core/Controllers/BetaFlags.swift | 12 +- .../Navigation/AppRouter+Destination.swift | 13 +- .../AppRouter+DestinationView.swift | 7 + .../ChatScreenRepresentable.swift | 4 + .../Conversation/ConversationScreen.swift | 23 ++- .../Screens/Profile/UserProfileScreen.swift | 154 ++++++++++++++++++ .../Screens/Settings/BlockedUsersScreen.swift | 90 ++++++++++ .../Settings/SettingsMyAccountScreen.swift | 7 + .../FlipcashUI/Chat/ChatProfileCardCell.swift | 60 ++++--- .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 12 +- 11 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/UserProfileScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index aa8d2716d..1b71c6a56 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -115,6 +115,7 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase case enableTips + case enableBlocking var id: String { localizedTitle @@ -128,6 +129,8 @@ extension BetaFlags { return "Enable Coinbase" case .enableTips: return "Tips" + case .enableBlocking: + return "Blocking" } } @@ -139,15 +142,18 @@ extension BetaFlags { return "If enabled, Coinbase onramp will be available regardless of region" case .enableTips: return "If enabled, the Tips tab is available from the scan screen" + case .enableBlocking: + return "If enabled, you can block users from a tip chat and manage blocked users in My Account" } } /// Which Settings surface exposes this flag's toggle. var availability: Availability { switch self { - case .vibrateOnScan: return .developer - case .enableCoinbase: return .developer - case .enableTips: return .publicBeta + case .vibrateOnScan: return .developer + case .enableCoinbase: return .developer + case .enableTips: return .publicBeta + case .enableBlocking: return .developer } } } diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 8db774b20..bdb2f8671 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -44,6 +44,7 @@ extension AppRouter { case settingsBetaFlags case settingsAccountSelection case settingsApplicationLogs + case blockedUsers case accessKey case withdraw @@ -61,6 +62,8 @@ extension AppRouter { /// trace shows "post-tip, keyboard up" distinctly, and so the ordinary /// tip-list / push-notification opens stay keyboard-closed untouched. case tipConversationWithKeyboard(ConversationID) + /// The counterpart's Flipcash profile, pushed from a tip DM's title/card; hosts the Block action. + case userProfile(UserID) // Conversation flow /// A DM conversation, pushed onto the `.send` stack — from the Chats @@ -82,10 +85,10 @@ extension AppRouter { return .balance case .settingsMyAccount, .settingsAdvancedFeatures, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, - .settingsApplicationLogs, .accessKey, .withdraw: + .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: return .settings case .profileName, .profilePhoto, .tipcard, - .tipConversation, .tipConversationWithKeyboard: + .tipConversation, .tipConversationWithKeyboard, .userProfile: return .tips case .dmConversation: return .send @@ -116,6 +119,7 @@ extension AppRouter { case .settingsBetaFlags: "settingsBetaFlags" case .settingsAccountSelection: "settingsAccountSelection" case .settingsApplicationLogs: "settingsApplicationLogs" + case .blockedUsers: "blockedUsers" case .accessKey: "accessKey" case .withdraw: "withdraw" case .profileName: "profileName" @@ -123,6 +127,7 @@ extension AppRouter { case .tipcard: "tipcard" case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" + case .userProfile: "userProfile" case .dmConversation: "dmConversation" } } @@ -145,11 +150,13 @@ extension AppRouter { case .tipConversation(let conversationID), .tipConversationWithKeyboard(let conversationID): return conversationID.description + case .userProfile(let userID): + return userID.uuidString case .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, .settingsMyAccount, .settingsAdvancedFeatures, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsBetaFlags, .settingsAccountSelection, - .settingsApplicationLogs, .accessKey, .withdraw, + .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard: return nil } diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index f8f389bb0..a9228d585 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -90,6 +90,9 @@ struct DestinationView: View { case .settingsApplicationLogs: ApplicationLogsScreen() + case .blockedUsers: + BlockedUsersScreen() + case .accessKey: AccessKeyBackupScreen(mnemonic: sessionContainer.session.keyAccount.mnemonic) .navigationTitle("Access Key") @@ -143,6 +146,10 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), openKeyboard: true) .id(conversationID) + case .userProfile(let userID): + UserProfileScreen(userID: userID) + .id(userID) + // MARK: - Conversation flow case .dmConversation(let context): diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index f978f05f6..47fe52282 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -32,6 +32,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the user taps the profile card's call to action. The owner opens the /// counterpart's contact card (or the add-contact sheet), same as tapping the nav title. let onContactAction: () -> Void + /// Fired when the user taps the profile card in a tip DM; nil disables the card tap. + let onProfileTap: (() -> Void)? let showsSendCash: Bool let chatExists: Bool let conversationID: ConversationID? @@ -56,6 +58,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCashCardTap = onCashCardTap screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction + screen.onProfileTap = onProfileTap screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -72,6 +75,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCashCardTap = onCashCardTap screen.onOpenURL = onOpenURL screen.onContactAction = onContactAction + screen.onProfileTap = onProfileTap // Scroll only when the user's *own* message was just appended — a new trailing message id // (skipping any trailing receipt) that is from me. Received messages and prepended history diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 00d401084..babf31bea 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -56,6 +56,7 @@ struct ConversationScreen: View { @Environment(PushController.self) private var pushController @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer + @Environment(BetaFlags.self) private var betaFlags @State private var didInitialRead = false @State private var barModel = ConversationBarModel() @@ -123,11 +124,21 @@ struct ConversationScreen: View { } } + /// For a tip DM (beta on), all counterpart taps open the profile screen — + /// even when the counterpart is also an address-book contact. + private var profileTapAction: (() -> Void)? { + guard betaFlags.hasEnabled(.enableBlocking), + let userID = tipCounterpart?.userID else { return nil } + return { router.push(.userProfile(userID)) } + } + /// Tapping the title opens the counterpart's contact card: their address-book /// card when they're a contact, otherwise the native "Add to Contacts" sheet - /// seeded with their number. Inert only when neither a contact nor a phone + /// seeded with their number. For a tip DM with the blocking beta on, opens the + /// profile screen instead. Inert only when neither a contact nor a phone /// number is known. private var titleTapAction: (() -> Void)? { + if let profileTapAction { return profileTapAction } guard contact != nil || addableContactPhone != nil else { return nil } return { openContactCard() } } @@ -165,6 +176,7 @@ struct ConversationScreen: View { onCashCardTap: openCurrencyInfo, onOpenURL: openLink, onContactAction: openContactCard, + onProfileTap: profileTapAction, showsSendCash: sendTarget != nil, chatExists: chatExists, conversationID: conversationID, @@ -192,7 +204,8 @@ struct ConversationScreen: View { imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, width: max(navBarWidth - Self.titleSideInset * 2, 0), - onTap: titleTapAction + onTap: titleTapAction, + opensProfile: profileTapAction != nil ) } } @@ -457,7 +470,7 @@ struct ConversationScreen: View { /// Avatar + name, left-aligned inside the centered principal slot (sized to /// the measured bar width; the system toolbar won't honor maxWidth on a /// principal item). When `onTap` is non-nil the whole item becomes a button -/// that opens the counterpart's contact card. +/// that opens the counterpart's contact card or profile screen. private struct ConversationTitleItem: View { let title: String @@ -467,6 +480,7 @@ private struct ConversationTitleItem: View { let blurhash: String? let width: CGFloat let onTap: (() -> Void)? + let opensProfile: Bool var body: some View { let label = ConversationTitleLabel( @@ -478,10 +492,11 @@ private struct ConversationTitleItem: View { width: width ) if let onTap { + let hint = opensProfile ? "Opens profile" : (contact != nil ? "Opens contact card" : "Adds to Contacts") Button(action: onTap) { label } .buttonStyle(.plain) .accessibilityLabel(title) - .accessibilityHint(contact != nil ? "Opens contact card" : "Adds to Contacts") + .accessibilityHint(hint) } else { label } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift new file mode 100644 index 000000000..021d495ed --- /dev/null +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -0,0 +1,154 @@ +// +// UserProfileScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The counterpart's Flipcash profile with a Block action, reached from a tip DM. +struct UserProfileScreen: View { + let userID: UserID + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(BlocklistController.self) private var blocklistController + @Environment(AppRouter.self) private var router + + var body: some View { + let seed = sessionContainer.conversationController.counterpartSeed(forUserID: userID) + let avatarData = sessionContainer.tipAvatars.data(for: userID) + UserProfileContent( + model: UserProfileViewModel( + userID: userID, + flipClient: sessionContainer.flipClient, + owner: sessionContainer.session.ownerKeyPair, + blocklistController: blocklistController, + router: router, + session: sessionContainer.session, + seed: seed, + avatarData: avatarData + ) + ) + } +} + +private struct UserProfileContent: View { + @State private var model: UserProfileViewModel + @State private var dialogItem: DialogItem? + + init(model: UserProfileViewModel) { + _model = State(initialValue: model) + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 16) { + ContactAvatarView( + id: model.userID.uuidString, + displayName: model.displayName, + imageData: model.imageData, + blurhash: model.blurhash, + size: 88 + ) + .padding(.top, 40) + + Text(model.displayName) + .font(.appDisplaySmall) + .foregroundStyle(.textMain) + + if let joined = model.joinedText { + Text(joined) + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + } + + Row(insets: .init(top: 25, leading: 0, bottom: 25, trailing: 0)) { + Image(systemName: "nosign") + .frame(minWidth: 45) + Text("Block") + .foregroundStyle(.textMain) + Spacer() + // Secondary (alpha-white) chevron, matching the profile card. + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.textSecondary) + } action: { + dialogItem = blockDialog() + } + .font(.appDisplayXS) + .padding(.top, 24) + + Spacer() + } + .padding(.horizontal, 20) + } + .navigationTitle("") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + .task { await model.loadProfile() } + } + + private func blockDialog() -> DialogItem { + .alert( + title: "Block \(model.displayName)?", + subtitle: "You won't see messages from them, but you will still receive cash they send you. Flipcash won't tell them you blocked them" + ) { + DialogAction.destructive("Block") { + Task { await model.block() } + } + DialogAction.cancel() + } + } +} + +@MainActor +@Observable +final class UserProfileViewModel { + let userID: UserID + private(set) var displayName: String + private(set) var imageData: Data? + private(set) var blurhash: String? + private(set) var joinedText: String? + + @ObservationIgnored private let flipClient: FlipClient + @ObservationIgnored private let owner: KeyPair + @ObservationIgnored private let blocklistController: BlocklistController + @ObservationIgnored private let router: AppRouter + @ObservationIgnored private let session: Session + + init(userID: UserID, flipClient: FlipClient, owner: KeyPair, blocklistController: BlocklistController, router: AppRouter, session: Session, seed: CounterpartSeed, avatarData: Data?) { + self.userID = userID + self.flipClient = flipClient + self.owner = owner + self.blocklistController = blocklistController + self.router = router + self.session = session + self.displayName = seed.displayName + self.imageData = avatarData ?? seed.imageData + self.blurhash = seed.blurhash + } + + /// Fetches the profile for a fresh name, join date, and avatar. + func loadProfile() async { + guard let profile = try? await flipClient.fetchProfile(userID: userID, owner: owner) else { return } + if let name = profile.displayName, !name.isEmpty { displayName = name } + if blurhash == nil { blurhash = profile.profilePicture?.thumbnailBlurhash } + if let joined = profile.joinedAt { + joinedText = "Joined \(joined.formatted(.dateTime.month(.wide).year()))" + } + } + + /// Blocks the user and returns to the Tips list; the blocklist reconcile hides the conversation. + func block() async { + do { + try await blocklistController.block(userID: userID, displayName: displayName, avatarBlurhash: blurhash) + router.popToRoot() + } catch { + session.dialogItem = .error(title: "Something Went Wrong", subtitle: "We were unable to block the user. Please try again") + ErrorReporting.captureError(error, reason: "Failed to block user") + } + } +} diff --git a/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift b/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift new file mode 100644 index 000000000..20da2a778 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/BlockedUsersScreen.swift @@ -0,0 +1,90 @@ +// +// BlockedUsersScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Lists the users the owner has blocked, with an unblock confirmation per row. +struct BlockedUsersScreen: View { + + @Environment(BlocklistController.self) private var blocklistController + @Environment(Session.self) private var session + @State private var dialogItem: DialogItem? + @State private var hasLoaded = false + + private let insets = EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0) + + var body: some View { + Background(color: .backgroundMain) { + Group { + if blocklistController.blockedUsers.isEmpty && hasLoaded { + VStack(spacing: 8) { + Text("No One Blocked") + .font(.appTextMedium) + .foregroundStyle(.textMain) + Text("Block people from sending you messages by tapping their profile and selecting block") + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 40) + } else if !blocklistController.blockedUsers.isEmpty { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(blocklistController.blockedUsers) { user in + blockedRow(user) + } + } + .font(.appDisplayXS) + .padding(.horizontal, 20) + } + } + } + } + .navigationTitle("Blocked") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + .task { await blocklistController.refresh(); hasLoaded = true } + } + + private func blockedRow(_ user: BlockedUserProfile) -> some View { + // Always blurred (imageData nil) — the Blocked list shows the blurred photo. + Row(insets: insets, accessory: .chevron) { + ContactAvatarView( + id: user.userID.uuidString, + displayName: user.displayName, + imageData: nil, + blurhash: user.avatarBlurhash, + size: 44 + ) + Text(user.displayName) + .foregroundStyle(.textMain) + } action: { + dialogItem = unblockDialog(user) + } + } + + private func unblockDialog(_ user: BlockedUserProfile) -> DialogItem { + .info( + title: "Unblock \(user.displayName)?", + subtitle: "The conversation with them will reappear in Tips" + ) { + DialogAction.standard("Unblock") { + Task { + do { + try await blocklistController.unblock(userID: user.userID) + } catch { + session.dialogItem = .error(title: "Something Went Wrong", subtitle: "We were unable to unblock the user. Please try again") + ErrorReporting.captureError(error, reason: "Failed to unblock user") + } + } + } + DialogAction.cancel() + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift index c638f3616..7608078e5 100644 --- a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift @@ -14,6 +14,7 @@ struct SettingsMyAccountScreen: View { @Environment(AppRouter.self) private var router @Environment(SessionAuthenticator.self) private var sessionAuthenticator @Environment(ContactSyncController.self) private var contactSyncController + @Environment(BetaFlags.self) private var betaFlags @State private var dialogItem: DialogItem? private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) @@ -46,6 +47,12 @@ struct SettingsMyAccountScreen: View { } } + if betaFlags.hasEnabled(.enableBlocking) { + SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { + router.push(.blockedUsers) + } + } + SettingsRow(asset: .logout, title: "Log Out", insets: insets) { dialogItem = .alert( title: "Are You Sure You Want To Log Out?", diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift index 278662dee..772789f8f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift @@ -18,9 +18,9 @@ public final class ChatProfileCardCell: UICollectionViewCell { public static let reuseIdentifier = "ChatProfileCardCell" - public func configure(with card: ChatProfileCard, onContactAction: @escaping () -> Void) { + public func configure(with card: ChatProfileCard, onContactAction: @escaping () -> Void, onProfileTap: (() -> Void)? = nil) { contentConfiguration = UIHostingConfiguration { - ProfileCardView(card: card, onContactAction: onContactAction) + ProfileCardView(card: card, onContactAction: onContactAction, onProfileTap: onProfileTap) } .margins(.all, 0) } @@ -32,23 +32,11 @@ private struct ProfileCardView: View { let card: ChatProfileCard let onContactAction: () -> Void + let onProfileTap: (() -> Void)? var body: some View { VStack(spacing: 0) { - ContactAvatarView( - id: card.avatarID, - displayName: card.name, - imageData: card.imageData, - blurhash: card.blurhash, - size: 80 - ) - .accessibilityHidden(true) - - Text(card.name) - .font(.appTextLarge) - .foregroundStyle(Color.textMain) - .lineLimit(1) - .padding(.top, 12) + header ProfileCardSubtitle(counterpart: card.counterpart) .font(.appTextSmall) @@ -76,6 +64,37 @@ private struct ProfileCardView: View { .padding(.top, 8) } + @ViewBuilder private var header: some View { + let content = VStack(spacing: 0) { + ContactAvatarView( + id: card.avatarID, + displayName: card.name, + imageData: card.imageData, + blurhash: card.blurhash, + size: 80 + ) + .accessibilityHidden(true) + + HStack(spacing: 4) { + Text(card.name) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if onProfileTap != nil { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(Color.textSecondary) + } + } + .padding(.top, 12) + } + if let onProfileTap { + Button(action: onProfileTap) { content }.buttonStyle(.plain) + } else { + content + } + } + } /// The line under the name: the contact's number, or the "Unknown Contact" flag. @@ -107,7 +126,8 @@ private struct ProfileCardSubtitle: View { imageData: nil, counterpart: .contact(phone: "519 802-3885") ), - onContactAction: {} + onContactAction: {}, + onProfileTap: nil ) ProfileCardView( card: ChatProfileCard( @@ -116,7 +136,8 @@ private struct ProfileCardSubtitle: View { imageData: nil, counterpart: .unknown ), - onContactAction: {} + onContactAction: {}, + onProfileTap: nil ) ProfileCardView( card: ChatProfileCard( @@ -125,7 +146,8 @@ private struct ProfileCardSubtitle: View { imageData: nil, counterpart: .none ), - onContactAction: {} + onContactAction: {}, + onProfileTap: nil ) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 4b7fad831..5814207c2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -76,6 +76,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onContactAction = newValue } } + /// Forwards profile-card taps from the transcript to the owner. + public var onProfileTap: (() -> Void)? { + get { transcript.onProfileTap } + set { transcript.onProfileTap = newValue } + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(Color.backgroundMain) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index ac3d667cf..6bda5b194 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -43,6 +43,9 @@ public final class ChatViewController: UICollectionViewController { /// counterpart's contact card (or the add-contact sheet), same as the nav title. public var onContactAction: (() -> Void)? + /// Called when the user taps the profile card header in a tip DM; nil disables the tap. + public var onProfileTap: (() -> Void)? + /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -236,9 +239,12 @@ public final class ChatViewController: UICollectionViewController { case .typingIndicator: break case .profileCard(let card): - (cell as! ChatProfileCardCell).configure(with: card) { [weak self] in - self?.onContactAction?() - } + let profileTap: (() -> Void)? = onProfileTap == nil ? nil : { [weak self] in self?.onProfileTap?() } + (cell as! ChatProfileCardCell).configure( + with: card, + onContactAction: { [weak self] in self?.onContactAction?() }, + onProfileTap: profileTap + ) case .dateSeparator(_, let text): (cell as! ChatDateSeparatorCell).configure(text: text) case .message(let message): From 9ac200b52bc338e79f84cf77a80439e6eb0e2acb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 31 Jul 2026 09:56:36 -0400 Subject: [PATCH 5/5] feat(blocklist): construct and inject BlocklistController with foreground refresh --- Flipcash/Core/AppDelegate.swift | 1 + .../Core/Session/SessionAuthenticator.swift | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index 124e74ea8..5d64ab330 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -115,6 +115,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { sessionContainer?.contactSyncController.didBecomeActive() sessionContainer?.conversationController.ensureConnected() sessionContainer?.conversationController.catchUpOpenChat() + Task { await sessionContainer?.blocklistController.refresh() } sessionContainer?.pushController.clearBadgeCount() case .inactive: break diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 1b4c77f7b..4b1c467c2 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -475,6 +475,7 @@ final class SessionContainer { let usdcSweepOperation: UsdcSweepOperation let quickActionsController: QuickActionsController let conversationController: ConversationController + let blocklistController: BlocklistController let chatSpotlightIndexer: ChatSpotlightIndexer let tipAvatars: TipAvatarStore @@ -556,6 +557,24 @@ final class SessionContainer { conversationController.start() self.conversationController = conversationController + let blocklistController = BlocklistController( + fetching: FlipBlocklisting(flipClient: flipClient, owner: owner), + database: database + ) + self.blocklistController = blocklistController + + // The blocklist drives which conversations the feed hides: it supplies the + // current set, reconciles the hidden flags on every change, and reconciles + // once now for the cache-seeded list. + conversationController.blockedUserIDs = { [weak blocklistController] in + Set((blocklistController?.blockedUsers ?? []).map(\.userID)) + } + blocklistController.onBlocklistChanged = { [weak conversationController] in + conversationController?.reconcileHidden() + } + conversationController.reconcileHidden() + Task { await blocklistController.refresh() } + // Suppress foreground chat pushes for the conversation that's currently // on screen — the user is already reading it. pushController.isViewingConversation = { [weak conversationController] conversationID in @@ -586,6 +605,7 @@ final class SessionContainer { .environment(coinbaseService) .environment(onrampDeeplinkInbox) .environment(conversationController) + .environment(blocklistController) } }