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.

5 changes: 5 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ final class ConversationController {
/// The emoji reactions concern: the user's taps, their calls, and the stream's updates.
let reactions: ConversationReactions

/// Moves new senders to the front of the mention picker's held suggestions. Set by the session
/// after init.
@ObservationIgnored var mentionSearch: ServerMentionSearch?

init(
fetching: any ConversationFetching,
membership: any ConversationMembership,
Expand Down Expand Up @@ -408,6 +412,7 @@ final class ConversationController {
self.logCounterpartRead(event)
self.applyTyping(event)
self.applyReactions(event)
self.mentionSearch?.apply(event)
if case .needsCatchUp(let conversationID, _) = gap {
self.scheduleGapCatchUp(conversationID)
}
Expand Down
9 changes: 8 additions & 1 deletion Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ protocol ConversationFetching: AnyObject, Sendable {
func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation
}

/// Mention suggestion surface used by `ServerMentionSearch`. Maps 1:1 to
/// `flipcash.chat.v1.Chat.GetMentionSuggestions`.
protocol MentionSuggestionFetching: AnyObject, Sendable {
/// The people to suggest after an `@` in a group chat, most relevant first.
func getMentionSuggestions(owner: KeyPair, conversationID: ConversationID) async throws -> [MentionSuggestion]
}

/// Group membership surface used by `ConversationController`. Separate from ``ConversationFetching``
/// because these write: they are the only chat RPCs that change what the caller is a member of.
protocol ConversationMembership: AnyObject, Sendable {
Expand Down Expand Up @@ -166,5 +173,5 @@ extension FlipClient {
}

extension FlipClient: ContactVerifying, OnrampAuthorizing, ContactSyncing,
ConversationFetching, ConversationMembership,
ConversationFetching, ConversationMembership, MentionSuggestionFetching,
ConversationViewerSettings, ConversationEventStreaming {}
62 changes: 62 additions & 0 deletions Flipcash/Core/Controllers/MentionSearchText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// MentionSearchText.swift
// Flipcash
//

import Foundation

/// The text rules mention search matches by. Android applies the same rules, so a query matches the
/// same members on both platforms.
nonisolated enum MentionSearchText {

/// Returns `text` NFKD-decomposed, lowercased, and stripped of nonspacing marks (general category
/// `Mn`), so "Érica" and "erica" compare equal. Lowercasing after NFKD folds "İ" to "i" plus a
/// combining dot, which the strip then drops. Spacing marks (`Mc`) are kept: in scripts such as
/// Devanagari they carry the vowel.
static func normalize(_ text: String) -> String {
let folded = text.decomposedStringWithCompatibilityMapping.lowercased()
var scalars = String.UnicodeScalarView()
for scalar in folded.unicodeScalars where scalar.properties.generalCategory != .nonspacingMark {
scalars.append(scalar)
}
return String(scalars)
}

/// Returns the normalized words of `text`, split on Unicode `White_Space` and separators
/// (general category `Z`), empty words dropped.
static func words(_ text: String) -> [String] {
normalize(text)
.unicodeScalars
.split(whereSeparator: isSeparator)
.map { String(String.UnicodeScalarView($0)) }
}

/// Returns the distinct search tokens for a member: each word of the display name, plus the
/// username without its `@`.
static func tokens(displayName: String, username: String?) -> Set<String> {
var tokens = Set(words(displayName))
if let username {
tokens.formUnion(words(username).map(droppingAt).filter { !$0.isEmpty })
}
return tokens
}

/// Returns the words of a typed query, with a leading `@` (either width) dropped from each word.
static func queryWords(_ query: String) -> [String] {
words(query).map(droppingAt).filter { !$0.isEmpty }
}

// NFKD folds the fullwidth "@" to "@", so one check covers both.
private static func droppingAt(_ word: String) -> String {
word.hasPrefix("@") ? String(word.dropFirst()) : word
}

private static func isSeparator(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.properties.generalCategory {
case .spaceSeparator, .lineSeparator, .paragraphSeparator:
return true
default:
return scalar.properties.isWhitespace
}
}
}
37 changes: 37 additions & 0 deletions Flipcash/Core/Controllers/RosterSearchSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// RosterSearchSource.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

/// A group member a roster search matched.
nonisolated struct MemberMatch: Identifiable, Hashable, Sendable {
let member: ConversationMember
let id: UserID
}

/// Searches a group's members by name or username, for the mention picker.
///
/// Callers depend on this rather than on a concrete search so the source of members can change
/// without them changing.
protocol RosterSearchSource: AnyObject {
/// Readies the source to answer for a group; called when the picker opens. Returns `false` when
/// it couldn't, so the caller keeps its current results rather than searching again.
@discardableResult
func prepare(chatID: ConversationID) async -> Bool

/// Returns up to `limit` members of a group matching `query`, best match first, never a blocked
/// user. An empty query matches everyone the source holds.
func search(chatID: ConversationID, query: String, limit: Int) async throws -> [MemberMatch]
}

extension RosterSearchSource {
/// Returns up to 20 members of a group matching `query`; see ``search(chatID:query:limit:)``.
func search(chatID: ConversationID, query: String) async throws -> [MemberMatch] {
try await search(chatID: chatID, query: query, limit: 20)
}
}
154 changes: 154 additions & 0 deletions Flipcash/Core/Controllers/ServerMentionSearch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// ServerMentionSearch.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

nonisolated private let logger = Logger(label: "flipcash.mention-search")

/// Answers mention searches from the pool `Chat.GetMentionSuggestions` returns.
///
/// The pool is fetched once per composing session, when the picker first opens in a visit, then held
/// and filtered locally as the user types: a suggestion matches when every word of the query is a
/// prefix of one of its ``MentionSearchText`` tokens, and matches keep the server's order. A new
/// message moves its sender to the front of the held pool; a sender the pool doesn't hold is not
/// added, since only the server decides who is suggested.
///
/// A chat whose pool can't be fetched, or that the server answers `DENIED` or `NOT_FOUND`, matches
/// nobody; the user can still type a handle by hand.
final class ServerMentionSearch: RosterSearchSource {

private let fetching: any MentionSuggestionFetching
private let owner: KeyPair
private let blockedUserIDs: () -> Set<UserID>
/// Writes fetched profiles into the local cache so their names and avatars resolve elsewhere.
private let cache: @Sendable (Profile, UserID) throws -> Void

private var pools: [ConversationID: Pool] = [:]

private enum Pool {
/// A fetch in flight, keyed so a superseded fetch can't overwrite a newer one when it lands.
case loading(UUID, Task<[MentionSuggestion]?, Never>)
case ready([MentionSuggestion])
/// The fetch failed; searches match nobody until the next ``prepare(chatID:)``.
case unavailable
}

init(
fetching: any MentionSuggestionFetching,
owner: KeyPair,
blockedUserIDs: @escaping () -> Set<UserID> = { [] },
cache: @escaping @Sendable (Profile, UserID) throws -> Void = { _, _ in }
) {
self.fetching = fetching
self.owner = owner
self.blockedUserIDs = blockedUserIDs
self.cache = cache
}

func prepare(chatID: ConversationID) async -> Bool {
let task: Task<[MentionSuggestion]?, Never>
if case .loading(_, let inFlight) = pools[chatID] {
task = inFlight
} else {
task = load(chatID)
}
return await task.value != nil
}

func search(chatID: ConversationID, query: String, limit: Int) async throws -> [MemberMatch] {
let pool: [MentionSuggestion]?
switch pools[chatID] {
case .ready(let held):
pool = held
case .loading(_, let task):
pool = await task.value
case .unavailable:
pool = nil
case nil:
// A search that beats `prepare` starts the session's fetch, which `prepare` then joins.
pool = await load(chatID).value
}
guard let pool else { return [] }
return Self.filter(pool, query: query, limit: limit, blocked: blockedUserIDs())
}

/// Moves each new message's sender to the front of that chat's held pool, when the message is
/// newer than the last one the pool knows they sent.
func apply(_ event: ConversationStreamEvent) {
guard case .chatEvents(let chatID, let events) = event, case .ready(var pool) = pools[chatID] else { return }
var moved = false
for mutation in events.flatMap(\.mutations) {
switch mutation {
case .sent(let message):
guard
let senderID = message.senderID,
let index = pool.firstIndex(where: { $0.userID == senderID })
else { continue }
let suggestion = pool[index]
if let lastSentAt = suggestion.lastSentAt, lastSentAt >= message.date { continue }
pool.remove(at: index)
pool.insert(MentionSuggestion(userID: senderID, profile: suggestion.profile, lastSentAt: message.date), at: 0)
moved = true
case .edited, .deleted:
continue
}
}
if moved { pools[chatID] = .ready(pool) }
}

/// The suggestions in `pool` matching `query`, in pool order; an empty query matches everyone.
static func filter(_ pool: [MentionSuggestion], query: String, limit: Int, blocked: Set<UserID>) -> [MemberMatch] {
let words = MentionSearchText.queryWords(query)
return pool.lazy
.filter { !blocked.contains($0.userID) }
.filter { suggestion in
let tokens = MentionSearchText.tokens(
displayName: suggestion.profile.displayName ?? "",
username: suggestion.profile.username?.value
)
return words.allSatisfy { word in tokens.contains { $0.hasPrefix(word) } }
}
.prefix(max(limit, 0))
.map { MemberMatch(member: $0.member, id: $0.userID) }
}

private func load(_ chatID: ConversationID) -> Task<[MentionSuggestion]?, Never> {
let token = UUID()
let task = Task { [weak self, fetching, owner, cache] () -> [MentionSuggestion]? in
let pool: [MentionSuggestion]?
do {
let fetched = try await fetching.getMentionSuggestions(owner: owner, conversationID: chatID)
Task.detached {
for suggestion in fetched {
do {
try cache(suggestion.profile, suggestion.userID)
} catch {
logger.warning("Failed to cache a mention suggestion's profile", metadata: ["error": "\(error)"])
}
}
}
pool = fetched
} catch {
logger.info("Mention suggestions unavailable", metadata: [
"conversationID": "\(chatID)",
"error": "\(error)",
])
pool = nil
}
self?.settle(chatID, token: token, pool: pool)
return pool
}
pools[chatID] = .loading(token, task)
return task
}

private func settle(_ chatID: ConversationID, token: UUID, pool: [MentionSuggestion]?) {
guard case .loading(let current, _) = pools[chatID], current == token else { return }
pools[chatID] = pool.map(Pool.ready) ?? .unavailable
}
}
Loading
Loading