From 7b5738cb1adad48e3af7ea460d30d05655d4ae4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:19:03 -0400 Subject: [PATCH 01/10] feat(chat): suggest group members after an @ in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing @ at the start of the text or after whitespace, with a collapsed cursor, opens a list of members above the reply strip. The fullwidth @ also triggers, matching Android. "a@b" never opens it. Whitespace, moving the cursor off the word, deleting the @, send, or a pick closes it. Groups only. The word after the @ goes to RosterSearchSource; a new word cancels the search in flight. Members without a username are left out, since a pick inserts "@username " as plain text. The first open per visit prepares the source and re-runs the query when it lands. The list shows 4 rows, 3 with a reply open, and 2 when the full count would leave under 120pt of transcript above the keyboard. Row metrics live in MentionListMetrics for the design pass. setBarHeight used to infer the reply strip's height from the delta after `replying` flipped and subtract that stored value on close. The list changes height while open, which would leave that value stale and put the clip below the bar. The bar now reports which cards are open and the height of any card still exiting; the clip subtracts the exiting height and travels only when the set of open cards changes. A row count change while open snaps. --- .../Core/Controllers/RosterSearchSource.swift | 37 +++ .../ChatScreenRepresentable.swift | 48 ++-- .../Screens/Conversation/ComposerModel.swift | 24 ++ .../Conversation/ConversationBottomBar.swift | 86 ++++++- .../Conversation/ConversationScreen.swift | 7 + .../Conversation/MentionPickerModel.swift | 84 +++++++ .../Conversation/MentionSuggestionList.swift | 105 ++++++++ .../Screens/Conversation/MentionTrigger.swift | 100 ++++++++ .../Chat/ChatScreenBarClipTests.swift | 132 ++++++---- .../Chat/ChatScreenKeyboardFloorTests.swift | 2 +- FlipcashTests/Chat/MentionPickerTests.swift | 226 ++++++++++++++++++ .../FlipcashUI/Chat/BarAccessories.swift | 44 ++++ .../Chat/ChatScreenViewController.swift | 90 +++---- 13 files changed, 867 insertions(+), 118 deletions(-) create mode 100644 Flipcash/Core/Controllers/RosterSearchSource.swift create mode 100644 Flipcash/Core/Screens/Conversation/MentionPickerModel.swift create mode 100644 Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift create mode 100644 Flipcash/Core/Screens/Conversation/MentionTrigger.swift create mode 100644 FlipcashTests/Chat/MentionPickerTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift 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/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 9b071d1f8..9f7822537 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,13 @@ 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 + coordinator.screen?.setBarHeight(height, accessories: accessories) } ) ) @@ -305,27 +310,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 +338,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/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 1b4e0e2c3..6de36bf05 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -75,6 +75,8 @@ 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 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) @@ -161,16 +163,50 @@ 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) { + // The list rides the reply strip's reveal: same clip travel in, same fade out. + AccessoryReveal(kind: .mentions, item: mentionCandidates) { candidates in + MentionSuggestionList( + candidates: candidates, + maxRows: MentionRowCap.rows( + replyOpen: replyOpen, + room: mentions?.room, + rowHeight: MentionListMetrics.rowHeight, + 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: composer.replyTarget) { 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 } .animation(replySpring, value: composer.replyTarget) + .animation(replySpring, value: mentionCandidates == nil) + .onChange(of: mentions == nil ? nil : composer.mentionQuery?.text, initial: true) { _, query in + mentions?.update(query: query) + } + } + + /// Whether a reply strip stands under the mention list. + private var replyOpen: Bool { composer.replyTarget != nil } + + /// 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 let mentions, composer.mentionQuery != nil, !mentions.candidates.isEmpty else { return nil } + return mentions.candidates } /// The tip CTA's title. Names the amount that opens the chat when a floor @@ -182,8 +218,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 +232,18 @@ 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 { +private struct AccessoryReveal: View { - let target: ComposerModel.ReplyTarget? - let onDismiss: () -> Void + /// Which card this is, as the bar reports it to the clip. + let kind: BarAccessories.Kind + let target: Item? + @ViewBuilder let card: (Item) -> Card + + init(kind: BarAccessories.Kind, item: Item?, @ViewBuilder card: @escaping (Item) -> Card) { + self.kind = kind + self.target = item + 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 +254,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 @@ -231,7 +275,7 @@ 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 } + private var shown: Item? { target ?? retained } /// 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 @@ -241,7 +285,7 @@ private struct ComposerReplyReveal: View { var body: some View { Group { if let shown { - ComposerReplyStrip(target: shown, onDismiss: onDismiss) + card(shown) .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { measured in guard naturalHeight != measured else { return } naturalHeight = measured @@ -261,6 +305,7 @@ private struct ComposerReplyReveal: View { // `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 @@ -297,6 +342,19 @@ 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 { + accessories.exitingHeight = naturalHeight + } + if kind == .mentions { accessories.mentionsHeight = 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() { @@ -304,6 +362,14 @@ private struct ComposerReplyReveal: View { } } +/// The cards above the composer row, gathered for the bar's measurement. +struct BarAccessoriesKey: PreferenceKey { + static let defaultValue = BarAccessories() + static func reduce(value: inout BarAccessories, nextValue: () -> BarAccessories) { + value = value.merged(with: nextValue()) + } +} + /// The glass type box: a multiline field with a confirm button — an arrow that appears once there's /// text, a checkmark for the length of an edit. Swiping the chat down lowers the keyboard and the box. struct ConversationComposer: View { @@ -320,7 +386,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..a454743e9 --- /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 + await source.prepare(chatID: chatID) + guard !Task.isCancelled else { return } + // New joiners appear without another keystroke. + 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..367169919 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -0,0 +1,105 @@ +// +// 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 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 + + private var visibleRows: Int { min(maxRows, candidates.count) } + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(candidates.enumerated()), id: \.element.id) { index, member in + Button { onPick(member) } label: { MentionRow(member: member) } + .buttonStyle(.plain) + if index < candidates.count - 1 { + Rectangle() + .fill(Color.textSecondary.opacity(0.25)) + .frame(height: 1 / UIScreen.main.scale) + .padding( + .leading, + MentionListMetrics.rowPadding + MentionListMetrics.avatarDiameter + MentionListMetrics.rowSpacing + ) + } + } + } + } + .scrollBounceBehavior(.basedOnSize) + .frame(height: CGFloat(visibleRows) * MentionListMetrics.rowHeight) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + .padding(.horizontal, MentionListMetrics.inset) + .padding(.top, MentionListMetrics.inset) + .padding(.bottom, MentionListMetrics.bottomPadding(replyOpen: replyOpen)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mention-suggestion-list") + } +} + +private struct MentionRow: View { + let member: ConversationMember + + var body: some View { + HStack(spacing: MentionListMetrics.rowSpacing) { + ContactAvatarView( + id: member.id, + displayName: member.displayName, + size: MentionListMetrics.avatarDiameter + ) + Text(member.displayName) + .font(.appTextHeading) + .foregroundStyle(ComplementaryPalette.color(.middle, for: member.userID)) + .lineLimit(1) + if let username = member.username { + Text("@\(username.value)") + .font(.default(size: 14, weight: .medium)) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, MentionListMetrics.rowPadding) + .frame(height: MentionListMetrics.rowHeight) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("mention-suggestion-\(member.username?.value ?? "")") + } +} diff --git a/Flipcash/Core/Screens/Conversation/MentionTrigger.swift b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift new file mode 100644 index 000000000..4a36d3515 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift @@ -0,0 +1,100 @@ +// +// 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 inserted = "@\(username) " + var result = text + result.replaceSubrange(query.range, with: inserted) + let offset = text.distance(from: text.startIndex, to: query.range.lowerBound) + inserted.count + 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 row count for a list whose rows are `rowHeight` tall and 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, chrome: CGFloat) -> Int { + let full = replyOpen ? rowsWithReply : rows + guard let room else { return full } + let list = CGFloat(full) * rowHeight + chrome + return room - list < minimumTranscript ? fallbackRows : full + } +} diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index 4585af9f8..ec5f6ea88 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,15 @@ 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) + screen.setBarHeight(height, accessories: BarAccessories(open: open, exitingHeight: exiting, mentionsHeight: mentions)) } } @@ -55,66 +59,108 @@ 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, replying: false) + report(screen, composerRow) + report(screen, composerRow + strip, open: [.reply]) - // 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) - #expect(clipHeight(bar, window) == composerRow + strip) + let secondLine: CGFloat = 20 + report(screen, composerRow + secondLine + strip, open: [.reply]) + #expect(clipHeight(bar, window) == composerRow + secondLine + strip) - report(screen, composerRow + strip, replying: false) - #expect(clipHeight(bar, window) == composerRow) + report(screen, composerRow + secondLine + strip, exiting: strip) + #expect(clipHeight(bar, window) == composerRow + secondLine) } - @Test("A draft wrapping to a second line mid-reply keeps the strip uncovered") - func barGrowsMidReply_keepsTheStripUncovered() { + @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, replying: false) - report(screen, composerRow + strip, replying: true) + 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) + } - let secondLine: CGFloat = 20 - report(screen, composerRow + secondLine + strip, replying: true) - #expect(clipHeight(bar, window) == composerRow + secondLine + strip) + @Test("Closing the reply under an open list keeps the list, even as its rows change mid-close") + func replyClosesUnderTheList_keepsTheList() { + let (screen, bar, window) = makeScreen() - report(screen, composerRow + secondLine + strip, replying: false) - #expect(clipHeight(bar, window) == composerRow + secondLine) + report(screen, composerRow) + report(screen, composerRow + strip + 3 * row, open: [.reply, .mentions], mentions: 3 * row) + + report(screen, composerRow + strip + 3 * row, open: [.mentions], exiting: strip, mentions: 3 * row) + #expect(clipHeight(bar, window) == composerRow + 3 * row) + + // A search answers while the strip is still fading and the list drops to one row. The strip + // is still in the measurement and still has to stay covered. + 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) + #expect(clipHeight(bar, window) == composerRow + row) + } + + @Test("A list gaining rows while open follows the bar") + func listRowsChange_followTheBar() { + let (screen, bar, window) = makeScreen() + + 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("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 + strip, open: [.reply]) + window.layoutIfNeeded() + let withStrip = rooms.last + + report(screen, composerRow + strip + 2 * row, open: [.reply, .mentions], mentions: 2 * row) + window.layoutIfNeeded() + #expect(rooms.last == withStrip) + + 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/MentionPickerTests.swift b/FlipcashTests/Chat/MentionPickerTests.swift new file mode 100644 index 000000000..288a2efcd --- /dev/null +++ b/FlipcashTests/Chat/MentionPickerTests.swift @@ -0,0 +1,226 @@ +// +// 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)..? + private var released = false + + func prepare(chatID: ConversationID) async { + prepareCount += 1 + guard !released else { return } + await withCheckedContinuation { gate = $0 } + } + + /// Lets the refresh finish, whether or not it has started waiting yet. + func release() { + released = true + 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("Maria", username: "maria")] + let model = MentionPickerModel(source: source, chatID: chatID) + + model.update(query: "ma") + await model.searchTask?.value + #expect(model.candidates.map(\.displayName) == ["Maria"]) + + source.members.append(member("Marco", username: "marco")) + source.release() + await model.refreshTask?.value + await model.searchTask?.value + + #expect(source.searches == ["ma", "ma"]) + #expect(model.candidates.map(\.displayName) == ["Maria", "Marco"]) + } + + @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/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift new file mode 100644 index 000000000..5d4ca3831 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -0,0 +1,44 @@ +// +// 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 mention list's current height, which the transcript's room is measured without. + public var mentionsHeight: CGFloat + + public init(open: Set = [], exitingHeight: CGFloat = 0, mentionsHeight: CGFloat = 0) { + self.open = open + self.exitingHeight = exitingHeight + self.mentionsHeight = mentionsHeight + } + + /// 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, + mentionsHeight: mentionsHeight + other.mentionsHeight + ) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a409e173f..fa065c466 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -102,12 +102,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] = [] @@ -632,56 +633,40 @@ 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 + /// frame can change by a card's whole height without moving anything that is on screen. + 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 + barAccessories = accessories + measuredBarHeight = height 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 } 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 + let travels = toggled && clipHeight != previousClip fade.isHidden = height <= 0 guard !isFirst, view.window != nil, travels else { guard barHeightConstraint.constant != height || barClipHeightConstraint.constant != clipHeight else { return } barHeightConstraint.constant = height barClipHeightConstraint.constant = clipHeight - // 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 - // `viewDidLayoutSubviews` reads the clip's frame during this pass. + // Every other height — a draft wrapping to a second line, the send arrow appearing, the + // mention list gaining a row — 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 `viewDidLayoutSubviews` reads the clip's frame during this pass. UIView.performWithoutAnimation { self.view.layoutIfNeeded() } @@ -695,7 +680,7 @@ public final class ChatScreenViewController: UIViewController { UIView.performWithoutAnimation { self.view.layoutIfNeeded() } - // Then the clip's edge travels, and that edge is the whole animation: it uncovers the strip + // Then 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. barClipHeightConstraint.constant = clipHeight @@ -735,6 +720,23 @@ public final class ChatScreenViewController: UIViewController { let covered = view.bounds.maxY - barClip.frame.minY let drop = keyboardFloor.isKeyboardUp ? Self.raisedTranscriptDrop : 0 transcript.setBottomInset(max(0, covered - keyboardFloor.systemInset - drop)) + reportMentionRoom() + } + + /// 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. + 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 - (measuredBarHeight - barAccessories.mentionsHeight) + guard room != reportedMentionRoom else { return } + reportedMentionRoom = room + onMentionRoomChange(room) } } From 8a6d52bbaea37e62692fcc74a44cc3764fa01e66 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:22:09 -0400 Subject: [PATCH 02/10] fix(chat): re-run the mention search only after a prepare succeeds RosterSearchSource.prepare now returns whether the source could ready itself. The picker searches again only on success, and a picker closed before prepare lands stays closed. Matches Android 345dde414. --- .../Conversation/MentionPickerModel.swift | 6 +- FlipcashTests/Chat/MentionPickerTests.swift | 60 +++++++++++++++---- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift b/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift index a454743e9..11e59db82 100644 --- a/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift +++ b/Flipcash/Core/Screens/Conversation/MentionPickerModel.swift @@ -57,9 +57,9 @@ final class MentionPickerModel { if !didRefresh { didRefresh = true refreshTask = Task { [source, chatID] in - await source.prepare(chatID: chatID) - guard !Task.isCancelled else { return } - // New joiners appear without another keystroke. + // 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() } } diff --git a/FlipcashTests/Chat/MentionPickerTests.swift b/FlipcashTests/Chat/MentionPickerTests.swift index 288a2efcd..5fa41b9e6 100644 --- a/FlipcashTests/Chat/MentionPickerTests.swift +++ b/FlipcashTests/Chat/MentionPickerTests.swift @@ -139,17 +139,17 @@ struct MentionPickerModelTests { private(set) var prepareCount = 0 private(set) var searches: [String] = [] private var gate: CheckedContinuation? - private var released = false + private var outcome: Bool? - func prepare(chatID: ConversationID) async { + func prepare(chatID: ConversationID) async -> Bool { prepareCount += 1 - guard !released else { return } - await withCheckedContinuation { gate = $0 } + if outcome == nil { await withCheckedContinuation { gate = $0 } } + return outcome ?? false } - /// Lets the refresh finish, whether or not it has started waiting yet. - func release() { - released = true + /// 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 } @@ -167,20 +167,54 @@ struct MentionPickerModelTests { @Test("A finished refresh re-runs the current query so new joiners appear") func refreshThenRequery() async throws { let source = FakeSource() - source.members = [member("Maria", username: "maria")] + source.members = [member("Érica", username: "erica")] let model = MentionPickerModel(source: source, chatID: chatID) - model.update(query: "ma") + model.update(query: "er") await model.searchTask?.value - #expect(model.candidates.map(\.displayName) == ["Maria"]) + #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) - source.members.append(member("Marco", username: "marco")) + 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 == ["ma", "ma"]) - #expect(model.candidates.map(\.displayName) == ["Maria", "Marco"]) + #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") From c4fbfb36bcc1f5fbce24e2de0b8eb9de67670765 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:36:10 -0400 Subject: [PATCH 03/10] fix(chat): match the mention list to the Figma spec Rows are 50pt with a 1pt white-10% divider inset to the name and none after the last, so 4/3/2 rows measure 203/152/101pt. The row cap now counts the dividers, through the same listHeight the frame uses. The handle takes #EBEBF5 at 60%, and a pressed row gets the dark tertiary fill. The card keeps the reply strip's glass, radius and inset rather than the spec's solid fill. --- .../Conversation/ConversationBottomBar.swift | 1 + .../Conversation/MentionSuggestionList.swift | 38 ++++++++++++++----- .../Screens/Conversation/MentionTrigger.swift | 13 +++++-- FlipcashTests/Chat/MentionPickerTests.swift | 32 +++++++++++----- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 6de36bf05..807fa5d78 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -171,6 +171,7 @@ struct ConversationBottomBar: View { replyOpen: replyOpen, room: mentions?.room, rowHeight: MentionListMetrics.rowHeight, + divider: MentionListMetrics.dividerHeight, chrome: MentionListMetrics.chrome(replyOpen: replyOpen) ), replyOpen: replyOpen diff --git a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift index 367169919..aa72aed76 100644 --- a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -20,6 +20,21 @@ enum MentionListMetrics { /// 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. @@ -49,21 +64,18 @@ struct MentionSuggestionList: View { LazyVStack(spacing: 0) { ForEach(Array(candidates.enumerated()), id: \.element.id) { index, member in Button { onPick(member) } label: { MentionRow(member: member) } - .buttonStyle(.plain) + .buttonStyle(MentionRowButtonStyle()) if index < candidates.count - 1 { Rectangle() - .fill(Color.textSecondary.opacity(0.25)) - .frame(height: 1 / UIScreen.main.scale) - .padding( - .leading, - MentionListMetrics.rowPadding + MentionListMetrics.avatarDiameter + MentionListMetrics.rowSpacing - ) + .fill(MentionListMetrics.dividerColor) + .frame(height: MentionListMetrics.dividerHeight) + .padding(.leading, MentionListMetrics.dividerInset) } } } } .scrollBounceBehavior(.basedOnSize) - .frame(height: CGFloat(visibleRows) * MentionListMetrics.rowHeight) + .frame(height: MentionListMetrics.listHeight(rows: visibleRows)) .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) .padding(.horizontal, MentionListMetrics.inset) @@ -91,7 +103,7 @@ private struct MentionRow: View { if let username = member.username { Text("@\(username.value)") .font(.default(size: 14, weight: .medium)) - .foregroundStyle(Color.textSecondary) + .foregroundStyle(MentionListMetrics.usernameColor) .lineLimit(1) } Spacer(minLength: 0) @@ -103,3 +115,11 @@ private struct MentionRow: View { .accessibilityIdentifier("mention-suggestion-\(member.username?.value ?? "")") } } + +/// 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 index 4a36d3515..398d26891 100644 --- a/Flipcash/Core/Screens/Conversation/MentionTrigger.swift +++ b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift @@ -88,13 +88,20 @@ nonisolated enum MentionRowCap { /// The transcript height the list must leave above it before it falls back. static let minimumTranscript: CGFloat = 120 - /// The row count for a list whose rows are `rowHeight` tall and whose padding and ground add + /// 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, chrome: CGFloat) -> Int { + 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 = CGFloat(full) * rowHeight + chrome + let list = listHeight(rows: full, rowHeight: rowHeight, divider: divider) + chrome return room - list < minimumTranscript ? fallbackRows : full } } diff --git a/FlipcashTests/Chat/MentionPickerTests.swift b/FlipcashTests/Chat/MentionPickerTests.swift index 5fa41b9e6..795e0d0f0 100644 --- a/FlipcashTests/Chat/MentionPickerTests.swift +++ b/FlipcashTests/Chat/MentionPickerTests.swift @@ -106,24 +106,38 @@ struct MentionInsertionTests { struct MentionRowCapTests { private let rowHeight: CGFloat = 56 + private let divider: CGFloat = 1 private let chrome: CGFloat = 16 + private func rows(replyOpen: Bool, room: CGFloat?) -> 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(MentionRowCap.rows(replyOpen: false, room: nil, rowHeight: rowHeight, chrome: chrome) == 4) - #expect(MentionRowCap.rows(replyOpen: true, room: nil, rowHeight: rowHeight, chrome: chrome) == 3) - #expect(MentionRowCap.rows(replyOpen: false, room: 400, rowHeight: rowHeight, chrome: chrome) == 4) - #expect(MentionRowCap.rows(replyOpen: true, room: 320, rowHeight: rowHeight, chrome: chrome) == 3) + #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 = 240pt; 300 - 240 = 60 < 120. - #expect(MentionRowCap.rows(replyOpen: false, room: 300, rowHeight: rowHeight, chrome: chrome) == 2) - // 3 rows = 184pt; 300 - 184 = 116 < 120. - #expect(MentionRowCap.rows(replyOpen: true, room: 300, rowHeight: rowHeight, chrome: chrome) == 2) + // 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(MentionRowCap.rows(replyOpen: false, room: 360, rowHeight: rowHeight, chrome: chrome) == 4) + #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) } } From 63ac6a6865cd7a4ee50aa837a59e1dca05c6e9d6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:38:37 -0400 Subject: [PATCH 04/10] fix(chat): reuse the whitespace after a picked mention A pick mid-text inserted "@username " before the existing space, leaving "hi @maria there". When whitespace already follows the word, insert "@username" alone and put the cursor after that whitespace. At the end of the text or before a non-space, the trailing space stays. --- .../Screens/Conversation/MentionTrigger.swift | 14 ++++++---- FlipcashTests/Chat/MentionPickerTests.swift | 28 +++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/MentionTrigger.swift b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift index 398d26891..0d68b296e 100644 --- a/Flipcash/Core/Screens/Conversation/MentionTrigger.swift +++ b/Flipcash/Core/Screens/Conversation/MentionTrigger.swift @@ -55,20 +55,24 @@ nonisolated enum MentionTrigger { return MentionQuery(range: range, text: String(text[text.index(after: start).. (text: String, cursor: String.Index) { - let inserted = "@\(username) " + 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)) } } diff --git a/FlipcashTests/Chat/MentionPickerTests.swift b/FlipcashTests/Chat/MentionPickerTests.swift index 795e0d0f0..4a15dd079 100644 --- a/FlipcashTests/Chat/MentionPickerTests.swift +++ b/FlipcashTests/Chat/MentionPickerTests.swift @@ -88,7 +88,7 @@ struct MentionInsertionTests { #expect(MentionTrigger.query(in: composer.draft, selection: cursor) == nil) } - @Test("A pick mid-text keeps the text after the word and puts the cursor after the space") + @Test("A pick before an existing space reuses it and puts the cursor after it") func midText() throws { let text = "hi @ma there" let cursor = text.index(text.startIndex, offsetBy: 6) @@ -96,7 +96,31 @@ struct MentionInsertionTests { let result = MentionTrigger.inserting(username: "maria", replacing: query, in: text) - #expect(result.text == "hi @maria there") + #expect(result.text == "hi @maria there") + #expect(result.text[result.text.startIndex.. Date: Thu, 1 Oct 2026 11:07:48 -0400 Subject: [PATCH 05/10] feat(chat): fetch mention suggestions from Chat.GetMentionSuggestions Add the flipcash2 0.15.0 RPC to ChatService and FlipClient. A MentionSuggestion carries the user's profile and their last_sent_at; one without a readable user id or username is dropped, since a pick inserts the username. ErrorGetMentionSuggestions maps the result explicitly and classifies like ErrorGetRoster. Builds only against the local 0.15.0 checkout (FLIPCASH_PROTO_LOCAL) until that version is tagged and the pin moves. --- .../Clients/Flip API/FlipClient+Chat.swift | 8 +++ .../Flip API/Services/ChatService.swift | 69 +++++++++++++++++++ .../Conversation/MentionSuggestion.swift | 53 ++++++++++++++ .../MentionSuggestionMappingTests.swift | 63 +++++++++++++++++ .../TransportClassificationTests.swift | 8 +++ 5 files changed, 201 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/MentionSuggestion.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/MentionSuggestionMappingTests.swift 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 From 3db7e6ff2416b3e3c6b2c766cab5495a39c1e64d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 11:11:58 -0400 Subject: [PATCH 06/10] feat(chat): back the mention picker with the server's suggestion pool ServerMentionSearch is the session's RosterSearchSource. It fetches Chat.GetMentionSuggestions once per composing session, when the picker first opens in a visit, and filters the held pool locally: every query word must prefix a display-name or username token, and matches keep the server's order. A new message moves its sender to the front of the pool; a sender the pool doesn't hold is not added, since the server decides who is suggested. Fetched profiles are written to the profile cache. A chat whose pool can't be fetched, or that the server answers DENIED or NOT_FOUND, matches nobody and shows no error; the user can still type a handle by hand. Text normalization lives in MentionSearchText, which follows Android's rules. --- .../Controllers/ConversationController.swift | 5 + .../Controllers/FlipClient+Protocols.swift | 9 +- .../Core/Controllers/MentionSearchText.swift | 62 ++++++ .../Controllers/ServerMentionSearch.swift | 154 ++++++++++++++ .../Core/Session/SessionAuthenticator.swift | 12 ++ .../Chat/MentionSearchTextTests.swift | 63 ++++++ .../Chat/ServerMentionSearchTests.swift | 194 ++++++++++++++++++ 7 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Controllers/MentionSearchText.swift create mode 100644 Flipcash/Core/Controllers/ServerMentionSearch.swift create mode 100644 FlipcashTests/Chat/MentionSearchTextTests.swift create mode 100644 FlipcashTests/Chat/ServerMentionSearchTests.swift 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/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/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/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..eb9aaeee9 --- /dev/null +++ b/FlipcashTests/Chat/ServerMentionSearchTests.swift @@ -0,0 +1,194 @@ +// +// 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("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?) -> MentionSuggestion { + let userID = UUID() + return MentionSuggestion( + userID: userID, + profile: Profile( + displayName: displayName, + phone: Phone?.none, + email: nil, + 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 + ) +} From e2519b9a6c04bf6d4aa03afa10b6a2a1f3baf27a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 13:38:05 -0400 Subject: [PATCH 07/10] chore(deps): bump flipcash2-client-protocol to 0.15.0 Picks up Chat.GetMentionSuggestions. Package.resolved moves from 0.14.1 to 0.15.0 (a86e91bd). --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- FlipcashAPI/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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" } } From dc22204bab492eef17f9e3d4858aa91c7ac19ddb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 15:05:13 -0400 Subject: [PATCH 08/10] fix(chat): show profile pictures in the mention suggestions The mention rows drew the monogram placeholder for everyone. Load each member's thumbnail through the shared ProfileAvatarStore and pass it to the avatar, as the reactors sheet does. --- .../Conversation/MentionSuggestionList.swift | 7 +++++++ .../Chat/ServerMentionSearchTests.swift | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift index aa72aed76..a3bf499ed 100644 --- a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -87,6 +87,8 @@ struct MentionSuggestionList: View { } private struct MentionRow: View { + @Environment(SessionContainer.self) private var sessionContainer + let member: ConversationMember var body: some View { @@ -94,6 +96,8 @@ private struct MentionRow: View { ContactAvatarView( id: member.id, displayName: member.displayName, + imageData: sessionContainer.profileAvatars.data(for: member.userID), + blurhash: member.profilePicture?.thumbnailBlurhash, size: MentionListMetrics.avatarDiameter ) Text(member.displayName) @@ -113,6 +117,9 @@ private struct MentionRow: View { .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) + } } } diff --git a/FlipcashTests/Chat/ServerMentionSearchTests.swift b/FlipcashTests/Chat/ServerMentionSearchTests.swift index eb9aaeee9..3dafb06fc 100644 --- a/FlipcashTests/Chat/ServerMentionSearchTests.swift +++ b/FlipcashTests/Chat/ServerMentionSearchTests.swift @@ -45,6 +45,21 @@ struct ServerMentionSearchTests { #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]) @@ -168,7 +183,7 @@ struct ServerMentionSearchTests { } } -private func suggestion(_ displayName: String, _ username: String, lastSentAt: TimeInterval?) -> MentionSuggestion { +private func suggestion(_ displayName: String, _ username: String, lastSentAt: TimeInterval?, picture: ProfilePicture? = nil) -> MentionSuggestion { let userID = UUID() return MentionSuggestion( userID: userID, @@ -176,6 +191,7 @@ private func suggestion(_ displayName: String, _ username: String, lastSentAt: T displayName: displayName, phone: Phone?.none, email: nil, + profilePicture: picture, userID: userID, username: Username(username) ), From aaab443c248fd4ff13e1efae016b17a2a910106b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:18:39 -0400 Subject: [PATCH 09/10] fix(chat): lift author names that are too dark to read on the bar Some palette hues are too dark to read as a name on the dark surface (#553ED9 is 2.2:1 against #262626). `ComplementaryPalette.nameColor` blends the `.middle` stop 15% toward white until it reaches 4.5:1, for at most ten steps, against a fixed #262626 so Android can apply the same rule and land on the same colour. Mention rows, the transcript's quote author and the composer's reply strip use it. The reply strip's rule takes the name colour too, so it keeps the same contrast as the name beside it. --- .../Conversation/ComposerReplyStrip.swift | 6 +- .../Conversation/MentionSuggestionList.swift | 2 +- .../Chat/ComplementaryPaletteTests.swift | 54 +++++++++++++++ .../FlipcashUI/Chat/ChatQuotePanelView.swift | 2 +- .../Theme/ComplementaryPalette.swift | 65 +++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index d43c64966..4346cd2de 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -43,8 +43,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 +95,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()) diff --git a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift index a3bf499ed..c93ff0fe1 100644 --- a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -102,7 +102,7 @@ private struct MentionRow: View { ) Text(member.displayName) .font(.appTextHeading) - .foregroundStyle(ComplementaryPalette.color(.middle, for: member.userID)) + .foregroundStyle(ComplementaryPalette.nameColor(for: member.userID)) .lineLimit(1) if let username = member.username { Text("@\(username.value)") 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/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/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( From 0e77a1eebea950054d0484de7b1fd2854ad04a9f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:20:12 -0400 Subject: [PATCH 10/10] feat(chat): split the reply strip and mention list from one glass surface The mention list and the reply strip now share a GlassEffectContainer, each with its own glass ID. With the list open, a reply peels out of the list's bottom edge, and closing it merges the strip back in. Typing `@` with a reply open splits the list out of the strip. The container's spacing equals the 12pt gap the cards rest at, so a neck joins the two shapes until the split lands. A card opening or closing on its own still slides out from behind the composer. This also fixes several jumps while the list resizes: - The composer slid down 219pt from the top of the bar when the list opened. The bar's host was resized inside SwiftUI's animated update, so the whole bar animated toward its new size. The host is now screen-tall and pinned to the bottom, and its height is reported after the update. - Filtering moved the list's bottom edge about a row off the composer and back. When results change, the list now resizes in a single animation, and the UIKit clip holds the taller of the old and new heights until the spring ends. - With both cards open, the transcript lost its bottom pin. The list's room included the list itself, so the bar flipped between 313.7 and 149.7pt within 17ms. The room now leaves the list out. - The transcript and the fade above the bar follow the glass edge during a held shrink, so they no longer wait out the hold and then snap. Rows are built eagerly. A lazy row created while the card grew slid in from the wrong place, along with its divider. --- .../ChatScreenRepresentable.swift | 7 +- .../Conversation/ComposerReplyStrip.swift | 7 +- .../Conversation/ConversationBottomBar.swift | 292 ++++++++++++++++-- .../Conversation/MentionSuggestionList.swift | 33 +- .../Chat/ChatScreenBarClipTests.swift | 34 +- .../FlipcashUI/Chat/BarAccessories.swift | 17 +- .../Sources/FlipcashUI/Chat/ChatMotion.swift | 8 +- .../Chat/ChatScreenViewController.swift | 146 ++++++--- 8 files changed, 456 insertions(+), 88 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 9f7822537..aed97cd68 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -290,7 +290,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { .environment(conversationController) .modifier( MeasuredBarHeight { height, accessories in - coordinator.screen?.setBarHeight(height, accessories: accessories) + // 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) + } } ) ) diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 4346cd2de..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 @@ -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 807fa5d78..4d5c6a6ad 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -78,6 +78,17 @@ struct ConversationBottomBar: View { /// 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) @@ -149,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) @@ -163,8 +178,10 @@ 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. - AccessoryReveal(kind: .mentions, item: mentionCandidates) { candidates in + // 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( @@ -185,29 +202,60 @@ struct ConversationBottomBar: View { // 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. - AccessoryReveal(kind: .reply, item: composer.replyTarget) { target in + 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) - .animation(replySpring, value: mentionCandidates == nil) .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 { composer.replyTarget != nil } + 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 let mentions, composer.mentionQuery != nil, !mentions.candidates.isEmpty else { return nil } - return mentions.candidates + 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 @@ -238,11 +286,21 @@ 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?, @ViewBuilder card: @escaping (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 } @@ -264,6 +322,9 @@ private struct AccessoryReveal: 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. @@ -276,33 +337,79 @@ private struct AccessoryReveal: 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. + /// + /// 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 { 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) @@ -321,17 +428,28 @@ private struct AccessoryReveal: 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 @@ -349,21 +467,151 @@ private struct AccessoryReveal: View { var accessories = BarAccessories() if target != nil, naturalHeight > 0 { accessories.open = [kind] - } else if target == nil, retained != nil { + } else if target == nil, retained != nil, !collapsesInPlace { accessories.exitingHeight = naturalHeight } - if kind == .mentions { accessories.mentionsHeight = revealHeight } + // 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) { diff --git a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift index c93ff0fe1..56d940f2d 100644 --- a/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift +++ b/Flipcash/Core/Screens/Conversation/MentionSuggestionList.swift @@ -56,31 +56,42 @@ struct MentionSuggestionList: View { 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 { - LazyVStack(spacing: 0) { + // 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 - Button { onPick(member) } label: { MentionRow(member: member) } - .buttonStyle(MentionRowButtonStyle()) - if index < candidates.count - 1 { - Rectangle() - .fill(MentionListMetrics.dividerColor) - .frame(height: MentionListMetrics.dividerHeight) - .padding(.leading, MentionListMetrics.dividerInset) + 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)) - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + .modifier(BarCardGlass(id: .mentions)) .padding(.horizontal, MentionListMetrics.inset) - .padding(.top, MentionListMetrics.inset) - .padding(.bottom, MentionListMetrics.bottomPadding(replyOpen: replyOpen)) + .padding(.top, collapsed ? 0 : MentionListMetrics.inset) + .padding(.bottom, collapsed ? 0 : MentionListMetrics.bottomPadding(replyOpen: replyOpen)) .accessibilityElement(children: .contain) .accessibilityIdentifier("mention-suggestion-list") } diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index ec5f6ea88..b8379cf2a 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -51,7 +51,10 @@ struct ChatScreenBarClipTests { mentions: CGFloat = 0 ) { UIView.performWithoutAnimation { - screen.setBarHeight(height, accessories: BarAccessories(open: open, exitingHeight: exiting, mentionsHeight: mentions)) + // 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)) } } @@ -113,25 +116,42 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow + strip + 3 * row) } - @Test("Closing the reply under an open list keeps the list, even as its rows change mid-close") - func replyClosesUnderTheList_keepsTheList() { + @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) + #expect(clipHeight(bar, window) >= composerRow + 3 * row) - // A search answers while the strip is still fading and the list drops to one row. The strip - // is still in the measurement and still has to stay covered. + // 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) + #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) + 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) + + 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() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift index 5d4ca3831..3e0873de5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -24,13 +24,18 @@ public struct BarAccessories: Equatable, Sendable { /// over them. public var exitingHeight: CGFloat - /// The mention list's current height, which the transcript's room is measured without. - public var mentionsHeight: CGFloat - - public init(open: Set = [], exitingHeight: CGFloat = 0, mentionsHeight: CGFloat = 0) { + /// 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.mentionsHeight = mentionsHeight + self.restHeight = restHeight } /// Two reports combined, as sibling cards report them. @@ -38,7 +43,7 @@ public struct BarAccessories: Equatable, Sendable { BarAccessories( open: open.union(other.open), exitingHeight: exitingHeight + other.exitingHeight, - mentionsHeight: mentionsHeight + other.mentionsHeight + 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/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index fa065c466..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! @@ -313,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 @@ -491,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 @@ -507,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([ @@ -520,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 @@ -530,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 @@ -640,15 +646,20 @@ public final class ChatScreenViewController: UIViewController { /// 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 a card's whole height without moving anything that is on screen. + /// 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 barHeightConstraint != nil else { return } + guard barClipHeightConstraint != nil else { return } let isFirst = !didMeasureBar didMeasureBar = true @@ -656,36 +667,89 @@ public final class ChatScreenViewController: UIViewController { // 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 - let travels = toggled && clipHeight != previousClip 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 + } - guard !isFirst, view.window != nil, travels else { - guard barHeightConstraint.constant != height || barClipHeightConstraint.constant != clipHeight else { return } - barHeightConstraint.constant = height - barClipHeightConstraint.constant = clipHeight - // Every other height — a draft wrapping to a second line, the send arrow appearing, the - // mention list gaining a row — 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 `viewDidLayoutSubviews` reads the clip's frame during this pass. + if inPlace, !isFirst, view.window != nil { + holdForInPlaceResize(clipHeight: clipHeight) + return + } + + 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. 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() } 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 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. - 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() + } } } @@ -717,14 +781,22 @@ 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)) - reportMentionRoom() } /// 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 @@ -733,7 +805,7 @@ public final class ChatScreenViewController: UIViewController { } else { top = view.safeAreaInsets.top } - let room = barClip.frame.maxY - top - (measuredBarHeight - barAccessories.mentionsHeight) + let room = barClip.frame.maxY - top - barAccessories.restHeight guard room != reportedMentionRoom else { return } reportedMentionRoom = room onMentionRoomChange(room)