Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Flipcash/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ extension BetaFlags {
case vibrateOnScan
case enableCoinbase
case enableTips
case enableBlocking

var id: String {
localizedTitle
Expand All @@ -128,6 +129,8 @@ extension BetaFlags {
return "Enable Coinbase"
case .enableTips:
return "Tips"
case .enableBlocking:
return "Blocking"
}
}

Expand All @@ -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
}
}
}
Expand Down
112 changes: 112 additions & 0 deletions Flipcash/Core/Controllers/BlocklistController.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
40 changes: 38 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<UserID> = { [] }

@ObservationIgnored private let fetching: any ConversationFetching
@ObservationIgnored private let messaging: any ConversationMessaging
@ObservationIgnored private let streaming: any ConversationEventStreaming
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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?
}
61 changes: 61 additions & 0 deletions Flipcash/Core/Controllers/Database/Database+Blocklist.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
}
}
Expand Down Expand Up @@ -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
)
)
Expand Down
25 changes: 25 additions & 0 deletions Flipcash/Core/Controllers/Database/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ nonisolated struct UserFlagsTable: Sendable {
let data = Expression <Data> ("data")
}

nonisolated struct BlocklistTable: Sendable {
static let name = "blocklist"

let table = Table(Self.name)
let userID = Expression <UUID> ("userID") // PK
let blockedAt = Expression <Double> ("blockedAt") // timeIntervalSinceReferenceDate
let displayName = Expression <String> ("displayName")
let avatarBlurhash = Expression <String?> ("avatarBlurhash")
}

// Verified reserve-state proofs, one per mint.
nonisolated struct VerifiedReserveTable: Sendable {
static let name = "verified_reserve"
Expand Down Expand Up @@ -174,6 +184,9 @@ nonisolated struct ConversationTable: Sendable {
let catchupCursor = Expression <UInt64?> ("catchupCursor")
// ConversationType raw value; scopes feed replaces and the Tips surfaces.
let type = Expression <Int> ("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 <Bool> ("isHidden")
}

nonisolated struct ConversationMemberTable: Sendable {
Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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)
})
}

}
}

Expand Down
Loading