diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bcfd04995..48c79371c 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "281efc5cb9bd92f7030588d8763a8481befea83f7b70827fb2bc9583dfbc93b0", + "originHash" : "9cf1f63bab7508c5285a752950d9d0615460c403bbd3882506965c3d40b1dfc3", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "084ac33fa5dd5df880a23fa24c92999f9252a920", - "version" : "0.14.1" + "revision" : "a86e91bd9e42ab7cded49ad647987332ddb23aa3", + "version" : "0.15.0" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 14c344536..ddcf7be7f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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, @@ -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) } diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index 915f94229..11d14254b 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -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 { @@ -166,5 +173,5 @@ extension FlipClient { } extension FlipClient: ContactVerifying, OnrampAuthorizing, ContactSyncing, - ConversationFetching, ConversationMembership, + ConversationFetching, ConversationMembership, MentionSuggestionFetching, ConversationViewerSettings, ConversationEventStreaming {} diff --git a/Flipcash/Core/Controllers/MentionSearchText.swift b/Flipcash/Core/Controllers/MentionSearchText.swift new file mode 100644 index 000000000..c74f606d6 --- /dev/null +++ b/Flipcash/Core/Controllers/MentionSearchText.swift @@ -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 { + 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 + } + } +} diff --git a/Flipcash/Core/Controllers/RosterSearchSource.swift b/Flipcash/Core/Controllers/RosterSearchSource.swift new file mode 100644 index 000000000..1428232f6 --- /dev/null +++ b/Flipcash/Core/Controllers/RosterSearchSource.swift @@ -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) + } +} diff --git a/Flipcash/Core/Controllers/ServerMentionSearch.swift b/Flipcash/Core/Controllers/ServerMentionSearch.swift new file mode 100644 index 000000000..5ba3ebfd2 --- /dev/null +++ b/Flipcash/Core/Controllers/ServerMentionSearch.swift @@ -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 + /// 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 = { [] }, + 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) -> [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 + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 9b071d1f8..aed97cd68 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -112,6 +112,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onGateJoin: () -> Void /// Whether a join is in flight; the gate panel's button stops taking taps. let isJoiningChat: Bool + /// The group's mention picker, or `nil` in a DM. + let mentions: MentionPickerModel? /// Avatar bytes for the group's members, keyed by user id. Empty in a DM, and empty for a group /// until the pictures download — the rows fall back to a BlurHash, then a monogram. let authorAvatars: [UserID: Data] @@ -160,6 +162,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onReactionStripSelect = onReactionStripSelect screen.onReactionStripAdd = onReactionStripAdd screen.onCancelEdit = { [composer] in composer.endEditing() } + screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.reportsReads = reportsReads screen.update(items: items) @@ -205,6 +208,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onReactionStripSelect = onReactionStripSelect screen.onReactionStripAdd = onReactionStripAdd screen.onCancelEdit = { [composer] in composer.endEditing() } + screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.reportsReads = reportsReads // The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it @@ -280,12 +284,18 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { gateMintName: gateMintName, onGateAddFunds: onGateAddFunds, onGateJoin: onGateJoin, - isJoiningChat: isJoiningChat + isJoiningChat: isJoiningChat, + mentions: mentions ) .environment(conversationController) .modifier( - MeasuredBarHeight(isReplying: composer.replyTarget != nil) { height, isReplying in - coordinator.screen?.setBarHeight(height, replying: isReplying) + MeasuredBarHeight { height, accessories in + // After the update that measured it, not inside it. Resized inside an animated + // update, the host's new size joins that transaction: SwiftUI springs the root + // toward it and centres it meanwhile, lifting the whole bar by half the change. + DispatchQueue.main.async { + coordinator.screen?.setBarHeight(height, accessories: accessories) + } } ) ) @@ -305,27 +315,24 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// multiline height — the hosting controller's intrinsic size mis-measures and lets the composer /// overflow under the keyboard. private struct MeasuredBarHeight: ViewModifier { - /// Whether a reply is open, reported alongside the height. The bar's clip decides what to do - /// with a height from the two together, so this has to arrive from the same layout pass rather - /// than be read separately afterwards. - let isReplying: Bool - let report: (CGFloat, Bool) -> Void - - /// The last height reported, so the reply can be reported without one. A reply that closes does - /// not change the bar's height — the strip stays mounted underneath while it fades — and the - /// clip still has to be told to close over it. - @State private var measured: CGFloat = 0 + /// Receives the height with the cards above the composer row. The clip decides what to do with + /// a height from the two together, so they arrive as one value from the same layout pass: a card + /// that opens changes both, and two separate reports would read as growth and then an opening + /// with nothing left to travel. + let report: (CGFloat, BarAccessories) -> Void func body(content: Content) -> some View { content .fixedSize(horizontal: false, vertical: true) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in - measured = height - report(height, isReplying) - } - .onChange(of: isReplying) { _, replying in - guard measured > 0 else { return } - report(measured, replying) + .overlayPreferenceValue(BarAccessoriesKey.self) { accessories in + GeometryReader { proxy in + Color.clear.onChange( + of: BarReport(height: proxy.size.height, accessories: accessories), + initial: true + ) { _, new in + report(new.height, new.accessories) + } + } } // Sit on the host's bottom edge rather than in the middle of it. The two heights are // never equal mid-change: SwiftUI ramps the content's own height on its spring while the @@ -336,3 +343,9 @@ private struct MeasuredBarHeight: ViewModifier { .frame(maxHeight: .infinity, alignment: .bottom) } } + +/// One measurement of the bar: its height and the cards that make it up. +private struct BarReport: Equatable { + let height: CGFloat + let accessories: BarAccessories +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index bb50e3960..1f75f8e53 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -7,6 +7,7 @@ import Foundation import Observation +import SwiftUI import FlipcashCore /// What the composer is writing, and the text it holds. Editing borrows the same field as a new @@ -41,6 +42,11 @@ final class ComposerModel { private(set) var mode: Mode = .new var draft = "" + /// The field's cursor or selection, while it has focus. + /// + /// Cleared wherever the draft is replaced wholesale: its indices belong to the text it was + /// taken from. + var selection: TextSelection? /// The unsent new-message draft, held while an edit occupies the field. @ObservationIgnored private var stashedDraft = "" @@ -113,6 +119,7 @@ final class ComposerModel { func restore(_ stored: ChatDraft) { guard case .new = mode, draft.isEmpty else { return } draft = stored.text + selection = nil if let target = stored.replyTarget { mode = .replying(to: ReplyTarget(target)) } @@ -130,6 +137,7 @@ final class ComposerModel { mode = .editing(messageID: messageID, stableID: stableID) originalText = currentText draft = currentText + selection = nil } /// Leaves editing and restores the stashed draft. @@ -138,6 +146,7 @@ final class ComposerModel { mode = .new originalText = "" draft = stashedDraft + selection = nil stashedDraft = "" } @@ -166,5 +175,20 @@ final class ComposerModel { originalText = "" stashedDraft = "" draft = "" + selection = nil + } + + /// The `@word` at the cursor that the mention picker searches for, or `nil` when it is closed. + var mentionQuery: MentionQuery? { + MentionTrigger.query(in: draft, selection: selection) + } + + /// Replaces the `@word` at the cursor with `@username ` and puts the cursor after it, as if it + /// had been typed. + func insertMention(username: String) { + guard let query = mentionQuery else { return } + let result = MentionTrigger.inserting(username: username, replacing: query, in: draft) + draft = result.text + selection = TextSelection(insertionPoint: result.cursor) } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index d43c64966..1a60664c9 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -23,6 +23,7 @@ struct ComposerReplyStrip: View { let target: ComposerModel.ReplyTarget let onDismiss: () -> Void + @Environment(\.barCardCollapsed) private var collapsed /// Wider than the 4pt a blockquote rule usually takes, because the quote's corner radius is the /// bar's 14: the leading edge is straight for only `contentHeight - 14 * 2` of its run, and the @@ -43,8 +44,8 @@ struct ComposerReplyStrip: View { private static let flagDiameter: CGFloat = 16 var body: some View { - let rule = ComplementaryPalette.color(.start, for: target.authorID) - let name = ComplementaryPalette.color(.middle, for: target.authorID) + // One colour for the rule and the name, so the rule keeps the name's contrast on the surface. + let name = ComplementaryPalette.nameColor(for: target.authorID) HStack(alignment: .center, spacing: Self.gutter) { VStack(alignment: .leading, spacing: 2) { @@ -95,7 +96,7 @@ struct ComposerReplyStrip: View { // centred inside `contentHeight` — leaving the rule a fraction short at both ends. // ``QuoteGround`` rounds off the two corners it passes. .overlay(alignment: .leading) { - QuoteRule(color: rule) + QuoteRule(color: name) .frame(width: Self.ruleWidth) } .modifier(QuoteGround()) @@ -103,8 +104,8 @@ struct ComposerReplyStrip: View { // One margin all the way round: the quote sits ``inset`` from the bar's top edge and the same // distance off the controls below. The bar already pads its own row, so only the remainder is // added here — padding both by ``inset`` would leave the gap underneath twice the one above. - .padding(.top, Self.inset) - .padding(.bottom, Self.inset - BarMetrics.contentPadding) + .padding(.top, collapsed ? 0 : Self.inset) + .padding(.bottom, collapsed ? 0 : Self.inset - BarMetrics.contentPadding) .accessibilityElement(children: .contain) .accessibilityIdentifier("composer-reply-strip") } @@ -183,6 +184,6 @@ private struct QuoteGround: ViewModifier { // The background form, not the wrapping one: the glass has to stay outside the clip. content .clipShape(Self.shape) - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + .modifier(BarCardGlass(id: .reply)) } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 1b4e0e2c3..4d5c6a6ad 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -75,6 +75,19 @@ struct ConversationBottomBar: View { var onGateJoin: () -> Void = {} /// Whether a join is in flight, so the gate panel's button can stop taking taps. var isJoiningChat: Bool = false + /// The group's mention picker. `nil` in a DM, which never offers one. + var mentions: MentionPickerModel? = nil + + /// The results the mention list draws: the model's, taken in through `onChange` so a new search + /// replacing an open list can land inside one animation that covers the whole bar. + @State private var listedCandidates: [ConversationMember] = [] + /// The reply as the bar draws it. Under an open mention list it changes in one transaction, so + /// the strip and the list's row cap move on one spring; elsewhere it is the composer's own. + @State private var listedReply: ComposerModel.ReplyTarget? + + /// The composer row's measured height, reported as part of what the mention list's room is + /// measured without. + @State private var composerRowHeight: CGFloat = 0 /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) @@ -147,6 +160,10 @@ struct ConversationBottomBar: View { .transition(.opacity) } } + // The Send Cash button morphs on the reply's spring. Inside the row's padding, so it covers + // the button and the field beside it but not where the row sits: animated there, the row + // slid from its old place every time a card opened above it and moved it down the stack. + .animation(replySpring, value: composer.replyTarget) .padding(.horizontal, BarMetrics.edgeInset) .padding(.horizontal, compactExtraInset) .animation(Self.widthSpring, value: isCompact) @@ -161,16 +178,84 @@ struct ConversationBottomBar: View { // The Send Cash button and the field are separate pills 10pt apart, so // they don't need to sample each other. return VStack(spacing: 0) { + BarCardGlassContainer { + // The list rides the reply strip's reveal: same clip travel in, same fade out. + // Under an open reply the list grows out of the strip's glass rather than under the clip. + AccessoryReveal(kind: .mentions, item: mentionCandidates, collapsesInPlace: barReply != nil) { candidates in + MentionSuggestionList( + candidates: candidates, + maxRows: MentionRowCap.rows( + replyOpen: replyOpen, + room: mentions?.room, + rowHeight: MentionListMetrics.rowHeight, + divider: MentionListMetrics.dividerHeight, + chrome: MentionListMetrics.chrome(replyOpen: replyOpen) + ), + replyOpen: replyOpen + ) { member in + guard let username = member.username else { return } + composer.insertMention(username: username.value) + } + } + .padding(.horizontal, compactExtraInset) + .animation(Self.widthSpring, value: isCompact) // No `withAnimation` at the dismiss site either: the `.animation(_, value:)` below // already drives this state in both directions, and wrapping the dismissal in a second // transaction gave the exit a curve the entry never had. - ComposerReplyReveal(target: composer.replyTarget) { composer.endReplying() } + AccessoryReveal(kind: .reply, item: barReply, collapsesInPlace: mentionCandidates != nil) { target in + ComposerReplyStrip(target: target) { composer.endReplying() } + } // The quote narrows with the row below it, so the two keep one margin. .padding(.horizontal, compactExtraInset) .animation(Self.widthSpring, value: isCompact) + } content + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { composerRowHeight = $0 } + .preference(key: BarAccessoriesKey.self, value: BarAccessories(restHeight: composerRowHeight)) } - .animation(replySpring, value: composer.replyTarget) + .onChange(of: mentions == nil ? nil : composer.mentionQuery?.text, initial: true) { _, query in + mentions?.update(query: query) + } + .onChange(of: mentions?.candidates ?? [], initial: true) { old, new in + // An open list changing size is the glass changing shape, so the whole bar moves in one + // transaction: the card, where the stack places it and where the host places the stack. + // Animated any narrower — on the card alone — the outer placement snapped while the card's + // size ramped, and the card's bottom lifted off the composer by a row and settled back. + // Opening and closing stay unanimated; the clip's edge carries those. + // Under an open reply strip, opening and closing are shape changes too: the list splits + // out of the strip's glass and merges back into it. + guard (!old.isEmpty && !new.isEmpty) || barReply != nil else { + listedCandidates = new + return + } + withAnimation(replySpring) { listedCandidates = new } + } + .onChange(of: composer.replyTarget, initial: true) { _, target in + // Under an open list, the strip appearing and the list giving up a row are one shape + // change. Apart, the list's card resized at once inside its animating frame and its top + // edge snapped by a row before the spring moved it. + guard mentionCandidates != nil else { + listedReply = target + return + } + withAnimation(target == nil ? ChatMotion.replyMerge.animation : replySpring) { listedReply = target } + } + } + + /// Whether a reply strip stands under the mention list. + private var replyOpen: Bool { barReply != nil } + + /// The reply the strip and the list's row cap follow. With the list closed it is the + /// composer's own, so the clip still uncovers the strip in the update that aims it. + private var barReply: ComposerModel.ReplyTarget? { + mentionCandidates == nil ? composer.replyTarget : listedReply + } + + /// What the mention list shows, or `nil` while it is closed. Tied to the live query as well as + /// the model, so a send or a pick closes it in the same update rather than after the search. + private var mentionCandidates: [ConversationMember]? { + guard mentions != nil, composer.mentionQuery != nil, !listedCandidates.isEmpty else { return nil } + return listedCandidates } /// The tip CTA's title. Names the amount that opens the chat when a floor @@ -182,8 +267,8 @@ struct ConversationBottomBar: View { } } -/// The reply strip's arrival and departure: the quote the bar's top edge uncovers on the way in and -/// closes back over on the way out. +/// A card's arrival and departure above the composer row — the reply strip or the mention list: +/// what the bar's top edge uncovers on the way in and closes back over on the way out. /// /// The travel itself is not here. The bar is hosted in a box that clips it, and that box's edge is /// what moves — see `ChatScreenViewController`'s `barClip`. This view only decides *what* height the @@ -196,10 +281,28 @@ struct ConversationBottomBar: View { /// .opacity)` slid the quote down behind the field and dissolved it there while the edge travelled /// separately. Clipping welds them — the quote holds still against the field below it while the /// edge uncovers it. -private struct ComposerReplyReveal: View { - - let target: ComposerModel.ReplyTarget? - let onDismiss: () -> Void +private struct AccessoryReveal: View { + + /// Which card this is, as the bar reports it to the clip. + let kind: BarAccessories.Kind + let target: Item? + /// Whether this card opens and closes by changing its own height instead of under the clip's + /// edge. The edge only ever moves at the top of the bar, so a card with another one open above + /// it has to resize itself: closed by the edge, the card above was cut away instead. + let collapsesInPlace: Bool + @ViewBuilder let card: (Item) -> Card + + init( + kind: BarAccessories.Kind, + item: Item?, + collapsesInPlace: Bool = false, + @ViewBuilder card: @escaping (Item) -> Card + ) { + self.kind = kind + self.target = item + self.collapsesInPlace = collapsesInPlace + self.card = card + } /// The strip's own height. Measured rather than declared: a snippet that wraps to a second line /// makes the sheet taller, and the clip has to know by how much. @@ -210,7 +313,7 @@ private struct ComposerReplyReveal: View { @State private var naturalHeight: CGFloat = 0 /// The last target seen, kept after the target clears. A strip that unmounts on the way out has /// nothing to draw while it collapses, and the sheet slides back under the field empty. - @State private var retained: ComposerModel.ReplyTarget? + @State private var retained: Item? /// The quote's own opacity, which only ever moves on the way out. /// /// Asymmetric on purpose. Coming in, the edge uncovering the quote is the whole effect and a @@ -219,6 +322,9 @@ private struct ComposerReplyReveal: View { /// bounce, which is what makes it safe to drive opacity: a spring that overshoots clamps at 0 /// and 1 and flickers. @State private var contentOpacity: CGFloat = 1 + /// Whether a list opening beside the strip has grown out of it yet. It mounts collapsed and grows + /// on the next turn; mounted at full size, the container faded its glass in where it stood. + @State private var grown = true /// What the strip draws: the live target while a reply is open, and the one it is closing over /// afterwards. @@ -231,36 +337,83 @@ private struct ComposerReplyReveal: View { /// there is no `onChange` to fire and be in place before `onAppear` would set anything. Latched, /// a missed transition was also unrecoverable: `ReplyTarget` is `Equatable`, so aiming at the /// same message again is not a change and `onChange` never fires for it twice. - private var shown: ComposerModel.ReplyTarget? { target ?? retained } + /// + /// Beside another open card there is no clip to close over, and the retained copy collapses its + /// own glass down into the composer instead — see `collapsing`. + private var shown: Item? { target ?? retained } + + /// Whether this card is closing beside another open one: its glass shrinks toward the composer + /// while the card beside it grows into the room. Removed outright instead, the glass dissolved + /// in place and the other card snapped to its new size. + private var collapsing: Bool { collapsesInPlace && (target == nil || !grown) } + + /// Whether this is the strip closing beside the open list. It keeps its shape and fades out, + /// glass and all, while the list grows down over the room it gives up. Shrunk into the composer + /// instead, the strip's glass was squeezed into a sliver. + private var fadingOut: Bool { kind == .reply && collapsing } + + /// Whether the card takes its own height instead of the measured one. Every change that isn't + /// the clip's to reveal is drawn by the card resizing, so its glass changes shape and the rows + /// inside keep their places. Held at the measured height, a list gaining a row as the strip + /// left was drawn into the old frame and showed its last rows until the frame caught up. + private var sizesItself: Bool { + collapsesInPlace || (target != nil && kind == .mentions && naturalHeight > 0) + } /// How much height the strip is asking the bar for. Zero until it has been measured, and held at /// full height right through the exit — the clip closes over the quote, so there has to be a /// quote there to close over. - private var revealHeight: CGFloat { shown == nil ? 0 : naturalHeight } + private var revealHeight: CGFloat { + guard shown != nil, !(collapsesInPlace && target == nil) else { return 0 } + return naturalHeight + } var body: some View { Group { if let shown { - ComposerReplyStrip(target: shown, onDismiss: onDismiss) + card(shown) + // Faded inside the glass, not around it: a glass container draws its cards + // itself, and an opacity outside the card never reached the quote, which stayed + // drawn across the list's last row through the whole merge. + .environment(\.barCardContentOpacity, contentOpacity) + .environment(\.barCardCollapsed, collapsing && !fadingOut) + .environment(\.barCardFading, fadingOut) .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { measured in guard naturalHeight != measured else { return } - naturalHeight = measured + // Drawn in place, the frame below follows the card in one transaction for the + // whole bar, for the reason in `ConversationBottomBar`'s `onChange(of: + // candidates)`: the mention list resizing while open, or the reply strip opening + // under it. Anything else is the clip's to reveal and stays unanimated. + let inPlace = target != nil + && (collapsesInPlace || (kind == .mentions && naturalHeight > 0)) + withAnimation(inPlace ? replySpring : nil) { naturalHeight = measured } } + // Out of the layout but still drawn, hung above the composer, so the list's + // lower edge travels down over it as it fades. A measured height rather than + // nil while open, so the step to zero is one the spring can interpolate: from + // nil it snapped, and the list jumped down in a frame instead of growing. + .frame(height: fadingOut ? 0 : (naturalHeight > 0 ? naturalHeight : nil), alignment: .bottom) } } // The strip keeps its own height whatever the frame below proposes, so the frame can clip it // instead of squashing it. .fixedSize(horizontal: false, vertical: true) - .frame(height: revealHeight, alignment: .top) - .clipped() - // Deliberately unanimated, and inside the bar's own `.animation(replySpring, - // value: replyTarget)` so it wins: this height is the bar's *requirement*, not the motion. - // The clip around the bar travels it, on that same spring, in UIKit. - .animation(nil, value: revealHeight) - .opacity(contentOpacity) + // Bottom-aligned, so the card's lower edge stays welded to the composer whatever this frame + // does. The card and this frame resize on the same spring but a measurement apart, and with + // the card hung from the top, any gap between the two showed as its bottom edge lifting off + // the composer and settling back. + .frame(height: sizesItself ? nil : revealHeight, alignment: .bottom) + .clipShape(RevealClip(overhang: fadingOut ? naturalHeight : 0)) + // Opening and closing under the clip stay unanimated, and innermost so they win if another + // change lands with them: there this height is the bar's *requirement*, not the motion, and + // the clip around the bar travels it in UIKit. A card under the open mention list has no + // clip edge to ride, so it draws its own open and close. + .animation(collapsesInPlace ? replySpring : nil, value: shown == nil) + .animation(collapsesInPlace ? replySpring : nil, value: target == nil) // `clipped()` is a drawing bound, not a hit-testing or accessibility one, so the collapsed // copy stays pressable and findable until the task below unmounts it. .allowsHitTesting(target != nil) + .preference(key: BarAccessoriesKey.self, value: report) // Drop the retained copy once it has finished sliding back under the field. It has to // outlive the target — there is nothing to draw during the collapse otherwise — but only by // the length of the collapse: left mounted, it leaves a zero-height quote in the @@ -275,17 +428,28 @@ private struct ComposerReplyReveal: View { // `.task(id:)` cancels on the next change, so replying again mid-collapse keeps its strip. .task(id: target) { guard target == nil, retained != nil else { return } - try? await Task.sleep(for: .seconds(ChatMotion.replySurface.duration)) + // A strip fading beside the list stays until the list has settled over it: its glass, + // joined to the list's, draws the bottom edge until it goes, and dropped at the spring's + // perceptual length it left the list's own edge 3pt short, creeping down after the strip + // was gone. + let stay = fadingOut ? ChatMotion.replySurface.duration * 1.6 : ChatMotion.replySurface.duration + try? await Task.sleep(for: .seconds(stay)) retained = nil // Back to opaque with nothing mounted, so the next reply starts from a clean state // rather than fading in from wherever the last exit left it. contentOpacity = 1 } - .onChange(of: target) { _, newValue in + .onChange(of: target) { oldValue, newValue in guard let newValue else { close() return } + if oldValue == nil, collapsesInPlace, kind == .mentions { + grown = false + Task { @MainActor in + withAnimation(replySpring) { grown = true } + } + } // Only for the exit: `shown` already draws the live target. This is the copy the clip // closes over once the target is gone. retained = newValue @@ -297,10 +461,161 @@ private struct ComposerReplyReveal: View { .onAppear { retained = target } } + /// What this card tells the clip: open once it has a height to open by, and closing for as + /// long as its retained copy is still mounted underneath. + private var report: BarAccessories { + var accessories = BarAccessories() + if target != nil, naturalHeight > 0 { + accessories.open = [kind] + } else if target == nil, retained != nil, !collapsesInPlace { + accessories.exitingHeight = naturalHeight + } + // Zero once it is collapsing beside the list: the room already has it back. + if kind == .reply { accessories.restHeight = revealHeight } + return accessories + } + /// The quote dissolves; its height stays. The clip is what closes over it, and it needs /// something to close over — the height goes back with the retained copy above. private func close() { - withAnimation(ChatMotion.replySurface.animation) { contentOpacity = 0 } + // The list's rows sit in a scroll view, which escapes the card's clip while the card + // collapses and drew the rows across the strip and the composer. Only its glass shrinks. + if collapsesInPlace, kind == .mentions { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { contentOpacity = 0 } + return + } + // The strip beside the open list leaves the glass container in the close's own animation, + // which draws it flowing back into the list along a neck: the peel run in reverse. + if collapsesInPlace, kind == .reply { + withAnimation(ChatMotion.replyMerge.animation) { retained = nil } + return + } + // Beside the list the quote goes well before its glass: the list grows over the strip as + // it fades, and the quote's text showed through under the list's new row. + withAnimation(fadingOut ? .easeOut(duration: 0.14) : ChatMotion.replySurface.animation) { + contentOpacity = 0 + } + } +} + +/// The cards above the composer row, gathered for the bar's measurement. +extension EnvironmentValues { + /// The namespace the bar's cards share their glass in, so one can split out of another. + @Entry var barCardGlassNamespace: Namespace.ID? = nil + /// How far a bar card's content has faded, applied beneath its glass. + @Entry var barCardContentOpacity: CGFloat = 1 + /// Whether a bar card is closing into the composer beside another open card, its glass and its + /// margins shrinking to nothing. + @Entry var barCardCollapsed: Bool = false + /// Whether a bar card is fading out whole, its glass included, beside another open card. + @Entry var barCardFading: Bool = false +} + +/// The reveal's clip, open above by `overhang` so a card fading out of the layout stays drawn. +nonisolated private struct RevealClip: Shape { + var overhang: CGFloat + + func path(in rect: CGRect) -> Path { + Path(CGRect(x: rect.minX, y: rect.minY - overhang, width: rect.width, height: rect.height + overhang)) + } +} + +/// Where the bar's cards keep their glass: one container, so a card opening beside another peels +/// off its surface instead of fading in, and merges back into it on the way out. +/// +/// The composer stays outside it. Its glass is a background behind an editable field, and a +/// container composites glass above sibling content. +private struct BarCardGlassContainer: View { + @Namespace private var namespace + @ViewBuilder let content: Content + + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: BarCardGlass.spacing) { + VStack(spacing: 0) { content } + } + .environment(\.barCardGlassNamespace, namespace) + } else { + VStack(spacing: 0) { content } + } + } +} + +/// A bar card's Liquid Glass, joined to the cards beside it when it has a container to share. +struct BarCardGlass: ViewModifier { + + /// How close two cards' glass has to come to flow together: the 12pt gap the cards rest at, so + /// a splitting card stays joined by a bridge across its whole travel and pinches off as it + /// settles. At 8pt the bridge broke in the first frames and the split read as a plain fade. + static let spacing: CGFloat = 12 + + let id: BarAccessories.Kind + @Environment(\.barCardGlassNamespace) private var namespace + @Environment(\.barCardContentOpacity) private var contentOpacity + @Environment(\.barCardCollapsed) private var collapsed + @Environment(\.barCardFading) private var fading + + /// How fast a card fading out beside another goes: the length of the reply spring, so the exit + /// takes as long as the peel it reverses. At 0.2s it read as faster than the enter. + private static let fade: Animation = .easeInOut(duration: ChatMotion.replySurface.duration) + + func body(content: Content) -> some View { + if #available(iOS 26, *), let namespace { + // Applied to the content, not as a background: a container draws background glass over + // the content beside it. + content + .collapsingCard(collapsed) + .opacity(contentOpacity) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: BarMetrics.cornerRadius)) + .glassEffectID(glassID, in: namespace) + .glassEffectTransition(.matchedGeometry) + .opacity(fading ? 0 : 1) + .animation(Self.fade, value: fading) + } else { + content + .collapsingCard(collapsed) + .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + .opacity(fading ? 0 : contentOpacity) + .animation(Self.fade, value: fading) + } + } + + /// A plain string, since `Kind`'s `Hashable` conformance is main-actor isolated and the glass + /// wants a `Sendable` one. + private var glassID: String { + switch id { + case .mentions: "mentions" + case .reply: "reply" + } + } +} + +private extension View { + /// How fast a collapsing card's content goes, well inside the shape's spring. + static var collapseFade: Animation { .easeOut(duration: 0.1) } + + /// Shrinks the card's shape to nothing against its lower edge, so the glass drawn around it + /// sinks into the composer rather than being cut off by a frame outside it. + /// + /// The content keeps its own height and is clipped, not laid out into the shrinking frame: + /// squeezed, the quote reflowed to one line and drew its author's name across it. + func collapsingCard(_ collapsed: Bool) -> some View { + fixedSize(horizontal: false, vertical: true) + // Gone before the shape is: faded on the shape's spring, the quote and its close button + // stayed drawn across the thinning sliver. + .opacity(collapsed ? 0 : 1) + .animation(Self.collapseFade, value: collapsed) + .frame(height: collapsed ? 0 : nil, alignment: .bottom) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + } +} + +struct BarAccessoriesKey: PreferenceKey { + static let defaultValue = BarAccessories() + static func reduce(value: inout BarAccessories, nextValue: () -> BarAccessories) { + value = value.merged(with: nextValue()) } } @@ -320,7 +635,7 @@ struct ConversationComposer: View { var body: some View { let field = HStack(alignment: .bottom, spacing: 10) { - TextField(fieldPrompt, text: $composer.draft, axis: .vertical) + TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) .font(.appTextMessage) .foregroundStyle(Color.textMain) .tint(.white) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 16787f3a0..0f8cab4ee 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -72,6 +72,8 @@ struct ConversationScreen: View { @State private var restoredDraftID: ConversationID? @State private var barModel = ConversationBarModel() @State private var composer = ComposerModel() + /// The group's mention picker for this visit, or `nil` in a DM. + @State private var mentions: MentionPickerModel? /// Seeded from the scene so the title has its full width on the first frame; the measurement /// below only corrects it. @State private var navBarWidth: CGFloat = UIApplication.shared.firstWindowScene?.coordinateSpace.bounds.width ?? 0 @@ -531,6 +533,7 @@ struct ConversationScreen: View { onGateAddFunds: addFunds, onGateJoin: joinChat, isJoiningChat: isJoiningChat, + mentions: mentions, authorAvatars: authorAvatars ) } @@ -690,6 +693,10 @@ struct ConversationScreen: View { guard groupConversation != nil else { return } await sessionContainer.knownAuthors.reload() } + // One picker per visit, so its suggestion fetch runs once per visit. + .onChange(of: groupConversation?.id, initial: true) { _, groupID in + mentions = groupID.map { MentionPickerModel(source: sessionContainer.rosterSearch, chatID: $0) } + } // Name the senders the chat's roster and the local cache both leave out. Keyed on that set, // so it runs when a page of older messages reveals a sender nothing here can name — and not // again once the fetch has landed them. diff --git a/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift b/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift new file mode 100644 index 000000000..11e59db82 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift @@ -0,0 +1,84 @@ +// +// MentionPickerModel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashCore +import CoreGraphics +import Observation + +/// The members a group chat's mention picker offers for the `@word` being typed, for one visit to +/// the chat. +@MainActor @Observable +final class MentionPickerModel { + + /// The most matches asked of the search. The list shows a few and scrolls through the rest. + static let searchLimit = 20 + + /// The members to offer, best first. Empty while the picker is closed or nothing matches. + private(set) var candidates: [ConversationMember] = [] + + /// The transcript's height without the list, as the chat screen last measured it. + var room: CGFloat? + + @ObservationIgnored private let source: any RosterSearchSource + @ObservationIgnored private let chatID: ConversationID + /// The text after the `@` being searched for, or `nil` while the picker is closed. + @ObservationIgnored private var query: String? + @ObservationIgnored private var didRefresh = false + + /// The search for the current query, cancelled when the query changes. + @ObservationIgnored private(set) var searchTask: Task? + /// The once-per-visit prepare of the source, which re-runs the current query when it lands. + @ObservationIgnored private(set) var refreshTask: Task? + + init(source: any RosterSearchSource, chatID: ConversationID) { + self.source = source + self.chatID = chatID + } + + deinit { + searchTask?.cancel() + refreshTask?.cancel() + } + + /// Searches for `query`, the text after the `@`, or closes the picker when it is `nil`. + func update(query: String?) { + guard query != self.query else { return } + self.query = query + searchTask?.cancel() + guard let query else { + searchTask = nil + if !candidates.isEmpty { candidates = [] } + return + } + if !didRefresh { + didRefresh = true + refreshTask = Task { [source, chatID] in + // A failed refresh changed nothing, so searching again would too. + guard await source.prepare(chatID: chatID), !Task.isCancelled else { return } + // New joiners appear without another keystroke; a picker closed meanwhile stays closed. + self.rerun() + } + } + run(query) + } + + private func rerun() { + guard let query else { return } + searchTask?.cancel() + run(query) + } + + private func run(_ query: String) { + searchTask = Task { [source, chatID] in + let matches = (try? await source.search(chatID: chatID, query: query, limit: Self.searchLimit)) ?? [] + guard !Task.isCancelled, self.query == query else { return } + // A pick writes `@username`, so a member without one has nothing to insert. + let members = matches.map(\.member).filter { $0.username != nil } + if members != candidates { candidates = members } + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift new file mode 100644 index 000000000..56d940f2d --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -0,0 +1,143 @@ +// +// MentionSuggestionList.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The mention list's measurements, kept together for the design pass. +enum MentionListMetrics { + /// One row's height, the same as the composer row's content. + static let rowHeight: CGFloat = BarMetrics.contentHeight + /// The margin around the list's ground, matching the reply strip's. + static let inset: CGFloat = 12 + /// The row's horizontal padding inside the ground. + static let rowPadding: CGFloat = 14 + /// The gap between a row's avatar, name and handle. + static let rowSpacing: CGFloat = 10 + static let avatarDiameter: CGFloat = 32 + /// The rule between rows, none after the last. + static let dividerHeight: CGFloat = 1 + /// The rule's leading inset, lining it up with the name rather than the avatar. + static let dividerInset: CGFloat = rowPadding + avatarDiameter + rowSpacing + static let dividerColor = Color.white.opacity(0.1) + /// The handle's colour, iOS's dark secondary label, which reads over the glass where + /// `textSecondary` goes muddy. + static let usernameColor = Color(red: 235 / 255, green: 235 / 255, blue: 245 / 255).opacity(0.6) + /// The fill under a row while it's pressed, iOS's dark tertiary fill. + static let pressedFill = Color(red: 118 / 255, green: 118 / 255, blue: 128 / 255).opacity(0.24) + + /// The list's height showing `rows` rows. + static func listHeight(rows: Int) -> CGFloat { + MentionRowCap.listHeight(rows: rows, rowHeight: rowHeight, divider: dividerHeight) + } + + /// The padding under the list: none above a reply strip, whose own top margin separates the two; + /// the strip's remainder above the composer row, so the gap matches the strip's. + static func bottomPadding(replyOpen: Bool) -> CGFloat { + replyOpen ? 0 : inset - BarMetrics.contentPadding + } + + /// What the list adds to the bar besides its rows. + static func chrome(replyOpen: Bool) -> CGFloat { + inset + bottomPadding(replyOpen: replyOpen) + } +} + +/// The members matching the `@word` being typed, in the reply strip's style: the same glass ground, +/// corner radius and margins, hairline dividers, and a scroll past `maxRows`. +struct MentionSuggestionList: View { + + let candidates: [ConversationMember] + let maxRows: Int + let replyOpen: Bool + let onPick: (ConversationMember) -> Void + @Environment(\.barCardCollapsed) private var collapsed + + private var visibleRows: Int { min(maxRows, candidates.count) } + + var body: some View { + ScrollView { + // Eager, not lazy: a lazy row built while the card grows was laid out inside the + // animation, so it and its divider slid in from the wrong place. Results cap at 20. + VStack(spacing: 0) { + // Each row carries the divider above it, so a filtered-out row takes its own + // divider with it instead of leaving one behind on its neighbour. + ForEach(Array(candidates.enumerated()), id: \.element.id) { index, member in + VStack(spacing: 0) { + if index > 0 { + Rectangle() + .fill(MentionListMetrics.dividerColor) + .frame(height: MentionListMetrics.dividerHeight) + .padding(.leading, MentionListMetrics.dividerInset) + } + Button { onPick(member) } label: { MentionRow(member: member) } + .buttonStyle(MentionRowButtonStyle()) + } + } + } + // A new search swaps the rows in one step. Animated, a row that kept its place in the + // results slid through the slots of the rows coming and going, and two names shared one + // cell while it passed. + .animation(nil, value: candidates.map(\.id)) + } + .scrollBounceBehavior(.basedOnSize) + .frame(height: MentionListMetrics.listHeight(rows: visibleRows)) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .modifier(BarCardGlass(id: .mentions)) + .padding(.horizontal, MentionListMetrics.inset) + .padding(.top, collapsed ? 0 : MentionListMetrics.inset) + .padding(.bottom, collapsed ? 0 : MentionListMetrics.bottomPadding(replyOpen: replyOpen)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mention-suggestion-list") + } +} + +private struct MentionRow: View { + @Environment(SessionContainer.self) private var sessionContainer + + let member: ConversationMember + + var body: some View { + HStack(spacing: MentionListMetrics.rowSpacing) { + ContactAvatarView( + id: member.id, + displayName: member.displayName, + imageData: sessionContainer.profileAvatars.data(for: member.userID), + blurhash: member.profilePicture?.thumbnailBlurhash, + size: MentionListMetrics.avatarDiameter + ) + Text(member.displayName) + .font(.appTextHeading) + .foregroundStyle(ComplementaryPalette.nameColor(for: member.userID)) + .lineLimit(1) + if let username = member.username { + Text("@\(username.value)") + .font(.default(size: 14, weight: .medium)) + .foregroundStyle(MentionListMetrics.usernameColor) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, MentionListMetrics.rowPadding) + .frame(height: MentionListMetrics.rowHeight) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("mention-suggestion-\(member.username?.value ?? "")") + .task(id: member.profilePicture) { + await sessionContainer.profileAvatars.load(userID: member.userID, picture: member.profilePicture) + } + } +} + +/// A row's pressed state: the tertiary fill across the row's full width, under the glass's content. +private struct MentionRowButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? MentionListMetrics.pressedFill : Color.clear) + } +} diff --git a/Flipcash/Core/Screens/Conversation/MentionTrigger.swift b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift new file mode 100644 index 000000000..0d68b296e --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift @@ -0,0 +1,111 @@ +// +// MentionTrigger.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI + +/// The `@word` a mention picker is searching for: where it sits in the draft and what follows its `@`. +nonisolated struct MentionQuery: Equatable, Sendable { + + /// The word's range in the draft, `@` included. + let range: Range + + /// The text after the `@`, as typed. + let text: String +} + +/// Decides whether the draft is asking for a mention, and writes a picked one back into it. +nonisolated enum MentionTrigger { + + /// The characters that open the picker. The fullwidth form counts too, for parity with Android. + static let triggers: Set = ["@", "@"] + + /// The mention being typed at `selection`, or `nil` when the picker should be closed. + /// + /// Open only for a collapsed cursor at the end of a whitespace-free word that starts with a + /// trigger. The word stops at whitespace, so its `@` always stands at the start of the text or + /// after whitespace, and `a@b` is not a mention. + static func query(in text: String, selection: TextSelection?) -> MentionQuery? { + guard let selection else { return nil } + switch selection.indices { + case .selection(let range): + guard range.isEmpty else { return nil } + return query(in: text, cursor: range.lowerBound) + case .multiSelection: + return nil + @unknown default: + return nil + } + } + + /// The mention being typed before `cursor`, or `nil` when there is none. + static func query(in text: String, cursor: String.Index) -> MentionQuery? { + guard cursor >= text.startIndex, cursor <= text.endIndex else { return nil } + var start = cursor + while start > text.startIndex { + let previous = text.index(before: start) + guard !text[previous].isWhitespace else { break } + start = previous + } + guard start < cursor, triggers.contains(text[start]) else { return nil } + let range = start.. (text: String, cursor: String.Index) { + let followedByWhitespace = query.range.upperBound < text.endIndex + && text[query.range.upperBound].isWhitespace + let inserted = followedByWhitespace ? "@\(username)" : "@\(username) " + var result = text + result.replaceSubrange(query.range, with: inserted) + let offset = text.distance(from: text.startIndex, to: query.range.lowerBound) + inserted.count + + (followedByWhitespace ? 1 : 0) + return (result, result.index(result.startIndex, offsetBy: offset)) + } +} + +/// How many suggestion rows the list may show. +nonisolated enum MentionRowCap { + + /// The most rows with no reply open. + static let rows = 4 + + /// The most rows with a reply open, which already takes a card's worth of the bar. + static let rowsWithReply = 3 + + /// The fewest rows the cap drops to when the transcript runs short. + static let fallbackRows = 2 + + /// The transcript height the list must leave above it before it falls back. + static let minimumTranscript: CGFloat = 120 + + /// The height of `count` rows `rowHeight` tall with a `divider` between each pair and none after + /// the last. + static func listHeight(rows count: Int, rowHeight: CGFloat, divider: CGFloat) -> CGFloat { + guard count > 0 else { return 0 } + return CGFloat(count) * rowHeight + CGFloat(count - 1) * divider + } + + /// The row count for a list of `rowHeight` rows split by `divider`s, whose padding and ground add + /// `chrome`, given `room`: the transcript's height without the list. Unmeasured room keeps the + /// full count. + static func rows(replyOpen: Bool, room: CGFloat?, rowHeight: CGFloat, divider: CGFloat, chrome: CGFloat) -> Int { + let full = replyOpen ? rowsWithReply : rows + guard let room else { return full } + let list = listHeight(rows: full, rowHeight: rowHeight, divider: divider) + chrome + return room - list < minimumTranscript ? fallbackRows : full + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 7ef123ac0..a9df04212 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -526,6 +526,8 @@ final class SessionContainer { let quickActionsController: QuickActionsController let conversationController: ConversationController let blocklistController: BlocklistController + /// Answers the mention picker's searches in group chats. + let rosterSearch: any RosterSearchSource let chatSpotlightIndexer: ChatSpotlightIndexer let profileAvatars: ProfileAvatarStore /// Identities for chat senders the chat's own roster leaves out — see ``KnownAuthorDirectory``. @@ -699,6 +701,16 @@ final class SessionContainer { database: database ) self.blocklistController = blocklistController + let mentionSearch = ServerMentionSearch( + fetching: flipClient, + owner: owner, + blockedUserIDs: { [weak blocklistController] in + Set(blocklistController?.blockedUsers.map(\.userID) ?? []) + }, + cache: { try database.upsertUserProfile($0, userID: $1) } + ) + conversationController.mentionSearch = mentionSearch + self.rosterSearch = mentionSearch // The blocklist drives which conversations the feed hides: it supplies the // current set, reconciles the hidden flags on every change, and reconciles diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index eaebb22f0..24b21358a 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.6.0" - case .flipcash2: return "0.14.1" + case .flipcash2: return "0.15.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 66e517cf0..5baad8d4a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -132,6 +132,14 @@ extension FlipClient { return RosterPage(members: members, rosterSummary: rosterSummary) } + /// The people to suggest after an `@` in a group chat, most relevant first; see + /// ``MentionSuggestion``. Fetch it once per composing session and filter it locally. + public func getMentionSuggestions(owner: KeyPair, conversationID: ConversationID) async throws -> [MentionSuggestion] { + try await withCheckedThrowingContinuation { c in + chatService.getMentionSuggestions(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + } + /// Edits a group chat's title and/or picture; every parameter left `nil` leaves that field /// unchanged, and passing both `nil` is a no-op that still returns the current metadata. Only a /// member with ``ConversationViewerState/canEdit`` may call this. diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 8baf36108..1f52f9f5b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -327,6 +327,36 @@ final class ChatService: Sendable { } } + /// Fetches the people to suggest after an `@` in a group chat, most relevant first. The pool is + /// neither paged nor complete: the caller filters it locally as the user types. A DM is `.denied`. + func getMentionSuggestions(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result<[MentionSuggestion], ErrorGetMentionSuggestions>) -> Void) { + let request = Flipcash_Chat_V1_GetMentionSuggestionsRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getMentionSuggestions(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to fetch mention suggestions", metadata: ["result": "\(response.result)"]) + await MainActor.run { completion(.failure(ErrorGetMentionSuggestions(response.result))) } + return + } + let suggestions = response.suggestions.compactMap(MentionSuggestion.init) + await MainActor.run { completion(.success(suggestions)) } + } catch let error as RPCError { + logger.error("Failed to fetch mention suggestions at the transport", metadata: [ + "code": "\(error.code)", + "message": "\(error.message)", + ]) + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + /// Edits a group chat's title and/or picture. Every field is optional — only fields set on the /// request change, atomically; a request that sets nothing is a no-op returning `.ok`. Only a /// member the server permits to edit (``ConversationViewerState/canEdit``) may call this; anyone @@ -473,6 +503,17 @@ public enum ErrorGetRoster: Error, Sendable, Equatable { case rejected } +/// Mapped explicitly from `GetMentionSuggestionsResponse.Result` — see +/// `ErrorGetMentionSuggestions.init(_:)` — like ``ErrorGetRoster``. +public enum ErrorGetMentionSuggestions: Error, Sendable, Equatable { + case denied + case notFound + case unknown + case transportFailure + case cancelled + case rejected +} + /// No `.ok` case, and modelled on `ErrorStartChat` for the same reason: `.titleModerated` carries the /// `flaggedCategory` the server reports for it. Mapped explicitly from `EditChatResponse.Result` — see /// `ErrorEditChat.init(_:flaggedCategory:)` — never via positional `rawValue:`, since @@ -633,6 +674,34 @@ extension ErrorGetRoster { } } +extension ErrorGetMentionSuggestions: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetMentionSuggestions { + /// Maps a non-`.ok` `GetMentionSuggestionsResponse.Result` to its domain error; total over the + /// proto enum, with `.ok` and `.UNRECOGNIZED` folding to `.unknown`. + init(_ result: Flipcash_Chat_V1_GetMentionSuggestionsResponse.Result) { + switch result { + case .ok: + self = .unknown + case .denied: + self = .denied + case .notFound: + self = .notFound + case .UNRECOGNIZED: + self = .unknown + } + } +} + extension ErrorEditChat: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MentionSuggestion.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MentionSuggestion.swift new file mode 100644 index 000000000..be70f293a --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MentionSuggestion.swift @@ -0,0 +1,53 @@ +// +// MentionSuggestion.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// One person the server suggests after an `@` in a group chat, from `Chat.GetMentionSuggestions`. +public struct MentionSuggestion: Sendable, Equatable { + public let userID: UserID + /// Their public profile, which always carries a username. + public let profile: Profile + /// When they last sent a message in the chat as the server recorded it, or `nil` when they are + /// suggested for another reason. + public let lastSentAt: Date? + + public init(userID: UserID, profile: Profile, lastSentAt: Date?) { + self.userID = userID + self.profile = profile + self.lastSentAt = lastSentAt + } + + /// The suggestion as a roster member, for the views that draw one. + public var member: ConversationMember { + ConversationMember( + userID: userID, + displayName: profile.displayName ?? "", + profilePicture: profile.profilePicture, + username: profile.username + ) + } +} + +extension MentionSuggestion { + /// `nil` for a suggestion without a readable user id or username, both of which the contract + /// says the server always sets: a pick inserts the username, so one without it is unusable. + init?(_ proto: Flipcash_Chat_V1_MentionSuggestion) { + guard + proto.hasUserProfile, + let profile = try? Profile(proto.userProfile), + let userID = profile.userID, + profile.username != nil + else { return nil } + self.init( + userID: userID, + profile: profile, + lastSentAt: proto.hasLastSentAt ? proto.lastSentAt.date : nil + ) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MentionSuggestionMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MentionSuggestionMappingTests.swift new file mode 100644 index 000000000..9c021d1a0 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/MentionSuggestionMappingTests.swift @@ -0,0 +1,63 @@ +// +// MentionSuggestionMappingTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI +@testable import FlipcashCore + +@Suite("MentionSuggestion proto mapping") +struct MentionSuggestionMappingTests { + + @Test("Maps the profile, user id and last sent time") + func mapsFields() throws { + let userID = UUID() + let sentAt = Date(timeIntervalSince1970: 1_700_000_000) + let proto = suggestion(userID: userID, username: "ted_1") { + $0.lastSentAt = .init(date: sentAt) + } + + let mapped = try #require(MentionSuggestion(proto)) + #expect(mapped.userID == userID) + #expect(mapped.lastSentAt == sentAt) + #expect(mapped.member.username?.value == "ted_1") + #expect(mapped.member.displayName == "Ted") + #expect(mapped.member.userID == userID) + } + + @Test("A suggestion for another reason has no last sent time") + func unsetLastSentAt() throws { + let mapped = try #require(MentionSuggestion(suggestion(userID: UUID(), username: "ted_1"))) + #expect(mapped.lastSentAt == nil) + } + + @Test("A suggestion without a username is dropped") + func dropsWithoutUsername() { + #expect(MentionSuggestion(suggestion(userID: UUID(), username: nil)) == nil) + } + + @Test("A suggestion without a user id is dropped") + func dropsWithoutUserID() { + #expect(MentionSuggestion(suggestion(userID: nil, username: "ted_1")) == nil) + } + + private func suggestion( + userID: UUID?, + username: String?, + configure: (inout Flipcash_Chat_V1_MentionSuggestion) -> Void = { _ in } + ) -> Flipcash_Chat_V1_MentionSuggestion { + var proto = Flipcash_Chat_V1_MentionSuggestion.with { + $0.userProfile = .with { + $0.displayName = "Ted" + if let userID { $0.userID = .with { $0.value = userID.data } } + if let username { $0.username = .with { $0.value = username } } + } + } + configure(&proto) + return proto + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 1b08d7882..adca719bc 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -84,6 +84,7 @@ struct TransportClassificationTests { @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) } @Test func errorUnmuteChat() { assertClassifies(ErrorUnmuteChat.self) } @Test func errorGetRoster() { assertClassifies(ErrorGetRoster.self) } + @Test func errorGetMentionSuggestions() { assertClassifies(ErrorGetMentionSuggestions.self) } @Test func errorEditChat() { assertClassifies(ErrorEditChat.self) } @Test func errorReport() { assertClassifies(ErrorReport.self) } @@ -148,6 +149,13 @@ struct TransportClassificationTests { #expect(ErrorGetRoster(.notFound) == .notFound) } + @Test("ErrorGetMentionSuggestions maps GetMentionSuggestionsResponse.Result explicitly") + func errorGetMentionSuggestionsMapsResult() { + #expect(ErrorGetMentionSuggestions(.denied) == .denied) + #expect(ErrorGetMentionSuggestions(.notFound) == .notFound) + #expect(ErrorGetMentionSuggestions(.ok) == .unknown) + } + // MARK: - Tier 2: associated-value errors that capture the transport error - // These don't conform to TransportClassifiableError (they carry the error in a // case rather than mapping to a dedicated one), so their `reportingLevel` is diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index 4585af9f8..b8379cf2a 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -9,14 +9,11 @@ import Testing import UIKit @testable import FlipcashUI -/// The box the bar is seen through, driven entirely by `setBarHeight(_:replying:)`. +/// The box the bar is seen through, driven entirely by `setBarHeight(_:accessories:)`. /// -/// The bar reports one number — its whole measured height — and whether a reply is open. The clip -/// has to turn those two into the height the screen actually sees, which is the same number while a -/// reply is open and that number minus the strip once it closes. The strip's own height is never -/// reported: it has to be inferred from the growth, and a draft restored into the composer is where -/// that inference has the least to go on, because the strip can be in the very first height the -/// screen is ever given. +/// The bar reports its whole measured height and the cards above the composer row: which are open, +/// and how tall the ones that have closed but are still mounted are. The clip is that height less +/// the closing cards, and it travels only when the open set changes. /// /// Animations are switched off around each call so the constraint's destination can be read off the /// frame in the same pass. @@ -26,6 +23,7 @@ struct ChatScreenBarClipTests { private let composerRow: CGFloat = 60 private let strip: CGFloat = 50 + private let row: CGFloat = 50 /// A screen on a live window, since the clip's animated path is taken only in one. private func makeScreen() -> (ChatScreenViewController, UIView, UIWindow) { @@ -45,9 +43,18 @@ struct ChatScreenBarClipTests { return bar.superview?.frame.height ?? 0 } - private func report(_ screen: ChatScreenViewController, _ height: CGFloat, replying: Bool) { + private func report( + _ screen: ChatScreenViewController, + _ height: CGFloat, + open: Set = [], + exiting: CGFloat = 0, + mentions: CGFloat = 0 + ) { UIView.performWithoutAnimation { - screen.setBarHeight(height, replying: replying) + // What the bar measures under the list: everything but the list, less a strip collapsing + // beside it. + let rest = height - mentions - (open.contains(.mentions) ? exiting : 0) + screen.setBarHeight(height, accessories: BarAccessories(open: open, exitingHeight: exiting, restHeight: rest)) } } @@ -55,66 +62,125 @@ struct ChatScreenBarClipTests { func replyOpenedAfterMeasurement_opensAndCloses() { let (screen, bar, window) = makeScreen() - report(screen, composerRow, replying: false) + report(screen, composerRow) #expect(clipHeight(bar, window) == composerRow) - report(screen, composerRow + strip, replying: true) + report(screen, composerRow + strip, open: [.reply]) #expect(clipHeight(bar, window) == composerRow + strip) - // The strip is still mounted while it fades, so the bar keeps measuring tall and the clip - // has to come back to the composer row on its own. - report(screen, composerRow + strip, replying: false) + // The strip is still mounted while it fades, so the bar keeps measuring tall and reports it + // as closing. + report(screen, composerRow + strip, exiting: strip) + #expect(clipHeight(bar, window) == composerRow) + + // It unmounts and the bar measures short; the clip was already there. + report(screen, composerRow) #expect(clipHeight(bar, window) == composerRow) } - @Test("A reply the bar arrives already open on shows the strip, and closes back to the composer row") + @Test("A reply the bar arrives already open on closes back to the composer row") func replyRestoredBeforeMeasurement_closesToTheComposerRow() { let (screen, bar, window) = makeScreen() // A restored draft is aimed before the bar has ever been measured, so the first height the // screen is given already carries the strip. - report(screen, composerRow + strip, replying: true) + report(screen, composerRow + strip, open: [.reply]) #expect(clipHeight(bar, window) == composerRow + strip) - // How much of that height was the strip is unknowable — the screen never saw this bar - // without one — so the close cannot take it back off, and the clip holds the bar's height - // rather than guessing at a number that was never measured. - report(screen, composerRow + strip, replying: false) - #expect(clipHeight(bar, window) == composerRow + strip) - - // It settles once the strip unmounts and the bar measures short on its own. - report(screen, composerRow, replying: false) + report(screen, composerRow + strip, exiting: strip) #expect(clipHeight(bar, window) == composerRow) } - @Test("A strip that arrives a pass after the reply does still closes back to the composer row") - func replyMeasuredInTwoSteps_closesToTheComposerRow() { + @Test("A draft wrapping to a second line mid-reply keeps the strip uncovered") + func barGrowsMidReply_keepsTheStripUncovered() { + let (screen, bar, window) = makeScreen() + + report(screen, composerRow) + report(screen, composerRow + strip, open: [.reply]) + + let secondLine: CGFloat = 20 + report(screen, composerRow + secondLine + strip, open: [.reply]) + #expect(clipHeight(bar, window) == composerRow + secondLine + strip) + + report(screen, composerRow + secondLine + strip, exiting: strip) + #expect(clipHeight(bar, window) == composerRow + secondLine) + } + + @Test("The mention list stacks over an open reply and the clip reaches the whole bar") + func mentionsOverReply_clipReachesTheWholeBar() { + let (screen, bar, window) = makeScreen() + + report(screen, composerRow) + report(screen, composerRow + strip, open: [.reply]) + report(screen, composerRow + strip + 3 * row, open: [.reply, .mentions], mentions: 3 * row) + #expect(clipHeight(bar, window) == composerRow + strip + 3 * row) + } + + @Test("Closing the reply under an open list never cuts into the list, and settles once the strip has collapsed") + func replyClosesUnderTheList_keepsTheList() async throws { + let (screen, bar, window) = makeScreen() + + report(screen, composerRow) + report(screen, composerRow + strip + 3 * row, open: [.reply, .mentions], mentions: 3 * row) + + // The strip collapses itself under the list, so the clip holds its height while it does. + report(screen, composerRow + strip + 3 * row, open: [.mentions], exiting: strip, mentions: 3 * row) + #expect(clipHeight(bar, window) >= composerRow + 3 * row) + + // A search answers mid-close and the list drops to one row: still held, still over the list. + report(screen, composerRow + strip + row, open: [.mentions], exiting: strip, mentions: row) + #expect(clipHeight(bar, window) >= composerRow + row) + + report(screen, composerRow + row, open: [.mentions], mentions: row) + try await Task.sleep(for: .seconds(ChatMotion.replyMerge.duration + 0.2)) + #expect(clipHeight(bar, window) == composerRow + row) + } + + @Test("Closing the list over an open reply never cuts into the strip, and settles once the list has merged back") + func listClosesOverTheReply_keepsTheStrip() async throws { let (screen, bar, window) = makeScreen() - report(screen, composerRow, replying: false) + report(screen, composerRow) + report(screen, composerRow + strip, open: [.reply]) + report(screen, composerRow + strip + 3 * row, open: [.reply, .mentions], mentions: 3 * row) + + // The list collapses into the strip's glass, so the clip holds its height while it does. + report(screen, composerRow + strip, open: [.reply]) + #expect(clipHeight(bar, window) == composerRow + strip + 3 * row) - // The strip mounts at zero height and takes its own a pass later, so the bar reports the - // reply as open before it reports the height that carries it. - report(screen, composerRow, replying: true) - report(screen, composerRow + strip, replying: true) + try await Task.sleep(for: .seconds(ChatMotion.replyMerge.duration + 0.2)) #expect(clipHeight(bar, window) == composerRow + strip) + } + + @Test("A list gaining rows while open follows the bar") + func listRowsChange_followTheBar() { + let (screen, bar, window) = makeScreen() - report(screen, composerRow + strip, replying: false) + report(screen, composerRow) + report(screen, composerRow + row, open: [.mentions], mentions: row) + report(screen, composerRow + 4 * row, open: [.mentions], mentions: 4 * row) + #expect(clipHeight(bar, window) == composerRow + 4 * row) + + report(screen, composerRow + 4 * row, exiting: 4 * row) #expect(clipHeight(bar, window) == composerRow) } - @Test("A draft wrapping to a second line mid-reply keeps the strip uncovered") - func barGrowsMidReply_keepsTheStripUncovered() { - let (screen, bar, window) = makeScreen() + @Test("The room reported leaves the list out and counts the reply strip") + func mentionRoom_excludesTheListAndCountsTheStrip() { + let (screen, _, window) = makeScreen() + var rooms: [CGFloat] = [] + screen.onMentionRoomChange = { rooms.append($0) } - report(screen, composerRow, replying: false) - report(screen, composerRow + strip, replying: true) + report(screen, composerRow + strip, open: [.reply]) + window.layoutIfNeeded() + let withStrip = rooms.last - let secondLine: CGFloat = 20 - report(screen, composerRow + secondLine + strip, replying: true) - #expect(clipHeight(bar, window) == composerRow + secondLine + strip) + report(screen, composerRow + strip + 2 * row, open: [.reply, .mentions], mentions: 2 * row) + window.layoutIfNeeded() + #expect(rooms.last == withStrip) - report(screen, composerRow + secondLine + strip, replying: false) - #expect(clipHeight(bar, window) == composerRow + secondLine) + report(screen, composerRow + 2 * row, open: [.mentions], mentions: 2 * row) + window.layoutIfNeeded() + #expect(rooms.last.map { $0 - strip } == withStrip) } } diff --git a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift index 36f1bec27..251b10a0b 100644 --- a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift +++ b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift @@ -30,7 +30,7 @@ struct ChatScreenKeyboardFloorTests { window.makeKeyAndVisible() screen.loadViewIfNeeded() UIView.performWithoutAnimation { - screen.setBarHeight(60, replying: false) + screen.setBarHeight(60, accessories: BarAccessories()) } window.layoutIfNeeded() return (screen, bar, window) diff --git a/FlipcashTests/Chat/ComplementaryPaletteTests.swift b/FlipcashTests/Chat/ComplementaryPaletteTests.swift index 85095a473..2615e60ea 100644 --- a/FlipcashTests/Chat/ComplementaryPaletteTests.swift +++ b/FlipcashTests/Chat/ComplementaryPaletteTests.swift @@ -64,4 +64,58 @@ struct ComplementaryPaletteTests { == ComplementaryPalette.color(.start, for: ada).hexString ) } + + // MARK: - Name colour + + /// (user id, `.middle` stop, name colour) shared with Android's identical rule. + private static let nameVectors: [(id: String, middle: String, name: String)] = [ + ("00000000-0000-0000-0000-000000000001", "#D93E7C", "#E374A0"), + ("6f1c2a9e-3b7d-4e21-9a55-0c4d8e7f1b23", "#3ED9BC", "#3ED9BC"), + ("a3e5b8c1-77d2-4f90-8b1e-2d6c9f0a4e57", "#D9C73E", "#D9C73E"), + ("deadbeef-0000-4000-8000-000000000000", "#3E91D9", "#5BA1DE"), + ("6513270e-269e-4d37-b2a7-4de452e6b438", "#D13ED9", "#D85BDE"), + ("73ab4876-7734-47c1-87fd-e805ec99108d", "#553ED9", "#9789E8"), + ] + + private func channels(_ hex: String) -> [Int] { + let value = Int(hex.dropFirst(), radix: 16)! + return [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF] + } + + private func matches(_ actual: String, _ expected: String) -> Bool { + zip(channels(actual), channels(expected)).allSatisfy { abs($0 - $1) <= 1 } + } + + @Test("The middle stop matches the shared vectors") + func middleMatchesVectors() { + for vector in Self.nameVectors { + let actual = ComplementaryPalette.color(.middle, for: UUID(uuidString: vector.id)!).hexString + #expect(matches(actual, vector.middle), "\(vector.id): \(actual) vs \(vector.middle)") + } + } + + @Test("The name colour matches the shared vectors") + func nameMatchesVectors() { + for vector in Self.nameVectors { + let id = UUID(uuidString: vector.id)! + let actual = ComplementaryPalette.nameColor(for: id).hexString + #expect(matches(actual, vector.name), "\(vector.id): \(actual) vs \(vector.name)") + #expect(matches(Color(ComplementaryPalette.uiNameColor(for: id)).hexString, vector.name)) + } + } + + @Test("Every name colour reads at 4.5:1 on #262626") + func nameContrast() { + let surface = ComplementaryPalette.relativeLuminance(0x26 / 255.0, 0x26 / 255.0, 0x26 / 255.0) + for _ in 0..<500 { + let rgb = ComplementaryPalette.nameRGB(for: UUID())! + let ratio = ComplementaryPalette.contrast(ComplementaryPalette.relativeLuminance(rgb.r, rgb.g, rgb.b), surface) + #expect(ratio >= 4.5) + } + } + + @Test("No id falls back to the secondary text colour") + func nameFallback() { + #expect(ComplementaryPalette.nameColor(for: nil).hexString == Color.textSecondary.hexString) + } } diff --git a/FlipcashTests/Chat/MentionPickerTests.swift b/FlipcashTests/Chat/MentionPickerTests.swift new file mode 100644 index 000000000..4a15dd079 --- /dev/null +++ b/FlipcashTests/Chat/MentionPickerTests.swift @@ -0,0 +1,298 @@ +// +// MentionPickerTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import SwiftUI +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Mention trigger") +struct MentionTriggerTests { + + private func query(_ text: String) -> String? { + MentionTrigger.query(in: text, cursor: text.endIndex)?.text + } + + @Test("An @ at the start of the text opens the picker") + func atStart() { + #expect(query("@") == "") + #expect(query("@ma") == "ma") + } + + @Test("An @ after whitespace opens the picker") + func afterSpace() { + #expect(query("hi @ma") == "ma") + #expect(query("hi\n@ma") == "ma") + } + + @Test("A fullwidth @ opens the picker") + func fullwidth() { + #expect(query("hi @ma") == "ma") + } + + @Test("An @ inside a word never opens the picker") + func insideWord() { + #expect(query("a@b") == nil) + #expect(query("a@") == nil) + } + + @Test("Typing whitespace after the word closes the picker") + func whitespaceCloses() { + #expect(query("hi @ma ") == nil) + } + + @Test("Deleting the @ closes the picker") + func deletedTrigger() { + #expect(query("hi ma") == nil) + } + + @Test("Moving the cursor off the word closes the picker") + func cursorMoved() { + let text = "hi @ma there" + #expect(MentionTrigger.query(in: text, cursor: text.endIndex) == nil) + let beforeTrigger = text.index(text.startIndex, offsetBy: 3) + #expect(MentionTrigger.query(in: text, cursor: beforeTrigger) == nil) + let afterWord = text.index(text.startIndex, offsetBy: 6) + #expect(MentionTrigger.query(in: text, cursor: afterWord)?.text == "ma") + } + + @Test("A selection never opens the picker") + func selectionNeverOpens() { + let text = "hi @ma" + let range = text.index(text.startIndex, offsetBy: 3).. Int { + MentionRowCap.rows(replyOpen: replyOpen, room: room, rowHeight: rowHeight, divider: divider, chrome: chrome) + } + + @Test("Four rows with no reply open, three with one") + func fullCounts() { + #expect(rows(replyOpen: false, room: nil) == 4) + #expect(rows(replyOpen: true, room: nil) == 3) + #expect(rows(replyOpen: false, room: 400) == 4) + #expect(rows(replyOpen: true, room: 320) == 3) + } + + @Test("Two rows when the full list would leave the transcript under 120pt") + func fallback() { + // 4 rows = 224 + 3 dividers + 16 = 243pt; 300 - 243 = 57 < 120. + #expect(rows(replyOpen: false, room: 300) == 2) + // 3 rows = 168 + 2 dividers + 16 = 186pt; 300 - 186 = 114 < 120. + #expect(rows(replyOpen: true, room: 300) == 2) + // Exactly 120pt left keeps the full count. + #expect(rows(replyOpen: false, room: 363) == 4) + } + + @Test("List heights count a divider between rows and none after the last") + func listHeights() { + #expect(MentionListMetrics.listHeight(rows: 4) == 203) + #expect(MentionListMetrics.listHeight(rows: 3) == 152) + #expect(MentionListMetrics.listHeight(rows: 2) == 101) + #expect(MentionListMetrics.listHeight(rows: 1) == 50) + #expect(MentionListMetrics.listHeight(rows: 0) == 0) + } +} + +@MainActor +@Suite("Mention picker model") +struct MentionPickerModelTests { + + private let chatID = ConversationID(data: Data(repeating: 0x09, count: 32)) + + @MainActor + final class FakeSource: RosterSearchSource { + var members: [ConversationMember] = [] + private(set) var prepareCount = 0 + private(set) var searches: [String] = [] + private var gate: CheckedContinuation? + private var outcome: Bool? + + func prepare(chatID: ConversationID) async -> Bool { + prepareCount += 1 + if outcome == nil { await withCheckedContinuation { gate = $0 } } + return outcome ?? false + } + + /// Lets the refresh finish with `succeeding`, whether or not it has started waiting yet. + func release(succeeding: Bool = true) { + outcome = succeeding + gate?.resume() + gate = nil + } + + func search(chatID: ConversationID, query: String, limit: Int) async throws -> [MemberMatch] { + searches.append(query) + return members.map { MemberMatch(member: $0, id: $0.userID!) } + } + } + + private func member(_ name: String, username: String?) -> ConversationMember { + ConversationMember(userID: UUID(), displayName: name, username: username.flatMap(Username.init), version: 1) + } + + @Test("A finished refresh re-runs the current query so new joiners appear") + func refreshThenRequery() async throws { + let source = FakeSource() + source.members = [member("Érica", username: "erica")] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "er") + await model.searchTask?.value + #expect(model.candidates.map(\.displayName) == ["Érica"]) + + source.members.append(member("Erin", username: "erin")) + source.release() + await model.refreshTask?.value + await model.searchTask?.value + + #expect(source.searches == ["er", "er"]) + #expect(model.candidates.map(\.displayName) == ["Érica", "Erin"]) + } + + @Test("A refresh that lands after the picker closed leaves it closed") + func refreshAfterClose() async { + let source = FakeSource() + source.members = [member("Érica", username: "erica")] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "er") + await model.searchTask?.value + model.update(query: nil) + source.release() + await model.refreshTask?.value + await model.searchTask?.value + + #expect(source.searches == ["er"]) + #expect(model.candidates.isEmpty) + } + + @Test("A failed refresh doesn't search again") + func failedRefresh() async { + let source = FakeSource() + source.members = [member("Érica", username: "erica")] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "er") + await model.searchTask?.value + source.members.append(member("Erin", username: "erin")) + source.release(succeeding: false) + await model.refreshTask?.value + await model.searchTask?.value + + #expect(source.searches == ["er"]) + #expect(model.candidates.map(\.displayName) == ["Érica"]) + } + + @Test("The source is prepared once per visit, not on every query") + func refreshOnce() async { + let source = FakeSource() + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "m") + model.update(query: nil) + model.update(query: "ma") + source.release() + await model.refreshTask?.value + + #expect(source.prepareCount == 1) + } + + @Test("Members without a username are not offered") + func usernameRequired() async { + let source = FakeSource() + source.members = [member("Maria", username: "maria"), member("Mateo", username: nil)] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "ma") + await model.searchTask?.value + + #expect(model.candidates.map(\.displayName) == ["Maria"]) + source.release() + } + + @Test("Closing the picker clears the candidates") + func closeClears() async { + let source = FakeSource() + source.members = [member("Maria", username: "maria")] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "ma") + await model.searchTask?.value + model.update(query: nil) + + #expect(model.candidates.isEmpty) + source.release() + } +} diff --git a/FlipcashTests/Chat/MentionSearchTextTests.swift b/FlipcashTests/Chat/MentionSearchTextTests.swift new file mode 100644 index 000000000..3591c02a9 --- /dev/null +++ b/FlipcashTests/Chat/MentionSearchTextTests.swift @@ -0,0 +1,63 @@ +// +// MentionSearchTextTests.swift +// FlipcashTests +// + +import Testing +import Foundation +@testable import Flipcash + +/// The text rules mention search matches by. Android runs the same cases, so a change here is a +/// cross-platform change. +@Suite("Mention search text normalization") +struct MentionSearchTextTests { + + @Test("Lowercases and strips diacritics", arguments: [ + ("Érica", "erica"), + ("JOSÉ", "jose"), + ("Zoë", "zoe"), + ("Ångström", "angstrom"), + ("Nguyễn", "nguyen"), + ]) + func stripsDiacritics(input: String, expected: String) { + #expect(MentionSearchText.normalize(input) == expected) + } + + @Test("Compatibility forms fold to their plain letters") + func foldsCompatibilityForms() { + #expect(MentionSearchText.normalize("Erica") == "erica") + #expect(MentionSearchText.normalize("fiona") == "fiona") + } + + @Test("Dotted capital I folds to a plain i") + func dottedCapitalI() { + #expect(MentionSearchText.normalize("İstanbul") == "istanbul") + } + + @Test("Spacing marks are kept, so Devanagari vowels survive") + func keepsSpacingMarks() { + // U+093F DEVANAGARI VOWEL SIGN I is category Mc. + #expect(MentionSearchText.normalize("कि") == "कि") + } + + @Test("Tokens are each display name word plus the username") + func tokens() { + let tokens = MentionSearchText.tokens(displayName: " Érica de la Cruz ", username: "ecruz") + #expect(tokens == ["erica", "de", "la", "cruz", "ecruz"]) + #expect(MentionSearchText.tokens(displayName: "Bo", username: "@bo_b") == ["bo", "bo_b"]) + } + + @Test("Any whitespace or separator splits words", arguments: ["\u{00A0}", "\u{2028}", "\u{3000}", "\t"]) + func separatorsSplit(separator: String) { + #expect(MentionSearchText.words("Ana\(separator)Lima") == ["ana", "lima"]) + } + + @Test("A query drops a leading @ of either width") + func queryDropsAt() { + #expect(MentionSearchText.queryWords("@Eri") == ["eri"]) + #expect(MentionSearchText.queryWords("@eri") == ["eri"]) + #expect(MentionSearchText.queryWords("@") == []) + #expect(MentionSearchText.queryWords("@@eri") == ["@eri"]) + #expect(MentionSearchText.queryWords("") == []) + } +} diff --git a/FlipcashTests/Chat/ServerMentionSearchTests.swift b/FlipcashTests/Chat/ServerMentionSearchTests.swift new file mode 100644 index 000000000..3dafb06fc --- /dev/null +++ b/FlipcashTests/Chat/ServerMentionSearchTests.swift @@ -0,0 +1,210 @@ +// +// ServerMentionSearchTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Server mention search") +struct ServerMentionSearchTests { + + private let chatID = ConversationID.test(0x0A) + private let zoe = suggestion("Zoe Adams", "zoe", lastSentAt: 300) + private let bob = suggestion("Bob Stone", "bob", lastSentAt: 200) + private let alice = suggestion("Alice Park", "alice", lastSentAt: nil) + + private func makeSearch( + pool: [MentionSuggestion] = [], + error: (any Error)? = nil, + blocked: Set = [] + ) -> (ServerMentionSearch, FakeFetching) { + let fetching = FakeFetching(pool: pool, error: error) + let search = ServerMentionSearch( + fetching: fetching, + owner: .generate()!, + blockedUserIDs: { blocked } + ) + return (search, fetching) + } + + private func names(_ matches: [MemberMatch]) -> [String] { + matches.map(\.member.displayName) + } + + @Test("Matches keep the server's order rather than sorting") + func keepsServerOrder() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice]) + await search.prepare(chatID: chatID) + + let matches = try await search.search(chatID: chatID, query: "a", limit: 20) + + #expect(names(matches) == ["Zoe Adams", "Alice Park"]) + } + + @Test("Matches keep the suggestion's profile picture") + func keepsProfilePicture() async throws { + let picture = ProfilePicture( + blobID: BlobID(data: Data(repeating: 0x01, count: 16)), + thumbnailBlobID: BlobID(data: Data(repeating: 0x02, count: 16)), + thumbnailBlurhash: "LEHV6nWB2yk8" + ) + let (search, _) = makeSearch(pool: [suggestion("Zoe Adams", "zoe", lastSentAt: nil, picture: picture), bob]) + await search.prepare(chatID: chatID) + + let matches = try await search.search(chatID: chatID, query: "", limit: 20) + + #expect(matches.map(\.member.profilePicture) == [picture, nil]) + } + + @Test("Every query word must prefix a name word or the username") + func matchesEveryWord() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice]) + await search.prepare(chatID: chatID) + + #expect(names(try await search.search(chatID: chatID, query: "bob st", limit: 20)) == ["Bob Stone"]) + #expect(names(try await search.search(chatID: chatID, query: "ali", limit: 20)) == ["Alice Park"]) + #expect(try await search.search(chatID: chatID, query: "zoe park", limit: 20).isEmpty) + } + + @Test("A bare @ shows the head of the pool") + func bareAtShowsHead() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice]) + await search.prepare(chatID: chatID) + + let matches = try await search.search(chatID: chatID, query: "", limit: 2) + + #expect(names(matches) == ["Zoe Adams", "Bob Stone"]) + } + + @Test("A blocked user is never offered") + func dropsBlocked() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice], blocked: [bob.userID]) + await search.prepare(chatID: chatID) + + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Zoe Adams", "Alice Park"]) + } + + @Test("The pool is fetched once per composing session") + func fetchesOncePerSession() async throws { + let (search, fetching) = makeSearch(pool: [zoe, bob]) + + #expect(await search.prepare(chatID: chatID)) + _ = try await search.search(chatID: chatID, query: "z", limit: 20) + _ = try await search.search(chatID: chatID, query: "zo", limit: 20) + #expect(fetching.calls == 1) + + await search.prepare(chatID: chatID) + #expect(fetching.calls == 2) + } + + @Test("A search before prepare fetches the pool, and prepare joins that fetch") + func searchBeforePrepare() async throws { + let (search, fetching) = makeSearch(pool: [zoe, bob]) + + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Zoe Adams", "Bob Stone"]) + #expect(fetching.calls == 1) + } + + @Test("A new message moves its sender to the front") + func movesSenderToFront() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice]) + await search.prepare(chatID: chatID) + + search.apply(.sent([message(from: alice.userID, at: 400)], in: chatID)) + + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Alice Park", "Zoe Adams", "Bob Stone"]) + } + + @Test("A message no newer than the sender's last send leaves the order alone") + func ignoresOlderMessage() async throws { + let (search, _) = makeSearch(pool: [zoe, bob, alice]) + await search.prepare(chatID: chatID) + + search.apply(.sent([message(from: bob.userID, at: 200)], in: chatID)) + + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Zoe Adams", "Bob Stone", "Alice Park"]) + } + + @Test("A sender outside the pool is not added") + func ignoresUnknownSender() async throws { + let (search, _) = makeSearch(pool: [zoe, bob]) + await search.prepare(chatID: chatID) + + search.apply(.sent([message(from: UUID(), at: 500)], in: chatID)) + search.apply(.sent([message(from: bob.userID, at: 500)], in: .test(0x0B))) + + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Zoe Adams", "Bob Stone"]) + } + + @Test( + "A failed fetch matches nobody and reports the prepare as failed", + arguments: [ErrorGetMentionSuggestions.denied, .notFound, .transportFailure] + ) + func failedFetchIsEmpty(error: ErrorGetMentionSuggestions) async throws { + let (search, _) = makeSearch(error: error) + + #expect(await search.prepare(chatID: chatID) == false) + #expect(try await search.search(chatID: chatID, query: "", limit: 20).isEmpty) + #expect(try await search.search(chatID: chatID, query: "al", limit: 20).isEmpty) + } + + @Test("The next prepare fetches again after a failure") + func retriesAfterFailure() async throws { + let (search, fetching) = makeSearch(error: ErrorGetMentionSuggestions.transportFailure) + #expect(await search.prepare(chatID: chatID) == false) + fetching.error = nil + fetching.pool = [zoe] + + #expect(await search.prepare(chatID: chatID)) + #expect(names(try await search.search(chatID: chatID, query: "", limit: 20)) == ["Zoe Adams"]) + } + + // MARK: - Fakes + + final class FakeFetching: MentionSuggestionFetching, @unchecked Sendable { + var pool: [MentionSuggestion] + var error: (any Error)? + private(set) var calls = 0 + + init(pool: [MentionSuggestion], error: (any Error)?) { + self.pool = pool + self.error = error + } + + func getMentionSuggestions(owner: KeyPair, conversationID: ConversationID) async throws -> [MentionSuggestion] { + calls += 1 + if let error { throw error } + return pool + } + } +} + +private func suggestion(_ displayName: String, _ username: String, lastSentAt: TimeInterval?, picture: ProfilePicture? = nil) -> MentionSuggestion { + let userID = UUID() + return MentionSuggestion( + userID: userID, + profile: Profile( + displayName: displayName, + phone: Phone?.none, + email: nil, + profilePicture: picture, + userID: userID, + username: Username(username) + ), + lastSentAt: lastSentAt.map { Date(timeIntervalSince1970: $0) } + ) +} + +private func message(from senderID: UserID, at time: TimeInterval) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: UInt64(time)), + senderID: senderID, + content: .text("hi"), + date: Date(timeIntervalSince1970: time), + unreadSeq: 0 + ) +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift new file mode 100644 index 000000000..3e0873de5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -0,0 +1,49 @@ +// +// BarAccessories.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import CoreGraphics + +/// The cards stacked above the composer row, as the bar reports them alongside its height. +public struct BarAccessories: Equatable, Sendable { + + /// A card that can stand above the composer row. + public enum Kind: Hashable, Sendable { + case mentions + case reply + } + + /// The cards that are open and already measured. A card joins only once it has its height, so + /// the report that adds it is the one whose height includes it. + public var open: Set + + /// The combined height of cards that have closed but are still mounted while the clip closes + /// over them. + public var exitingHeight: CGFloat + + /// The height of the bar under the mention list — the reply strip and the composer row — + /// which the list's room is measured without. + /// + /// Measured from those views rather than taken as the bar less the list: the bar and the list + /// are measured a pass apart, so the difference moved with the list and fed back into its row + /// count, and a list under a reply flipped between two sizes without settling. + public var restHeight: CGFloat + + public init(open: Set = [], exitingHeight: CGFloat = 0, restHeight: CGFloat = 0) { + self.open = open + self.exitingHeight = exitingHeight + self.restHeight = restHeight + } + + /// Two reports combined, as sibling cards report them. + public func merged(with other: BarAccessories) -> BarAccessories { + BarAccessories( + open: open.union(other.open), + exitingHeight: exitingHeight + other.exitingHeight, + restHeight: restHeight + other.restHeight + ) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index e09065ac9..37c45e657 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -122,7 +122,13 @@ public nonisolated enum ChatMotion { /// monotonic, no overshoot in either direction. Bounce is zero for the same reason /// `keyboardScroll`'s is: the transcript's bottom inset tracks this height every frame, and a bar /// that overshoots drags the messages past their resting place and back. - public static let replySurface = ChatSpring(duration: 0.22, bounce: 0) + /// + /// Lengthened from the reference's 0.22s to 0.28s: the mention list resizing around the strip + /// rides this spring too, and at 0.22s that change of shape read as abrupt. + public static let replySurface = ChatSpring(duration: 0.28, bounce: 0) + /// The reply strip merging back into the open mention list. Slower than `replySurface`: a glass + /// shape leaving its container settles faster than one arriving, so this matches the peel. + public static let replyMerge = ChatSpring(duration: 0.5, bounce: 0) /// The "Delivered" line appearing under a sent bubble. Slow and gentle: it arrives after the /// message has landed and shouldn't compete with it. public static let delivered = ChatSpring(duration: 0.40, bounce: 0.12) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 806e03269..885905a8b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -153,7 +153,7 @@ final class ChatQuotePanelView: UIView { // to the neutral secondary, which is what `ComplementaryPalette` returns for a nil id. let ruleColor = ComplementaryPalette.uiColor(.start, for: quote.authorID) rule.backgroundColor = ruleColor - authorLabel.textColor = ComplementaryPalette.uiColor(.middle, for: quote.authorID) + authorLabel.textColor = ComplementaryPalette.uiNameColor(for: quote.authorID) backgroundColor = ruleColor.withAlphaComponent(Self.cellTint) // An unavailable original has no author to name, so the author line collapses rather than // rendering an empty run. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a409e173f..a7565d986 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -50,12 +50,14 @@ public final class ChatScreenViewController: UIViewController { /// exactly the keyboard's region: the whole keyboard when one is up, nothing at all when it is /// down. Nothing else can see it: every point it covers is a point the keyboard is covering. private let keyboardCornerCover = UIView() - /// The bar's height is driven by its *measured* SwiftUI height, so the frame matches its - /// content exactly — a hosting controller's intrinsic size mis-measures multiline growth and - /// lets the composer overflow below its frame, under the keyboard. Always applied unanimated. - private var barHeightConstraint: NSLayoutConstraint! - /// The clip's height — what the screen and the transcript actually see as the bar. Equal to the - /// bar's own height except while the reply strip is arriving or leaving. + /// The clip's height — what the screen and the transcript actually see as the bar. Driven by the + /// bar's *measured* SwiftUI height, minus a card that is on its way out. + /// The clip height the transcript reserves room for while the clip is held taller than the glass + /// during an in-place resize; `nil` when it reserves the clip's own height. + private var transcriptCover: CGFloat? + /// Holds the fade's top edge on the transcript's while the clip is held above it, so the + /// ramp's dark end never lands on the bubble the transcript just moved down. + private var fadeTopConstraint: NSLayoutConstraint! private var barClipHeightConstraint: NSLayoutConstraint! /// Keeps the bar above the keyboard. Not `view.keyboardLayoutGuide`: see `KeyboardFloor`. private var keyboardFloor: KeyboardFloor! @@ -102,12 +104,13 @@ public final class ChatScreenViewController: UIViewController { /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false - /// Whether the last measured bar height included the reply strip. - private var barShowsReply = false - /// What the strip added to the bar when it opened, so the clip knows what to close back over. - /// The bar itself still reports the taller height on the way out — the strip stays mounted under - /// the clip while it fades — so the closed height cannot be read off the measurement. - private var replyStripHeight: CGFloat = 0 + /// The cards the last measured bar height reported, so a card opening or closing is told apart + /// from ordinary growth. + private var barAccessories = BarAccessories() + /// The bar's last measured height, which the transcript's room is taken against. + private var measuredBarHeight: CGFloat = 0 + /// The last room reported through ``onMentionRoomChange``, so an unchanged one is not sent again. + private var reportedMentionRoom: CGFloat? /// The pop gestures switched off for the length of an edit, kept so only those are switched back /// on and one that was already off stays off. private var suspendedPopGestures: [UIGestureRecognizer] = [] @@ -312,7 +315,6 @@ public final class ChatScreenViewController: UIViewController { // Under the blur, so the shapes are only ever seen through it. gatePlaceholder.install(in: view, below: transcriptBlur.view) - barHeightConstraint = constraints.height barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -490,12 +492,12 @@ public final class ChatScreenViewController: UIViewController { } /// Adds a hosted bar sitting on the bottom edge of a clip box that spans the view's width; - /// returns the bar's own height constraint and the clip's (both driven later by the bar's - /// measured SwiftUI content height) and the clip's bottom constraint (driven by the keyboard). + /// returns the clip's height constraint (driven later by the bar's measured SwiftUI content + /// height) and the clip's bottom constraint (driven by the keyboard). private func addBar( _ bar: UIView, controller: UIViewController? - ) -> (height: NSLayoutConstraint, clipHeight: NSLayoutConstraint, bottom: NSLayoutConstraint, keyboardEdge: NSLayoutConstraint) { + ) -> (clipHeight: NSLayoutConstraint, bottom: NSLayoutConstraint, keyboardEdge: NSLayoutConstraint) { if let controller { addChild(controller) } barClip.translatesAutoresizingMaskIntoConstraints = false barClip.clipsToBounds = true @@ -506,7 +508,6 @@ public final class ChatScreenViewController: UIViewController { barClip.addSubview(bar) controller?.didMove(toParent: self) - let heightConstraint = bar.heightAnchor.constraint(equalToConstant: 80) let clipHeightConstraint = barClip.heightAnchor.constraint(equalToConstant: 80) let bottomConstraint = barClip.bottomAnchor.constraint(equalTo: view.bottomAnchor) NSLayoutConstraint.activate([ @@ -519,9 +520,14 @@ public final class ChatScreenViewController: UIViewController { // Bottom, not top: the bar grows upward out of the clip, so the composer row keeps its // place on the keyboard while the strip above it is uncovered. bar.bottomAnchor.constraint(equalTo: barClip.bottomAnchor), - heightConstraint, + // As tall as the screen, whatever the bar's content measures, so the host never resizes. + // Resized under a SwiftUI animation, it re-placed its bottom-aligned content outside that + // animation while the content's own layout was still springing inside it, and the two + // disagreed by the whole change in height: the composer lifted off the keyboard and slid + // back. The clip alone decides how much of the bar is seen. + bar.heightAnchor.constraint(equalTo: view.heightAnchor), ]) - return (heightConstraint, clipHeightConstraint, bottomConstraint, keyboardEdge) + return (clipHeightConstraint, bottomConstraint, keyboardEdge) } /// Adds the fade from the bar's top edge down to the keyboard's top edge, or the screen's bottom @@ -529,11 +535,12 @@ public final class ChatScreenViewController: UIViewController { private func addComposerFade(below clip: UIView) -> NSLayoutConstraint { fade.translatesAutoresizingMaskIntoConstraints = false view.insertSubview(fade, belowSubview: clip) + fadeTopConstraint = fade.topAnchor.constraint(equalTo: clip.topAnchor) let bottom = fade.bottomAnchor.constraint(equalTo: view.bottomAnchor) NSLayoutConstraint.activate([ fade.leadingAnchor.constraint(equalTo: view.leadingAnchor), fade.trailingAnchor.constraint(equalTo: view.trailingAnchor), - fade.topAnchor.constraint(equalTo: clip.topAnchor), + fadeTopConstraint, bottom, ]) return bottom @@ -632,55 +639,62 @@ public final class ChatScreenViewController: UIViewController { } } - /// Set the bar's height to its measured SwiftUI content height. `replying` says whether a reply - /// is open, which decides what the clip around the bar does with that height. + /// The height between the top bar and the keyboard floor that the transcript would keep without + /// the mention list, sent whenever it changes. + public var onMentionRoomChange: ((CGFloat) -> Void)? + + /// Set the bar's height to its measured SwiftUI content height. `accessories` says which cards + /// stand above the composer row, which decides what the clip around the bar does with that height. /// - /// The bar always takes the height flat. It is pinned to the bottom of the clip, so its own - /// frame can change by the strip's whole height without moving anything that is on screen. - public func setBarHeight(_ height: CGFloat, replying: Bool) { - // Read before the early exits: a reply closing has to move the clip even though the measured - // height does not change — the strip stays mounted under the clip while it fades. - let opensReply = replying && !barShowsReply - let closesReply = !replying && barShowsReply - barShowsReply = replying - guard barHeightConstraint != nil else { return } - - // A reply's own height is not known when it opens. On a first reply the strip mounts at zero - // height, reports what it wants, and takes it a pass later; re-replying while the last one is - // still fading keeps the strip it already has and never reports again. So the opening is - // whichever pass actually makes the bar taller after the target changed, not the target - // change itself — and once that height is known, later ones are ordinary bar growth. - if opensReply { replyStripHeight = 0 } + /// Only the clip moves. The bar itself is as tall as the screen and pinned to the clip's bottom, + /// so its content is already laid out at any height the clip uncovers. + public func setBarHeight(_ height: CGFloat, accessories: BarAccessories) { + // Read before the early exits: a card closing has to move the clip even though the measured + // height does not change — the card stays mounted under the clip while it fades. + let toggled = accessories.open != barAccessories.open + // A change the bar draws itself, with a card open before and after it: the list changing its + // row count, or one card splitting out of the other's glass or merging back into it. The glass + // changes shape in SwiftUI, so the clip only has to stay out of its way. + let inPlace = !accessories.open.isEmpty && !barAccessories.open.isEmpty + barAccessories = accessories + defer { reportMentionRoom() } + measuredBarHeight = height + guard barClipHeightConstraint != nil else { return } + let isFirst = !didMeasureBar didMeasureBar = true let previousClip = barClipHeightConstraint.constant - let clipHeight: CGFloat - let travels: Bool - if replying { - clipHeight = height - // Nothing to read the strip's height off on the very first measurement: the clip is - // still at its placeholder constant and the screen has never seen this bar without a - // strip. A restored draft is aimed before the bar is measured at all, so that is the - // pass it lands in — and a difference taken against the placeholder is not the strip. - // Left unknown, the close below keeps the clip at the bar's height until the strip - // unmounts and the bar measures short, which is late but never wrong. - travels = !isFirst && replyStripHeight == 0 && clipHeight != previousClip - if travels { replyStripHeight = clipHeight - previousClip } - } else { - // Closing, the strip is still in the measurement and has to come back off it. - clipHeight = closesReply ? height - replyStripHeight : height - travels = closesReply - if !closesReply { replyStripHeight = 0 } - } + // A closing card is still in the measurement and has to come back off it. A card only + // counts as open once it is measured, so the report that opens it already carries its height. + let clipHeight = height - accessories.exitingHeight fade.isHidden = height <= 0 + guard clipHeight != previousClip else { + // Back to the height a shrink is still holding the clip at, before the hold settled: + // the settle no longer matches and won't run, so the transcript would keep reserving + // the shrink's height and its last message would sit under the cards. + if transcriptCover != nil, !isFirst { + transcriptCover = nil + fadeTopConstraint.constant = 0 + ChatMotion.replySurface.animate { + self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + } + return + } + + if inPlace, !isFirst, view.window != nil { + holdForInPlaceResize(clipHeight: clipHeight) + return + } - guard !isFirst, view.window != nil, travels else { - guard barHeightConstraint.constant != height || barClipHeightConstraint.constant != clipHeight else { return } - barHeightConstraint.constant = height - barClipHeightConstraint.constant = clipHeight + transcriptCover = nil + fadeTopConstraint?.constant = 0 + barClipHeightConstraint.constant = clipHeight + guard !isFirst, view.window != nil, toggled else { // Every other height — a draft wrapping to a second line, the send arrow appearing — is - // one the content has *already* laid itself out at by the time the number arrives. Clip - // and bar match it in the same frame; the transcript's inset comes with them, since + // one the content has *already* laid itself out at by the time the number arrives. The + // clip matches it in the same frame; the transcript's inset comes with it, since // `viewDidLayoutSubviews` reads the clip's frame during this pass. UIView.performWithoutAnimation { self.view.layoutIfNeeded() @@ -688,19 +702,54 @@ public final class ChatScreenViewController: UIViewController { return } - // Two passes, deliberately. The bar takes its new height first, with the clip still at the - // old one, so the strip is laid out at full size behind the clip's edge and the composer row - // is already standing where it will stay. - barHeightConstraint.constant = height + // A card opening or closing: the clip's edge travels, and that edge is the whole animation. + // It uncovers the card on the way in and closes back over it on the way out. The transcript's + // inset is read inside this pass too, so the bubbles are pushed by the edge rather than + // teleporting ahead of it. + ChatMotion.replySurface.animate { + self.view.layoutIfNeeded() + } + } + + /// Makes room for a change the bar draws itself, without animating anything. + /// + /// The glass grows and shrinks on its own spring, its bottom on the composer, and a second + /// animator here would only fight it. The clip takes the taller of the two heights at once, which + /// can never cut into the glass, and comes down to a shrink's height once the spring is done. + private func holdForInPlaceResize(clipHeight: CGFloat) { + let heldClip = max(clipHeight, barClipHeightConstraint.constant) + // The clip jumps, the transcript doesn't: it keeps reserving what it reserved until the + // spring below moves it, so the bubbles ride the glass's edge instead of the clip's. + let cover = transcriptCover ?? barClipHeightConstraint.constant + transcriptCover = cover + barClipHeightConstraint.constant = heldClip + fadeTopConstraint.constant = heldClip - cover UIView.performWithoutAnimation { self.view.layoutIfNeeded() } - // Then the clip's edge travels, and that edge is the whole animation: it uncovers the strip - // on the way in and closes back over it on the way out. The transcript's inset is read inside - // this pass too, so the bubbles are pushed by the edge rather than teleporting ahead of it. - barClipHeightConstraint.constant = clipHeight + transcriptCover = clipHeight + fadeTopConstraint.constant = heldClip - clipHeight ChatMotion.replySurface.animate { self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + guard heldClip > clipHeight else { + transcriptCover = nil + return + } + let height = measuredBarHeight + // Long enough for the slower of the two shape changes, the strip merging back into the list. + let settle = max(ChatMotion.replySurface.duration, ChatMotion.replyMerge.duration) + DispatchQueue.main.asyncAfter(deadline: .now() + settle) { [weak self] in + // A later report may already have moved the bar on; only the one still current lands. + guard let self, self.measuredBarHeight == height, + self.barClipHeightConstraint.constant == heldClip else { return } + self.barClipHeightConstraint.constant = clipHeight + self.transcriptCover = nil + self.fadeTopConstraint.constant = 0 + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } } } @@ -732,10 +781,35 @@ public final class ChatScreenViewController: UIViewController { // collection view's adjusted inset by the safe area, or by the keyboard when it's up, so // counting those again overscrolls by a whole keyboard. Measured from the bar's top edge // rather than taken as its height, so a bar resting into the safe area gives that back. - let covered = view.bounds.maxY - barClip.frame.minY + updateTranscriptInset() + reportMentionRoom() + } + + private func updateTranscriptInset() { + let top = barClip.frame.maxY - (transcriptCover ?? barClip.frame.height) + let covered = view.bounds.maxY - top let drop = keyboardFloor.isKeyboardUp ? Self.raisedTranscriptDrop : 0 transcript.setBottomInset(max(0, covered - keyboardFloor.systemInset - drop)) } + + /// Sends the transcript's room without the mention list: from the top bar's bottom edge down to + /// the keyboard floor, less the rest of the bar. Measured, so a reply strip that wraps counts. + /// + /// Also sent from ``setBarHeight(_:accessories:)``: a bar height that changes inside the clip + /// only lays out the clip, so the root view's layout pass never comes. + private func reportMentionRoom() { + guard let onMentionRoomChange, didMeasureBar else { return } + let top: CGFloat + if let navigationBar = hostNavigationController?.navigationBar, !navigationBar.isHidden { + top = max(view.safeAreaInsets.top, navigationBar.convert(navigationBar.bounds, to: view).maxY) + } else { + top = view.safeAreaInsets.top + } + let room = barClip.frame.maxY - top - barAccessories.restHeight + guard room != reportedMentionRoom else { return } + reportedMentionRoom = room + onMentionRoomChange(room) + } } /// Holds a bar clear of the keyboard by driving its bottom constraint from the keyboard diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/ComplementaryPalette.swift b/FlipcashUI/Sources/FlipcashUI/Theme/ComplementaryPalette.swift index 582251e3b..fcd15d43a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/ComplementaryPalette.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/ComplementaryPalette.swift @@ -58,6 +58,71 @@ public enum ComplementaryPalette { } #endif + /// The person's colour as text: the `.middle` stop, lightened until it reads on the dark surface. + /// + /// Some hues come out of the palette too dark to read as a name (`#553ED9` is 2.2:1). Blends the + /// stop 15% toward white, in gamma sRGB, until it reaches 4.5:1 against a fixed `#262626` or + /// ten steps have run. The surface is a constant rather than read from the theme so Android, + /// which applies the identical rule, lands on the same colour. `.start` and `.middle` are left + /// alone: the rule and Android's avatar gradient use them as they are. + public static func nameColor(for id: UserID?) -> Color { + guard let rgb = nameRGB(for: id) else { return .textSecondary } + return Color(.sRGB, red: rgb.r, green: rgb.g, blue: rgb.b, opacity: 1) + } + + #if canImport(UIKit) + /// See ``nameColor(for:)``. The transcript draws its quote panel in UIKit. + public static func uiNameColor(for id: UserID?) -> UIColor { + guard let rgb = nameRGB(for: id) else { return UIColor(Color.textSecondary) } + return UIColor(red: rgb.r, green: rgb.g, blue: rgb.b, alpha: 1) + } + #endif + + /// The surface names are corrected against, sRGB `#262626`. + static let nameSurface = 0x26 / 255.0 + static let nameMinimumContrast = 4.5 + private static let nameMaxSteps = 10 + private static let nameLift = 0.15 + + static func nameRGB(for id: UserID?) -> (r: Double, g: Double, b: Double)? { + guard let hsb = components(.middle, for: id) else { return nil } + var rgb = rgb(hue: hsb.hue * 360, saturation: hsb.saturation, value: hsb.brightness) + let surface = relativeLuminance(nameSurface, nameSurface, nameSurface) + var steps = 0 + while contrast(relativeLuminance(rgb.r, rgb.g, rgb.b), surface) < nameMinimumContrast, steps < nameMaxSteps { + rgb = (rgb.r + (1 - rgb.r) * nameLift, rgb.g + (1 - rgb.g) * nameLift, rgb.b + (1 - rgb.b) * nameLift) + steps += 1 + } + return rgb + } + + /// WCAG contrast ratio of two relative luminances. + static func contrast(_ a: Double, _ b: Double) -> Double { + (max(a, b) + 0.05) / (min(a, b) + 0.05) + } + + static func relativeLuminance(_ r: Double, _ g: Double, _ b: Double) -> Double { + func linear(_ c: Double) -> Double { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) } + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + } + + private static func rgb(hue: Double, saturation: Double, value: Double) -> (r: Double, g: Double, b: Double) { + let c = value * saturation + let h = hue / 60 + let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1)) + let m = value - c + let (r, g, b): (Double, Double, Double) + switch Int(h) % 6 { + case 0: (r, g, b) = (c, x, 0) + case 1: (r, g, b) = (x, c, 0) + case 2: (r, g, b) = (0, c, x) + case 3: (r, g, b) = (0, x, c) + case 4: (r, g, b) = (x, 0, c) + default: (r, g, b) = (c, 0, x) + } + return (r + m, g + m, b + m) + } + /// Hue normalized to 0...1 for both colour types, which take it that way where Compose takes /// degrees. The saturation and brightness are Android's numbers untouched. private static func components(