diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b1aa30814..e087622ee 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,102 @@ 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 as a chat opens: opening it is reading it, so the mark goes. + func chatOpened(_ id: ConversationID) { + chatMarkedUnread?.clear(id) + } + /// 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 +519,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 +1505,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 +1570,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,15 +1921,40 @@ 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 }, + 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)" @@ -1836,7 +1983,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)" @@ -1926,7 +2073,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/Conversation/ChatMarkedUnreadStore.swift b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift new file mode 100644 index 000000000..aa8e29468 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMarkedUnreadStore.swift @@ -0,0 +1,77 @@ +// +// 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) +} + +/// The device-only ``ChatMarkedUnreadStore``: a JSON file beside the drafts in Application Support, +/// 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 +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() + } + + /// 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/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 88247c76e..2b06b5931 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -154,8 +154,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)) @@ -236,7 +241,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/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 3d053cad3..078579185 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1295,6 +1295,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..fd8b53df1 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,8 @@ struct HomeTabView: View { private var isTabBarHidden: Bool { if isSearchSelected { return sessionContainer.session.isShowingBill } + // 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, @@ -105,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 { @@ -284,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. @@ -412,6 +425,7 @@ struct HomeTabView: View { // would discard. if selection == .chat || hasOpenedChat { ChatTab() + .environment(chatSelection) } else { Color.backgroundMain } @@ -668,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/ArchivedChatsScreen.swift b/Flipcash/Core/Screens/Main/Tips/ArchivedChatsScreen.swift index 4ae769dc9..bd3570bf2 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 @@ -34,13 +36,18 @@ 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") } + .chatRowActions( + for: conversation, + in: .archived, + isEnabled: true, + onMute: { muteTarget = ArchivedMuteTarget(id: conversation.id) }, + onArchive: { conversationController.unarchive(conversation.id) } + ) } } .listStyle(.plain) @@ -49,5 +56,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..3fee482bf --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -0,0 +1,131 @@ +// +// 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. + /// 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: "checkmark.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 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 + 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..38c0eb8ed --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/ChatRowActions.swift @@ -0,0 +1,518 @@ +// +// ChatRowActions.swift +// Flipcash +// + +import SwiftUI +import UIKit +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 { + // 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: { + 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) + .accessibilityLabel(action.title) + } + } + .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. 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 list: ChatRowList + let onMute: () -> Void + let onArchive: () -> Void + + @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: { + // Hosted outside this view's tree, so its dependencies are handed on. + ChatRowPreview( + conversationID: conversation.id, + conversationController: conversationController, + session: session, + sessionContainer: sessionContainer, + toasts: container.toasts + ) + } + } else { + content.contextMenu { items } + } + } + + @ViewBuilder private var items: some View { + 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) + } + } + } +} + +// 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) }, + // 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. + .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() + + /// 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() { + 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() + } +} + +/// 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 conversationController: ConversationController + let linkCardSource: any LinkCardSource + 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 + @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, + mintMediaURL: @escaping (BlobID) async throws -> URL?, + mediaBlobDecrypt: @escaping () async -> ChatMediaURLResolver.BlobDecrypt?, + toasts: ToastController + ) { + self.conversationController = conversationController + self.linkCardSource = linkCardSource + self.knownAuthors = knownAuthors + self.avatarData = avatarData + self.loadAvatars = loadAvatars + self.mintMediaURL = mintMediaURL + self.mediaBlobDecrypt = mediaBlobDecrypt + self.toasts = toasts + _coordinator = State(initialValue: coordinator) + } + + var body: some View { + 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) }) + } + } + + private var isGroup: Bool { + conversationController.conversation(withID: coordinator.conversationID)?.type == .group + } + + /// 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, + mintMediaURL: mintMediaURL, + mediaBlobDecrypt: mediaBlobDecrypt, + isReadOnlyPreview: true + ) + } + + /// 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 + } + return avatars + } +} + +// 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: "checkmark.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: - 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. +/// 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 + @Environment(Session.self) private var session + + 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 { await conversationController.unmuteReporting(ids, session: session) } + onDone() + } + } + .disabled(!hasSelection) + } + .padding(.horizontal, 16) + } +} + +/// 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) + .contentShape(.capsule) + } + .buttonStyle(.plain) + // Outside the button: a disabled plain button fades its label, and the glass would fade with it. + .selectionCapsuleGlass() + .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..8209882f6 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -32,13 +32,23 @@ 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 { + let isSelecting = selection != nil Button(action: onTap) { - RecipientRowBody( + // 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) + .opacity(isSelecting ? 1 : 0) + .accessibilityHidden(!isSelecting) + RecipientRowBody( avatarID: avatarID, title: title, subtitle: subtitle, @@ -46,11 +56,78 @@ struct RecipientRowScaffold: View { blurhash: blurhash, accessoryPlacement: accessoryPlacement, mute: mute - ) { - trailing + ) { + 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) + .recipientRowChrome(accessibilityLabel: accessibilityLabel, isSelected: selection == true) + .accessibilityAddTraits(selection == true ? .isSelected : []) + } +} + +/// 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 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 + + 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, 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 { + + let isSelected: Bool + + var body: some View { + // One image whose symbol swaps, so a tap changes the circle in place. Two images in an + // 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. + .symbolRenderingMode(isSelected ? .palette : .monochrome) + .foregroundStyle( + isSelected ? Color.textMain : Color.textSecondary, + Color.unreadIndicator + ) + .contentTransition(.symbolEffect(.replace)) } } @@ -117,11 +194,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 +217,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 +239,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/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 e9a907919..0d2ff29ba 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -20,6 +20,8 @@ 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 + @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 @@ -28,6 +30,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 +98,45 @@ 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 { + // 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( + for: conversation, + in: .main, + isEnabled: !isSelecting, + onMute: { muteTarget = MuteTarget(ids: [conversation.id]) }, + onArchive: { archive([conversation.id]) } + ) + } + } + .animation(.easeInOut(duration: 0.2), value: isSelecting) + // 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) { + if isSelecting, !capsulesTakeTabBarSlot { + selectionActions(for: conversations) + .padding(.bottom, 8) + .transition(capsuleTransition) } } .listStyle(.plain) @@ -114,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. @@ -127,24 +174,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") { setSelecting(false) } + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .accessibilityIdentifier("chat-select-done") } - } - ToolbarItem(placement: .topBarTrailing) { - NewChatButton() + } else { + ToolbarItem(placement: .topBarLeading) { + ChatListMoreMenu( + canReadAll: conversationController.hasUnreadInChatList, + onSelect: { setSelecting(true) }, + onReadAll: confirmReadAll + ) + } + toolbarTrailingItems } } // Search filters currencies by name over what Discover returned; fetched here so the @@ -204,6 +260,82 @@ struct TipConversationsScreen: View { } } + private var isSelecting: Bool { selection.isActive } + + /// 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) + withAnimation(animation) { + if isOn { selection.begin() } else { selection.end() } + } + } + + @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() + setSelecting(false) + } + } + /// Group senders of the listed rows' last messages that nothing on the device can name yet. private var unnamedSenders: [UserID] { conversationController.unnamedLastMessageSenders( @@ -218,12 +350,9 @@ 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. - 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) @@ -234,12 +363,9 @@ 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") - .environment(\.symbolVariants, .none) + SwipeActionLabel(title: "Archive", systemImage: "archivebox") } .tint(.backgroundRow) // A swipe action is also a VoiceOver custom action, next to the mute one. @@ -259,7 +385,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 +458,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 +490,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 +512,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 +526,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/Members/GroupMembersScreen.swift b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift index 981594a2d..bba9d3e37 100644 --- a/Flipcash/Core/Screens/Members/GroupMembersScreen.swift +++ b/Flipcash/Core/Screens/Members/GroupMembersScreen.swift @@ -113,19 +113,29 @@ 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 { 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: "mic.slash", glyphColor: .warning) } .tint(.warningSecondary) .accessibilityLabel("Restrict") @@ -133,24 +143,13 @@ 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") case nil: EmptyView() } - if options.canRemove { - Button { - moderator.confirmRemove(moderationTarget(for: portrait)) - } label: { - Image(uiImage: UIImage(systemName: SystemSymbol.personBadgeMinus.rawValue)! - .withTintColor(UIColor(Color.Sentiment.negative), renderingMode: .alwaysOriginal)) - } - .tint(Color.Sentiment.negativeSecondary) - .accessibilityLabel("Remove from group") - } } // MARK: - Row - 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/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/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/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index d9522a073..50d412f46 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -693,6 +693,10 @@ 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. 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. 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..a7879948d --- /dev/null +++ b/FlipcashTests/Chat/ChatMarkUnreadTests.swift @@ -0,0 +1,415 @@ +// +// 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: 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)) + + LocalChatMarkedUnreadStore(directory: directory, owner: accountX).mark(.test(1)) + + // 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") + 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))) + + // 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 }, + mintMediaURL: { _ in nil }, + mediaBlobDecrypt: { nil }, + 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) + 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) + } + + /// 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") + } + + @Test("Leaving select mode drops the picks") + func endingSelectionClearsPicks() { + let selection = ChatSelection() + let id = ConversationID.test(1) + selection.begin() + selection.toggle(id) + #expect(selection.isActive) + #expect(selection.selected == [id]) + + selection.end() + + #expect(!selection.isActive) + #expect(selection.selected.isEmpty) + } + + 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 cd0e31875..4ee1719a0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -362,6 +362,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 4008f95de..b3f38feb4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -238,6 +238,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 — @@ -1086,7 +1089,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 @@ -2105,7 +2108,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/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 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) } 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"