From 35c54e5e63f7ca86e0b26d333219183cdcb58291 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 13:01:38 -0500 Subject: [PATCH 01/26] feat(chat): mark chats unread or read from the chat list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a leading swipe and a long-press menu on chat rows, a "…" menu with Select Chats and Read All, and a select mode with bulk read, archive and mute. Mark as Unread is a device-only flag in ChatMarkedUnreadStore, kept beside ChatArchiveStore; Mark as Read advances READ as opening the chat does. The long-press preview is a read-only view that never calls markSeen. --- .../Controllers/ConversationController.swift | 162 +++++++- .../Conversation/ChatMarkedUnreadStore.swift | 86 +++++ .../Conversation/ConversationScreen.swift | 2 + .../Core/Screens/Main/Home/HomeTabView.swift | 4 + .../Main/Tips/ArchivedChatsScreen.swift | 20 + .../Core/Screens/Main/Tips/ArchivedRow.swift | 12 +- .../Screens/Main/Tips/ChatReadActions.swift | 123 ++++++ .../Screens/Main/Tips/ChatRowActions.swift | 314 +++++++++++++++ .../Core/Screens/Main/Tips/RecipientRow.swift | 39 +- .../Main/Tips/TipConversationsScreen.swift | 145 +++++-- .../Core/Screens/Profile/MuteChatSheet.swift | 27 +- .../Core/Session/SessionAuthenticator.swift | 11 + FlipcashTests/Chat/ChatMarkUnreadTests.swift | 364 ++++++++++++++++++ 13 files changed, 1265 insertions(+), 44 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift create mode 100644 FlipcashTests/Chat/ChatMarkUnreadTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b1aa30814..21f815e7b 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -103,6 +103,8 @@ final class ConversationController { var chatListProjection: ChatListProjection { // Reading the store's tracked set here is what makes archiving re-render the list. let archived = chatArchive?.archivedIDs ?? [] + // Same for the marks: reading the tracked set re-renders rows, chips and the badge. + let marked = chatMarkedUnread?.markedIDs ?? [] let entries = chatListSource.map { conversation in ChatListEntry( id: conversation.id, @@ -111,7 +113,7 @@ final class ConversationController { isArchived: archived.contains(conversation.id), isMuted: conversation.isMuted(at: .now), isHidden: conversation.isHidden, - unread: chatListUnread(for: conversation), + unread: chatListUnread(for: conversation, isMarked: marked.contains(conversation.id)), joinRequests: chatListJoinRequests(for: conversation.id) ) } @@ -130,8 +132,14 @@ final class ConversationController { return ChatListJoinRequests(count: requests.count, newestEnteredAt: requests.compactMap(\.enteredAt).max()) } - private func chatListUnread(for conversation: Conversation) -> ChatListUnread { - guard conversation.hasUnread(for: selfUserID) else { return .count(0) } + /// The unread state the list rules see for `conversation`, mark included. + func chatListUnread(for conversation: Conversation) -> ChatListUnread { + chatListUnread(for: conversation, isMarked: isMarkedUnread(conversation.id)) + } + + /// A marked chat with nothing unread is the no-count unread state; a known count wins over the mark. + private func chatListUnread(for conversation: Conversation, isMarked: Bool) -> ChatListUnread { + guard conversation.hasUnread(for: selfUserID) else { return isMarked ? .unknown : .count(0) } return unreadCount(for: conversation).map(ChatListUnread.count) ?? .unknown } @@ -169,6 +177,116 @@ final class ConversationController { chatArchive?.unarchive(id) } + /// Archives every chat in `ids`. + func archive(_ ids: [ConversationID]) { + ids.forEach(archive) + } + + /// Unarchives every chat in `ids`. + func unarchive(_ ids: [ConversationID]) { + ids.forEach(unarchive) + } + + // MARK: - Mark unread + + /// Whether the user marked the chat unread on this device. + func isMarkedUnread(_ id: ConversationID) -> Bool { + chatMarkedUnread?.markedIDs.contains(id) ?? false + } + + /// Unread messages or a mark: what decides between Mark as Read and Mark as Unread. A pending + /// join request doesn't count; it is not something reading the chat clears. + func isUnread(_ conversation: Conversation) -> Bool { + conversation.hasUnread(for: selfUserID) || isMarkedUnread(conversation.id) + } + + /// The read action a chat's swipe, menu and selection offer; nil for a chat the user hasn't + /// joined, such as one still in Requests, which can't be marked. + func readAction(for conversation: Conversation) -> ChatReadAction? { + guard !ChatListSelection.source(from: [conversation], isMember: store.isMember(of:)).isEmpty else { return nil } + return isUnread(conversation) ? .markRead : .markUnread + } + + /// Runs `action` on one chat. + func perform(_ action: ChatReadAction, on id: ConversationID, source: ChatReadSource) { + switch action { + case .markRead: markRead(id, source: source) + case .markUnread: markUnread(id, source: source) + } + } + + /// Marks the chat unread on this device. Sends nothing. + func markUnread(_ id: ConversationID, source: ChatReadSource) { + guard let conversation = conversation(withID: id), readAction(for: conversation) != nil else { return } + chatMarkedUnread?.mark(id) + } + + /// Clears the mark and advances READ to the newest message, the same call opening the chat makes. + /// Other members see the receipt. A chat with nothing unread sends nothing. + func markRead(_ id: ConversationID, source: ChatReadSource) { + chatMarkedUnread?.clear(id) + guard let conversation = conversation(withID: id), + conversation.hasUnread(for: selfUserID), + let newest = conversation.lastMessage?.id + else { return } + advanceReadPointer(to: newest, in: id) + } + + /// Read All: reads every unread chat in the main list, muted included, and clears every mark + /// there. Archived chats keep theirs. Returns how many chats it read, for analytics. + @discardableResult + func readAllChats() -> Int { + let unread = chatListConversations.filter(isUnread) + for conversation in unread { + markRead(conversation.id, source: .menu) + } + return unread.count + } + + /// The select-mode read capsule: reads every chat in `ids`, or marks them all, per `toggle`. + /// Read All with nothing selected goes through ``readAllChats()`` after its confirmation instead. + func apply(_ toggle: ChatSelectionReadToggle, to ids: [ConversationID]) { + switch toggle { + case .readAll: return + case .read: ids.forEach { markRead($0, source: .selection) } + case .unread: ids.forEach { markUnread($0, source: .selection) } + } + } + + /// The read capsule for a selection of `ids`. + func readToggle(for ids: [ConversationID]) -> ChatSelectionReadToggle { + ChatSelectionReadToggle(selectedUnread: ids.compactMap(conversation(withID:)).map(isUnread)) + } + + /// The mute capsule for a selection of `ids`. + func muteToggle(for ids: [ConversationID]) -> ChatSelectionMute { + ChatSelectionMute(selectedMuted: ids.map { isMuted(conversationID: $0) }) + } + + /// Whether Read All has anything to do. + var hasUnreadInChatList: Bool { + chatListConversations.contains(where: isUnread) + } + + /// Called at logout: marks are the signed-in account's alone, so the next sign-in starts with none. + func prepareForLogout() { + chatMarkedUnread?.clearAll() + } + + /// Called as a chat opens: opening it is reading it, so the mark goes. + func chatOpened(_ id: ConversationID) { + chatMarkedUnread?.clear(id) + } + + /// Up to `limit` of the newest messages stored locally, oldest first, for the long-press preview. + /// Read straight from the database: no network, no read pointer, and the open chat's window + /// cache is left alone. Falls back to the feed's last message when the transcript isn't stored. + func previewMessages(for conversation: Conversation, limit: Int = 10) -> [ConversationMessage] { + let stored = (try? database.messagesWindow(conversationID: conversation.id, before: nil, limit: limit)) ?? [] + if !stored.isEmpty { return stored } + return conversation.lastMessage.map { [$0] } ?? [] + } + /// The unread count a conversation row shows; nil when the chat is unread but its READ /// watermark's message is neither stored nor fetched yet. See /// ``Conversation/unreadCount(for:unreadSeqAt:)`` and ``resolveUnreadCount(for:)``. @@ -415,6 +533,9 @@ final class ConversationController { /// `chatDrafts`; tests attach one only when they exercise archive. @ObservationIgnored var chatArchive: ChatArchiveStore? + /// The chats marked unread on this device, wired by `SessionAuthenticator` like ``chatArchive``. + @ObservationIgnored var chatMarkedUnread: (any ChatMarkedUnreadStore)? + @ObservationIgnored var chatDrafts: ChatDraftStore? { didSet { failedSends = chatDrafts.map(FailedSendDrafts.init(store:)) } } @@ -1398,6 +1519,7 @@ final class ConversationController { // The server clears archive with mute on leave, so the local record is stale the moment the // leave lands. A hidden chat's record is deliberately left alone elsewhere (rule 5). chatArchive?.unarchive(conversationID) + chatMarkedUnread?.clear(conversationID) } /// Records the signed-in user's own join or leave when a roster update names them, so a membership @@ -1462,6 +1584,20 @@ final class ConversationController { applyViewerState(viewerState, in: conversationID) } + /// Mutes every chat in `ids` until `mute` lapses, one call each, stopping at the first failure. + func mute(_ ids: [ConversationID], _ mute: ConversationMuteState) async throws { + for id in ids { + try await self.mute(conversationID: id, mute) + } + } + + /// Unmutes every chat in `ids`, one call each, stopping at the first failure. + func unmute(_ ids: [ConversationID]) async throws { + for id in ids { + try await unmute(conversationID: id) + } + } + /// Seats a viewer state the RPC returned through the same version comparison a streamed /// `viewerStateChanged` goes through, so a response overtaken by the stream loses rather than /// reinstating the state it already replaced. @@ -1799,6 +1935,26 @@ final class ConversationController { return "Typing…" } guard let message = conversation.lastMessage else { return nil } + return messagePreview( + message, + in: conversation, + knownAuthors: knownAuthors, + viewerAmount: viewerAmount, + requestTitle: requestTitle, + currencyName: currencyName + ) + } + + /// One message as a line of text, attributed to whoever wrote it: what the feed row shows for + /// the last message, and what the long-press preview shows for each of the last few. + func messagePreview( + _ message: ConversationMessage, + in conversation: Conversation, + knownAuthors: KnownAuthorDirectory.Snapshot = .empty, + viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount }, + requestTitle: (PaymentRequestID) -> String? = { _ in nil }, + currencyName: (PublicKey) -> String? + ) -> String? { let isFromSelf = message.isFromSelf(selfUserID) let senderName = groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors) diff --git a/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift new file mode 100644 index 000000000..b565f282a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift @@ -0,0 +1,86 @@ +// +// ChatMarkedUnreadStore.swift +// Flipcash +// + +import Foundation +import Observation +import FlipcashCore + +/// The chats the user has marked unread. Every caller reads and writes through this, so a +/// server-backed store can replace the local one without any caller changing. +/// +/// `markedIDs` must be observable: the list projection reads it, and the rows, chips and tab badge +/// re-render when it changes. +@MainActor +protocol ChatMarkedUnreadStore: AnyObject { + + /// The marked chats. + var markedIDs: Set { get } + + /// Marks one chat. + func mark(_ id: ConversationID) + + /// Clears one chat's mark. + func clear(_ id: ConversationID) + + /// Clears the mark on each of `ids`. + func clear(_ ids: Set) + + /// Clears every mark. + func clearAll() +} + +/// The device-only ``ChatMarkedUnreadStore``: a JSON file beside the drafts in Application Support, +/// owner-scoped like ``ChatArchiveStore``. Outside `FlipcashStore`, whose version bump deletes the +/// database, because nothing can re-fetch a mark until the server stores it. +@MainActor +@Observable +final class LocalChatMarkedUnreadStore: ChatMarkedUnreadStore { + + private(set) var markedIDs: Set + + @ObservationIgnored private let url: URL + + /// A file that cannot be read is treated as no marks; the user can mark again. + init(directory: URL, owner: PublicKey) { + self.url = directory.appendingPathComponent("flipcash-\(owner.base58)-marked-unread.json") + let keys = (try? Data(contentsOf: url)).flatMap { try? JSONDecoder().decode([String].self, from: $0) } ?? [] + self.markedIDs = Set(keys.compactMap { Data(base64Encoded: $0).map(ConversationID.init(data:)) }) + } + + func mark(_ id: ConversationID) { + guard !markedIDs.contains(id) else { return } + markedIDs.insert(id) + write() + } + + func clear(_ id: ConversationID) { + guard markedIDs.contains(id) else { return } + markedIDs.remove(id) + write() + } + + func clear(_ ids: Set) { + guard !markedIDs.isDisjoint(with: ids) else { return } + markedIDs.subtract(ids) + write() + } + + func clearAll() { + guard !markedIDs.isEmpty else { return } + markedIDs = [] + write() + } + + /// Keys as ``ChatArchiveFile/key(_:)`` does: base64 of the id's bytes, which fits both the + /// 32-byte DM shape and a group's 16 bytes. + private func write() { + do { + let keys = markedIDs.map { $0.data.base64EncodedString() }.sorted() + try JSONEncoder().encode(keys).write(to: url, options: .atomic) + } catch { + ErrorReporting.captureError(error, reason: "Failed to write marked-unread chats") + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 92464d757..1eca9b77c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1287,6 +1287,8 @@ struct ConversationScreen: View { content .task(id: conversationID) { guard let conversationID else { return } + // Opening a chat is reading it, so a mark from the list goes, whatever the gate says. + conversationController.chatOpened(conversationID) // Ensure the conversation metadata is in the store before the title, tip styling, and Send // Cash target rely on it. The post-tip open (and any push/link that lands here before the // feed or stream has the freshly-created chat) would otherwise render the unresolved diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index a383460ee..ca13e2947 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -36,6 +36,8 @@ struct HomeTabView: View { /// The You tab's icon, once the profile has a picture. Owned here rather /// than by either bar, because both bars want the same download. @State private var profilePhoto = TabBarProfilePhoto() + /// The Chats list's select mode. Held here because its capsules take the tab bar's place. + @State private var chatSelection = ChatSelection() init() { _selection = State(initialValue: HomeTab.initial) @@ -98,6 +100,7 @@ struct HomeTabView: View { private var isTabBarHidden: Bool { if isSearchSelected { return sessionContainer.session.isShowingBill } + if selection == .chat, chatSelection.isActive { return true } return selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, @@ -412,6 +415,7 @@ struct HomeTabView: View { // would discard. if selection == .chat || hasOpenedChat { ChatTab() + .environment(chatSelection) } else { Color.backgroundMain } diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift index 4ae769dc9..146a63a1c 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift @@ -14,6 +14,8 @@ struct ArchivedChatsScreen: View { @Environment(ConversationController.self) private var conversationController @Environment(AppRouter.self) private var router + @State private var muteTarget: ArchivedMuteTarget? + var body: some View { let conversations = conversationController.archivedConversations @@ -41,6 +43,13 @@ struct ArchivedChatsScreen: View { .tint(.backgroundRow) .accessibilityLabel("Unarchive chat") } + .chatRowActions( + for: conversation, + in: .archived, + isEnabled: true, + onMute: { muteTarget = ArchivedMuteTarget(id: conversation.id) }, + onArchive: { conversationController.unarchive(conversation.id) } + ) } } .listStyle(.plain) @@ -49,5 +58,16 @@ struct ArchivedChatsScreen: View { } .navigationTitle("Archived") .toolbarTitleDisplayMode(.inline) + .sheet(item: $muteTarget) { target in + MuteChatSheet( + conversationID: target.id, + isPresented: Binding(get: { muteTarget != nil }, set: { if !$0 { muteTarget = nil } }) + ) + } } } + +/// The archived chat whose mute sheet the long-press menu opened. +private struct ArchivedMuteTarget: Identifiable { + let id: ConversationID +} diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift index 1c2e3db9f..12b104db6 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedRow.swift @@ -45,10 +45,20 @@ struct ArchivedRow: View { extension ToastController.Toast { /// "Chat archived", with an Undo that runs `undo`. static func archivedChat(undo: @escaping () -> Void) -> Self { + archivedChats(count: 1, undo: undo) + } + + /// "Chat archived" for one chat, "%d chats archived" for more, with one Undo that runs `undo`. + static func archivedChats(count: Int, undo: @escaping () -> Void) -> Self { .init( - "Chat archived", + archivedChatsTitle(count: count), systemImage: "archivebox", action: .init("Undo", accessibilityIdentifier: "chat-archive-undo", handler: undo) ) } + + /// The archive toast's text for `count` chats. + static func archivedChatsTitle(count: Int) -> String { + count == 1 ? "Chat archived" : "\(count) chats archived" + } } diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift new file mode 100644 index 000000000..e532c747f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -0,0 +1,123 @@ +// +// ChatReadActions.swift +// Flipcash +// + +import Foundation +import Observation +import FlipcashCore + +/// The read action a chat row offers: Mark as Read while the chat is unread, Mark as Unread otherwise. +enum ChatReadAction: Equatable { + case markRead + case markUnread + + /// The swipe and menu label. + var title: String { + switch self { + case .markRead: "Mark as Read" + case .markUnread: "Mark as Unread" + } + } + + /// The SF Symbol the swipe and menu draw. + var systemImage: String { + switch self { + case .markRead: "message" + case .markUnread: "message.badge" + } + } +} + +/// Where a read action came from. The `Chat Marked Unread` and `Chat Marked Read` events carry it +/// once the shared analytics catalogue that defines them is pinned. +enum ChatReadSource: Equatable { + case swipe + case menu + case selection +} + +/// The Chats list's select mode: whether it is on, and which chats are picked. +/// +/// Owned by the tab view, which hides the tab bar while it is on, and handed to the list through the +/// environment. +@MainActor +@Observable +final class ChatSelection { + + private(set) var isActive = false + private(set) var selected: Set = [] + + /// Enters select mode with nothing selected. + func begin() { + selected = [] + isActive = true + } + + /// Leaves select mode. + func end() { + isActive = false + selected = [] + } + + func toggle(_ id: ConversationID) { + if selected.contains(id) { + selected.remove(id) + } else { + selected.insert(id) + } + } + + /// Drops picks that are no longer in the list, so an archived or left chat isn't acted on. + func retain(_ ids: Set) { + let kept = selected.intersection(ids) + if kept != selected { selected = kept } + } +} + +/// What the select-mode read capsule does. +enum ChatSelectionReadToggle: Equatable { + /// Nothing selected: the capsule is Read All. + case readAll + /// At least one selected chat is unread: read them all. + case read + /// Every selected chat is read: mark them all. + case unread + + init(selectedUnread: [Bool]) { + if selectedUnread.isEmpty { + self = .readAll + } else if selectedUnread.contains(true) { + self = .read + } else { + self = .unread + } + } + + var title: String { + switch self { + case .readAll: "Read All" + case .read: "Read" + case .unread: "Unread" + } + } +} + +/// What the select-mode mute capsule does. +enum ChatSelectionMute: Equatable { + /// At least one selected chat is audible: open the mute sheet once for all of them. + case mute + /// Every selected chat is muted: unmute them all. + case unmute + + init(selectedMuted: [Bool]) { + self = !selectedMuted.isEmpty && selectedMuted.allSatisfy { $0 } ? .unmute : .mute + } + + var title: String { + switch self { + case .mute: "Mute" + case .unmute: "Unmute" + } + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift new file mode 100644 index 000000000..fa4dbab46 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -0,0 +1,314 @@ +// +// ChatRowActions.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Which list a row sits in: the third long-press item archives on the main list and unarchives on +/// the archived one. +enum ChatRowList { + case main + case archived +} + +extension View { + + /// A chat row's leading read swipe and its long-press menu. Off in select mode, and for a chat + /// the user can't mark (one still in Requests), where neither appears. + /// + /// - Parameters: + /// - onMute: opens ``MuteChatSheet`` for the chat, as the trailing swipe does. + /// - onArchive: archives (main list) or unarchives (archived list) the chat, toast included. + func chatRowActions( + for conversation: Conversation, + in list: ChatRowList, + isEnabled: Bool, + onMute: @escaping () -> Void, + onArchive: @escaping () -> Void + ) -> some View { + modifier(ChatRowActionsModifier( + conversation: conversation, + list: list, + isEnabled: isEnabled, + onMute: onMute, + onArchive: onArchive + )) + } +} + +private struct ChatRowActionsModifier: ViewModifier { + + let conversation: Conversation + let list: ChatRowList + let isEnabled: Bool + let onMute: () -> Void + let onArchive: () -> Void + + @Environment(ConversationController.self) private var conversationController + + func body(content: Content) -> some View { + if isEnabled, let action = conversationController.readAction(for: conversation) { + content + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + conversationController.perform(action, on: conversation.id, source: .swipe) + } label: { + Label(action.title, systemImage: action.systemImage) + .labelStyle(.iconOnly) + } + .tint(.unreadIndicator) + .accessibilityLabel(action.title) + } + .modifier(ChatRowContextMenu( + conversation: conversation, + action: action, + list: list, + onMute: onMute, + onArchive: onArchive + )) + } else { + content + } + } +} + +/// The long-press menu. A chat with a message in the local store previews its transcript above the +/// menu; one with none shows the menu alone. +private struct ChatRowContextMenu: ViewModifier { + + let conversation: Conversation + let action: ChatReadAction + let list: ChatRowList + let onMute: () -> Void + let onArchive: () -> Void + + @Environment(ConversationController.self) private var conversationController + + func body(content: Content) -> some View { + if conversation.lastMessage != nil { + content.contextMenu { + items + } preview: { + ChatPreview(conversation: conversation) + // The preview is hosted outside this view's tree, so the controller is handed on. + .environment(conversationController) + } + } else { + content.contextMenu { items } + } + } + + @ViewBuilder private var items: some View { + Button(action.title, systemImage: action.systemImage) { + conversationController.perform(action, on: conversation.id, source: .menu) + } + if conversationController.isMuted(conversationID: conversation.id) { + Button("Unmute", systemImage: SystemSymbol.bell.rawValue) { + Task { try? await conversationController.unmute(conversationID: conversation.id) } + } + } else { + Button("Mute", systemImage: SystemSymbol.bellSlash.rawValue, action: onMute) + } + switch list { + case .main: + Button("Archive", systemImage: "archivebox", action: onArchive) + case .archived: + Button("Unarchive", systemImage: "tray.and.arrow.up", action: onArchive) + } + } +} + +// MARK: - Preview - + +/// The long-press preview: the chat's last messages from the local store, read-only, with no input +/// bar. Nothing here touches the read pointer: it is not `ConversationScreen`, which marks messages +/// seen as they appear, so looking leaves the chat as unread as it was. +struct ChatPreview: View { + + let conversation: Conversation + + @Environment(ConversationController.self) private var conversationController + + var body: some View { + let messages = conversationController.previewMessages(for: conversation) + let selfUserID = conversationController.selfUserID + + VStack(alignment: .leading, spacing: 0) { + Text(conversationController.displayName(for: conversation)) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + Divider().overlay(Color.rowSeparator) + VStack(spacing: 6) { + Spacer(minLength: 0) + ForEach(messages, id: \.id) { message in + if let text = conversationController.messagePreview( + message, + in: conversation, + currencyName: { _ in nil } + ) { + ChatPreviewBubble(text: text, isFromSelf: message.isFromSelf(selfUserID)) + } + } + } + .padding(12) + } + .frame(width: 340, height: 430) + .background(Color.backgroundMain) + } +} + +/// One message in the preview: the transcript's sides, without its cells. +private struct ChatPreviewBubble: View { + + let text: String + let isFromSelf: Bool + + var body: some View { + Text(text) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(4) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isFromSelf ? Color.backgroundSecondary : Color.backgroundRow, in: .rect(cornerRadius: 16)) + .frame(maxWidth: 260, alignment: isFromSelf ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: isFromSelf ? .trailing : .leading) + } +} + +// MARK: - Read All - + +extension DialogItem { + + /// Read All's confirmation: reading every chat sends read receipts, so it asks first. + static func markAllChatsRead(onConfirm: @escaping () -> Void) -> DialogItem { + .info( + title: "Mark All Chats as Read?", + subtitle: "Everyone in these chats will see that you've read their messages" + ) { + .standard("Mark as Read", action: onConfirm); + .cancel() + } + } +} + +// MARK: - "…" menu - + +/// The Chats title bar's "…", opposite `+` and drawn the same way: Select Chats and Read All. +struct ChatListMoreMenu: View { + + /// Whether any chat in the main list is unread; Read All is disabled otherwise. + let canReadAll: Bool + let onSelect: () -> Void + let onReadAll: () -> Void + + var body: some View { + Menu { + Button("Select Chats", systemImage: "checkmark.circle", action: onSelect) + Button("Read All", systemImage: "message", action: onReadAll) + .disabled(!canReadAll) + } label: { + // Bar items get their glass from the system on iOS 26, as `+` does. + Image(systemName: "ellipsis") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + } + .accessibilityLabel("More") + .accessibilityIdentifier("chat-more-button") + } +} + +// MARK: - Select mode actions - + +/// Select mode's three floating capsules, in place of the tab bar: the read toggle, Archive, Mute. +/// With nothing picked the read toggle is Read All and the other two are disabled. +struct ChatSelectionActions: View { + + /// The picks, in list order. + let selected: [ConversationID] + let onReadAll: () -> Void + let onMute: ([ConversationID]) -> Void + let onArchive: ([ConversationID]) -> Void + /// Leaves select mode once an action has run. + let onDone: () -> Void + + @Environment(ConversationController.self) private var conversationController + + var body: some View { + let toggle = conversationController.readToggle(for: selected) + let mute = conversationController.muteToggle(for: selected) + let hasSelection = !selected.isEmpty + + HStack { + ChatSelectionCapsule(title: toggle.title, identifier: "chat-select-read") { + if toggle == .readAll { + onReadAll() + } else { + conversationController.apply(toggle, to: selected) + onDone() + } + } + Spacer(minLength: 8) + ChatSelectionCapsule(title: "Archive", identifier: "chat-select-archive") { + onArchive(selected) + } + .disabled(!hasSelection) + Spacer(minLength: 8) + ChatSelectionCapsule(title: mute.title, identifier: "chat-select-mute") { + switch mute { + case .mute: + onMute(selected) + case .unmute: + let ids = selected + Task { try? await conversationController.unmute(ids) } + onDone() + } + } + .disabled(!hasSelection) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } +} + +/// One select-mode capsule: a label on glass, the height of the title bar's own capsules. +private struct ChatSelectionCapsule: View { + + let title: String + let identifier: String + let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + Button(action: action) { + Text(title) + .font(.appTextMedium) + .foregroundStyle(isEnabled ? Color.textMain : Color.textSecondary) + .padding(.horizontal, 20) + .frame(height: 44) + .selectionCapsuleGlass() + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +private extension View { + + /// Liquid Glass on iOS 26; the ultra-thin material before it, as the pin bar does. + @ViewBuilder + func selectionCapsuleGlass() -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.interactive(), in: .capsule) + } else { + background(.ultraThinMaterial, in: .capsule) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 1c6514530..85b8b6b3d 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -32,13 +32,21 @@ struct RecipientRowScaffold: View { /// The viewer's mute on this chat, drawn right after the title. The mute rather than a /// muted flag — ``MuteBell`` owns the countdown, because a timed one lapses with nothing sent. var mute: ConversationMuteState? = nil + /// Select mode: nil outside it; otherwise whether this row is picked. A leading circle shows it + /// and the row tints while picked. + var selection: Bool? = nil let accessibilityLabel: String let onTap: () -> Void @ViewBuilder let trailing: Trailing var body: some View { Button(action: onTap) { - RecipientRowBody( + HStack(spacing: 12) { + if let selection { + RecipientRowSelectCircle(isSelected: selection) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + RecipientRowBody( avatarID: avatarID, title: title, subtitle: subtitle, @@ -46,11 +54,25 @@ struct RecipientRowScaffold: View { blurhash: blurhash, accessoryPlacement: accessoryPlacement, mute: mute - ) { - trailing + ) { + trailing + } } } - .recipientRowChrome(accessibilityLabel: accessibilityLabel) + .recipientRowChrome(accessibilityLabel: accessibilityLabel, isSelected: selection == true) + .accessibilityAddTraits(selection == true ? .isSelected : []) + } +} + +/// Select mode's leading circle: the same 22pt outline and filled check as Select People. +private struct RecipientRowSelectCircle: View { + + let isSelected: Bool + + var body: some View { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) } } @@ -117,11 +139,12 @@ struct RecipientRowBody: View { private extension View { /// Row chrome shared by every conversation row: list insets, clear /// background, separator tint, and single-element button accessibility. - func recipientRowChrome(accessibilityLabel: String) -> some View { + func recipientRowChrome(accessibilityLabel: String, isSelected: Bool = false) -> some View { self .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)) - .listRowBackground(Color.clear) + // A picked row in select mode takes the row fill; otherwise rows sit on the screen. + .listRowBackground(isSelected ? Color.backgroundRow : Color.clear) .listRowSeparatorTint(.rowSeparator) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(accessibilityLabel)) @@ -139,6 +162,8 @@ struct RecipientRowAccessory: View { /// The count the unread pill carries. Nil while unread draws a bare dot: the count can't /// always be known, and a guessed number would be wrong. var unreadCount: Int? = nil + /// Off in select mode, where a tap picks the row rather than opening it. + var showsChevron = true var body: some View { let count = hasUnread ? unreadCount.flatMap { $0 > 0 ? $0 : nil } : nil @@ -159,7 +184,7 @@ struct RecipientRowAccessory: View { Circle() .fill(Color.unreadIndicator) .frame(width: 9, height: 9) - } else { + } else if showsChevron { Image(systemName: "chevron.right") .resizable() .aspectRatio(contentMode: .fit) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index e9a907919..937865dcb 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -28,6 +28,12 @@ struct TipConversationsScreen: View { /// The hidden chips' fade, set per frame by the scroll view. Only the chip row reads it, so a /// pull doesn't re-render the whole list every frame. @State private var chipFade = ChipFade() + /// Select mode, owned by the tab view so it can swap the tab bar for the action capsules. Where no + /// tab view provides one (the `.tips` sheet), the screen keeps its own. + @Environment(ChatSelection.self) private var sharedSelection: ChatSelection? + @State private var localSelection = ChatSelection() + + private var selection: ChatSelection { sharedSelection ?? localSelection } /// The projection once per body pass: rows, chips, the Archived row and the badge agree. private var projection: ChatListProjection { @@ -90,17 +96,52 @@ struct TipConversationsScreen: View { .listRowBackground(Color.clear) } ForEach(Array(conversations.enumerated()), id: \.element.id) { index, conversation in - TipConversationRow(conversation: conversation) { - router.push(.tipConversation(conversation.id)) + TipConversationRow( + conversation: conversation, + selection: isSelecting ? selection.selected.contains(conversation.id) : nil + ) { + if isSelecting { + selection.toggle(conversation.id) + } else { + router.push(.tipConversation(conversation.id)) + } } // Separators divide rows from each other; the first // row's leading one just draws a line under the bar. .listRowSeparator(index == 0 ? .hidden : .automatic, edges: .top) // Mute first: the first button is the outer, full-swipe action. .swipeActions(edge: .trailing, allowsFullSwipe: true) { - muteAction(for: conversation) - archiveAction(for: conversation) + if !isSelecting { + muteAction(for: conversation) + archiveAction(for: conversation) + } } + .chatRowActions( + for: conversation, + in: .main, + isEnabled: !isSelecting, + onMute: { muteTarget = MuteTarget(ids: [conversation.id]) }, + onArchive: { archive([conversation.id]) } + ) + } + } + .animation(.easeInOut(duration: 0.2), value: isSelecting) + .safeAreaInset(edge: .bottom) { + if isSelecting { + ChatSelectionActions( + selected: selectedIDs(in: conversations), + onReadAll: confirmReadAll, + onMute: { ids in + muteTarget = MuteTarget(ids: ids) + selection.end() + }, + onArchive: { ids in + archive(ids) + selection.end() + }, + onDone: selection.end + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) } } .listStyle(.plain) @@ -127,24 +168,33 @@ struct TipConversationsScreen: View { } } .sheet(item: $muteTarget) { target in - MuteChatSheet(conversationID: target.id, isPresented: isPickingMuteDuration) + MuteChatSheet(conversationIDs: target.ids, isPresented: isPickingMuteDuration) } - .navigationTitle("Chat") + .navigationTitle(isSelecting ? "\(selection.selected.count) Selected" : "Chat") .toolbarTitleDisplayMode(.inline) + // An archived or left chat drops out of the picks, so no action reaches a chat no longer listed. + .onChange(of: conversationController.chatListProjection.main) { _, ids in + selection.retain(Set(ids)) + } + // Select mode belongs to the visible list; leaving the screen leaves it. + .onDisappear { selection.end() } .toolbar { - if showsSearchButton { - ToolbarItem(placement: .topBarTrailing) { - Button { - router.push(.chatSearch) - } label: { - Image(systemName: "magnifyingglass") - } - .accessibilityLabel("Search") - .accessibilityIdentifier("chat-search-button") + if isSelecting { + ToolbarItem(placement: .topBarLeading) { + Button("Done", action: selection.end) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .accessibilityIdentifier("chat-select-done") } - } - ToolbarItem(placement: .topBarTrailing) { - NewChatButton() + } else { + ToolbarItem(placement: .topBarLeading) { + ChatListMoreMenu( + canReadAll: conversationController.hasUnreadInChatList, + onSelect: selection.begin, + onReadAll: confirmReadAll + ) + } + toolbarTrailingItems } } // Search filters currencies by name over what Discover returned; fetched here so the @@ -204,6 +254,45 @@ struct TipConversationsScreen: View { } } + private var isSelecting: Bool { selection.isActive } + + @ToolbarContentBuilder private var toolbarTrailingItems: some ToolbarContent { + if showsSearchButton { + ToolbarItem(placement: .topBarTrailing) { + Button { + router.push(.chatSearch) + } label: { + Image(systemName: "magnifyingglass") + } + .accessibilityLabel("Search") + .accessibilityIdentifier("chat-search-button") + } + } + ToolbarItem(placement: .topBarTrailing) { + NewChatButton() + } + } + + /// The picks, in list order, limited to rows still listed. + private func selectedIDs(in conversations: [Conversation]) -> [ConversationID] { + conversations.map(\.id).filter(selection.selected.contains) + } + + /// Archives `ids` with one toast whose Undo restores all of them. + private func archive(_ ids: [ConversationID]) { + guard !ids.isEmpty else { return } + conversationController.archive(ids) + toasts.show(.archivedChats(count: ids.count) { conversationController.unarchive(ids) }) + } + + /// Read All asks first: it sends read receipts to every unread chat. + private func confirmReadAll() { + sessionContainer.session.dialogItem = .markAllChatsRead { + conversationController.readAllChats() + selection.end() + } + } + /// Group senders of the listed rows' last messages that nothing on the device can name yet. private var unnamedSenders: [UserID] { conversationController.unnamedLastMessageSenders( @@ -218,7 +307,7 @@ struct TipConversationsScreen: View { let isMuted = conversation.isMuted(at: .now) let symbol: SystemSymbol = isMuted ? .bell : .bellSlash return Button { - muteTarget = MuteTarget(id: conversation.id) + muteTarget = MuteTarget(ids: [conversation.id]) } label: { // A swipe action draws its label white whatever the style says, so the amber has to be // baked into the image. @@ -234,8 +323,7 @@ struct TipConversationsScreen: View { /// partial swipe and a tap. private func archiveAction(for conversation: Conversation) -> some View { Button { - conversationController.archive(conversation.id) - toasts.show(.archivedChat { conversationController.unarchive(conversation.id) }) + archive([conversation.id]) } label: { // Swipe actions fill symbols by default; the shared design uses the outline. Image(systemName: "archivebox") @@ -259,7 +347,8 @@ struct TipConversationsScreen: View { /// and `.sheet(item:)` keeps the item through the dismissal animation where an optional-backed /// `isPresented` would blank the sheet's content on the way out. private struct MuteTarget: Identifiable { - let id: ConversationID + let ids: [ConversationID] + var id: [ConversationID] { ids } } // MARK: - NewChatButton - @@ -331,6 +420,8 @@ private struct NoChatsView: View { struct TipConversationRow: View { let conversation: Conversation + /// Select mode: nil outside it; otherwise whether the row is picked. + var selection: Bool? = nil let onTap: () -> Void @Environment(ConversationController.self) private var conversationController @@ -361,7 +452,9 @@ struct TipConversationRow: View { let hasUnreadMessages = conversation.hasUnread(for: conversationController.selfUserID) let unreadMessageCount = hasUnreadMessages ? conversationController.unreadCount(for: conversation) : nil let subtitle = joinRequests.subtitle(preview: self.subtitle, hasUnreadMessages: hasUnreadMessages) - let hasUnread = hasUnreadMessages || joinRequests.count > 0 + // Marked unread with nothing unread draws the no-count state; a known count still wins. + let isMarkedUnread = !hasUnreadMessages && conversationController.isMarkedUnread(conversation.id) + let hasUnread = hasUnreadMessages || joinRequests.count > 0 || isMarkedUnread let unreadCount = joinRequests.pillCount(hasUnreadMessages: hasUnreadMessages, unreadMessageCount: unreadMessageCount) // A pending request moves the row up, so the time shown is the one it sorts by. let timestamp = conversationController.chatListJoinRequests(for: conversation.id) @@ -381,10 +474,11 @@ struct TipConversationRow: View { blurhash: avatarPicture?.thumbnailBlurhash, accessoryPlacement: .titleLine, mute: conversation.viewerState?.mute, + selection: selection, accessibilityLabel: accessibilityLabel( title: title, joinRequests: joinRequests.line, - hasUnreadMessages: hasUnreadMessages, + hasUnreadMessages: hasUnreadMessages || isMarkedUnread, unreadMessageCount: unreadMessageCount, isMuted: isMuted ), @@ -394,7 +488,8 @@ struct TipConversationRow: View { timestamp: timestamp, isUnknown: false, hasUnread: hasUnread, - unreadCount: unreadCount + unreadCount: unreadCount, + showsChevron: selection == nil ) } .accessibilityIdentifier(chatTypeIdentifier) diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index f6d4de57f..420aad684 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -20,10 +20,20 @@ import FlipcashUI /// and it appears only once there is a mute to clear. struct MuteChatSheet: View { - let conversationID: ConversationID + /// The chats a choice applies to: one from a row or the chat's settings, several from select mode. + let conversationIDs: [ConversationID] @Binding var isPresented: Bool + init(conversationID: ConversationID, isPresented: Binding) { + self.init(conversationIDs: [conversationID], isPresented: isPresented) + } + + init(conversationIDs: [ConversationID], isPresented: Binding) { + self.conversationIDs = conversationIDs + self._isPresented = isPresented + } + @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer @@ -58,8 +68,9 @@ struct MuteChatSheet: View { /// away: read live, the list gained or lost its first row on the way out. @State private var pinnedOffersUnmute: Bool? + /// Every chat muted; with any audible one the choice mutes, so "Never" would do nothing to it. private var isMuted: Bool { - conversationController.conversation(withID: conversationID)?.isMuted(at: .now) ?? false + !conversationIDs.isEmpty && conversationIDs.allSatisfy { conversationController.isMuted(conversationID: $0) } } /// Whether to offer the unmute at all — on an audible chat it is a row that would do nothing. @@ -117,20 +128,20 @@ struct MuteChatSheet: View { pinnedOffersUnmute = offersUnmute isWorking = true defer { isWorking = false } - let chatType = conversationController.conversation(withID: conversationID)?.type + let chatTypes = conversationIDs.map { conversationController.conversation(withID: $0)?.type } do { switch option { case .until(let offset, _): - try await conversationController.mute(conversationID: conversationID, .until(.now.addingTimeInterval(offset))) + try await conversationController.mute(conversationIDs, .until(.now.addingTimeInterval(offset))) case .forever: - try await conversationController.mute(conversationID: conversationID, .forever) + try await conversationController.mute(conversationIDs, .forever) case .never: - try await conversationController.unmute(conversationID: conversationID) + try await conversationController.unmute(conversationIDs) } - track(option, chatType: chatType, error: nil) + chatTypes.forEach { track(option, chatType: $0, error: nil) } isPresented = false } catch { - track(option, chatType: chatType, error: error) + track(option, chatType: chatTypes.first ?? nil, error: error) isPresented = false let unmuting = option == .never sessionContainer.session.dialogItem = .error( diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index d9522a073..54268378f 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -572,6 +572,7 @@ final class SessionAuthenticator { container.pushController.prepareForLogout() container.usdcSweepOperation.cancel() container.conversationController.stop() + container.conversationController.prepareForLogout() container.chatSpotlightIndexer.stop() QuickActionsController.clear() } @@ -693,6 +694,9 @@ final class SessionContainer { /// NotificationService extension reads it to decide whether a push may interrupt. let chatArchive: ChatArchiveStore + /// The chats the user has marked unread, on this device only. Cleared at logout. + let chatMarkedUnread: any ChatMarkedUnreadStore + /// The signed-in user's private-group chat keys, in memory and reopened from the stored envelope or the server. let groupKeys: GroupKeyEnvelopes @@ -805,6 +809,12 @@ final class SessionContainer { ) self.chatArchive = chatArchive + let chatMarkedUnread = LocalChatMarkedUnreadStore( + directory: .applicationSupportDirectory, + owner: owner.publicKey + ) + self.chatMarkedUnread = chatMarkedUnread + let groupKeys = GroupKeyEnvelopes( sealer: SharedCoreGroupKeySealer(), owner: session.ownerKeyPair, @@ -881,6 +891,7 @@ final class SessionContainer { // puts its text back — none of which the controller can do before it has the store. conversationController.chatDrafts = chatDrafts conversationController.chatArchive = chatArchive + conversationController.chatMarkedUnread = chatMarkedUnread // Same owner-scoped placement as the drafts; a photo whose send a kill interrupted comes back // through `start()`, which needs the uploader to finish it. conversationController.pendingMedia = PendingMediaStore( diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift new file mode 100644 index 000000000..45510c557 --- /dev/null +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -0,0 +1,364 @@ +// +// ChatMarkUnreadTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashStore +import FlipcashUI +@testable import Flipcash + +/// The chat mark-unread spec's test table (`docs/specs/chat-mark-unread.md` in the orchestrator), +/// one test per row, numbered as there. +@MainActor +@Suite("Chat mark unread") +struct ChatMarkUnreadTests { + + private let me = UUID() + private let them = UUID() + + // MARK: - Fixtures + + private func message(_ id: UInt64) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: them, + content: .text("m\(id)"), + date: Date(timeIntervalSince1970: TimeInterval(id)), + unreadSeq: id + ) + } + + /// A tip DM whose newest message is `last` and whose READ sits at `pointer`: read when they match. + private func chat( + _ n: UInt8, + pointer: UInt64, + last: UInt64 = 5, + type: ConversationType = .tipDm, + muted: Bool = false + ) -> Conversation { + Conversation( + id: .test(n), + members: [ + ConversationMember(userID: me, displayName: "", readPointer: MessageID(value: pointer)), + ConversationMember(userID: them, displayName: "Them"), + ], + lastMessage: message(last), + lastActivity: Date(timeIntervalSince1970: TimeInterval(1_000 - Int(n))), + type: type, + viewerState: muted ? ConversationViewerState(mute: .forever, version: 1) : nil + ) + } + + private struct Fixture { + let mock: MockConversations + let controller: ConversationController + let marks: LocalChatMarkedUnreadStore + let archive: ChatArchiveStore + let database: Database + } + + private func fixture(feed: [Conversation], groups: [Conversation] = []) async throws -> Fixture { + let mock = MockConversations() + mock.feed = feed + mock.groupFeed = groups + let database = try Database.makeTemp().database + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: me + ) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("marks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let owner = try PublicKey(Data(repeating: 7, count: 32)) + let archive = ChatArchiveStore(directory: directory, owner: owner) + let marks = LocalChatMarkedUnreadStore(directory: directory, owner: owner) + controller.chatArchive = archive + controller.chatMarkedUnread = marks + await controller.loadFeed() + if !groups.isEmpty { await controller.loadGroupFeed() } + return Fixture(mock: mock, controller: controller, marks: marks, archive: archive, database: database) + } + + private func pointer(_ controller: ConversationController, _ n: UInt8) -> MessageID? { + controller.conversation(withID: .test(n))?.selfReadPointer(for: me) + } + + /// Lets any read-pointer RPC the call queued go out, so "no RPC" is checked after it would have. + private func settle(_ controller: ConversationController) async throws { + try await waitUntil { controller.readPointerSyncTasks.isEmpty } + } + + // MARK: - Rows + + @Test("1: Mark as Unread on a read chat marks it, badges it once and sends nothing") + func markUnreadOnReadChat() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5)]) + #expect(f.controller.readAction(for: chat(1, pointer: 5)) == .markUnread) + #expect(f.controller.unreadChatListCount == 0) + + f.controller.markUnread(.test(1), source: .swipe) + + #expect(f.marks.markedIDs == [.test(1)]) + // No unread messages, so the row draws the no-count state: unread with no count. + #expect(f.controller.chatListUnread(for: chat(1, pointer: 5)) == .unknown) + #expect(f.controller.chatListProjection.unreadChip == [.test(1)]) + #expect(f.controller.unreadChatListCount == 1) + try await settle(f.controller) + #expect(f.mock.markedRead.isEmpty) + } + + @Test("2: Mark as Read on a marked chat with nothing unread clears the mark and sends nothing") + func markReadOnMarkedChat() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5)]) + f.marks.mark(.test(1)) + #expect(f.controller.readAction(for: chat(1, pointer: 5)) == .markRead) + + f.controller.markRead(.test(1), source: .menu) + + #expect(f.marks.markedIDs.isEmpty) + #expect(f.controller.unreadChatListCount == 0) + try await settle(f.controller) + #expect(f.mock.markedRead.isEmpty) + } + + @Test("3: the swipe on a chat with 3 unread offers Mark as Read, which advances READ to the latest") + func swipeOnUnreadChatReads() async throws { + let f = try await fixture(feed: [chat(1, pointer: 2, last: 5)]) + let conversation = try #require(f.controller.conversation(withID: .test(1))) + let action = try #require(f.controller.readAction(for: conversation)) + #expect(action == .markRead) + + f.controller.perform(action, on: .test(1), source: .swipe) + + #expect(pointer(f.controller, 1) == MessageID(value: 5)) + try await waitUntil { f.mock.markedRead == [MessageID(value: 5)] } + } + + @Test("4: opening a marked chat clears its mark") + func openingClearsMark() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5)]) + f.marks.mark(.test(1)) + + f.controller.chatOpened(.test(1)) + + #expect(f.marks.markedIDs.isEmpty) + } + + @Test("5: a new message in a marked chat keeps the mark; the count shows and the badge counts it once") + func newMessageKeepsMark() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5, last: 5)]) + f.marks.mark(.test(1)) + // The new message lands: stored, and the feed's last message moves past READ. + try f.database.upsertConversationMessages([message(5), message(6)], conversationID: .test(1)) + f.mock.feed = [chat(1, pointer: 5, last: 6)] + await f.controller.loadFeed() + + #expect(f.marks.markedIDs == [.test(1)]) + let updated = try #require(f.controller.conversation(withID: .test(1))) + #expect(f.controller.chatListUnread(for: updated) == .count(1)) + #expect(f.controller.unreadChatListCount == 1) + } + + @Test("6: archiving and unarchiving a marked chat keeps the mark throughout") + func archiveKeepsMark() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5)]) + f.marks.mark(.test(1)) + + f.controller.archive(.test(1)) + #expect(f.controller.isMarkedUnread(.test(1))) + #expect(f.controller.archivedConversations.map(\.id) == [.test(1)]) + + f.controller.unarchive(.test(1)) + #expect(f.controller.isMarkedUnread(.test(1))) + #expect(f.controller.unreadChatListCount == 1) + } + + @Test("7: leaving a marked group clears its mark") + func leaveClearsMark() async throws { + let f = try await fixture(feed: [], groups: [chat(9, pointer: 5, type: .group)]) + f.marks.mark(.test(9)) + + try await f.controller.leave(conversationID: .test(9)) + + #expect(f.marks.markedIDs.isEmpty) + } + + @Test("8: logout empties the marked set, on disk too") + func logoutClearsMarks() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5), chat(2, pointer: 5)]) + f.marks.mark(.test(1)) + f.marks.mark(.test(2)) + + f.controller.prepareForLogout() + + #expect(f.marks.markedIDs.isEmpty) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("marks-reload-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let owner = try PublicKey(Data(repeating: 8, count: 32)) + let reloaded = LocalChatMarkedUnreadStore(directory: directory, owner: owner) + reloaded.mark(.test(3)) + #expect(LocalChatMarkedUnreadStore(directory: directory, owner: owner).markedIDs == [.test(3)]) + } + + @Test("9: Read All reads A, clears B, leaves C, keeps archived D marked, and counts 2") + func readAll() async throws { + let f = try await fixture(feed: [ + chat(1, pointer: 2, last: 5), // A: unread + chat(2, pointer: 5), // B: marked + chat(3, pointer: 5), // C: read + chat(4, pointer: 5), // D: archived, marked + ]) + f.marks.mark(.test(2)) + f.marks.mark(.test(4)) + f.controller.archive(.test(4)) + + let count = f.controller.readAllChats() + + #expect(count == 2) + #expect(pointer(f.controller, 1) == MessageID(value: 5)) + #expect(!f.controller.isMarkedUnread(.test(2))) + #expect(pointer(f.controller, 3) == MessageID(value: 5)) + #expect(f.controller.isMarkedUnread(.test(4))) + try await waitUntil { f.mock.markedRead == [MessageID(value: 5)] } + try await settle(f.controller) + // Only A had anything to send. + #expect(f.mock.markedRead == [MessageID(value: 5)]) + #expect(f.controller.unreadChatListCount == 0) + } + + @Test("10: with nothing unread in the main list, Read All is disabled") + func readAllDisabledWhenNothingUnread() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5), chat(2, pointer: 5)]) + f.marks.mark(.test(2)) + f.controller.archive(.test(2)) + + // An archived mark doesn't count toward the main list. + #expect(!f.controller.hasUnreadInChatList) + } + + @Test("11: cancelling the Read All dialog changes nothing") + func readAllCancel() async throws { + let f = try await fixture(feed: [chat(1, pointer: 2, last: 5), chat(2, pointer: 5)]) + f.marks.mark(.test(2)) + var confirmed = false + let dialog = DialogItem.markAllChatsRead { confirmed = true } + + #expect(dialog.title == "Mark All Chats as Read?") + #expect(dialog.subtitle == "Everyone in these chats will see that you've read their messages") + #expect(dialog.actions.map(\.title) == ["Mark as Read", "Cancel"]) + let cancel = try #require(dialog.actions.first { $0.title == "Cancel" }) + cancel.action() + + #expect(!confirmed) + #expect(pointer(f.controller, 1) == MessageID(value: 2)) + #expect(f.controller.isMarkedUnread(.test(2))) + try await settle(f.controller) + #expect(f.mock.markedRead.isEmpty) + } + + @Test("12: a selection with an unread chat offers Read, and both end read") + func selectionRead() async throws { + let f = try await fixture(feed: [chat(1, pointer: 2, last: 5), chat(3, pointer: 5)]) + let ids: [ConversationID] = [.test(1), .test(3)] + + let toggle = f.controller.readToggle(for: ids) + #expect(toggle == .read) + #expect(toggle.title == "Read") + f.controller.apply(toggle, to: ids) + + #expect(f.controller.readToggle(for: ids) == .unread) + #expect(pointer(f.controller, 1) == MessageID(value: 5)) + #expect(f.marks.markedIDs.isEmpty) + } + + @Test("13: a selection of read chats offers Unread, and both end marked") + func selectionUnread() async throws { + let f = try await fixture(feed: [chat(3, pointer: 5), chat(5, pointer: 5)]) + let ids: [ConversationID] = [.test(3), .test(5)] + + let toggle = f.controller.readToggle(for: ids) + #expect(toggle == .unread) + #expect(toggle.title == "Unread") + f.controller.apply(toggle, to: ids) + + #expect(f.marks.markedIDs == [.test(3), .test(5)]) + #expect(ChatSelectionReadToggle(selectedUnread: []) == .readAll) + #expect(ChatSelectionReadToggle.readAll.title == "Read All") + } + + @Test("14: a muted and an unmuted chat offer Mute; one choice applies to both") + func selectionMute() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5, muted: true), chat(2, pointer: 5)]) + let ids: [ConversationID] = [.test(1), .test(2)] + + #expect(f.controller.muteToggle(for: ids) == .mute) + try await f.controller.mute(ids, .forever) + + #expect(f.mock.muted.map(\.conversationID) == ids) + } + + @Test("15: a selection that is all muted offers Unmute, which unmutes both") + func selectionUnmute() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5, muted: true), chat(2, pointer: 5, muted: true)]) + let ids: [ConversationID] = [.test(1), .test(2)] + + let mute = f.controller.muteToggle(for: ids) + #expect(mute == .unmute) + #expect(mute.title == "Unmute") + try await f.controller.unmute(ids) + + #expect(f.mock.unmuted == ids) + } + + @Test("16: archiving two shows one \"2 chats archived\" toast; Undo restores both") + func selectionArchiveUndo() async throws { + let f = try await fixture(feed: [chat(1, pointer: 5), chat(2, pointer: 5), chat(3, pointer: 5)]) + let ids: [ConversationID] = [.test(1), .test(2)] + f.controller.archive(ids) + #expect(f.controller.archivedConversations.map(\.id) == ids) + #expect(f.controller.chatListConversations.map(\.id) == [.test(3)]) + #expect(ToastController.Toast.archivedChatsTitle(count: 2) == "2 chats archived") + #expect(ToastController.Toast.archivedChatsTitle(count: 1) == "Chat archived") + + // The toast's Undo runs this. + f.controller.unarchive(ids) + + #expect(f.controller.archivedConversations.isEmpty) + #expect(f.controller.chatListConversations.map(\.id) == [.test(1), .test(2), .test(3)]) + } + + @Test("17: the long-press preview on an unread chat leaves READ and the unread state alone") + func previewDoesNotRead() async throws { + let f = try await fixture(feed: [chat(1, pointer: 2, last: 5)]) + try f.database.upsertConversationMessages((1...5).map(message), conversationID: .test(1)) + let conversation = try #require(f.controller.conversation(withID: .test(1))) + + #expect(f.controller.previewMessages(for: conversation).map(\.id.value) == [1, 2, 3, 4, 5]) + // Render the preview as the menu would, then let it go without a tap. + let host = UIHostingController(rootView: ChatPreview(conversation: conversation).environment(f.controller)) + host.view.frame = CGRect(x: 0, y: 0, width: 340, height: 430) + host.view.layoutIfNeeded() + + #expect(pointer(f.controller, 1) == MessageID(value: 2)) + #expect(f.controller.conversation(withID: .test(1))?.hasUnread(for: me) == true) + try await settle(f.controller) + #expect(f.mock.markedRead.isEmpty) + } + + @Test("18: a chat still in Requests offers no read action and can't be marked") + func requestsHaveNoReadActions() async throws { + let f = try await fixture(feed: []) + // A group the user hasn't joined: a request, not a member chat. + let requested = chat(7, pointer: 5, type: .group) + + #expect(f.controller.readAction(for: requested) == nil) + f.controller.markUnread(requested.id, source: .swipe) + #expect(f.marks.markedIDs.isEmpty) + } +} From 879991232df404a7f55555ad168a25fd57120646 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 13:30:11 -0500 Subject: [PATCH 02/26] fix(chat): name group senders in the long-press preview and keep its newest message in view The preview passed no known-author snapshot, so group senders the roster couldn't name got no label; it now takes the list's snapshot and drops the "Name:" / "You:" prefix inside bubbles. An overflowing transcript was centred in the fixed frame, clipping the newest message; a bottom-anchored scroll view keeps it in view. Select mode: the selected circle uses Color.unreadIndicator with a white check (frame E2), and only a disabled capsule's text dims, not its glass. --- .../Controllers/ConversationController.swift | 13 +++- .../Screens/Main/Tips/ChatRowActions.swift | 73 +++++++++++++------ .../Core/Screens/Main/Tips/RecipientRow.swift | 14 +++- 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 21f815e7b..f20a73b0b 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1953,17 +1953,22 @@ final class ConversationController { knownAuthors: KnownAuthorDirectory.Snapshot = .empty, viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount }, requestTitle: (PaymentRequestID) -> String? = { _ in nil }, + attributed: Bool = true, currencyName: (PublicKey) -> String? ) -> String? { let isFromSelf = message.isFromSelf(selfUserID) - let senderName = groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors) + // Unattributed lines drop the "You:" / "Name:" prefix: the preview's bubble side and sender + // label already say who wrote them. + let senderName = attributed + ? groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors) + : nil switch message.content { case .text(let text): // A bare prefix says less than no line at all, so an empty body previews as nothing. guard !text.isEmpty else { return nil } let text = InviteLinkText.redacting(text) - if isFromSelf { return "You: \(text)" } + if isFromSelf && attributed { return "You: \(text)" } guard let senderName else { return text } return "\(senderName): \(text)" @@ -1992,7 +1997,7 @@ final class ConversationController { case .media(_, let caption): let body = ChatMediaStrings.listPreview(caption: caption) - if isFromSelf { return "You: \(body)" } + if isFromSelf && attributed { return "You: \(body)" } guard let senderName else { return body } return "\(senderName): \(body)" @@ -2082,7 +2087,7 @@ final class ConversationController { /// attribution: the viewer's own message is covered by "You", and a DM's other party is what /// the row is already titled after. A sender the chat's roster subset omits is named from /// `knownAuthors`, and left unattributed until the directory has a name for them. - private func groupSenderName( + func groupSenderName( for message: ConversationMessage, in conversation: Conversation, isFromSelf: Bool, diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index fa4dbab46..340cb0c1b 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -86,13 +86,14 @@ private struct ChatRowContextMenu: ViewModifier { let onArchive: () -> Void @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer func body(content: Content) -> some View { if conversation.lastMessage != nil { content.contextMenu { items } preview: { - ChatPreview(conversation: conversation) + ChatPreview(conversation: conversation, knownAuthors: sessionContainer.knownAuthors.snapshot) // The preview is hosted outside this view's tree, so the controller is handed on. .environment(conversationController) } @@ -129,6 +130,7 @@ private struct ChatRowContextMenu: ViewModifier { struct ChatPreview: View { let conversation: Conversation + var knownAuthors: KnownAuthorDirectory.Snapshot = .empty @Environment(ConversationController.self) private var conversationController @@ -136,28 +138,41 @@ struct ChatPreview: View { let messages = conversationController.previewMessages(for: conversation) let selfUserID = conversationController.selfUserID - VStack(alignment: .leading, spacing: 0) { - Text(conversationController.displayName(for: conversation)) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .lineLimit(1) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - Divider().overlay(Color.rowSeparator) + // Newest at the bottom, as the transcript lands; older messages clip off the top. A fixed + // frame alone centres an overflowing stack, so a bottom-anchored, unscrollable scroll view + // keeps the latest message in view. + ScrollView { VStack(spacing: 6) { - Spacer(minLength: 0) - ForEach(messages, id: \.id) { message in + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in if let text = conversationController.messagePreview( message, in: conversation, + knownAuthors: knownAuthors, + attributed: false, currencyName: { _ in nil } ) { - ChatPreviewBubble(text: text, isFromSelf: message.isFromSelf(selfUserID)) + let isFromSelf = message.isFromSelf(selfUserID) + let startsRun = index == 0 || messages[index - 1].senderID != message.senderID + ChatPreviewBubble( + text: text, + senderName: startsRun + ? conversationController.groupSenderName( + for: message, + in: conversation, + isFromSelf: isFromSelf, + knownAuthors: knownAuthors + ) + : nil, + isFromSelf: isFromSelf + ) } } } .padding(12) } + .defaultScrollAnchor(.bottom) + .scrollDisabled(true) + .scrollIndicators(.hidden) .frame(width: 340, height: 430) .background(Color.backgroundMain) } @@ -167,18 +182,28 @@ struct ChatPreview: View { private struct ChatPreviewBubble: View { let text: String + let senderName: String? let isFromSelf: Bool var body: some View { - Text(text) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .lineLimit(4) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(isFromSelf ? Color.backgroundSecondary : Color.backgroundRow, in: .rect(cornerRadius: 16)) - .frame(maxWidth: 260, alignment: isFromSelf ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: isFromSelf ? .trailing : .leading) + VStack(alignment: .leading, spacing: 4) { + if let senderName { + Text(senderName) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .padding(.horizontal, 4) + } + Text(text) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(4) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isFromSelf ? Color.backgroundSecondary : Color.backgroundRow, in: .rect(cornerRadius: 16)) + } + .frame(maxWidth: 260, alignment: isFromSelf ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: isFromSelf ? .trailing : .leading) } } @@ -211,7 +236,7 @@ struct ChatListMoreMenu: View { var body: some View { Menu { Button("Select Chats", systemImage: "checkmark.circle", action: onSelect) - Button("Read All", systemImage: "message", action: onReadAll) + Button("Read All", systemImage: "checkmark.message", action: onReadAll) .disabled(!canReadAll) } label: { // Bar items get their glass from the system on iOS 26, as `+` does. @@ -293,9 +318,11 @@ private struct ChatSelectionCapsule: View { .foregroundStyle(isEnabled ? Color.textMain : Color.textSecondary) .padding(.horizontal, 20) .frame(height: 44) - .selectionCapsuleGlass() + .contentShape(.capsule) } .buttonStyle(.plain) + // Outside the button: a disabled plain button fades its label, and the glass would fade with it. + .selectionCapsuleGlass() .accessibilityIdentifier(identifier) } } diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 85b8b6b3d..16989dd23 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -70,9 +70,17 @@ private struct RecipientRowSelectCircle: View { let isSelected: Bool var body: some View { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 22)) - .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) + if isSelected { + // A white check on the unread blue, the selection colour the design uses. + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.textMain, Color.unreadIndicator) + } else { + Image(systemName: "circle") + .font(.system(size: 22)) + .foregroundStyle(Color.textSecondary) + } } } From a008b5bf76b1c265ba46ef6da85eab226d1444d3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 14:03:13 -0500 Subject: [PATCH 03/26] fix(chat): swap the select circle in place and give the read swipe Mute's tonal style The select circle was two Image views in an if/else, so each toggle took the row's slide-in transition and the circle slid. It is now one Image whose symbol switches between circle and checkmark.circle.fill with a symbol replace effect; the slide stays for entering and leaving select mode. The leading Unread/Read swipe now matches Mute: the glyph baked in unreadIndicator on a new flat unreadIndicatorSecondary (24, 36, 49), the blue at 10% over backgroundMain, derived the way warningSecondary is from warning. --- .../Screens/Main/Tips/ChatRowActions.swift | 9 ++++++--- .../Core/Screens/Main/Tips/RecipientRow.swift | 20 +++++++++---------- .../Sources/FlipcashUI/Theme/Colors.swift | 4 ++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 340cb0c1b..48dff8000 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -56,10 +56,13 @@ private struct ChatRowActionsModifier: ViewModifier { Button { conversationController.perform(action, on: conversation.id, source: .swipe) } label: { - Label(action.title, systemImage: action.systemImage) - .labelStyle(.iconOnly) + // A swipe action draws its label white whatever the style says, so the + // blue has to be baked into the image, as the Mute action does its amber. + Image(uiImage: UIImage(systemName: action.systemImage)! + .withTintColor(UIColor(Color.unreadIndicator), renderingMode: .alwaysOriginal)) } - .tint(.unreadIndicator) + // Mute's tonal style in blue: the glyph on its own colour at low strength. + .tint(.unreadIndicatorSecondary) .accessibilityLabel(action.title) } .modifier(ChatRowContextMenu( diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 16989dd23..760224d66 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -70,17 +70,17 @@ private struct RecipientRowSelectCircle: View { let isSelected: Bool var body: some View { - if isSelected { + // One image whose symbol swaps, so a tap changes the circle in place. Two images in an + // if/else would each take the row's slide-in transition and the circle would slide. + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) // A white check on the unread blue, the selection colour the design uses. - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 22)) - .symbolRenderingMode(.palette) - .foregroundStyle(Color.textMain, Color.unreadIndicator) - } else { - Image(systemName: "circle") - .font(.system(size: 22)) - .foregroundStyle(Color.textSecondary) - } + .symbolRenderingMode(isSelected ? .palette : .monochrome) + .foregroundStyle( + isSelected ? Color.textMain : Color.textSecondary, + Color.unreadIndicator + ) + .contentTransition(.symbolEffect(.replace)) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index 023e9700f..46d76f2c1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -43,6 +43,10 @@ extension ShapeStyle where Self == Color { public static var coverPlaceholder: Color { Color(r: 50, g: 50, b: 52) } public static var unreadIndicator: Color { Color(r: 10, g: 132, b: 255) } + /// The ground `unreadIndicator` sits on in the chat row's Unread/Read swipe — the blue at a + /// tenth of its strength over ``backgroundMain``, baked flat the way ``warningSecondary`` is. + /// Only true against the main background; it is not a general wash. + public static var unreadIndicatorSecondary: Color { Color(r: 24, g: 36, b: 49) } /// `#0A84FF` — the badge/focus token, used by the jump-to-bottom unread badge. Same value as `unreadIndicator`, named for its Figma token. public static var badgeFocus: Color { Color(r: 10, g: 132, b: 255) } From 524112125e714fc6a228c9b4b4e41f658e301c54 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 15:08:39 -0500 Subject: [PATCH 04/26] fix(chat): keep each account's unread marks across logout Marks are stored per account in flipcash--marked-unread.json, so logout no longer clears them: signing back in to the same account shows them again, and another account reads its own file and sees none. Drops ConversationController.prepareForLogout() and the store's clearAll(), which had no other caller. --- .../Controllers/ConversationController.swift | 5 ---- .../Conversation/ChatMarkedUnreadStore.swift | 11 +-------- .../Core/Session/SessionAuthenticator.swift | 4 ++-- FlipcashTests/Chat/ChatMarkUnreadTests.swift | 24 +++++++++---------- 4 files changed, 14 insertions(+), 30 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index f20a73b0b..29f523b82 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -268,11 +268,6 @@ final class ConversationController { chatListConversations.contains(where: isUnread) } - /// Called at logout: marks are the signed-in account's alone, so the next sign-in starts with none. - func prepareForLogout() { - chatMarkedUnread?.clearAll() - } - /// Called as a chat opens: opening it is reading it, so the mark goes. func chatOpened(_ id: ConversationID) { chatMarkedUnread?.clear(id) diff --git a/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift index b565f282a..aa8e29468 100644 --- a/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift +++ b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift @@ -26,13 +26,10 @@ protocol ChatMarkedUnreadStore: AnyObject { /// Clears the mark on each of `ids`. func clear(_ ids: Set) - - /// Clears every mark. - func clearAll() } /// The device-only ``ChatMarkedUnreadStore``: a JSON file beside the drafts in Application Support, -/// owner-scoped like ``ChatArchiveStore``. Outside `FlipcashStore`, whose version bump deletes the +/// owner-scoped like ``ChatArchiveStore``, so each account keeps its own marks across logout. Outside `FlipcashStore`, whose version bump deletes the /// database, because nothing can re-fetch a mark until the server stores it. @MainActor @Observable @@ -67,12 +64,6 @@ final class LocalChatMarkedUnreadStore: ChatMarkedUnreadStore { write() } - func clearAll() { - guard !markedIDs.isEmpty else { return } - markedIDs = [] - write() - } - /// Keys as ``ChatArchiveFile/key(_:)`` does: base64 of the id's bytes, which fits both the /// 32-byte DM shape and a group's 16 bytes. private func write() { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 54268378f..50d412f46 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -572,7 +572,6 @@ final class SessionAuthenticator { container.pushController.prepareForLogout() container.usdcSweepOperation.cancel() container.conversationController.stop() - container.conversationController.prepareForLogout() container.chatSpotlightIndexer.stop() QuickActionsController.clear() } @@ -694,7 +693,8 @@ final class SessionContainer { /// NotificationService extension reads it to decide whether a push may interrupt. let chatArchive: ChatArchiveStore - /// The chats the user has marked unread, on this device only. Cleared at logout. + /// The chats the user has marked unread, on this device only. Kept per account: logout leaves + /// the file, so signing back in to the same account shows its marks again. let chatMarkedUnread: any ChatMarkedUnreadStore /// The signed-in user's private-group chat keys, in memory and reopened from the stored envelope or the server. diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 45510c557..2feb467ae 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -189,21 +189,19 @@ struct ChatMarkUnreadTests { #expect(f.marks.markedIDs.isEmpty) } - @Test("8: logout empties the marked set, on disk too") - func logoutClearsMarks() async throws { - let f = try await fixture(feed: [chat(1, pointer: 5), chat(2, pointer: 5)]) - f.marks.mark(.test(1)) - f.marks.mark(.test(2)) + @Test("8: marks are kept per account: another account sees none, and the same account sees its own again") + func marksArePerAccount() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("marks-accounts-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let accountX = try PublicKey(Data(repeating: 8, count: 32)) + let accountY = try PublicKey(Data(repeating: 9, count: 32)) - f.controller.prepareForLogout() + LocalChatMarkedUnreadStore(directory: directory, owner: accountX).mark(.test(1)) - #expect(f.marks.markedIDs.isEmpty) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("marks-reload-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let owner = try PublicKey(Data(repeating: 8, count: 32)) - let reloaded = LocalChatMarkedUnreadStore(directory: directory, owner: owner) - reloaded.mark(.test(3)) - #expect(LocalChatMarkedUnreadStore(directory: directory, owner: owner).markedIDs == [.test(3)]) + // Account switch: a store for Y over the same directory. + #expect(LocalChatMarkedUnreadStore(directory: directory, owner: accountY).markedIDs.isEmpty) + // Signing back in to X: a fresh store reads X's file. + #expect(LocalChatMarkedUnreadStore(directory: directory, owner: accountX).markedIDs == [.test(1)]) } @Test("9: Read All reads A, clears B, leaves C, keeps archived D marked, and counts 2") From ae492a04bc97136f62d8c295eb449fb1e09347e4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 15:52:06 -0500 Subject: [PATCH 05/26] feat(chat): render the long-press preview with the chat's own transcript The preview drew its own bubbles, so it lost date separators, quotes, reactions and the chat background. It now hosts ChatScreenRepresentable in a read-only mode: no input bar or jump button, no touches, opened at the newest message, read reporting off and every action a no-op. ConversationScreen still isn't used, so showing the preview never moves READ (test 17 now hosts this path). --- .../Controllers/ConversationController.swift | 9 - .../ChatScreenRepresentable.swift | 36 +++ .../Screens/Main/Tips/ChatRowActions.swift | 209 ++++++++++++------ FlipcashTests/Chat/ChatMarkUnreadTests.swift | 38 +++- .../Chat/ChatScreenViewController.swift | 5 + .../FlipcashUI/Chat/ChatViewController.swift | 5 +- 6 files changed, 214 insertions(+), 88 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 29f523b82..e087622ee 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -273,15 +273,6 @@ final class ConversationController { chatMarkedUnread?.clear(id) } - /// Up to `limit` of the newest messages stored locally, oldest first, for the long-press preview. - /// Read straight from the database: no network, no read pointer, and the open chat's window - /// cache is left alone. Falls back to the feed's last message when the transcript isn't stored. - func previewMessages(for conversation: Conversation, limit: Int = 10) -> [ConversationMessage] { - let stored = (try? database.messagesWindow(conversationID: conversation.id, before: nil, limit: limit)) ?? [] - if !stored.isEmpty { return stored } - return conversation.lastMessage.map { [$0] } ?? [] - } - /// The unread count a conversation row shows; nil when the chat is unread but its READ /// watermark's message is neither stored nor fetched yet. See /// ``Conversation/unreadCount(for:unreadSeqAt:)`` and ``resolveUnreadCount(for:)``. diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 8537f7d74..25c48cca8 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -152,8 +152,13 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var snapDistanceFromNewest: CGFloat? = nil /// The latest request to land on a message, from a message link; each new token scrolls once. var messageJump: MessageJump? = nil + /// Draws the transcript alone, for the chat list's long-press preview: no bar, no jump button, + /// no touches, opened at the newest message. Read reporting is the caller's to turn off, through + /// `reportsReads` and `onMessagesSeen`, as it is for the screen. + var isReadOnlyPreview = false func makeUIViewController(context: Context) -> ChatScreenViewController { + if isReadOnlyPreview { return makePreviewController(context: context) } let jump = JumpToBottomModel() context.coordinator.jump = jump let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) @@ -233,7 +238,38 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { return screen } + /// The transcript with the same rows, cells and attribution as the screen, over a plain empty + /// bar the clip closes to nothing. Nothing in it takes a touch. + private func makePreviewController(context: Context) -> ChatScreenViewController { + let screen = ChatScreenViewController(bar: UIView()) + screen.opensAtUnreadDivider = false + screen.authorAvatars = authorAvatars + screen.ownProfile = ownProfile + screen.linkCardSource = linkCardSource + screen.updateAction = updateAction + context.coordinator.mintMediaURL = mintMediaURL + context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt + screen.mediaURLResolver = context.coordinator.mediaURLResolver + screen.reactionStripEntries = reactionStripEntries + screen.onMessagesSeen = onMessagesSeen + screen.reportsReads = reportsReads + screen.loadViewIfNeeded() + screen.view.isUserInteractionEnabled = false + screen.setBarHeight(0, accessories: BarAccessories()) + screen.update(items: items) + context.coordinator.screen = screen + context.coordinator.lastMessageID = lastMessageID(of: items) + return screen + } + func updateUIViewController(_ screen: ChatScreenViewController, context: Context) { + if isReadOnlyPreview { + screen.authorAvatars = authorAvatars + screen.onMessagesSeen = onMessagesSeen + screen.reportsReads = reportsReads + screen.update(items: items) + return + } // Re-supply the bar with current inputs; SwiftUI diffs it, so the composer's draft and // focus survive across updates. context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 48dff8000..1c0b27539 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -90,15 +90,31 @@ private struct ChatRowContextMenu: ViewModifier { @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer + @Environment(Session.self) private var session + @Environment(Container.self) private var container func body(content: Content) -> some View { if conversation.lastMessage != nil { content.contextMenu { items } preview: { - ChatPreview(conversation: conversation, knownAuthors: sessionContainer.knownAuthors.snapshot) - // The preview is hosted outside this view's tree, so the controller is handed on. - .environment(conversationController) + // Hosted outside this view's tree, so its dependencies are handed on. + ChatPreview( + coordinator: ConversationLoadCoordinator( + conversationID: conversation.id, + controller: conversationController, + session: session, + knownAuthors: sessionContainer.knownAuthors, + paymentRequests: sessionContainer.paymentRequests, + openingLoadLanded: true + ), + conversationController: conversationController, + linkCardSource: sessionContainer.linkCardFeed, + knownAuthors: sessionContainer.knownAuthors, + avatarData: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.data(for: $0) }, + loadAvatars: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.preload($0) }, + toasts: container.toasts + ) } } else { content.contextMenu { items } @@ -127,86 +143,133 @@ private struct ChatRowContextMenu: ViewModifier { // MARK: - Preview - -/// The long-press preview: the chat's last messages from the local store, read-only, with no input -/// bar. Nothing here touches the read pointer: it is not `ConversationScreen`, which marks messages -/// seen as they appear, so looking leaves the chat as unread as it was. +/// The long-press preview: the chat's own transcript (the same cells, background, date separators, +/// sender names and reactions), read-only, with no input bar, opened at the newest message. +/// +/// It is not `ConversationScreen`, which marks messages seen as they appear. Here read reporting is +/// off and every action is a no-op, so looking leaves the chat as unread as it was. The rows come +/// from the local store through the same ``ConversationLoadCoordinator`` the screen uses; paging +/// older history, the one path that fetches, is off too. struct ChatPreview: View { - let conversation: Conversation - var knownAuthors: KnownAuthorDirectory.Snapshot = .empty - - @Environment(ConversationController.self) private var conversationController + let conversationController: ConversationController + let linkCardSource: any LinkCardSource + let knownAuthors: KnownAuthorDirectory + let avatarData: (UserID) -> Data? + let loadAvatars: ([(userID: UserID?, picture: ProfilePicture?)]) -> Void + let toasts: ToastController + + @State private var coordinator: ConversationLoadCoordinator + @State private var barModel = ConversationBarModel() + @State private var composer = ComposerModel() + + /// - Parameter coordinator: loads the chat's rows from the local store, as it does for the + /// screen; pass one built with `openingLoadLanded: true` so it doesn't wait on a fetch. + init( + coordinator: ConversationLoadCoordinator, + conversationController: ConversationController, + linkCardSource: any LinkCardSource, + knownAuthors: KnownAuthorDirectory, + avatarData: @escaping (UserID) -> Data?, + loadAvatars: @escaping ([(userID: UserID?, picture: ProfilePicture?)]) -> Void, + toasts: ToastController + ) { + self.conversationController = conversationController + self.linkCardSource = linkCardSource + self.knownAuthors = knownAuthors + self.avatarData = avatarData + self.loadAvatars = loadAvatars + self.toasts = toasts + _coordinator = State(initialValue: coordinator) + } var body: some View { - let messages = conversationController.previewMessages(for: conversation) - let selfUserID = conversationController.selfUserID - - // Newest at the bottom, as the transcript lands; older messages clip off the top. A fixed - // frame alone centres an overflowing stack, so a bottom-anchored, unscrollable scroll view - // keeps the latest message in view. - ScrollView { - VStack(spacing: 6) { - ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in - if let text = conversationController.messagePreview( - message, - in: conversation, - knownAuthors: knownAuthors, - attributed: false, - currencyName: { _ in nil } - ) { - let isFromSelf = message.isFromSelf(selfUserID) - let startsRun = index == 0 || messages[index - 1].senderID != message.senderID - ChatPreviewBubble( - text: text, - senderName: startsRun - ? conversationController.groupSenderName( - for: message, - in: conversation, - isFromSelf: isFromSelf, - knownAuthors: knownAuthors - ) - : nil, - isFromSelf: isFromSelf - ) - } - } + transcript + .frame(width: 340, height: 430) + .background(Color.backgroundMain) + // The screen's own lookups for who wrote each row, so names and pictures match the + // chat. All three only read: the local author cache, profiles by user id, and pictures. + .task(id: isGroup) { + guard isGroup else { return } + await knownAuthors.reload() + } + .task(id: coordinator.unattributedSenders) { + guard !coordinator.unattributedSenders.isEmpty else { return } + await knownAuthors.resolve(coordinator.unattributedSenders) + } + .task(id: coordinator.attributedMembers.compactMap(\.userID)) { + loadAvatars(coordinator.attributedMembers.map { (userID: $0.userID, picture: $0.profilePicture) }) } - .padding(12) - } - .defaultScrollAnchor(.bottom) - .scrollDisabled(true) - .scrollIndicators(.hidden) - .frame(width: 340, height: 430) - .background(Color.backgroundMain) } -} -/// One message in the preview: the transcript's sides, without its cells. -private struct ChatPreviewBubble: View { + private var isGroup: Bool { + conversationController.conversation(withID: coordinator.conversationID)?.type == .group + } - let text: String - let senderName: String? - let isFromSelf: Bool + /// The screen's transcript in its read-only mode. + private var transcript: ChatScreenRepresentable { + ChatScreenRepresentable( + items: coordinator.items, + onReachTop: {}, + onRetry: { _ in }, + onCashCardTap: { _ in }, + onOpenURL: { _ in }, + onMentionTap: { _ in }, + onShareProfile: { _, _ in }, + ownProfile: nil, + onLinkCardTap: { _, _ in }, + linkCardSource: linkCardSource, + updateAction: nil, + toasts: toasts, + onEncryptionMarkerTap: {}, + onInviteCardTap: {}, + onAuthorTap: { _ in }, + onMessageAction: { _, _ in }, + onQuoteTap: { _ in }, + onMessagesSeen: { _ in }, + reportsReads: false, + unreadBelowCount: 0, + onUserScrollBegan: {}, + onReactionTap: { _, _ in }, + onReactionLongPress: { _, _ in }, + onReactionAdd: { _ in }, + onReactionBoost: { _ in }, + reactionStripEntries: { _ in [] }, + onReactionStripSelect: { _, _ in }, + onReactionStripAdd: { _ in }, + showsSendCash: false, + conversationID: coordinator.conversationID, + symbol: "", + onSendCash: {}, + conversationController: conversationController, + barModel: barModel, + composer: composer, + editingStableID: nil, + focusOnAppear: false, + gate: .open, + showsGatePlaceholder: false, + gateMintName: nil, + gateShortfall: nil, + // The gate is open and its placeholder hidden, so no amount is ever drawn in it. + gateDisplayCurrency: DisplayCurrency(currency: .usd, rates: [Rate]()), + onGateAddFunds: {}, + onGateJoin: {}, + isJoiningChat: false, + mentions: nil, + authorAvatars: authorAvatars, + isReadOnlyPreview: true + ) + } - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if let senderName { - Text(senderName) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .lineLimit(1) - .padding(.horizontal, 4) - } - Text(text) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .lineLimit(4) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(isFromSelf ? Color.backgroundSecondary : Color.backgroundRow, in: .rect(cornerRadius: 16)) + /// Avatar bytes for the transcript's senders, read as the screen reads them. + private var authorAvatars: [UserID: Data] { + var avatars: [UserID: Data] = [:] + for member in coordinator.attributedMembers { + guard let userID = member.userID, + let data = avatarData(userID) else { continue } + avatars[userID] = data } - .frame(maxWidth: 260, alignment: isFromSelf ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: isFromSelf ? .trailing : .leading) + return avatars } } diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 2feb467ae..2ed7e6ea4 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -337,11 +337,33 @@ struct ChatMarkUnreadTests { try f.database.upsertConversationMessages((1...5).map(message), conversationID: .test(1)) let conversation = try #require(f.controller.conversation(withID: .test(1))) - #expect(f.controller.previewMessages(for: conversation).map(\.id.value) == [1, 2, 3, 4, 5]) - // Render the preview as the menu would, then let it go without a tap. - let host = UIHostingController(rootView: ChatPreview(conversation: conversation).environment(f.controller)) - host.view.frame = CGRect(x: 0, y: 0, width: 340, height: 430) - host.view.layoutIfNeeded() + // The preview loads its rows as the chat screen does and draws them with the screen's + // transcript, read-only. Show it in a window, as the menu would, then let it go untapped. + let authors = KnownAuthorDirectory(read: { [:] }, fetch: { _ in throw CancellationError() }, cache: { _, _ in }) + let coordinator = ConversationLoadCoordinator( + conversationID: .test(1), + controller: f.controller, + session: .mock, + knownAuthors: authors, + openingLoadLanded: true + ) + let rows = coordinator.items.filter { if case .message = $0 { true } else { false } } + #expect(rows.count == 5) + let preview = ChatPreview( + coordinator: coordinator, + conversationController: f.controller, + linkCardSource: NoLinkCards(), + knownAuthors: authors, + avatarData: { _ in nil }, + loadAvatars: { _ in }, + toasts: ToastController() + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 340, height: 430)) + window.rootViewController = UIHostingController(rootView: preview) + window.makeKeyAndVisible() + window.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(300)) + window.isHidden = true #expect(pointer(f.controller, 1) == MessageID(value: 2)) #expect(f.controller.conversation(withID: .test(1))?.hasUnread(for: me) == true) @@ -359,4 +381,10 @@ struct ChatMarkUnreadTests { f.controller.markUnread(requested.id, source: .swipe) #expect(f.marks.markedIDs.isEmpty) } + + /// A link-card source that knows nothing; the preview's chat has no links. + private final class NoLinkCards: LinkCardSource { + func known(_ card: LinkCard) -> LinkCard.State? { nil } + func states(for card: LinkCard) -> AsyncStream { AsyncStream { $0.finish() } } + } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 3960ab418..7d98b9709 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -356,6 +356,11 @@ public final class ChatScreenViewController: UIViewController { get { transcript.reportsReads } set { transcript.reportsReads = newValue } } + /// Whether the first open lands on the unread divider — see ``ChatViewController/opensAtUnreadDivider``. + public var opensAtUnreadDivider: Bool { + get { transcript.opensAtUnreadDivider } + set { transcript.opensAtUnreadDivider = newValue } + } /// Forwards a reaction-pill tap from the transcript, with the row's id and the toggled emoji. public var onReactionTap: ((String, String) -> Void)? { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 380c64bf4..6e6e9d0cb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -235,6 +235,9 @@ public final class ChatViewController: UICollectionViewController { /// open (a non-animated update) goes to the bottom as before, rather than back to the divider /// the reader may already have scrolled past. private var hasPlacedUnreadDivider = false + /// Whether the first open lands on the unread divider rather than the newest message. Off for a + /// read-only preview of the chat, which shows the divider where it falls but opens at the bottom. + public var opensAtUnreadDivider = true /// Set once the opening scroll has landed. Until then the transcript sits wherever the first /// layout put it — at the bottom, for a chat that is about to move up to its unread divider — @@ -1043,7 +1046,7 @@ public final class ChatViewController: UICollectionViewController { needsInitialScroll = false scrollToBottom(animated: false) armReadReportingOnceLanded() - guard !hasPlacedUnreadDivider, + guard opensAtUnreadDivider, !hasPlacedUnreadDivider, let divider = items.firstIndex(where: { if case .unreadDivider = $0 { true } else { false } }) else { return } hasPlacedUnreadDivider = true From 2a608553ac64ce9ee0cb4d9b67c8b26209940ada Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 18:58:51 -0500 Subject: [PATCH 06/26] fix(chat): report menu and bulk unmute like the Mute sheet The long-press Unmute and the select-mode Unmute capsule called unmute with try?, so neither sent chatUnmuted and a failure showed nothing. Both now go through unmuteReporting, which tracks per chat and shows the sheet's error dialog. Android's ChatRowActions.unmute already tracks both outcomes. --- .../Screens/Main/Tips/ChatRowActions.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 1c0b27539..208507273 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -127,7 +127,7 @@ private struct ChatRowContextMenu: ViewModifier { } if conversationController.isMuted(conversationID: conversation.id) { Button("Unmute", systemImage: SystemSymbol.bell.rawValue) { - Task { try? await conversationController.unmute(conversationID: conversation.id) } + Task { await conversationController.unmuteReporting([conversation.id], session: session) } } } else { Button("Mute", systemImage: SystemSymbol.bellSlash.rawValue, action: onMute) @@ -315,6 +315,30 @@ struct ChatListMoreMenu: View { } } +// MARK: - Unmute - + +extension ConversationController { + + /// Unmutes `ids` the way the Mute sheet's Unmute row does: a `chatUnmuted` event per chat, and + /// on failure the same error dialog and captured error. For the paths that skip the sheet. + func unmuteReporting(_ ids: [ConversationID], session: Session) async { + let chatTypes = ids.map { conversation(withID: $0)?.type } + do { + try await unmute(ids) + chatTypes.forEach { Analytics.chatUnmuted(chatType: $0, error: nil) } + } catch { + Analytics.chatUnmuted(chatType: chatTypes.first ?? nil, error: error) + session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: ids.count == 1 + ? "We were unable to unmute this chat. Please try again" + : "We were unable to unmute these chats. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to unmute chat") + } + } +} + // MARK: - Select mode actions - /// Select mode's three floating capsules, in place of the tab bar: the read toggle, Archive, Mute. @@ -330,6 +354,7 @@ struct ChatSelectionActions: View { let onDone: () -> Void @Environment(ConversationController.self) private var conversationController + @Environment(Session.self) private var session var body: some View { let toggle = conversationController.readToggle(for: selected) @@ -357,7 +382,7 @@ struct ChatSelectionActions: View { onMute(selected) case .unmute: let ids = selected - Task { try? await conversationController.unmute(ids) } + Task { await conversationController.unmuteReporting(ids, session: session) } onDone() } } From 4047adcf73acbe6adbbcaaa0e80962eede9f435d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 19:32:58 -0500 Subject: [PATCH 07/26] feat(chat): title every swipe action under its tonal pill Each chat-list and Members swipe action draws its title under it via .labelStyle(.titleAndIcon): Unread/Read, Mute/Unmute, Archive/Unarchive, Restrict, Lift Restriction, Remove from Group. iOS lays a titled swipe action out as a tonal pill rather than a circle. VoiceOver labels and full-swipe behaviour are unchanged. --- .../Main/Tips/ArchivedChatsScreen.swift | 4 +- .../Screens/Main/Tips/ChatReadActions.swift | 8 ++++ .../Screens/Main/Tips/ChatRowActions.swift | 9 +++-- .../Screens/Main/Tips/SwipeActionLabel.swift | 38 +++++++++++++++++++ .../Main/Tips/TipConversationsScreen.swift | 9 +---- .../Screens/Members/GroupMembersScreen.swift | 15 ++++---- 6 files changed, 61 insertions(+), 22 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Tips/SwipeActionLabel.swift diff --git a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift index 146a63a1c..bd3570bf2 100644 --- a/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift @@ -36,9 +36,7 @@ struct ArchivedChatsScreen: View { Button { conversationController.unarchive(conversation.id) } label: { - // Swipe actions fill symbols by default; the shared design uses the outline. - Image(systemName: "tray.and.arrow.up") - .environment(\.symbolVariants, .none) + SwipeActionLabel(title: "Unarchive", systemImage: "tray.and.arrow.up") } .tint(.backgroundRow) .accessibilityLabel("Unarchive chat") diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift index e532c747f..a8b4b9648 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -21,6 +21,14 @@ enum ChatReadAction: Equatable { } /// The SF Symbol the swipe and menu draw. + /// The shorter title drawn under the swipe action's circle, as the select-mode capsule reads. + var swipeTitle: String { + switch self { + case .markRead: "Read" + case .markUnread: "Unread" + } + } + var systemImage: String { switch self { case .markRead: "message" diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 208507273..22a2b4623 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -56,10 +56,11 @@ private struct ChatRowActionsModifier: ViewModifier { Button { conversationController.perform(action, on: conversation.id, source: .swipe) } label: { - // A swipe action draws its label white whatever the style says, so the - // blue has to be baked into the image, as the Mute action does its amber. - Image(uiImage: UIImage(systemName: action.systemImage)! - .withTintColor(UIColor(Color.unreadIndicator), renderingMode: .alwaysOriginal)) + SwipeActionLabel( + title: action.swipeTitle, + systemImage: action.systemImage, + glyphColor: .unreadIndicator + ) } // Mute's tonal style in blue: the glyph on its own colour at low strength. .tint(.unreadIndicatorSecondary) diff --git a/Flipcash/Core/Screens/Main/Tips/SwipeActionLabel.swift b/Flipcash/Core/Screens/Main/Tips/SwipeActionLabel.swift new file mode 100644 index 000000000..dc2a7f63a --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/SwipeActionLabel.swift @@ -0,0 +1,38 @@ +// +// SwipeActionLabel.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// A swipe action's content: the symbol in its tonal circle with the title drawn under it. +/// +/// `.titleAndIcon` is what makes the row draw the title; without it a swipe action shows only the +/// symbol. The button's own `.tint` sets the circle and its `.accessibilityLabel` still names the +/// action for VoiceOver. +struct SwipeActionLabel: View { + + let title: String + let systemImage: String + + /// The glyph's colour, baked into the image because a swipe action draws its label white + /// whatever the style says. `nil` keeps the default white outline glyph. + var glyphColor: Color? = nil + + var body: some View { + Label { + Text(title) + } icon: { + if let glyphColor { + Image(uiImage: UIImage(systemName: systemImage)! + .withTintColor(UIColor(glyphColor), renderingMode: .alwaysOriginal)) + } else { + // Swipe actions fill symbols by default; the shared design uses the outline. + Image(systemName: systemImage) + .environment(\.symbolVariants, .none) + } + } + .labelStyle(.titleAndIcon) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 937865dcb..3e44ef19f 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -309,10 +309,7 @@ struct TipConversationsScreen: View { return Button { muteTarget = MuteTarget(ids: [conversation.id]) } label: { - // A swipe action draws its label white whatever the style says, so the amber has to be - // baked into the image. - Image(uiImage: UIImage(systemName: symbol.rawValue)! - .withTintColor(UIColor(Color.warning), renderingMode: .alwaysOriginal)) + SwipeActionLabel(title: isMuted ? "Unmute" : "Mute", systemImage: symbol.rawValue, glyphColor: .warning) } // The mute chip's amber-on-amber, not the delete red: nothing is lost by it. .tint(.warningSecondary) @@ -325,9 +322,7 @@ struct TipConversationsScreen: View { Button { archive([conversation.id]) } label: { - // Swipe actions fill symbols by default; the shared design uses the outline. - Image(systemName: "archivebox") - .environment(\.symbolVariants, .none) + SwipeActionLabel(title: "Archive", systemImage: "archivebox") } .tint(.backgroundRow) // A swipe action is also a VoiceOver custom action, next to the mute one. diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index 981594a2d..2a9a951fc 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -122,10 +122,7 @@ struct GroupMembersScreen: View { Button { silenceTarget = moderationTarget(for: portrait) } label: { - // A swipe action draws its label white whatever the style says, so the colour is - // baked into the image, as the chat list's Mute does. - Image(uiImage: UIImage(systemName: SystemSymbol.clock.rawValue)! - .withTintColor(UIColor(Color.warning), renderingMode: .alwaysOriginal)) + SwipeActionLabel(title: "Restrict", systemImage: SystemSymbol.clock.rawValue, glyphColor: .warning) } .tint(.warningSecondary) .accessibilityLabel("Restrict") @@ -133,8 +130,7 @@ struct GroupMembersScreen: View { Button { Task { await moderator.lift(portrait.userID, firstName: GroupModerationCopy.firstName(of: portrait.name)) } } label: { - Image(systemName: "lock.open") - .environment(\.symbolVariants, .none) + SwipeActionLabel(title: "Lift Restriction", systemImage: "lock.open") } .tint(.backgroundRow) .accessibilityLabel("Lift restriction") @@ -145,8 +141,11 @@ struct GroupMembersScreen: View { Button { moderator.confirmRemove(moderationTarget(for: portrait)) } label: { - Image(uiImage: UIImage(systemName: SystemSymbol.personBadgeMinus.rawValue)! - .withTintColor(UIColor(Color.Sentiment.negative), renderingMode: .alwaysOriginal)) + SwipeActionLabel( + title: "Remove from Group", + systemImage: SystemSymbol.personBadgeMinus.rawValue, + glyphColor: Color.Sentiment.negative + ) } .tint(Color.Sentiment.negativeSecondary) .accessibilityLabel("Remove from group") From 73937f99baffbbb096fa9be2be101ffd0e0fa09d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 20:15:58 -0500 Subject: [PATCH 08/26] test(chat): pin the short swipe titles beside the menu's long ones --- FlipcashTests/Chat/ChatMarkUnreadTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 2ed7e6ea4..07de55bd6 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -383,6 +383,14 @@ struct ChatMarkUnreadTests { } /// A link-card source that knows nothing; the preview's chat has no links. + @Test("Swipe titles are the short form; the menu and VoiceOver keep \"Mark as …\"") + func swipeTitlesAreShort() { + #expect(ChatReadAction.markUnread.swipeTitle == "Unread") + #expect(ChatReadAction.markRead.swipeTitle == "Read") + #expect(ChatReadAction.markUnread.title == "Mark as Unread") + #expect(ChatReadAction.markRead.title == "Mark as Read") + } + private final class NoLinkCards: LinkCardSource { func known(_ card: LinkCard) -> LinkCard.State? { nil } func states(for card: LinkCard) -> AsyncStream { AsyncStream { $0.finish() } } From 79728cf73170dbc35f514a78f0f212d070e07da2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 20:56:22 -0500 Subject: [PATCH 09/26] fix(members): title the remove swipe action "Remove" The row already sits in the group's member list, so the swipe title drops "from Group". The member sheet and VoiceOver keep "Remove from Group". --- Flipcash/Core/Screens/Members/GroupMembersScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index 2a9a951fc..e7d344009 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -142,7 +142,7 @@ struct GroupMembersScreen: View { moderator.confirmRemove(moderationTarget(for: portrait)) } label: { SwipeActionLabel( - title: "Remove from Group", + title: "Remove", systemImage: SystemSymbol.personBadgeMinus.rawValue, glyphColor: Color.Sentiment.negative ) From 8968acccf0f19257748ff278c86eff5c97276481 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 21:57:40 -0500 Subject: [PATCH 10/26] fix(members): put Remove at the outer edge of the member swipe Android already places Remove outermost; iOS had Restrict there. --- .../Screens/Members/GroupMembersScreen.swift | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index e7d344009..8568a2d37 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -113,10 +113,23 @@ struct GroupMembersScreen: View { // MARK: - Swipe - - /// Restrict or Lift outermost, then Remove, in the chat list's tonal style. No full swipe: each + /// Remove outermost, then Restrict or Lift, matching Android, in the chat list's tonal style. No full swipe: each /// one needs a choice or a confirm. @ViewBuilder private func swipeActions(for portrait: GroupPortrait, options: MemberModerationOptions) -> some View { + if options.canRemove { + Button { + moderator.confirmRemove(moderationTarget(for: portrait)) + } label: { + SwipeActionLabel( + title: "Remove", + systemImage: SystemSymbol.personBadgeMinus.rawValue, + glyphColor: Color.Sentiment.negative + ) + } + .tint(Color.Sentiment.negativeSecondary) + .accessibilityLabel("Remove from group") + } switch options.silence { case .restrict: Button { @@ -137,19 +150,6 @@ struct GroupMembersScreen: View { case nil: EmptyView() } - if options.canRemove { - Button { - moderator.confirmRemove(moderationTarget(for: portrait)) - } label: { - SwipeActionLabel( - title: "Remove", - systemImage: SystemSymbol.personBadgeMinus.rawValue, - glyphColor: Color.Sentiment.negative - ) - } - .tint(Color.Sentiment.negativeSecondary) - .accessibilityLabel("Remove from group") - } } // MARK: - Row - From a8020592e73a0676a7b1bd85a8dcfc36b107defe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 23:35:09 -0500 Subject: [PATCH 11/26] fix(members): use the speaker for the Restrict swipe The profile menu and the Restricted tag already use speaker.slash, as Android does; the clock read as a timed mute. --- Flipcash/Core/Screens/Members/GroupMembersScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index 8568a2d37..e9bcaaac1 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -135,7 +135,7 @@ struct GroupMembersScreen: View { Button { silenceTarget = moderationTarget(for: portrait) } label: { - SwipeActionLabel(title: "Restrict", systemImage: SystemSymbol.clock.rawValue, glyphColor: .warning) + SwipeActionLabel(title: "Restrict", systemImage: "speaker.slash", glyphColor: .warning) } .tint(.warningSecondary) .accessibilityLabel("Restrict") From cbb60c34162f6cbe4660f9a0443a904eee3f5630 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 02:44:03 -0500 Subject: [PATCH 12/26] fix(moderation): use the crossed-out mic for Restrict Restrict stops a member posting; a muted speaker read as "I can't hear them", closer to Mute. Covers the Members swipe, profile menu, Restricted tag, toast, Forever option and the message action. --- Flipcash/Core/Screens/Members/GroupMembersScreen.swift | 2 +- Flipcash/Core/Screens/Moderation/GroupModeration.swift | 2 +- Flipcash/Core/Screens/Moderation/ModerationPrompts.swift | 2 +- Flipcash/Core/Screens/Profile/ChatProfileScreen.swift | 2 +- Flipcash/Core/Screens/Profile/ProfileMenuItems.swift | 2 +- FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift | 2 +- FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index e9bcaaac1..bba9d3e37 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -135,7 +135,7 @@ struct GroupMembersScreen: View { Button { silenceTarget = moderationTarget(for: portrait) } label: { - SwipeActionLabel(title: "Restrict", systemImage: "speaker.slash", glyphColor: .warning) + SwipeActionLabel(title: "Restrict", systemImage: "mic.slash", glyphColor: .warning) } .tint(.warningSecondary) .accessibilityLabel("Restrict") diff --git a/Flipcash/Core/Screens/Moderation/GroupModeration.swift b/Flipcash/Core/Screens/Moderation/GroupModeration.swift index 61a22d6f2..dd9af5cd2 100644 --- a/Flipcash/Core/Screens/Moderation/GroupModeration.swift +++ b/Flipcash/Core/Screens/Moderation/GroupModeration.swift @@ -38,7 +38,7 @@ enum SilenceDuration: CaseIterable, Hashable { var systemSymbol: SystemSymbol { switch self { case .oneHour, .eightHours, .oneWeek: .clock - case .forever: .speakerSlash + case .forever: .micSlash } } diff --git a/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift b/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift index 29ed580ca..982aac6dd 100644 --- a/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift +++ b/Flipcash/Core/Screens/Moderation/ModerationPrompts.swift @@ -63,7 +63,7 @@ struct SilenceUserSheet: View { // The sheet is still on its way out, and a toast shown over it is dropped. Task { try? await Task.sleep(for: .milliseconds(600)) - toasts.show(.init(message, systemImage: "speaker.slash", duration: .seconds(3))) + toasts.show(.init(message, systemImage: "mic.slash", duration: .seconds(3))) } }, didChangeSilence: { [restrictions = sessionContainer.groupRestrictions, conversationID = target.conversationID] in diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 31c6f8b6b..016535b11 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -681,7 +681,7 @@ struct ChatProfileScreen: View { Button { router.push(.silencedMembers(conversationID)) } label: { - Label("Restricted", systemImage: "speaker.slash") + Label("Restricted", systemImage: "mic.slash") } Button { router.push(.removedMembers(conversationID)) diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index 50b53c285..23934a428 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -34,7 +34,7 @@ nonisolated enum ProfileMenuItem: Equatable { case .report: return "exclamationmark.bubble" case .block: return "nosign" case .unblock: return "checkmark.circle" - case .silence: return "speaker.slash" + case .silence: return "mic.slash" case .removeFromGroup: return "person.badge.minus" } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6e6e9d0cb..b620c2d72 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -2065,7 +2065,7 @@ extension MessageCapability { case .forward: .forwardArrow case .pin: .pinFill case .unpin: .pinSlash - case .silence: .speakerSlash + case .silence: .micSlash case .remove: .personBadgeMinus } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index c1e4cf850..ae248c237 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -90,7 +90,7 @@ public enum SystemSymbol: String { /// The bare glyph, for a confirm affordance that carries its own backing shape. case checkmark = "checkmark" - case speakerSlash = "speaker.slash.fill" + case micSlash = "mic.slash.fill" /// A person with a minus, for taking someone out of a group. case personBadgeMinus = "person.badge.minus" From fb93f7e619dc515b216491eb01ccc6819b302d9e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 11:31:41 -0500 Subject: [PATCH 13/26] feat(chats): archive takes the trailing full swipe --- Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 3e44ef19f..a8c8a700d 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -112,8 +112,10 @@ struct TipConversationsScreen: View { // Mute first: the first button is the outer, full-swipe action. .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !isSelecting { - muteAction(for: conversation) + // First is outermost and takes the full swipe: Archive, the + // action that takes the row out of the list. archiveAction(for: conversation) + muteAction(for: conversation) } } .chatRowActions( From a9ea9f55bb0fe50043b5e0a69bdeae171f111a8a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 14:20:45 -0500 Subject: [PATCH 14/26] fix(chats): slide the select circles in with the row content --- .../Core/Screens/Main/Tips/RecipientRow.swift | 22 ++++++++++++++----- .../Main/Tips/TipConversationsScreen.swift | 1 - 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 760224d66..4273fb0cc 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -39,13 +39,21 @@ struct RecipientRowScaffold: View { let onTap: () -> Void @ViewBuilder let trailing: Trailing + /// The circle plus the gap after it: the width the row's content moves right by. + private static var selectSlot: CGFloat { 22 + 12 } + var body: some View { + let isSelecting = selection != nil Button(action: onTap) { - HStack(spacing: 12) { - if let selection { - RecipientRowSelectCircle(isSelected: selection) - .transition(.move(edge: .leading).combined(with: .opacity)) - } + HStack(spacing: 0) { + // Always laid out, never inserted. The slot grows from zero while the circle rides in + // from just off its leading edge, so circle and content move as one. An inserted + // circle with a move transition slid on its own while the content beside it jumped. + RecipientRowSelectCircle(isSelected: selection == true) + .offset(x: isSelecting ? 0 : -Self.selectSlot) + .opacity(isSelecting ? 1 : 0) + .frame(width: isSelecting ? Self.selectSlot : 0, alignment: .leading) + .accessibilityHidden(!isSelecting) RecipientRowBody( avatarID: avatarID, title: title, @@ -58,6 +66,8 @@ struct RecipientRowScaffold: View { trailing } } + // Inside the row: a List cell doesn't reliably pick up an animation set on the List. + .animation(.smooth(duration: 0.3), value: isSelecting) } .recipientRowChrome(accessibilityLabel: accessibilityLabel, isSelected: selection == true) .accessibilityAddTraits(selection == true ? .isSelected : []) @@ -71,7 +81,7 @@ private struct RecipientRowSelectCircle: View { var body: some View { // One image whose symbol swaps, so a tap changes the circle in place. Two images in an - // if/else would each take the row's slide-in transition and the circle would slide. + // if/else would be two views, and the tap would swap one for the other instead of the symbol. Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.system(size: 22)) // A white check on the unread blue, the selection colour the design uses. diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index a8c8a700d..20684d098 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -109,7 +109,6 @@ struct TipConversationsScreen: View { // Separators divide rows from each other; the first // row's leading one just draws a line under the bar. .listRowSeparator(index == 0 ? .hidden : .automatic, edges: .top) - // Mute first: the first button is the outer, full-swipe action. .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !isSelecting { // First is outermost and takes the full swipe: Archive, the From dc560bb5a1676bbc660181071f28dded8423b323 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 14:28:07 -0500 Subject: [PATCH 15/26] fix(chats): keep the row's identity when select mode starts --- .../Screens/Main/Tips/ChatRowActions.swift | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 22a2b4623..1480334b7 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -50,9 +50,13 @@ private struct ChatRowActionsModifier: ViewModifier { @Environment(ConversationController.self) private var conversationController func body(content: Content) -> some View { - if isEnabled, let action = conversationController.readAction(for: conversation) { - content - .swipeActions(edge: .leading, allowsFullSwipe: true) { + // One modifier chain whether or not the actions are on; only what's inside empties. Branching + // on `isEnabled` gave select mode a different view, so the List swapped the row for a new one + // and cross-faded the two while the select circle slid in. + let action = isEnabled ? conversationController.readAction(for: conversation) : nil + content + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if let action { Button { conversationController.perform(action, on: conversation.id, source: .swipe) } label: { @@ -66,25 +70,24 @@ private struct ChatRowActionsModifier: ViewModifier { .tint(.unreadIndicatorSecondary) .accessibilityLabel(action.title) } - .modifier(ChatRowContextMenu( - conversation: conversation, - action: action, - list: list, - onMute: onMute, - onArchive: onArchive - )) - } else { - content - } + } + .modifier(ChatRowContextMenu( + conversation: conversation, + action: action, + list: list, + onMute: onMute, + onArchive: onArchive + )) } } /// The long-press menu. A chat with a message in the local store previews its transcript above the -/// menu; one with none shows the menu alone. +/// menu; one with none shows the menu alone. With no `action` the menu has no items, which turns the +/// long press off. private struct ChatRowContextMenu: ViewModifier { let conversation: Conversation - let action: ChatReadAction + let action: ChatReadAction? let list: ChatRowList let onMute: () -> Void let onArchive: () -> Void @@ -123,21 +126,23 @@ private struct ChatRowContextMenu: ViewModifier { } @ViewBuilder private var items: some View { - Button(action.title, systemImage: action.systemImage) { - conversationController.perform(action, on: conversation.id, source: .menu) - } - if conversationController.isMuted(conversationID: conversation.id) { - Button("Unmute", systemImage: SystemSymbol.bell.rawValue) { - Task { await conversationController.unmuteReporting([conversation.id], session: session) } + if let action { + Button(action.title, systemImage: action.systemImage) { + conversationController.perform(action, on: conversation.id, source: .menu) + } + if conversationController.isMuted(conversationID: conversation.id) { + Button("Unmute", systemImage: SystemSymbol.bell.rawValue) { + Task { await conversationController.unmuteReporting([conversation.id], session: session) } + } + } else { + Button("Mute", systemImage: SystemSymbol.bellSlash.rawValue, action: onMute) + } + switch list { + case .main: + Button("Archive", systemImage: "archivebox", action: onArchive) + case .archived: + Button("Unarchive", systemImage: "tray.and.arrow.up", action: onArchive) } - } else { - Button("Mute", systemImage: SystemSymbol.bellSlash.rawValue, action: onMute) - } - switch list { - case .main: - Button("Archive", systemImage: "archivebox", action: onArchive) - case .archived: - Button("Unarchive", systemImage: "tray.and.arrow.up", action: onArchive) } } } From a4aaf2a9e311ea8d673589fee158a99a8088bb6a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:14:40 -0500 Subject: [PATCH 16/26] fix(chats): lay the select row out from one animated progress Animating the select slot's width let the title and subtitle each re-truncate and cross-fade on their own while the circle slid in, so two copies of the subtitle drew at once mid-slide. RecipientRowSelectReveal is an animatable Layout: SwiftUI interpolates its progress and lays the row out again every frame, so the circle, avatar, title, subtitle, and trailing content move as one block, as Android's TipChatRow does. --- .../Core/Screens/Main/Tips/RecipientRow.swift | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 4273fb0cc..e2fe758fa 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -39,20 +39,14 @@ struct RecipientRowScaffold: View { let onTap: () -> Void @ViewBuilder let trailing: Trailing - /// The circle plus the gap after it: the width the row's content moves right by. - private static var selectSlot: CGFloat { 22 + 12 } - var body: some View { let isSelecting = selection != nil Button(action: onTap) { - HStack(spacing: 0) { - // Always laid out, never inserted. The slot grows from zero while the circle rides in - // from just off its leading edge, so circle and content move as one. An inserted - // circle with a move transition slid on its own while the content beside it jumped. + // Always laid out, never inserted: an inserted circle with a move transition slid on its + // own while the content beside it jumped. + RecipientRowSelectReveal(progress: isSelecting ? 1 : 0) { RecipientRowSelectCircle(isSelected: selection == true) - .offset(x: isSelecting ? 0 : -Self.selectSlot) .opacity(isSelecting ? 1 : 0) - .frame(width: isSelecting ? Self.selectSlot : 0, alignment: .leading) .accessibilityHidden(!isSelecting) RecipientRowBody( avatarID: avatarID, @@ -74,6 +68,48 @@ struct RecipientRowScaffold: View { } } +/// Lays out select mode's circle and the row content beside it from one `progress`: 0 outside +/// select mode, 1 in it. Takes exactly two subviews, the circle then the content. +/// +/// Animating `progress` lays the whole row out again on every frame, so avatar, title, subtitle, +/// and trailing content move as one block, the circle sliding in from off the leading edge with +/// them. Animating a slot's width instead let each `Text` re-truncate and cross-fade on its own. +private struct RecipientRowSelectReveal: Layout { + + var progress: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + /// The circle plus the gap after it: the width the row's content moves right by. + private static let slot: CGFloat = 22 + 12 + + private var offset: CGFloat { Self.slot * progress } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let content = subviews[1].sizeThatFits( + ProposedViewSize(width: proposal.width.map { max(0, $0 - offset) }, height: proposal.height) + ) + let circle = subviews[0].sizeThatFits(.unspecified) + return CGSize(width: offset + content.width, height: max(content.height, circle.height)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews[0].place( + at: CGPoint(x: bounds.minX + offset - Self.slot, y: bounds.midY), + anchor: .leading, + proposal: .unspecified + ) + subviews[1].place( + at: CGPoint(x: bounds.minX + offset, y: bounds.minY), + anchor: .topLeading, + proposal: ProposedViewSize(width: max(0, bounds.width - offset), height: bounds.height) + ) + } +} + /// Select mode's leading circle: the same 22pt outline and filled check as Select People. private struct RecipientRowSelectCircle: View { From 8ad402ffaeaac3e56987c57faa512ceba0572df3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:23:32 -0500 Subject: [PATCH 17/26] fix(chats): build the long-press preview only when it shows contextMenu evaluates its preview closure on every row update, and every row updates when the "..." menu opens or select mode starts. The closure built a ConversationLoadCoordinator, which read and mapped each listed chat's messages on the main thread. A main-thread sample of the first menu open after launch spent 359 ms there, and select entry spent 57 ms. ChatRowPreview builds the coordinator in its body, which runs only when the preview is presented. Busy main-thread time on the first menu open drops from 705 ms to 34 ms, and on select entry from 253 ms to 75 ms. --- .../Screens/Main/Tips/ChatRowActions.swift | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 1480334b7..cf52bee4f 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -103,20 +103,11 @@ private struct ChatRowContextMenu: ViewModifier { items } preview: { // Hosted outside this view's tree, so its dependencies are handed on. - ChatPreview( - coordinator: ConversationLoadCoordinator( - conversationID: conversation.id, - controller: conversationController, - session: session, - knownAuthors: sessionContainer.knownAuthors, - paymentRequests: sessionContainer.paymentRequests, - openingLoadLanded: true - ), + ChatRowPreview( + conversationID: conversation.id, conversationController: conversationController, - linkCardSource: sessionContainer.linkCardFeed, - knownAuthors: sessionContainer.knownAuthors, - avatarData: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.data(for: $0) }, - loadAvatars: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.preload($0) }, + session: session, + sessionContainer: sessionContainer, toasts: container.toasts ) } @@ -149,6 +140,39 @@ private struct ChatRowContextMenu: ViewModifier { // MARK: - Preview - +/// Builds ``ChatPreview`` in `body`, which runs only once the preview shows. +/// +/// `contextMenu` evaluates its preview closure every time the row updates, and every row updates +/// together when select mode starts or the "…" menu opens. Building the coordinator there read and +/// mapped each listed chat's messages on the main thread, stalling the first frames of both. +private struct ChatRowPreview: View { + + let conversationID: ConversationID + let conversationController: ConversationController + let session: Session + let sessionContainer: SessionContainer + let toasts: ToastController + + var body: some View { + ChatPreview( + coordinator: ConversationLoadCoordinator( + conversationID: conversationID, + controller: conversationController, + session: session, + knownAuthors: sessionContainer.knownAuthors, + paymentRequests: sessionContainer.paymentRequests, + openingLoadLanded: true + ), + conversationController: conversationController, + linkCardSource: sessionContainer.linkCardFeed, + knownAuthors: sessionContainer.knownAuthors, + avatarData: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.data(for: $0) }, + loadAvatars: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.preload($0) }, + toasts: toasts + ) + } +} + /// The long-press preview: the chat's own transcript (the same cells, background, date separators, /// sender names and reactions), read-only, with no input bar, opened at the newest message. /// From 91e422b726460afcb6cfd3fe25bb84824852941f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:10:00 -0500 Subject: [PATCH 18/26] feat(chats): fade the select circle in place while the row slides Select mode's circle now sits at its final position and fades in while the avatar, title, subtitle and trailing content slide right to make room; leaving select mode runs it backwards. Before, the circle slid in from off the leading edge with the content. The same animated progress still lays the content out every frame, so it moves as one block. The circle's opacity animates in the same transaction and curve. Measured per frame on a simulator recording, the circle's leading edge stays at x 66 px through entry and exit while its stroke brightness rises and falls; before, it moved from 27 to 66. Until the content clears it (about the first two-thirds of the slide), the avatar passes over the fading circle, which is drawn behind it. --- Flipcash/Core/Screens/Main/Tips/RecipientRow.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index e2fe758fa..8209882f6 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -72,8 +72,9 @@ struct RecipientRowScaffold: View { /// select mode, 1 in it. Takes exactly two subviews, the circle then the content. /// /// Animating `progress` lays the whole row out again on every frame, so avatar, title, subtitle, -/// and trailing content move as one block, the circle sliding in from off the leading edge with -/// them. Animating a slot's width instead let each `Text` re-truncate and cross-fade on its own. +/// and trailing content slide right as one block. The circle stays at the leading edge and fades +/// in with the same animation. Animating a slot's width instead let each `Text` re-truncate and +/// cross-fade on its own. private struct RecipientRowSelectReveal: Layout { var progress: CGFloat @@ -98,7 +99,7 @@ private struct RecipientRowSelectReveal: Layout { func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { subviews[0].place( - at: CGPoint(x: bounds.minX + offset - Self.slot, y: bounds.midY), + at: CGPoint(x: bounds.minX, y: bounds.midY), anchor: .leading, proposal: .unspecified ) From aef6fdc0bc3e8867f3bfec83ecc1a91dc42d58c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 17:13:33 -0500 Subject: [PATCH 19/26] feat(chats): frost the chat list behind a row's long-press preview A chat row's long press only dimmed the list, so the rows stayed readable behind the preview and menu. A long-pressed message in a chat frosts the screen instead. The row preview now presents the chat's own MessageBackdrop over the window's root view when it appears and fades it out when it goes, so the material and the 0.2 s fade are the chat's. MessageBackdrop is made public for this. SwiftUI's contextMenu still draws its own dim above the frost, with no way to turn it off, so the list reads darker than a chat: a fixed patch measures rgb ~23 against ~30-37 in the chat (12-13 before, dim only). --- .../Screens/Main/Tips/ChatRowActions.swift | 30 +++++++++++++++++++ .../FlipcashUI/Chat/MessageBackdrop.swift | 8 +++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index cf52bee4f..2f5a1f18f 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -4,6 +4,7 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI @@ -170,6 +171,35 @@ private struct ChatRowPreview: View { loadAvatars: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.preload($0) }, toasts: toasts ) + // Mounted only while the menu is up, so it brackets the presentation. + .onAppear { ChatListBackdrop.present() } + .onDisappear { ChatListBackdrop.dismiss() } + } +} + +/// Frosts the screen behind a chat row's long-press preview with the blur a chat puts behind a +/// long-pressed message: the same ``MessageBackdrop``, so the material, strength, and fade match. +/// +/// The system menu adds its own dimming above it, which SwiftUI's `contextMenu` offers no way to +/// turn off, so the frost reads darker here than in a chat. One shared backdrop, since only one +/// menu can be up at a time. +@MainActor +private enum ChatListBackdrop { + + private static let backdrop = MessageBackdrop() + + /// Covers the window's root view, which takes in the navigation and tab bars. The menu sits in + /// its own container above the root view, so it stays sharp. + static func present() { + let window = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first + guard let host = window?.rootViewController?.view else { return } + backdrop.present(over: host) + } + + static func dismiss() { + backdrop.dismiss() } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index 6f7074848..d274d1b40 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -23,7 +23,9 @@ import UIKit /// sharp — draw over it; and it carries a detached copy of the edited bubble above itself and takes /// the taps that land outside it. @MainActor -final class MessageBackdrop { +public final class MessageBackdrop { + + public init() {} /// How long the blur takes to fade in or out. private static let fadeDuration: TimeInterval = 0.2 @@ -51,7 +53,7 @@ final class MessageBackdrop { private weak var clearance: UIView? /// Fades the blur in over `host`. Presenting twice is a no-op. - func present(over host: UIView) { + public func present(over host: UIView) { guard effectView == nil else { return } let blur = UIVisualEffectView(effect: nil) @@ -177,7 +179,7 @@ final class MessageBackdrop { /// Fades the blur out and takes it off screen once it has gone, keeping a hole over `revealing` /// as it moves so a copy landing underneath stays sharp. A held blur ignores this: the edit it /// belongs to outlives the lift, and ends it with `release`. - func dismiss(revealing view: UIView? = nil) { + public func dismiss(revealing view: UIView? = nil) { guard !isHeld, let blur = effectView else { return } effectView = nil let strength = blurStrength From 75b8600474127dfe98f253adbd3a12cea47efda3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 17:28:22 -0500 Subject: [PATCH 20/26] fix(chats): fade the chat list's frost in instead of cutting to it The frost from ee700e230 appeared in a single frame on the chat list, while a long-pressed message's frost fades in over about 0.2 s. The row preview appears inside UIKit's performWithoutAnimation as the menu presents, so MessageBackdrop's fade ran with animations off. ChatListBackdrop now starts the frost on the next turn of the main queue, and skips it if the menu was dismissed in between. In a 60 fps recording the list's frost now ramps over frames 351-362, alongside the preview lifting, and fades out over about 0.23 s as the menu leaves. --- .../Screens/Main/Tips/ChatRowActions.swift | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 2f5a1f18f..e51b79d7e 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -188,17 +188,28 @@ private enum ChatListBackdrop { private static let backdrop = MessageBackdrop() + /// Whether the menu is still up, so a present that runs after its dismiss does nothing. + private static var isWanted = false + /// Covers the window's root view, which takes in the navigation and tab bars. The menu sits in /// its own container above the root view, so it stays sharp. + /// + /// The preview appears inside UIKit's `performWithoutAnimation` while the menu presents, which + /// would apply the fade at once, so the frost starts on the next turn of the main queue. static func present() { - let window = UIApplication.shared.connectedScenes - .compactMap { ($0 as? UIWindowScene)?.keyWindow } - .first - guard let host = window?.rootViewController?.view else { return } - backdrop.present(over: host) + isWanted = true + DispatchQueue.main.async { + guard isWanted else { return } + let window = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first + guard let host = window?.rootViewController?.view else { return } + backdrop.present(over: host) + } } static func dismiss() { + isWanted = false backdrop.dismiss() } } From 9349bfaead84f617f57a579c1c740cb7c74d4fe2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 17:28:23 -0500 Subject: [PATCH 21/26] fix(chats): load photos in the chat list's long-press preview A photo in the long-press preview stayed an empty gray box, such as the chart card above a Moony buy. ChatPreview never passed the transcript a way to mint or decrypt photo URLs, so it fell back to the default minter, which returns nil. The gap dates from b600540b6, when the preview started using the chat's transcript. The row preview now passes the same blob URL minter and decrypter that ConversationScreen uses, built from the session's flipClient and the conversation controller. The chart card renders in the preview on the simulator. --- .../Core/Screens/Main/Tips/ChatRowActions.swift | 16 ++++++++++++++++ FlipcashTests/Chat/ChatMarkUnreadTests.swift | 2 ++ 2 files changed, 18 insertions(+) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index e51b79d7e..31982c9ee 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -169,6 +169,14 @@ private struct ChatRowPreview: View { knownAuthors: sessionContainer.knownAuthors, avatarData: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.data(for: $0) }, loadAvatars: { [profileAvatars = sessionContainer.profileAvatars] in profileAvatars.preload($0) }, + // The chat's own photo lookups, as `ConversationScreen` passes them, so a photo row + // downloads its image instead of staying an empty placeholder. + mintMediaURL: { [flipClient = sessionContainer.flipClient, owner = session.ownerKeyPair, conversationID] blobID in + try await flipClient.blobDownloadURL(blobID: blobID, owner: owner, accessContext: .chatMessage(conversationID)) + }, + mediaBlobDecrypt: { [conversationController, conversationID] in + await conversationController.mediaBlobDecrypt(for: conversationID) + }, toasts: toasts ) // Mounted only while the menu is up, so it brackets the presentation. @@ -228,6 +236,8 @@ struct ChatPreview: View { let knownAuthors: KnownAuthorDirectory let avatarData: (UserID) -> Data? let loadAvatars: ([(userID: UserID?, picture: ProfilePicture?)]) -> Void + let mintMediaURL: (BlobID) async throws -> URL? + let mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? let toasts: ToastController @State private var coordinator: ConversationLoadCoordinator @@ -243,6 +253,8 @@ struct ChatPreview: View { knownAuthors: KnownAuthorDirectory, avatarData: @escaping (UserID) -> Data?, loadAvatars: @escaping ([(userID: UserID?, picture: ProfilePicture?)]) -> Void, + mintMediaURL: @escaping (BlobID) async throws -> URL?, + mediaBlobDecrypt: @escaping () async -> ChatMediaURLResolver.BlobDecrypt?, toasts: ToastController ) { self.conversationController = conversationController @@ -250,6 +262,8 @@ struct ChatPreview: View { self.knownAuthors = knownAuthors self.avatarData = avatarData self.loadAvatars = loadAvatars + self.mintMediaURL = mintMediaURL + self.mediaBlobDecrypt = mediaBlobDecrypt self.toasts = toasts _coordinator = State(initialValue: coordinator) } @@ -328,6 +342,8 @@ struct ChatPreview: View { isJoiningChat: false, mentions: nil, authorAvatars: authorAvatars, + mintMediaURL: mintMediaURL, + mediaBlobDecrypt: mediaBlobDecrypt, isReadOnlyPreview: true ) } diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 07de55bd6..6db398f64 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -356,6 +356,8 @@ struct ChatMarkUnreadTests { knownAuthors: authors, avatarData: { _ in nil }, loadAvatars: { _ in }, + mintMediaURL: { _ in nil }, + mediaBlobDecrypt: { nil }, toasts: ToastController() ) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 340, height: 430)) From e56be26e6ec380550575e08b142a1dcb508601ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 10:47:15 -0600 Subject: [PATCH 22/26] feat(chats): draw Mark as Read as a speech bubble with a check Mark as Read used the plain `message` outline, which reads as "open chat" next to Mark as Unread's `message.badge`. Android now draws a bubble with a centred check (code-android-app #1956). SF Symbols ships that shape as `checkmark.message`, built on the same outline, so it matches the weight of Mark as Unread, Mute and Archive without a custom symbol. The swipe action, long-press menu and selection capsule all read it from `systemImage`. --- Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift index a8b4b9648..599d7dc2e 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -31,7 +31,7 @@ enum ChatReadAction: Equatable { var systemImage: String { switch self { - case .markRead: "message" + case .markRead: "checkmark.message" case .markUnread: "message.badge" } } From 9d97084b75fca92451f1e794deb94faa5aadbdb6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 10:47:16 -0600 Subject: [PATCH 23/26] fix(chats): keep the bottom blur while swapping the tab bar for edit capsules Entering select mode hid the tab bar, and its scroll edge blur went with it. The capsules sat in a plain `safeAreaInset`, which has no blur, so the list showed through sharp until the capsules' own glass faded in, and the capsules first drew above the still-visible tab bar before dropping into place. Put the capsules in a bottom `scrollEdgeBar` so the edge blur stays behind them, and enter and leave select mode through one `withAnimation` spring so the tab bar and capsules cross-fade together. The capsules fade and scale in from the bottom edge; with Reduce Motion they only fade. --- .../Main/Tips/TipConversationsScreen.swift | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 20684d098..3a21ad104 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -20,6 +20,7 @@ struct TipConversationsScreen: View { @State private var muteTarget: MuteTarget? @State private var filter: ChatListFilter = .all @Environment(ToastController.self) private var toasts + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Set by a pull down that starts at the top of the list; cleared when the screen goes away. @State private var chipsRevealed = false @State private var isTabBarOnSide = false @@ -127,22 +128,24 @@ struct TipConversationsScreen: View { } } .animation(.easeInOut(duration: 0.2), value: isSelecting) - .safeAreaInset(edge: .bottom) { + // A bar, not a plain inset: hiding the tab bar takes its scroll edge blur with + // it, and only a bar puts one back behind the capsules. + .scrollEdgeBar(.bottom) { if isSelecting { ChatSelectionActions( selected: selectedIDs(in: conversations), onReadAll: confirmReadAll, onMute: { ids in muteTarget = MuteTarget(ids: ids) - selection.end() + setSelecting(false) }, onArchive: { ids in archive(ids) - selection.end() + setSelecting(false) }, - onDone: selection.end + onDone: { setSelecting(false) } ) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .transition(selectionBarTransition) } } .listStyle(.plain) @@ -182,7 +185,7 @@ struct TipConversationsScreen: View { .toolbar { if isSelecting { ToolbarItem(placement: .topBarLeading) { - Button("Done", action: selection.end) + Button("Done") { setSelecting(false) } .font(.appTextMedium) .foregroundStyle(Color.textMain) .accessibilityIdentifier("chat-select-done") @@ -191,7 +194,7 @@ struct TipConversationsScreen: View { ToolbarItem(placement: .topBarLeading) { ChatListMoreMenu( canReadAll: conversationController.hasUnreadInChatList, - onSelect: selection.begin, + onSelect: { setSelecting(true) }, onReadAll: confirmReadAll ) } @@ -257,6 +260,18 @@ struct TipConversationsScreen: View { private var isSelecting: Bool { selection.isActive } + /// Enters or leaves select mode on one animation, so the tab bar and the capsules swap together. + private func setSelecting(_ isOn: Bool) { + let animation: Animation = reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.15) + withAnimation(animation) { + if isOn { selection.begin() } else { selection.end() } + } + } + + private var selectionBarTransition: AnyTransition { + reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.9, anchor: .bottom)) + } + @ToolbarContentBuilder private var toolbarTrailingItems: some ToolbarContent { if showsSearchButton { ToolbarItem(placement: .topBarTrailing) { @@ -290,7 +305,7 @@ struct TipConversationsScreen: View { private func confirmReadAll() { sessionContainer.session.dialogItem = .markAllChatsRead { conversationController.readAllChats() - selection.end() + setSelecting(false) } } From 8e6298fe5731aa4f535d158dd9abb0b7d72d8edc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 11:09:44 -0600 Subject: [PATCH 24/26] fix(chats): hide the tab bar in one frame when entering select mode The system tab bar's animated hide drops its icons at once and leaves its empty glass on screen for about four frames before the capsules arrive. Hide it with animations disabled instead, in an update of its own: sharing an update with the animated `begin()` left the bar on screen for the whole of select mode. The capsules' edge bar is inserted in that same update with the capsules at zero opacity, so its blur takes over from the tab bar's in the same frame, and they fade in on the next turn. Leaving select mode is unchanged. --- .../Core/Screens/Main/Home/HomeTabView.swift | 2 +- .../Screens/Main/Tips/ChatReadActions.swift | 9 ++++++ .../Main/Tips/TipConversationsScreen.swift | 28 +++++++++++++------ FlipcashTests/Chat/ChatMarkUnreadTests.swift | 14 ++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index ca13e2947..0eaebb889 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -100,7 +100,7 @@ struct HomeTabView: View { private var isTabBarHidden: Bool { if isSearchSelected { return sessionContainer.session.isShowingBill } - if selection == .chat, chatSelection.isActive { return true } + if selection == .chat, chatSelection.hidesTabBar { return true } return selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift index 599d7dc2e..21d0d5cac 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -54,6 +54,9 @@ enum ChatReadSource: Equatable { final class ChatSelection { private(set) var isActive = false + /// Whether the tab bar is hidden for select mode. Set apart from `isActive` so the list can hide + /// the bar without animation before the capsules animate in. + private(set) var hidesTabBar = false private(set) var selected: Set = [] /// Enters select mode with nothing selected. @@ -62,9 +65,15 @@ final class ChatSelection { isActive = true } + /// Hides the tab bar ahead of `begin()`. + func hideTabBar() { + hidesTabBar = true + } + /// Leaves select mode. func end() { isActive = false + hidesTabBar = false selected = [] } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 3a21ad104..9b404fc7b 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -131,7 +131,10 @@ struct TipConversationsScreen: View { // A bar, not a plain inset: hiding the tab bar takes its scroll edge blur with // it, and only a bar puts one back behind the capsules. .scrollEdgeBar(.bottom) { - if isSelecting { + // Inserted with the tab bar's instant hide and holding the capsules from + // the first frame, so the edge blur never lapses; only their opacity and + // scale animate, once select mode begins. + if selection.hidesTabBar { ChatSelectionActions( selected: selectedIDs(in: conversations), onReadAll: confirmReadAll, @@ -145,7 +148,8 @@ struct TipConversationsScreen: View { }, onDone: { setSelecting(false) } ) - .transition(selectionBarTransition) + .opacity(isSelecting ? 1 : 0) + .scaleEffect(isSelecting || reduceMotion ? 1 : 0.9, anchor: .bottom) } } .listStyle(.plain) @@ -260,18 +264,24 @@ struct TipConversationsScreen: View { private var isSelecting: Bool { selection.isActive } - /// Enters or leaves select mode on one animation, so the tab bar and the capsules swap together. + /// Enters or leaves select mode. Leaving cross-fades the tab bar back over the capsules. private func setSelecting(_ isOn: Bool) { let animation: Animation = reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.15) - withAnimation(animation) { - if isOn { selection.begin() } else { selection.end() } + guard isOn else { + withAnimation(animation) { selection.end() } + return + } + // The system bar's animated hide drops its icons at once and leaves its empty glass behind, + // so it goes in one frame, alone: sharing an update with the animated `begin()` leaves it + // on screen. The capsules fade in on the next turn, over the bar's edge blur. + var instant = Transaction() + instant.disablesAnimations = true + withTransaction(instant) { selection.hideTabBar() } + Task { @MainActor in + withAnimation(animation) { selection.begin() } } } - private var selectionBarTransition: AnyTransition { - reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.9, anchor: .bottom)) - } - @ToolbarContentBuilder private var toolbarTrailingItems: some ToolbarContent { if showsSearchButton { ToolbarItem(placement: .topBarTrailing) { diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 6db398f64..26cccb929 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -393,6 +393,20 @@ struct ChatMarkUnreadTests { #expect(ChatReadAction.markRead.title == "Mark as Read") } + @Test("Leaving select mode brings the tab bar back") + func endingSelectionShowsTabBar() { + let selection = ChatSelection() + selection.hideTabBar() + selection.begin() + #expect(selection.hidesTabBar) + #expect(selection.isActive) + + selection.end() + + #expect(!selection.hidesTabBar) + #expect(!selection.isActive) + } + private final class NoLinkCards: LinkCardSource { func known(_ card: LinkCard) -> LinkCard.State? { nil } func states(for card: LinkCard) -> AsyncStream { AsyncStream { $0.finish() } } From c0ec1051adeafa5a41fc52095abd454c037ab2c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 12:24:34 -0600 Subject: [PATCH 25/26] fix(chats): float the select-mode capsules over the list with no edge blur The capsules sat in a bottom `scrollEdgeBar`, so a scroll edge blur band ran behind them and blurred the bottom of the list for the whole of select mode. Put them in a plain `safeAreaInset` instead. Each capsule keeps its own glass, the list behind them stays sharp, and the inset still lets the last row scroll clear of them. The tab bar still goes in one frame and the capsules fade in after it; with Reduce Motion they only fade. --- .../Screens/Main/Tips/TipConversationsScreen.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 9b404fc7b..2759c1718 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -128,12 +128,12 @@ struct TipConversationsScreen: View { } } .animation(.easeInOut(duration: 0.2), value: isSelecting) - // A bar, not a plain inset: hiding the tab bar takes its scroll edge blur with - // it, and only a bar puts one back behind the capsules. - .scrollEdgeBar(.bottom) { - // Inserted with the tab bar's instant hide and holding the capsules from - // the first frame, so the edge blur never lapses; only their opacity and - // scale animate, once select mode begins. + // A plain inset, not a bar: the capsules float on their own glass over the + // list, with no scroll edge effect behind them. The inset still lets the last + // row scroll clear of them. + .safeAreaInset(edge: .bottom, spacing: 0) { + // Inserted at zero opacity in the frame the tab bar goes; only their opacity + // and scale animate, once select mode begins. if selection.hidesTabBar { ChatSelectionActions( selected: selectedIDs(in: conversations), @@ -273,7 +273,7 @@ struct TipConversationsScreen: View { } // The system bar's animated hide drops its icons at once and leaves its empty glass behind, // so it goes in one frame, alone: sharing an update with the animated `begin()` leaves it - // on screen. The capsules fade in on the next turn, over the bar's edge blur. + // on screen. The capsules fade in on the next turn. var instant = Transaction() instant.disablesAnimations = true withTransaction(instant) { selection.hideTabBar() } From 98591825e7d34ce09b7516088e575dd4d8d706be Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 12:59:45 -0600 Subject: [PATCH 26/26] fix(chats): fade the tab bar for select mode so the bottom blur stays Hiding the system tab bar also removed its 83pt bottom inset. The list's soft bottom scroll edge effect is drawn over that inset, so the blur went in the frame the bar hid and came back at full strength in one frame on the way out. On iOS 26 the bar now stays in place and only fades: alpha to 0, with touches and accessibility off. The inset and the edge effect never change. The capsules sit over the faded bar in its pill's 62pt slot and cross-fade with it in one animation. In a replica of the tab view and list, the bottom band outside the pills measures within 1 per channel of the tab bar state, against 2 to 23 when the bar was hidden. The `.tips` sheet, the pre-iOS 26 pill (which still hides) and a bar on a side edge keep the capsules in their own bottom inset. --- .../Core/Screens/Main/Home/HomeTabView.swift | 78 +++++++++++++---- .../Screens/Main/Tips/ChatReadActions.swift | 13 +-- .../Screens/Main/Tips/ChatRowActions.swift | 1 - .../Main/Tips/TipConversationsScreen.swift | 85 +++++++++++-------- FlipcashTests/Chat/ChatMarkUnreadTests.swift | 11 +-- 5 files changed, 121 insertions(+), 67 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 0eaebb889..fd8b53df1 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -100,7 +100,8 @@ struct HomeTabView: View { private var isTabBarHidden: Bool { if isSearchSelected { return sessionContainer.session.isShowingBill } - if selection == .chat, chatSelection.hidesTabBar { return true } + // The system bar fades out for select mode instead (see `nativeTabs`). + if #unavailable(iOS 26), isChatSelecting { return true } return selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, @@ -108,6 +109,11 @@ struct HomeTabView: View { ) } + /// Whether the Chat list is in select mode, where its capsules take the tab bar's place. + private var isChatSelecting: Bool { + selection == .chat && !isSearchSelected && chatSelection.isActive + } + /// Unread chats among the ones the Chat tab lists, surfaced as a badge on the tab. /// Reactive: reads the `@Observable` conversation store, so the badge updates as chats are read. private var chatBadgeCount: Int { @@ -287,6 +293,10 @@ struct HomeTabView: View { chatBadgeCount: chatBadgeCount, onLongPress: handleLongPress(on:) )) + // Faded rather than hidden for select mode: a hidden bar takes its inset with it, and the + // list's bottom scroll edge effect is drawn over that inset, so the blur behind the + // capsules would go too. + .background(TabBarFade(isFaded: isChatSelecting)) } /// A tab in the iOS 26 bar: one of the shared tabs, or the search tab that sits apart from them. @@ -672,22 +682,58 @@ private struct TabBarSelectedIcons: UIViewControllerRepresentable { self?.apply() } } + } +} - /// This controller is attached alongside the `TabView` rather than inside - /// a tab, so `tabBarController` is nil — the bar has to be found from the - /// window instead. - private var resolvedTabBar: UITabBar? { - if let bar = tabBarController?.tabBar { return bar } - guard let root = view.window?.rootViewController else { return nil } - - var queue: [UIViewController] = [root] - while let next = queue.first { - queue.removeFirst() - if let tabs = next as? UITabBarController { return tabs.tabBar } - queue.append(contentsOf: next.children) - if let presented = next.presentedViewController { queue.append(presented) } - } - return nil +/// Fades the system tab bar out and back in, with the animation of the SwiftUI update that changed +/// `isFaded`. The bar keeps its place and its inset; it only stops drawing and taking touches. +@available(iOS 26, *) +private struct TabBarFade: UIViewControllerRepresentable { + let isFaded: Bool + + func makeUIViewController(context: Context) -> Probe { + Probe() + } + + /// Re-applies on every update, not only on a change: SwiftUI can rebuild the bar mid-fade. + func updateUIViewController(_ probe: Probe, context: Context) { + guard probe.isFaded != isFaded else { + probe.apply() + return + } + probe.isFaded = isFaded + context.animate { probe.apply() } + } + + final class Probe: UIViewController { + var isFaded = false + + func apply() { + guard let bar = resolvedTabBar else { return } + bar.alpha = isFaded ? 0 : 1 + // A view mid-fade still takes touches, and the bar sits above the SwiftUI content. + bar.isUserInteractionEnabled = !isFaded + bar.accessibilityElementsHidden = isFaded + } + } +} + +private extension UIViewController { + + /// This controller is attached alongside the `TabView` rather than inside + /// a tab, so `tabBarController` is nil — the bar has to be found from the + /// window instead. + var resolvedTabBar: UITabBar? { + if let bar = tabBarController?.tabBar { return bar } + guard let root = view.window?.rootViewController else { return nil } + + var queue: [UIViewController] = [root] + while let next = queue.first { + queue.removeFirst() + if let tabs = next as? UITabBarController { return tabs.tabBar } + queue.append(contentsOf: next.children) + if let presented = next.presentedViewController { queue.append(presented) } } + return nil } } diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift index 21d0d5cac..3fee482bf 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -47,16 +47,13 @@ enum ChatReadSource: Equatable { /// The Chats list's select mode: whether it is on, and which chats are picked. /// -/// Owned by the tab view, which hides the tab bar while it is on, and handed to the list through the -/// environment. +/// Owned by the tab view, which fades the tab bar out while it is on, and handed to the list through +/// the environment. @MainActor @Observable final class ChatSelection { private(set) var isActive = false - /// Whether the tab bar is hidden for select mode. Set apart from `isActive` so the list can hide - /// the bar without animation before the capsules animate in. - private(set) var hidesTabBar = false private(set) var selected: Set = [] /// Enters select mode with nothing selected. @@ -65,15 +62,9 @@ final class ChatSelection { isActive = true } - /// Hides the tab bar ahead of `begin()`. - func hideTabBar() { - hidesTabBar = true - } - /// Leaves select mode. func end() { isActive = false - hidesTabBar = false selected = [] } diff --git a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift index 31982c9ee..38c0eb8ed 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -476,7 +476,6 @@ struct ChatSelectionActions: View { .disabled(!hasSelection) } .padding(.horizontal, 16) - .padding(.bottom, 8) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 2759c1718..0d2ff29ba 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -21,6 +21,7 @@ struct TipConversationsScreen: View { @State private var filter: ChatListFilter = .all @Environment(ToastController.self) private var toasts @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.floatingTabBar) private var floatingTabBar /// Set by a pull down that starts at the top of the list; cleared when the screen goes away. @State private var chipsRevealed = false @State private var isTabBarOnSide = false @@ -128,28 +129,14 @@ struct TipConversationsScreen: View { } } .animation(.easeInOut(duration: 0.2), value: isSelecting) - // A plain inset, not a bar: the capsules float on their own glass over the - // list, with no scroll edge effect behind them. The inset still lets the last - // row scroll clear of them. + // With no tab bar below to stand in for (the `.tips` sheet, the pre-iOS 26 + // pill, which hides for select mode, or a bar on a side edge), the capsules + // take an inset of their own so the last row scrolls clear of them. .safeAreaInset(edge: .bottom, spacing: 0) { - // Inserted at zero opacity in the frame the tab bar goes; only their opacity - // and scale animate, once select mode begins. - if selection.hidesTabBar { - ChatSelectionActions( - selected: selectedIDs(in: conversations), - onReadAll: confirmReadAll, - onMute: { ids in - muteTarget = MuteTarget(ids: ids) - setSelecting(false) - }, - onArchive: { ids in - archive(ids) - setSelecting(false) - }, - onDone: { setSelecting(false) } - ) - .opacity(isSelecting ? 1 : 0) - .scaleEffect(isSelecting || reduceMotion ? 1 : 0.9, anchor: .bottom) + if isSelecting, !capsulesTakeTabBarSlot { + selectionActions(for: conversations) + .padding(.bottom, 8) + .transition(capsuleTransition) } } .listStyle(.plain) @@ -163,6 +150,17 @@ struct TipConversationsScreen: View { let insets = proxy.safeAreaInsets return max(insets.leading, insets.trailing) > insets.bottom } action: { isTabBarOnSide = $0 } + // Over the faded system tab bar, in its pill's slot just below the list's + // safe area: the bar keeps its inset, so the list's bottom blur stays exactly + // as it is behind the tab bar. + .overlay(alignment: .bottom) { + if isSelecting, capsulesTakeTabBarSlot { + selectionActions(for: conversations) + .frame(height: Self.tabBarPillHeight) + .offset(y: Self.tabBarPillHeight) + .transition(capsuleTransition) + } + } .overlay { // Over the List rather than a row in it, so it centres in the whole // screen and the List keeps the pull that reveals the chips. @@ -264,21 +262,40 @@ struct TipConversationsScreen: View { private var isSelecting: Bool { selection.isActive } - /// Enters or leaves select mode. Leaving cross-fades the tab bar back over the capsules. + /// The height of the iOS 26 system tab bar's pill, measured on a 402pt-wide iPhone. + private static let tabBarPillHeight: CGFloat = 62 + + /// Whether a system tab bar sits below the list, faded out for select mode, so the capsules can + /// take its pill's slot. The legacy pill hides instead, which clears `floatingTabBar`. + private var capsulesTakeTabBarSlot: Bool { + floatingTabBar != nil && !isTabBarOnSide + } + + private var capsuleTransition: AnyTransition { + reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.9, anchor: .bottom)) + } + + private func selectionActions(for conversations: [Conversation]) -> some View { + ChatSelectionActions( + selected: selectedIDs(in: conversations), + onReadAll: confirmReadAll, + onMute: { ids in + muteTarget = MuteTarget(ids: ids) + setSelecting(false) + }, + onArchive: { ids in + archive(ids) + setSelecting(false) + }, + onDone: { setSelecting(false) } + ) + } + + /// Enters or leaves select mode, cross-fading the tab bar and the capsules in one animation. private func setSelecting(_ isOn: Bool) { let animation: Animation = reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.15) - guard isOn else { - withAnimation(animation) { selection.end() } - return - } - // The system bar's animated hide drops its icons at once and leaves its empty glass behind, - // so it goes in one frame, alone: sharing an update with the animated `begin()` leaves it - // on screen. The capsules fade in on the next turn. - var instant = Transaction() - instant.disablesAnimations = true - withTransaction(instant) { selection.hideTabBar() } - Task { @MainActor in - withAnimation(animation) { selection.begin() } + withAnimation(animation) { + if isOn { selection.begin() } else { selection.end() } } } diff --git a/FlipcashTests/Chat/ChatMarkUnreadTests.swift b/FlipcashTests/Chat/ChatMarkUnreadTests.swift index 26cccb929..a7879948d 100644 --- a/FlipcashTests/Chat/ChatMarkUnreadTests.swift +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -393,18 +393,19 @@ struct ChatMarkUnreadTests { #expect(ChatReadAction.markRead.title == "Mark as Read") } - @Test("Leaving select mode brings the tab bar back") - func endingSelectionShowsTabBar() { + @Test("Leaving select mode drops the picks") + func endingSelectionClearsPicks() { let selection = ChatSelection() - selection.hideTabBar() + let id = ConversationID.test(1) selection.begin() - #expect(selection.hidesTabBar) + selection.toggle(id) #expect(selection.isActive) + #expect(selection.selected == [id]) selection.end() - #expect(!selection.hidesTabBar) #expect(!selection.isActive) + #expect(selection.selected.isEmpty) } private final class NoLinkCards: LinkCardSource {