From 656a8cd50e4ea0a09d7dadcf8718ea4923194292 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 19:49:13 -0400 Subject: [PATCH 01/13] feat(chat): mute a group chat, and keep a muted chat quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract sync to dd5e92db added MuteChat/UnmuteChat and the muted flag on the push payload; the RPCs were wrapped but nothing called them and nothing read the flag. This adds the UI and both notification paths. Entry point is the group chat profile, between the invite row and Leave Group. Tapping it opens a sheet offering 1 hour, 8 hours, 1 week, or Always — WhatsApp's set plus Telegram's 1 hour. Once muted, the row flips to "Unmute Notifications" and reads "Muted until 5:56 PM" on the right; unmuting is that same row, since UnmuteChat is its own RPC with nothing to ask. DMs have no settings screen to hang this on, so it is group-only. A timed mute lapses with no server signal, so nothing stores a computed muted boolean: the until-timestamp is kept and compared against now on read, and the profile screen re-reads `now` exactly at the expiry so the row flips back on its own. Mute is cleared server-side on leave and its version restarts, so leaving drops the cached viewer state entirely — keeping the old version would make a rejoined chat's first update look stale and be swallowed. Both notification paths read the payload flag rather than local state, which is what the extension has to do anyway: - Foreground: `willPresent` returns empty presentation options for a muted chat. Everything above that point still runs, so the message is still stored and still counted as unread. - Background: the service extension cannot drop a notification — whatever it returns is displayed — so `.passive` with no sound is the floor. A muted chat's message still joins the notification list. Unread counts are deliberately untouched. They come from the stored message and the read watermark, never from the notification, so muting silences the banner without zeroing the badge. Viewer state is now cached in the conversations table as JSON, the same way `rules` is, storing the whole struct so the version survives a cold start and a reload cannot beat a fresher stream update. Without it a muted chat renders unmuted between the cache load and the feed refresh; Android is persisting it too. Schema version bumped accordingly. Also fixes two switches the scaffold commit left non-exhaustive (`persist(event:)` and `hydrateIfUnknown`), and restores the exact signatures `ConversationFetching`/`ConversationMessaging` require — a defaulted `viewMode` does not witness a requirement that omits it, so the protocol conformance had stopped compiling. --- .../Controllers/ConversationController.swift | 48 +++++++ .../Controllers/FlipClient+Protocols.swift | 38 +++++- .../Core/Controllers/PushController.swift | 23 ++-- .../Screens/Profile/ChatProfileScreen.swift | 93 +++++++++++++- .../Core/Screens/Profile/MuteChatSheet.swift | 118 +++++++++++++++++ .../Core/Session/SessionAuthenticator.swift | 1 + .../Models/Conversation/Conversation.swift | 9 ++ .../Conversation/ConversationRules.swift | 25 +++- .../Conversation/ConversationStore.swift | 10 ++ .../Push/NotificationPayload.swift | 7 +- .../Push/NotificationPresentation.swift | 53 ++++++++ .../Database+Conversations.swift | 15 ++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 7 + .../ConversationMuteTests.swift | 111 ++++++++++++++++ .../Push/NotificationPresentationTests.swift | 72 +++++++++++ .../Chat/ConversationMembershipTests.swift | 2 +- .../Chat/ConversationMuteWiringTests.swift | 121 ++++++++++++++++++ .../Chat/ConversationReceiptWiringTests.swift | 2 +- .../Chat/ConversationReplySendTests.swift | 2 +- .../Chat/MessageLoaderRevealTests.swift | 2 +- .../ConversationControllerTests.swift | 2 +- FlipcashTests/ConversationMutationTests.swift | 2 +- .../Database+ConversationsTests.swift | 28 ++++ FlipcashTests/MessageLoaderTests.swift | 2 +- .../Regression_6a510fab7372e33c88417bd4.swift | 2 +- .../TestSupport/MockConversations.swift | 34 ++++- .../FlipcashUI/Theme/Image+Symbols.swift | 4 + NotificationService/NotificationService.swift | 38 ++++-- 29 files changed, 837 insertions(+), 36 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/MuteChatSheet.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Push/NotificationPresentation.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPresentationTests.swift create mode 100644 FlipcashTests/Chat/ConversationMuteWiringTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 883bc0872..cb2cdba9b 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -134,6 +134,7 @@ final class ConversationController { @ObservationIgnored private let fetching: any ConversationFetching @ObservationIgnored private let membership: any ConversationMembership + @ObservationIgnored private let viewerSettings: any ConversationViewerSettings @ObservationIgnored let messaging: any ConversationMessaging @ObservationIgnored private let streaming: any ConversationEventStreaming @ObservationIgnored private let contactNaming: any DMContactNaming @@ -173,6 +174,7 @@ final class ConversationController { init( fetching: any ConversationFetching, membership: any ConversationMembership, + viewerSettings: any ConversationViewerSettings, messaging: any ConversationMessaging, streaming: any ConversationEventStreaming, contactNaming: any DMContactNaming, @@ -186,6 +188,7 @@ final class ConversationController { ) { self.fetching = fetching self.membership = membership + self.viewerSettings = viewerSettings self.messaging = messaging self.streaming = streaming self.contactNaming = contactNaming @@ -503,6 +506,11 @@ final class ConversationController { case .typingChanged: // A typing event for an unknown conversation isn't worth a metadata fetch — it's transient. return + case .viewerStateChanged: + // Mute is the viewer's own state, and only a chat they are in can carry it — so an + // unknown chat here means the feed hasn't landed, and the feed will bring the state + // with it. Nothing to fetch. + return } guard !store.conversations.contains(where: { $0.id == conversationID }), !hydratingConversationIDs.contains(conversationID) else { @@ -655,6 +663,11 @@ final class ConversationController { } store.setMembership(false, in: conversationID) persistMembership(false, in: conversationID) + // The server clears mute on leave, so the cached state is stale the moment the leave lands. + // Dropping it locally keeps a rejoin from showing the old mute, and discards the version + // with it so the rejoined chat's updates aren't swallowed as stale. + store.clearViewerState(in: conversationID) + persistConversation(conversationID) } /// Records the signed-in user's own join or leave when a roster update names them, so a membership @@ -692,6 +705,37 @@ final class ConversationController { } } + // MARK: - Mute + + /// Whether the chat is muted right now. + /// + /// Takes `date` so the caller can drive the recomputation: a timed mute lapses with no server + /// signal, so there is no event to invalidate a cached answer and nothing may store one. + func isMuted(conversationID: ConversationID, at date: Date = .now) -> Bool { + conversation(withID: conversationID)?.isMuted(at: date) ?? false + } + + /// Mutes a chat until `mute` lapses, or forever, seating the viewer state the server returns. + /// Throws so the caller can surface the failure; nothing local moves unless the server accepted it. + func mute(conversationID: ConversationID, _ mute: ConversationMuteState) async throws { + let viewerState = try await viewerSettings.muteChat(owner: owner, conversationID: conversationID, mute: mute) + applyViewerState(viewerState, in: conversationID) + } + + /// Unmutes a chat. Its own RPC, not a mute of zero duration. + func unmute(conversationID: ConversationID) async throws { + let viewerState = try await viewerSettings.unmuteChat(owner: owner, conversationID: conversationID) + applyViewerState(viewerState, in: conversationID) + } + + /// 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. + private func applyViewerState(_ viewerState: ConversationViewerState, in conversationID: ConversationID) { + store.applyViewerStateChanged(viewerState, in: conversationID) + persistConversation(conversationID) + } + // MARK: - Backfill /// How many conversations backfill at once. The transcripts are wanted soon, not instantly — a @@ -814,6 +858,10 @@ final class ConversationController { // `applyRosterMembership`, which persists it itself. persistConversation(conversationID) refreshFeedPreview(for: conversationID) + case .viewerStateChanged(let conversationID, _): + // Mute is cached so a chat restored cold renders muted rather than flickering unmuted + // until the next metadata fetch — the same reason the roster summary is cached. + persistConversation(conversationID) case .typingChanged: break } diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index 02dcbe185..2a1f53756 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -75,6 +75,17 @@ protocol ConversationMembership: AnyObject, Sendable { func leaveChat(owner: KeyPair, conversationID: ConversationID) async throws } +/// Per-viewer chat settings used by `ConversationController` — currently only mute. Separate from +/// ``ConversationMembership`` because nothing here changes who is in the chat: mute is the viewer's +/// own state, and the server keeps it per-viewer. +protocol ConversationViewerSettings: AnyObject, Sendable { + /// Mutes a chat until `mute` lapses, or forever. Returns the resulting viewer state, version + /// included, so the caller can seat it under the same greater-value-wins rule the stream uses. + func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState) async throws -> ConversationViewerState + /// Unmutes a chat. Its own RPC rather than a zero-length mute — the contract has no such shape. + func unmuteChat(owner: KeyPair, conversationID: ConversationID) async throws -> ConversationViewerState +} + /// DM message send/read surface used by `ConversationController`. Maps to the /// `flipcash.messaging.v1.Messaging` RPCs. protocol ConversationMessaging: AnyObject, Sendable { @@ -115,6 +126,31 @@ protocol ConversationEventStreaming: AnyObject, Sendable { func closeConversationStream() } +/// The chat reads that `FlipClient` exposes with a defaulted `viewMode`, restated without it. +/// +/// A default argument doesn't witness a protocol requirement that omits the parameter, so these +/// forward at `.full` — the only mode this app reads in. A screen that needs a redacted read belongs +/// on the protocol above, not here. +extension FlipClient { + + func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation { + try await getChat(owner: owner, conversationID: conversationID, viewMode: .full) + } + + func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] { + try await getMessages(owner: owner, conversationID: conversationID, before: before, viewMode: .full) + } + + func getDelta( + owner: KeyPair, + conversationID: ConversationID, + afterSequence: UInt64, + onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void + ) async throws -> UInt64 { + try await getDelta(owner: owner, conversationID: conversationID, afterSequence: afterSequence, viewMode: .full, onBatch: onBatch) + } +} + extension FlipClient: ContactVerifying, OnrampAuthorizing, ContactSyncing, ConversationFetching, ConversationMembership, ConversationMessaging, - ConversationEventStreaming {} + ConversationViewerSettings, ConversationEventStreaming {} diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index b0b4ac970..374ad720c 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -298,17 +298,22 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification NotificationCenter.default.post(name: .pushNotificationWillPresent, object: nil) } - // Stay silent for the conversation the user is already reading; other - // chats and every non-chat push still present normally. - if let conversationID = NotificationPayload.chatID(userInfo), - isViewingConversation?(conversationID) == true { - logger.info("Suppressing chat push for the open conversation", metadata: [ - "conversationID": "\(conversationID)", + // Stay silent for a muted chat and for the conversation the user is already reading; other + // chats and every non-chat push still present normally. Everything above this point has + // already run — the push is stored and counted either way, only the banner is withheld. + let decision = NotificationPayload.presentationDecision(userInfo) { conversationID in + isViewingConversation?(conversationID) == true + } + switch decision { + case .present: + break + case .suppressedMuted, .suppressedOpenConversation: + logger.info("Suppressing chat push", metadata: [ + "reason": "\(decision)", + "conversationID": "\(NotificationPayload.chatID(userInfo).map { "\($0)" } ?? "none")", ]) - return [] } - - return [.badge, .list, .sound, .banner] + return decision.options } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index e4e2d366e..771a093d8 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -12,9 +12,9 @@ import FlipcashUI /// A group chat's own profile — its picture, title, size and entry rule — reached by tapping the /// chat's head card or its navigation title, the way a DM's title opens the counterpart's profile. /// -/// It carries the two actions a member has over a group: handing out the invite link, and leaving. -/// The head card offers the invite as well, but only while the group is still empty (node -/// 10127:118280), so once anyone else has joined this is the only way to the link. +/// It carries the actions a member has over a group: handing out the invite link, silencing its +/// notifications, and leaving. The head card offers the invite as well, but only while the group is +/// still empty (node 10127:118280), so once anyone else has joined this is the only way to the link. struct ChatProfileScreen: View { let conversationID: ConversationID @@ -25,8 +25,16 @@ struct ChatProfileScreen: View { @State private var isInviting = false @State private var isLeaving = false + @State private var isPickingMuteDuration = false + @State private var isUnmuting = false @State private var dialogItem: DialogItem? + /// The instant the mute row is evaluated against, re-read when a timed mute lapses. + /// + /// A timed mute expires with nothing sent from the server, so no event can invalidate the row; + /// the screen has to notice on its own. See ``muteExpiry``. + @State private var now = Date.now + private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } @@ -50,6 +58,32 @@ struct ChatProfileScreen: View { return conversationController.isMember(of: conversation) } + private var isMuted: Bool { + conversation?.isMuted(at: now) ?? false + } + + /// The mute as the row reports it on the right — "Muted until 5:56 PM" for a timed mute, plain + /// "Muted" for an indefinite one. Nil when the chat isn't muted. + /// + /// Stating the deadline is what makes a timed mute trustworthy: without it the row can't be + /// told apart from an indefinite one, and the user has no way to know when the chat comes back. + private var muteDetail: String? { + guard isMuted, let mute = conversation?.viewerState?.mute else { return nil } + switch mute { + case .until(let expiry): + return "Muted until \(expiry.formattedRelatively(useTimeForToday: true))" + case .forever: + return "Muted" + } + } + + /// When the current mute lapses, or nil when it is indefinite or absent — the deadline the + /// refresh below waits on. + private var muteExpiry: Date? { + guard case .until(let expiry) = conversation?.viewerState?.mute else { return nil } + return expiry + } + var body: some View { Background(color: .backgroundMain) { VStack(spacing: 16) { @@ -89,6 +123,32 @@ struct ChatProfileScreen: View { } .accessibilityIdentifier("chat-profile-invite") + Row( + insets: rowInsets, + disabled: isUnmuting, + accessory: isUnmuting ? .loader(.textMain) : .chevron + ) { + Image(systemName: isMuted ? "bell.slash" : "bell") + .frame(minWidth: 45) + Text(isMuted ? "Unmute Notifications" : "Mute Notifications") + .foregroundStyle(.textMain) + if let muteDetail { + Text(muteDetail) + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } action: { + // Muting asks for how long; unmuting has nothing to ask, so it is the + // tap itself rather than a second sheet with one row on it. + if isMuted { + Task { await unmute() } + } else { + isPickingMuteDuration = true + } + } + .accessibilityIdentifier("chat-profile-mute") + Row( insets: rowInsets, disabled: isLeaving, @@ -117,15 +177,42 @@ struct ChatProfileScreen: View { .sheet(isPresented: $isInviting) { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) } + .sheet(isPresented: $isPickingMuteDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) + } .task { await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) } + // Flip the row back to "Mute" the moment a timed mute lapses. Re-run whenever the expiry + // changes, so muting again while the screen is open re-arms it; cancelled with the screen. + .task(id: muteExpiry) { + guard let muteExpiry, muteExpiry > .now else { return } + try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) + guard !Task.isCancelled else { return } + now = .now + } } private var rowInsets: EdgeInsets { .init(top: 25, leading: 0, bottom: 25, trailing: 0) } + /// Unmutes, staying on the screen — unlike leaving, there is nothing to unwind to. + private func unmute() async { + isUnmuting = true + defer { isUnmuting = false } + do { + try await conversationController.unmute(conversationID: conversationID) + now = .now + } catch { + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to unmute this chat. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to unmute chat") + } + } + private func leaveDialog() -> DialogItem { .alert( title: "Leave \(title)?", diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift new file mode 100644 index 000000000..7fc2a9978 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -0,0 +1,118 @@ +// +// MuteChatSheet.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// How long to silence a chat's notifications for. Drawn as the chats flow's action rows, the way +/// ``GroupInviteSheet`` draws its two. +/// +/// The durations are the client's choice; the contract admits only two mute shapes — until a +/// timestamp, or forever — so every timed option is the same shape with a different offset. +/// Unmuting is not on this sheet: it is its own RPC, not a mute of zero length, and the row that +/// opens this sheet becomes the unmute once a chat is muted. +struct MuteChatSheet: View { + + let conversationID: ConversationID + + @Binding var isPresented: Bool + + @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer + + /// The offered durations, longest last. `nil` is the indefinite option. + /// + /// The set is WhatsApp's (8 hours, 1 week, Always) plus Telegram's 1 hour, which is the shortest + /// option either offers and the one a noisy group most often wants. The labels double as the + /// state readout on the row that opens this sheet, so they are named for the resulting state + /// rather than the action — "1 Week", not "Mute for 1 week". + private static let options: [(title: String, duration: TimeInterval?)] = [ + ("1 Hour", 60 * 60), + ("8 Hours", 8 * 60 * 60), + ("1 Week", 7 * 24 * 60 * 60), + ("Always", nil), + ] + + @State private var isMuting = false + + var body: some View { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header + + // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth + // saying here because the answer isn't obvious and is easy to get wrong: a muted + // chat still delivers, still stores, and still counts unread. + Text("You'll still receive messages and see unread counts. Only notifications are silenced.") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + VStack(spacing: 12) { + ForEach(Self.options, id: \.title) { option in + ChatActionRow( + icon: option.duration == nil ? .system(.bellSlash) : .system(.clock), + title: option.title, + accessibilityIdentifier: "mute-chat-\(option.duration.map { "\(Int($0))" } ?? "forever")" + ) { + guard !isMuting else { return } + Task { await mute(option.duration) } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// The sheet's own title bar — ``PartialSheet`` sizes itself to its content, so there is no + /// navigation bar to hang a title on. Mirrors ``GroupInviteSheet/header``. + private var header: some View { + ZStack { + Text("Mute Notifications") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + HStack { + Spacer() + CloseButton(binding: $isPresented) + .foregroundStyle(Color.textMain) + } + } + } + + /// The design's 16 under the last row, less whatever the sheet already holds back for the home + /// indicator. + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } + + /// Mutes, then dismisses. The expiry is computed here, at the tap, rather than carried as a + /// duration: the server stores an instant, and the client owns the countdown against it. + private func mute(_ duration: TimeInterval?) async { + isMuting = true + defer { isMuting = false } + let mute: ConversationMuteState = duration.map { .until(.now.addingTimeInterval($0)) } ?? .forever + do { + try await conversationController.mute(conversationID: conversationID, mute) + isPresented = false + } catch { + isPresented = false + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to mute this chat. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to mute chat") + } + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 6120cb5c3..50988c557 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -623,6 +623,7 @@ final class SessionContainer { let conversationController = ConversationController( fetching: flipClient, membership: flipClient, + viewerSettings: flipClient, messaging: flipClient, streaming: flipClient, contactNaming: contactSyncController, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 493c47623..ffeceae97 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -178,6 +178,15 @@ extension Conversation { guard let read = selfReadPointer(for: selfUserID) else { return true } return read < lastMessage.id } + + /// Whether the signed-in user has this chat muted as of `date`. + /// + /// Unread state is deliberately independent of this: a muted chat still accrues unread + /// messages and still counts toward the Chats badge. Muting silences the notification, not the + /// message. + public func isMuted(at date: Date = .now) -> Bool { + viewerState?.isMuted(at: date) ?? false + } } /// A member's READ watermark paired with the time they last advanced it. Drives diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 13e02eb32..5d86c0295 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -42,7 +42,7 @@ extension ConversationRosterSummary { /// The signed-in user's per-viewer state for a chat — currently just mute status. See /// `chat.v1.ViewerState`. -public struct ConversationViewerState: Hashable, Sendable { +public struct ConversationViewerState: Hashable, Codable, Sendable { /// Present iff the chat is muted for the viewer. Muted pushes are still delivered (so the /// client can store the message) but flagged for the client to suppress the notification. @@ -68,8 +68,18 @@ extension ConversationViewerState { } } +extension ConversationViewerState { + /// Whether the viewer has this chat muted as of `date`. + /// + /// Computed on every read rather than stored: a timed mute lapses with no server signal, so a + /// cached boolean would keep a chat silent after its mute expired. + public func isMuted(at date: Date = .now) -> Bool { + mute?.isActive(at: date) ?? false + } +} + /// Whether, and until when, the signed-in user has muted a chat. See `chat.v1.MuteState`. -public enum ConversationMuteState: Hashable, Sendable { +public enum ConversationMuteState: Hashable, Codable, Sendable { /// Muted until this date, after which the mute lapses client-side. Nothing is sent when it /// does, so the client owns the countdown. case until(Date) @@ -89,6 +99,17 @@ extension ConversationMuteState { } } + /// Whether the mute is still in force as of `date`. A ``until(_:)`` mute in the past has + /// lapsed; the server sends nothing when it does, so the client owns the comparison. + public func isActive(at date: Date = .now) -> Bool { + switch self { + case .until(let expiry): + return expiry > date + case .forever: + return true + } + } + /// Builds the wire form for `MuteChatRequest.mute`. var proto: Flipcash_Chat_V1_MuteState { .with { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 1cbc654d9..47d74d4ee 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -490,6 +490,16 @@ public struct ConversationStore: Sendable { conversations[index].viewerState = viewerState } + /// Drops the cached viewer state for a chat the signed-in user just left. + /// + /// The server clears mute on leave and restarts the version with it, so this discards the + /// version too rather than keeping it: a retained higher version would make + /// ``applyViewerStateChanged(_:in:)`` swallow every update the chat sends after a rejoin. + public mutating func clearViewerState(in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].viewerState = nil + } + private mutating func upsert(_ conversation: Conversation) { let conversation = seated(conversation) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index c6878097e..dbd5f1784 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -64,8 +64,11 @@ public enum NotificationPayload { /// Whether the recipient had the chat muted when a CHAT push was sent. The push is still /// delivered so the client can store the message, but the client must not present a /// notification for it. `false` when the push isn't a chat message or carries no chat metadata - /// (system messages, or a legacy server that omits it) — scaffolded here only; no caller wires - /// this into notification presentation yet. + /// (system messages, or a legacy server that omits it). + /// + /// Both presentation paths read this: the app's foreground delegate via + /// ``presentationDecision(_:isViewingConversation:)``, and the notification service extension, + /// which has no access to local state and so has nothing else to read. public static func isMuted(_ userInfo: [AnyHashable: Any]) -> Bool { guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { return false diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPresentation.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPresentation.swift new file mode 100644 index 000000000..c834ce0fc --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPresentation.swift @@ -0,0 +1,53 @@ +// +// NotificationPresentation.swift +// FlipcashCore +// + +import Foundation +import UserNotifications + +/// Whether a push that arrived while the app is in the foreground should be shown, and if not, why. +/// +/// Suppression is about presentation only. The push is still delivered and still stored — the +/// server sends a muted chat's message deliberately so the client can keep the transcript and the +/// unread count complete; it just must not interrupt. +public enum NotificationPresentationDecision: Equatable, Sendable { + case present + /// The chat is muted for this viewer, per the flag the server set on the payload. + case suppressedMuted + /// The user is already reading this chat. + case suppressedOpenConversation + + /// What to hand back from `userNotificationCenter(_:willPresent:)`. + public var options: UNNotificationPresentationOptions { + switch self { + case .present: + return [.badge, .list, .sound, .banner] + case .suppressedMuted, .suppressedOpenConversation: + return [] + } + } +} + +extension NotificationPayload { + + /// Decides how a foreground push should present. + /// + /// Mute is read from the payload rather than from local state: the flag is the server's answer + /// at send time, it is the only thing the notification service extension can see, and reading + /// the same source in both paths keeps them from disagreeing. + /// + /// `isViewingConversation` is asked only for a chat push that isn't already suppressed. + public static func presentationDecision( + _ userInfo: [AnyHashable: Any], + isViewingConversation: (ConversationID) -> Bool + ) -> NotificationPresentationDecision { + if isMuted(userInfo) { + return .suppressedMuted + } + if let conversationID = chatID(userInfo), isViewingConversation(conversationID) { + return .suppressedOpenConversation + } + return .present + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 1964f7d55..1966bf492 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -98,7 +98,8 @@ nonisolated extension Database { memberCount: row[c.rosterMemberCount], version: row[c.rosterVersion] ), - rules: conversationRules(from: row) + rules: conversationRules(from: row), + viewerState: conversationViewerState(from: row) ) } } @@ -386,6 +387,7 @@ nonisolated extension Database { c.rosterMemberCount <- conversation.rosterSummary.memberCount, c.rosterVersion <- conversation.rosterSummary.version, c.rules <- conversation.rules.flatMap { try? JSONEncoder().encode($0) }, + c.viewerState <- conversation.viewerState.flatMap { try? JSONEncoder().encode($0) }, onConflictOf: c.id ) ) @@ -527,6 +529,17 @@ nonisolated extension Database { return try? JSONDecoder().decode(ConversationRules.self, from: data) } + /// Returns nil when the chat carries no viewer state, and also when the + /// stored JSON doesn't decode. Degrading to "nothing known" is safe: it + /// renders as unmuted for the moment it takes the next metadata fetch to + /// replace it, and notification suppression reads the push payload rather + /// than this. + private func conversationViewerState(from row: RowIterator.Element) -> ConversationViewerState? { + let c = ConversationTable() + guard let data = row[c.viewerState] else { return nil } + return try? JSONDecoder().decode(ConversationViewerState.self, from: data) + } + /// Returns nil unless both rendition columns are present — the pair is /// written together, so a lone column is treated as no picture. func memberProfilePicture(from row: RowIterator.Element) -> ProfilePicture? { diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index f8869473c..098a630cd 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -194,7 +194,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 37 + public static let schemaVersion = 38 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index e67dc969c..88eae5045 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -273,6 +273,12 @@ nonisolated public struct ConversationTable: Sendable { // FlipcashAPI, so the proto can't be stored; ConversationRules is Codable // for exactly this. public let rules = Expression ("rules") + // The signed-in viewer's per-chat state (currently mute) as JSON, same + // reason as `rules`. The whole struct is stored, version included, so a + // cold-start reload can't beat a fresher stream update; the mute is stored + // as its until-timestamp, never as a computed muted boolean, because a + // timed mute lapses with no server signal. + public let viewerState = Expression ("viewerState") } // The group chats the signed-in user has joined. Its own table rather than a @@ -562,6 +568,7 @@ nonisolated extension Database { t.column(conversationTable.rosterMemberCount, defaultValue: 0) t.column(conversationTable.rosterVersion, defaultValue: 0) t.column(conversationTable.rules) + t.column(conversationTable.viewerState) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift new file mode 100644 index 000000000..1104ba35a --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMuteTests.swift @@ -0,0 +1,111 @@ +// +// ConversationMuteTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +/// Mute state: a timed mute lapses with nothing sent from the server, so the client owns the +/// comparison against now; and the state converges by version like the roster summary does. +@Suite("Conversation mute") +struct ConversationMuteTests { + + private func conversationID(_ byte: UInt8) -> ConversationID { + ConversationID(data: Data(repeating: byte, count: 32)) + } + + private func conversation(_ byte: UInt8, viewerState: ConversationViewerState? = nil) -> Conversation { + Conversation( + id: conversationID(byte), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .group, + viewerState: viewerState + ) + } + + private let noon = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: - Lapse + + @Test("A mute until a future instant is active") + func timedMuteActiveBeforeExpiry() { + let mute = ConversationMuteState.until(noon.addingTimeInterval(60)) + + #expect(mute.isActive(at: noon)) + } + + @Test("A mute lapses on its own, with nothing applied") + func timedMuteLapses() { + let state = ConversationViewerState(mute: .until(noon), version: 1) + + #expect(state.isMuted(at: noon.addingTimeInterval(-1))) + // At the instant it expires, not one tick after: the comparison is strict. + #expect(state.isMuted(at: noon) == false) + #expect(state.isMuted(at: noon.addingTimeInterval(60)) == false) + } + + @Test("A forever mute never lapses") + func foreverMuteNeverLapses() { + let state = ConversationViewerState(mute: .forever, version: 1) + + #expect(state.isMuted(at: noon)) + #expect(state.isMuted(at: .distantFuture)) + } + + @Test("A chat with no viewer state is unmuted") + func absentViewerStateIsUnmuted() { + #expect(conversation(1).isMuted(at: noon) == false) + #expect(conversation(1, viewerState: ConversationViewerState(version: 3)).isMuted(at: noon) == false) + } + + // MARK: - Convergence + + @Test("A greater version wins and a lesser one is dropped") + func convergesByVersion() { + var store = ConversationStore() + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 2)))) + + // Older: the mute stands. + store.applyViewerStateChanged(ConversationViewerState(mute: nil, version: 1), in: conversationID(1)) + #expect(store.conversations[0].isMuted(at: noon)) + + // Equal version is not greater, so it is dropped too. + store.applyViewerStateChanged(ConversationViewerState(mute: nil, version: 2), in: conversationID(1)) + #expect(store.conversations[0].isMuted(at: noon)) + + // Newer: applied. + store.applyViewerStateChanged(ConversationViewerState(mute: nil, version: 3), in: conversationID(1)) + #expect(store.conversations[0].isMuted(at: noon) == false) + } + + /// Mute is cleared server-side on leave, and the version restarts with it. Keeping the old + /// version would make every post-rejoin update look stale and be dropped. + @Test("Leaving clears the viewer state, version included") + func clearViewerStateDropsTheVersion() { + var store = ConversationStore() + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 9)))) + + store.clearViewerState(in: conversationID(1)) + #expect(store.conversations[0].viewerState == nil) + #expect(store.conversations[0].isMuted(at: noon) == false) + + // A rejoin starts the version over; it must still be applied. + store.applyViewerStateChanged(ConversationViewerState(mute: .forever, version: 1), in: conversationID(1)) + #expect(store.conversations[0].isMuted(at: noon)) + } + + @Test("A metadata refresh carries viewer state, so an unmute elsewhere isn't lost") + func metadataRefreshCarriesViewerState() { + var store = ConversationStore() + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: .forever, version: 2)))) + store.apply(.metadataRefresh(conversation(1, viewerState: ConversationViewerState(mute: nil, version: 3)))) + + #expect(store.conversations[0].isMuted(at: noon) == false) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPresentationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPresentationTests.swift new file mode 100644 index 000000000..88e40d334 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPresentationTests.swift @@ -0,0 +1,72 @@ +// +// NotificationPresentationTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashAPI +@testable import FlipcashCore + +/// The foreground presentation decision. Mute is read off the push payload rather than local state, +/// so these drive it entirely from encoded payloads — the same bytes both the app and the +/// notification service extension see. +@Suite("Notification presentation") +struct NotificationPresentationTests { + + private static let chatIDBytes = Data((0..<32).map { UInt8($0) }) + private static var chatID: ConversationID { ConversationID(data: chatIDBytes) } + + private static func userInfo(muted: Bool, chatMetadata: Bool = true) throws -> [AnyHashable: Any] { + let payload = Flipcash_Push_V1_Payload.with { + $0.category = .chat + $0.navigation = .with { $0.chatID = .with { $0.value = chatIDBytes } } + if chatMetadata { + $0.chatMetadata = .with { $0.muted = muted } + } + } + return [NotificationPayload.userInfoKey: try payload.serializedData().base64EncodedString()] + } + + @Test("A muted chat's push presents nothing") + func mutedPresentsNothing() throws { + let decision = NotificationPayload.presentationDecision(try Self.userInfo(muted: true)) { _ in false } + + #expect(decision == .suppressedMuted) + #expect(decision.options.isEmpty) + } + + @Test("An unmuted chat's push presents normally") + func unmutedPresentsNormally() throws { + let decision = NotificationPayload.presentationDecision(try Self.userInfo(muted: false)) { _ in false } + + #expect(decision == .present) + #expect(decision.options == [.badge, .list, .sound, .banner]) + } + + @Test("A push for the chat already on screen presents nothing") + func openConversationPresentsNothing() throws { + let decision = NotificationPayload.presentationDecision(try Self.userInfo(muted: false)) { + $0 == Self.chatID + } + + #expect(decision == .suppressedOpenConversation) + #expect(decision.options.isEmpty) + } + + /// A server that predates the flag sends no chat metadata at all; that must read as unmuted + /// rather than swallowing every notification. + @Test("A push without chat metadata presents normally") + func missingChatMetadataPresentsNormally() throws { + let userInfo = try Self.userInfo(muted: false, chatMetadata: false) + + #expect(NotificationPayload.presentationDecision(userInfo) { _ in false } == .present) + } + + @Test("A push carrying no payload at all presents normally") + func noPayloadPresentsNormally() { + #expect(NotificationPayload.presentationDecision([:]) { _ in false } == .present) + } +} diff --git a/FlipcashTests/Chat/ConversationMembershipTests.swift b/FlipcashTests/Chat/ConversationMembershipTests.swift index fc72b00ef..d9e92f31d 100644 --- a/FlipcashTests/Chat/ConversationMembershipTests.swift +++ b/FlipcashTests/Chat/ConversationMembershipTests.swift @@ -28,7 +28,7 @@ struct ConversationMembershipTests { database: Database ) -> ConversationController { ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: database, owner: .generate()!, selfUserID: selfUserID diff --git a/FlipcashTests/Chat/ConversationMuteWiringTests.swift b/FlipcashTests/Chat/ConversationMuteWiringTests.swift new file mode 100644 index 000000000..bb434dd5a --- /dev/null +++ b/FlipcashTests/Chat/ConversationMuteWiringTests.swift @@ -0,0 +1,121 @@ +// +// ConversationMuteWiringTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// Muting through the controller: the RPC's viewer state is seated under the same version rule the +/// stream uses, cached so a cold start renders it, and dropped when the user leaves. +@MainActor +@Suite("ConversationController mute") +struct ConversationMuteWiringTests { + + private func makeController(_ mock: MockConversations, database: Database) -> ConversationController { + ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: UUID() + ) + } + + private func group(_ byte: UInt8) -> Conversation { + Conversation( + id: .test(byte), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .group, + title: "Group \(byte)" + ) + } + + private let noon = Date(timeIntervalSince1970: 1_700_000_000) + + @Test("Muting seats the returned viewer state and caches it") + func muteSeatsAndPersists() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.groupFeed = [group(1)] + let controller = makeController(mock, database: database) + await controller.loadGroupFeed() + + let expiry = noon.addingTimeInterval(3600) + try await controller.mute(conversationID: .test(1), .until(expiry)) + + #expect(mock.muted.map(\.conversationID) == [.test(1)]) + #expect(mock.muted.first?.mute == .until(expiry)) + #expect(controller.isMuted(conversationID: .test(1), at: noon)) + // And it lapses on its own, with nothing further applied. + #expect(controller.isMuted(conversationID: .test(1), at: expiry) == false) + + let cached = try #require(try database.getConversations().first) + #expect(cached.isMuted(at: noon)) + } + + @Test("Unmuting is its own call and clears the mute") + func unmuteClearsTheMute() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.groupFeed = [group(1)] + let controller = makeController(mock, database: database) + await controller.loadGroupFeed() + + try await controller.mute(conversationID: .test(1), .forever) + mock.viewerStateResult = ConversationViewerState(mute: nil, version: 2) + try await controller.unmute(conversationID: .test(1)) + + #expect(mock.unmuted == [.test(1)]) + #expect(controller.isMuted(conversationID: .test(1), at: noon) == false) + #expect(try #require(try database.getConversations().first).isMuted(at: noon) == false) + } + + @Test("A failed mute leaves the chat unmuted") + func failedMuteChangesNothing() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.groupFeed = [group(1)] + mock.muteError = ErrorMuteChat.denied + let controller = makeController(mock, database: database) + await controller.loadGroupFeed() + + await #expect(throws: ErrorMuteChat.self) { + try await controller.mute(conversationID: .test(1), .forever) + } + #expect(controller.isMuted(conversationID: .test(1), at: noon) == false) + } + + /// The server clears mute on leave and restarts the version with it, so a rejoin must not show + /// the old mute — and must not have its first update dropped as stale. + @Test("Leaving drops the mute, and a rejoin's first update still applies") + func leaveClearsMute() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.groupFeed = [group(1)] + let controller = makeController(mock, database: database) + await controller.loadGroupFeed() + + mock.viewerStateResult = ConversationViewerState(mute: .forever, version: 9) + try await controller.mute(conversationID: .test(1), .forever) + #expect(controller.isMuted(conversationID: .test(1), at: noon)) + + try await controller.leave(conversationID: .test(1)) + #expect(controller.isMuted(conversationID: .test(1), at: noon) == false) + #expect(try #require(try database.getConversations().first).viewerState == nil) + + controller.store.applyViewerStateChanged( + ConversationViewerState(mute: .forever, version: 1), + in: .test(1) + ) + #expect(controller.isMuted(conversationID: .test(1), at: noon)) + } +} diff --git a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift index 3e0872b25..ea2d06ae7 100644 --- a/FlipcashTests/Chat/ConversationReceiptWiringTests.swift +++ b/FlipcashTests/Chat/ConversationReceiptWiringTests.swift @@ -27,7 +27,7 @@ struct ConversationReceiptWiringTests { database: Database ) -> ConversationController { ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: database, owner: .generate()!, selfUserID: selfUserID, diff --git a/FlipcashTests/Chat/ConversationReplySendTests.swift b/FlipcashTests/Chat/ConversationReplySendTests.swift index 0225160e7..14f72a61f 100644 --- a/FlipcashTests/Chat/ConversationReplySendTests.swift +++ b/FlipcashTests/Chat/ConversationReplySendTests.swift @@ -18,7 +18,7 @@ struct ConversationReplySendTests { /// Mirrors `ConversationControllerTests.makeController` — the same four mocks and a temp DB. private func makeController(_ mock: MockConversations, selfUserID: UserID = UUID()) -> ConversationController { ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: try! Database.makeTemp().database, owner: .generate()!, selfUserID: selfUserID, diff --git a/FlipcashTests/Chat/MessageLoaderRevealTests.swift b/FlipcashTests/Chat/MessageLoaderRevealTests.swift index 93027cad5..c99e45f81 100644 --- a/FlipcashTests/Chat/MessageLoaderRevealTests.swift +++ b/FlipcashTests/Chat/MessageLoaderRevealTests.swift @@ -18,7 +18,7 @@ struct MessageLoaderRevealTests { /// The same construction `MessageLoaderTests` uses — four mocks over a real temp database. private func makeController(_ database: Database) -> ConversationController { ConversationController( - fetching: MockConversations(), membership: MockConversations(), messaging: MockConversations(), streaming: MockConversations(), + fetching: MockConversations(), membership: MockConversations(), viewerSettings: MockConversations(), messaging: MockConversations(), streaming: MockConversations(), contactNaming: MockDMContactNaming(), database: database, owner: .generate()!, selfUserID: UUID(), diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index e347df21d..73e2ab7da 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -31,7 +31,7 @@ struct ConversationControllerTests { incomingTypingExpiry: Duration = .seconds(10) ) -> ConversationController { ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: naming, database: database ?? (try! Database.makeTemp().database), owner: .generate()!, selfUserID: selfUserID, diff --git a/FlipcashTests/ConversationMutationTests.swift b/FlipcashTests/ConversationMutationTests.swift index 122e2c342..05c5b8639 100644 --- a/FlipcashTests/ConversationMutationTests.swift +++ b/FlipcashTests/ConversationMutationTests.swift @@ -29,7 +29,7 @@ struct ConversationMutationTests { let mock = MockConversations() let database = try Database.makeTemp().database let controller = ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: database, owner: .generate()!, selfUserID: selfUserID, diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index b49e720f5..787808327 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -485,6 +485,34 @@ struct DatabaseConversationsTests { #expect(loaded.lastMessage == preview) } + @Test("Mute state round-trips as its expiry and version, not as a boolean") + func viewerStateRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let expiry = Date(timeIntervalSince1970: 1_700_000_000) + var stored = conversation(byte: 1) + stored.viewerState = ConversationViewerState(mute: .until(expiry), version: 7) + + try database.upsertConversation(stored) + + let loaded = try #require(try database.getConversations().first) + // The version comes back with it, so a cold-start reload can't beat a fresher stream update. + #expect(loaded.viewerState == stored.viewerState) + // And the expiry, not a muted flag: the mute lapses on its own after a cold start. + #expect(loaded.isMuted(at: expiry.addingTimeInterval(-1))) + #expect(loaded.isMuted(at: expiry) == false) + } + + @Test("A conversation with no viewer state round-trips as none") + func viewerStateAbsentRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.upsertConversation(conversation(byte: 1)) + + let loaded = try #require(try database.getConversations().first) + #expect(loaded.viewerState == nil) + } + @Test("A conversation with no stored messages loads a nil preview") func conversationWithoutMessages() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/MessageLoaderTests.swift b/FlipcashTests/MessageLoaderTests.swift index 4fc60d602..c9184f1ef 100644 --- a/FlipcashTests/MessageLoaderTests.swift +++ b/FlipcashTests/MessageLoaderTests.swift @@ -15,7 +15,7 @@ struct MessageLoaderTests { private func makeController(_ database: Database) -> ConversationController { ConversationController( - fetching: MockConversations(), membership: MockConversations(), messaging: MockConversations(), streaming: MockConversations(), + fetching: MockConversations(), membership: MockConversations(), viewerSettings: MockConversations(), messaging: MockConversations(), streaming: MockConversations(), contactNaming: MockDMContactNaming(), database: database, owner: .generate()!, selfUserID: UUID(), diff --git a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift index f48119d4d..96d1d7b21 100644 --- a/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift +++ b/FlipcashTests/Regressions/Regression_6a510fab7372e33c88417bd4.swift @@ -27,7 +27,7 @@ struct Regression_6a510fa { private func makeController(_ mock: MockConversations) -> ConversationController { ConversationController( - fetching: mock, membership: mock, messaging: mock, streaming: mock, + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: try! Database.makeTemp().database, owner: .generate()!, selfUserID: UUID() diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index cce164e04..41b42fc94 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -9,7 +9,7 @@ import FlipcashCore /// Scriptable conformer for the conversation capability protocols. Records /// every call; the test sets scripted responses before driving the controller. -final class MockConversations: ConversationFetching, ConversationMembership, ConversationMessaging, ConversationEventStreaming, @unchecked Sendable { +final class MockConversations: ConversationFetching, ConversationMembership, ConversationViewerSettings, ConversationMessaging, ConversationEventStreaming, @unchecked Sendable { struct Sent: Sendable { let conversationID: ConversationID @@ -41,6 +41,10 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con private var _left: [ConversationID] = [] private var _joinError: Error? private var _leaveError: Error? + private var _muted: [(conversationID: ConversationID, mute: ConversationMuteState)] = [] + private var _unmuted: [ConversationID] = [] + private var _viewerStateResult: ConversationViewerState? + private var _muteError: Error? private var _messages: [ConversationMessage] = [] private var _olderMessages: [ConversationMessage] = [] private var _olderQueries: [MessageID] = [] @@ -89,6 +93,20 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con get { lock.withLock { _leaveError } } set { lock.withLock { _leaveError = newValue } } } + + /// Every `MuteChat` call, in order, with the duration each asked for. + var muted: [(conversationID: ConversationID, mute: ConversationMuteState)] { lock.withLock { _muted } } + var unmuted: [ConversationID] { lock.withLock { _unmuted } } + /// Viewer state both mute RPCs return. Unset returns one at version 1 carrying the requested + /// mute, which is enough for any test that isn't exercising version comparison. + var viewerStateResult: ConversationViewerState? { + get { lock.withLock { _viewerStateResult } } + set { lock.withLock { _viewerStateResult = newValue } } + } + var muteError: Error? { + get { lock.withLock { _muteError } } + set { lock.withLock { _muteError = newValue } } + } var messages: [ConversationMessage] { get { lock.withLock { _messages } } set { lock.withLock { _messages = newValue } } @@ -218,6 +236,20 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con if let leaveError { throw leaveError } } + // MARK: - ConversationViewerSettings + + func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState) async throws -> ConversationViewerState { + lock.withLock { _muted.append((conversationID, mute)) } + if let muteError { throw muteError } + return viewerStateResult ?? ConversationViewerState(mute: mute, version: 1) + } + + func unmuteChat(owner: KeyPair, conversationID: ConversationID) async throws -> ConversationViewerState { + lock.withLock { _unmuted.append(conversationID) } + if let muteError { throw muteError } + return viewerStateResult ?? ConversationViewerState(mute: nil, version: 1) + } + // MARK: - ConversationMessaging func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] { diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index ec66f9bf8..f336cd043 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -65,6 +65,10 @@ public enum SystemSymbol: String { case touchID = "touchid" case faceID = "faceid" case trophy = "trophy.fill" + + /// Timed and indefinite mute, on the mute-duration picker. + case clock = "clock" + case bellSlash = "bell.slash" case chevronDown = "chevron.down" case chevronRight = "chevron.right" diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index da5c8aabc..990df1bdc 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -148,18 +148,40 @@ final class NotificationService: UNNotificationServiceExtension { return } + // A muted chat's push is still delivered and still stored — the server sends it deliberately + // so the client keeps the message and its unread count — but it must not interrupt. + // + // This is the quietest the extension can be. `UNNotificationServiceExtension` has no way to + // drop a notification: whatever it returns is displayed, and returning nothing displays the + // original content. `.passive` is the system's floor — the notification joins the + // notification list without lighting the screen or playing a sound. The foreground path in + // `PushController` can suppress outright; this one cannot, and a muted chat's message will + // still appear in the list when the phone is next unlocked. + // + // Communication styling is skipped with it: it exists to make the banner prominent, which is + // the opposite of what a muted chat wants. + let isMuted = NotificationPayload.isMuted(request.content.userInfo) + // "Sent You Cash" (CHAT) renders as a communication notification so the sender's avatar — or // the system monogram fallback — shows like a chat app. This styled copy (when a sender // resolves; otherwise the substituted content) is what's delivered, from both the prefetch // completion and the expiry deadline. - let finalContent: UNNotificationContent = - titleContacts.compactMap { $0 }.first.map { sender in - communicationContent( - from: bestAttemptContent, - sender: sender, - conversationIdentifier: payload.groupKey - ) - } ?? bestAttemptContent.copy() as! UNNotificationContent + let finalContent: UNNotificationContent + if isMuted { + let silenced = bestAttemptContent.mutableCopy() as! UNMutableNotificationContent + silenced.sound = nil + silenced.interruptionLevel = .passive + finalContent = silenced + } else { + finalContent = + titleContacts.compactMap { $0 }.first.map { sender in + communicationContent( + from: bestAttemptContent, + sender: sender, + conversationIdentifier: payload.groupKey + ) + } ?? bestAttemptContent.copy() as! UNNotificationContent + } // Prefetch the recent transcript into the shared cache so the content extension renders from // the cache on expand with no resident gRPC connection. The banner is held until the transcript From d60074223347e49c355efecc2f6b071a1524ee7f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 20:20:44 -0400 Subject: [PATCH 02/13] feat(chat): mute the DM with someone from their profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muting was reachable only from a group's profile screen. A DM has no such screen — a tip DM's title opens the counterpart's user profile, which carried only Block — so the chat type most likely to be noisy had no way to be silenced. Move the control out of ChatProfileScreen into ChatMuteRow and host it on both screens. The row owns the picker presentation, the unmute call and the countdown that lapses a timed mute, so the two surfaces can't drift; they differ only in the chevron they draw, which each screen already styles its own way. UserProfileScreen resolves the chat through tipDM(withUserID:), which matches the roster rather than going through counterpart(excluding:) — that falls back to the first member and would answer a malformed single-member chat with the viewer themselves. Hidden chats are excluded, since a blocked counterpart's DM is off the feed and muting a chat the user can't see isn't a control worth offering. The lookup re-runs on every pass, so a DM a tip creates while the screen is open brings its mute row with it. --- .../Controllers/ConversationController.swift | 16 ++ .../Core/Screens/Profile/ChatMuteRow.swift | 144 ++++++++++++++++++ .../Screens/Profile/ChatProfileScreen.swift | 87 +---------- .../Screens/Profile/UserProfileScreen.swift | 52 +++++-- .../Chat/ConversationMuteWiringTests.swift | 94 +++++++++++- 5 files changed, 291 insertions(+), 102 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/ChatMuteRow.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index cb2cdba9b..204076fec 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1073,6 +1073,22 @@ final class ConversationController { ) } + /// The tip DM with `userID`, or nil when the viewer has none — what the counterpart's profile + /// asks so it knows whether there is a chat to mute. + /// + /// Matched against the roster rather than through `counterpart(excluding:)`, which falls back to + /// the first member and would answer a malformed single-member chat with the viewer themselves. + /// Hidden chats are excluded: a blocked counterpart's DM is off the feed, and muting a chat the + /// user can't see is not a control worth offering. + func tipDM(withUserID userID: UserID) -> Conversation? { + guard userID != selfUserID else { return nil } + return conversations.first { conversation in + conversation.type == .tipDm + && !conversation.isHidden + && conversation.members.contains { $0.userID == userID } + } + } + private func contactName(for conversationID: ConversationID) -> String? { guard let name = contactNaming.contactDisplayName(forDMChat: conversationID), !name.isEmpty else { diff --git a/Flipcash/Core/Screens/Profile/ChatMuteRow.swift b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift new file mode 100644 index 000000000..ae6d9a088 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift @@ -0,0 +1,144 @@ +// +// ChatMuteRow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The mute control as both chat settings surfaces draw it: one row that opens the duration picker +/// while the chat is audible, and unmutes on the tap once it isn't. +/// +/// A named unit rather than a copy per screen, because the two have to agree. A DM and a group are +/// the same mute — the contract doesn't distinguish them — so a row that read its state or drew its +/// deadline differently on one would be a bug neither screen could catch from the other. +struct ChatMuteRow: View { + + /// How the trailing chevron draws. The two hosts style it differently and each is right on its + /// own screen, so the caller says which rather than one of them taking the other's look. + enum Chevron { + /// ``Row``'s own accessory chevron, matching the chat profile's other rows. + case standard + /// The smaller secondary chevron the user profile uses to match its profile card. + case secondary + } + + let conversationID: ConversationID + let insets: EdgeInsets + var chevron: Chevron = .standard + + @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer + + @State private var isPickingDuration = false + @State private var isUnmuting = false + + /// The instant the row is evaluated against, re-read when a timed mute lapses. + /// + /// A timed mute expires with nothing sent from the server, so no event can invalidate the row; + /// it has to notice on its own. See ``muteExpiry``. + @State private var now = Date.now + + private var conversation: Conversation? { + conversationController.conversation(withID: conversationID) + } + + private var isMuted: Bool { + conversation?.isMuted(at: now) ?? false + } + + /// The mute as the row reports it on the right — "Muted until 5:56 PM" for a timed mute, plain + /// "Muted" for an indefinite one. Nil when the chat isn't muted. + /// + /// Stating the deadline is what makes a timed mute trustworthy: without it the row can't be + /// told apart from an indefinite one, and the user has no way to know when the chat comes back. + private var muteDetail: String? { + guard isMuted, let mute = conversation?.viewerState?.mute else { return nil } + switch mute { + case .until(let expiry): + return "Muted until \(expiry.formattedRelatively(useTimeForToday: true))" + case .forever: + return "Muted" + } + } + + /// When the current mute lapses, or nil when it is indefinite or absent — the deadline the + /// refresh below waits on. + private var muteExpiry: Date? { + guard case .until(let expiry) = conversation?.viewerState?.mute else { return nil } + return expiry + } + + /// The trailing accessory: the loader while unmuting, else ``Row``'s chevron for the standard + /// style. The secondary style draws its own inside the content, so it takes no accessory. + private var accessory: RowAccessory? { + if isUnmuting { return .loader(.textMain) } + return chevron == .standard ? .chevron : nil + } + + var body: some View { + Row(insets: insets, disabled: isUnmuting, accessory: accessory) { + Image(systemName: isMuted ? "bell.slash" : "bell") + .frame(minWidth: 45) + Text(isMuted ? "Unmute Notifications" : "Mute Notifications") + .foregroundStyle(.textMain) + + if let muteDetail { + Text(muteDetail) + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if chevron == .secondary, !isUnmuting { + // Nothing has claimed the slack when there is no deadline to print, so the chevron + // needs the spacer the accessory would have brought. + if muteDetail == nil { + Spacer() + } + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.textSecondary) + } + } action: { + // Muting asks for how long; unmuting has nothing to ask, so it is the tap itself rather + // than a second sheet with one row on it. + if isMuted { + Task { await unmute() } + } else { + isPickingDuration = true + } + } + .accessibilityIdentifier("chat-mute") + .sheet(isPresented: $isPickingDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingDuration) + } + // Flip the row back to "Mute" the moment a timed mute lapses. Re-run whenever the expiry + // changes, so muting again while the screen is open re-arms it; cancelled with the screen. + .task(id: muteExpiry) { + guard let muteExpiry, muteExpiry > .now else { return } + try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) + guard !Task.isCancelled else { return } + now = .now + } + } + + /// Unmutes, staying on the screen — unlike leaving, there is nothing to unwind to. + private func unmute() async { + isUnmuting = true + defer { isUnmuting = false } + do { + try await conversationController.unmute(conversationID: conversationID) + now = .now + } catch { + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to unmute this chat. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to unmute chat") + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 771a093d8..7a735b0ad 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -25,16 +25,8 @@ struct ChatProfileScreen: View { @State private var isInviting = false @State private var isLeaving = false - @State private var isPickingMuteDuration = false - @State private var isUnmuting = false @State private var dialogItem: DialogItem? - /// The instant the mute row is evaluated against, re-read when a timed mute lapses. - /// - /// A timed mute expires with nothing sent from the server, so no event can invalidate the row; - /// the screen has to notice on its own. See ``muteExpiry``. - @State private var now = Date.now - private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } @@ -58,32 +50,6 @@ struct ChatProfileScreen: View { return conversationController.isMember(of: conversation) } - private var isMuted: Bool { - conversation?.isMuted(at: now) ?? false - } - - /// The mute as the row reports it on the right — "Muted until 5:56 PM" for a timed mute, plain - /// "Muted" for an indefinite one. Nil when the chat isn't muted. - /// - /// Stating the deadline is what makes a timed mute trustworthy: without it the row can't be - /// told apart from an indefinite one, and the user has no way to know when the chat comes back. - private var muteDetail: String? { - guard isMuted, let mute = conversation?.viewerState?.mute else { return nil } - switch mute { - case .until(let expiry): - return "Muted until \(expiry.formattedRelatively(useTimeForToday: true))" - case .forever: - return "Muted" - } - } - - /// When the current mute lapses, or nil when it is indefinite or absent — the deadline the - /// refresh below waits on. - private var muteExpiry: Date? { - guard case .until(let expiry) = conversation?.viewerState?.mute else { return nil } - return expiry - } - var body: some View { Background(color: .backgroundMain) { VStack(spacing: 16) { @@ -123,31 +89,7 @@ struct ChatProfileScreen: View { } .accessibilityIdentifier("chat-profile-invite") - Row( - insets: rowInsets, - disabled: isUnmuting, - accessory: isUnmuting ? .loader(.textMain) : .chevron - ) { - Image(systemName: isMuted ? "bell.slash" : "bell") - .frame(minWidth: 45) - Text(isMuted ? "Unmute Notifications" : "Mute Notifications") - .foregroundStyle(.textMain) - if let muteDetail { - Text(muteDetail) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } action: { - // Muting asks for how long; unmuting has nothing to ask, so it is the - // tap itself rather than a second sheet with one row on it. - if isMuted { - Task { await unmute() } - } else { - isPickingMuteDuration = true - } - } - .accessibilityIdentifier("chat-profile-mute") + ChatMuteRow(conversationID: conversationID, insets: rowInsets) Row( insets: rowInsets, @@ -177,42 +119,15 @@ struct ChatProfileScreen: View { .sheet(isPresented: $isInviting) { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) } - .sheet(isPresented: $isPickingMuteDuration) { - MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) - } .task { await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) } - // Flip the row back to "Mute" the moment a timed mute lapses. Re-run whenever the expiry - // changes, so muting again while the screen is open re-arms it; cancelled with the screen. - .task(id: muteExpiry) { - guard let muteExpiry, muteExpiry > .now else { return } - try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) - guard !Task.isCancelled else { return } - now = .now - } } private var rowInsets: EdgeInsets { .init(top: 25, leading: 0, bottom: 25, trailing: 0) } - /// Unmutes, staying on the screen — unlike leaving, there is nothing to unwind to. - private func unmute() async { - isUnmuting = true - defer { isUnmuting = false } - do { - try await conversationController.unmute(conversationID: conversationID) - now = .now - } catch { - sessionContainer.session.dialogItem = .error( - title: "Something Went Wrong", - subtitle: "We were unable to unmute this chat. Please try again" - ) - ErrorReporting.captureError(error, reason: "Failed to unmute chat") - } - } - private func leaveDialog() -> DialogItem { .alert( title: "Leave \(title)?", diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 8c830d2a3..2d821e5ef 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -9,7 +9,12 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The counterpart's Flipcash profile with a Block action, reached from a tip DM. +/// The counterpart's Flipcash profile, carrying the actions the viewer has over the person rather +/// than over one chat: muting the DM with them, and blocking them outright. +/// +/// Reached from a tip DM's title and from a face in a group transcript. Both land here because both +/// are the same question — who is this — so the actions are the person's either way: Block already +/// works on the person globally, and the mute row silences the DM with them when one exists. struct UserProfileScreen: View { let userID: UserID @@ -20,6 +25,9 @@ struct UserProfileScreen: View { var body: some View { let seed = sessionContainer.conversationController.counterpartSeed(forUserID: userID) UserProfileContent( + // Nil until a tip creates the chat server-side, and re-read on every pass, so a DM that + // appears while the screen is open brings its mute row with it. + conversationID: sessionContainer.conversationController.tipDM(withUserID: userID)?.id, model: UserProfileViewModel( userID: userID, flipClient: sessionContainer.flipClient, @@ -35,10 +43,14 @@ struct UserProfileScreen: View { } private struct UserProfileContent: View { + /// The DM to mute, or nil when there is no chat with this person yet. + let conversationID: ConversationID? + @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? - init(model: UserProfileViewModel) { + init(conversationID: ConversationID?, model: UserProfileViewModel) { + self.conversationID = conversationID _model = State(initialValue: model) } @@ -75,18 +87,26 @@ private struct UserProfileContent: View { } } - Row(insets: .init(top: 25, leading: 0, bottom: 25, trailing: 0)) { - Image(systemName: "nosign") - .frame(minWidth: 45) - Text("Block") - .foregroundStyle(.textMain) - Spacer() - // Secondary (alpha-white) chevron, matching the profile card. - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.textSecondary) - } action: { - dialogItem = blockDialog() + VStack(spacing: 0) { + // Mute sits above Block the way it sits above Leave on a group: the reversible + // action first, the one that ends the relationship last. + if let conversationID { + ChatMuteRow(conversationID: conversationID, insets: rowInsets, chevron: .secondary) + } + + Row(insets: rowInsets) { + Image(systemName: "nosign") + .frame(minWidth: 45) + Text("Block") + .foregroundStyle(.textMain) + Spacer() + // Secondary (alpha-white) chevron, matching the profile card. + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.textSecondary) + } action: { + dialogItem = blockDialog() + } } .font(.appDisplayXS) .padding(.top, 24) @@ -101,6 +121,10 @@ private struct UserProfileContent: View { .task { await model.loadProfile() } } + private var rowInsets: EdgeInsets { + .init(top: 25, leading: 0, bottom: 25, trailing: 0) + } + private func blockDialog() -> DialogItem { .alert( title: "Block \(model.displayName)?", diff --git a/FlipcashTests/Chat/ConversationMuteWiringTests.swift b/FlipcashTests/Chat/ConversationMuteWiringTests.swift index bb434dd5a..37afa7302 100644 --- a/FlipcashTests/Chat/ConversationMuteWiringTests.swift +++ b/FlipcashTests/Chat/ConversationMuteWiringTests.swift @@ -15,12 +15,29 @@ import FlipcashStore @Suite("ConversationController mute") struct ConversationMuteWiringTests { - private func makeController(_ mock: MockConversations, database: Database) -> ConversationController { + private func makeController( + _ mock: MockConversations, + selfUserID: UserID = UUID(), + database: Database + ) -> ConversationController { ConversationController( fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: database, - owner: .generate()!, selfUserID: UUID() + owner: .generate()!, selfUserID: selfUserID + ) + } + + private func tipDM(_ byte: UInt8, between selfUserID: UserID, and them: UserID) -> Conversation { + Conversation( + id: .test(byte), + members: [ + ConversationMember(userID: selfUserID, displayName: "me"), + ConversationMember(userID: them, displayName: "them"), + ], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .tipDm ) } @@ -118,4 +135,77 @@ struct ConversationMuteWiringTests { ) #expect(controller.isMuted(conversationID: .test(1), at: noon)) } + + // MARK: - The DM behind a counterpart's profile + + @Test("A counterpart's tip DM is the chat their profile offers to mute") + func tipDMResolvesByCounterpart() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let me = UUID(), them = UUID() + let mock = MockConversations() + mock.feed = [tipDM(2, between: me, and: them)] + let controller = makeController(mock, selfUserID: me, database: database) + _ = await controller.loadFeed(type: .tipDm) + + #expect(controller.tipDM(withUserID: them)?.id == .test(2)) + + // And it mutes like any other chat — nothing below the UI knows a DM from a group. + try await controller.mute(conversationID: .test(2), .forever) + #expect(controller.isMuted(conversationID: .test(2), at: noon)) + } + + @Test("Someone the user has no DM with has no chat to mute") + func noDMResolvesToNil() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let me = UUID(), them = UUID() + let controller = makeController(MockConversations(), selfUserID: me, database: database) + + // A tip DM doesn't exist server-side until the first tip, and the profile is reachable + // before then — from a face in a group transcript. No chat, so no row. + #expect(controller.tipDM(withUserID: them) == nil) + } + + @Test("A group the two are both in is not a DM to mute") + func sharedGroupIsNotADM() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let me = UUID(), them = UUID() + let mock = MockConversations() + mock.groupFeed = [ + Conversation( + id: .test(7), + members: [ + ConversationMember(userID: me, displayName: "me"), + ConversationMember(userID: them, displayName: "them"), + ], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .group, + title: "Group 7" + ) + ] + let controller = makeController(mock, selfUserID: me, database: database) + _ = await controller.loadGroupFeed() + + // Tapping their face in this group's transcript opens their profile. Muting there must not + // reach for the group — that mute belongs to the group's own screen. + #expect(controller.tipDM(withUserID: them) == nil) + } + + @Test("The viewer's own id resolves to no DM") + func selfResolvesToNil() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let me = UUID(), them = UUID() + let mock = MockConversations() + mock.feed = [tipDM(2, between: me, and: them)] + let controller = makeController(mock, selfUserID: me, database: database) + _ = await controller.loadFeed(type: .tipDm) + + // The viewer is a member of their own DMs, so an unguarded roster match would hand back a + // chat to mute for a profile that is the user themselves. + #expect(controller.tipDM(withUserID: me) == nil) + } } From e5f9a1229038028d40984a440bdab9eacee2c500 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 20:24:57 -0400 Subject: [PATCH 03/13] chore(deps): resolve flipcash2-client-protocol 0.10.0 in the workspace #804 bumped FlipcashAPI's exact pin to 0.10.0 but left the workspace Package.resolved on 0.9.0, so every clean resolve rewrites the file and shows up as a dirty tree. Commit the resolution the pin already requires. --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d281b2a91..62bd26c91 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "aed29fb96a06fc4b4514cb6991307c322cfa4a2ad686c7806ffe5025beaab834", + "originHash" : "3391ce35006df2625af311bfa583f85ad459f6fc5efbf8b41f7f809f4c056521", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "5003762cf59ad88bb2ead31658975611a5b4002a", - "version" : "0.9.0" + "revision" : "18807ff845c2af23053554375797d6058e22bbe8", + "version" : "0.10.0" } }, { From 3c82077a0026e1189bfc76afe653bdb6be500c60 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 22:24:28 -0400 Subject: [PATCH 04/13] feat(chat): move the mute state off the row and unmute onto the sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mute row overflowed in the state that needed it most. Muted, it drew "Unmute Notifications" at 20pt next to "Muted until 5:56 PM" at 14pt, a 45pt icon slot and a chevron — the two longest strings always appeared together, because the wording that grew was the muted one. The label also swapped under the tap that changed it, which read as a missed tap. The row is now always "Mute Notifications" and always opens the picker. What it used to report moves under the chat title beside the member count, as ChatMuteStatusLabel — shared for the same reason the row is, since a DM and a group are the same mute. The sheet gains "Never", which leads the list once there is a mute to clear and is left off when there isn't; it calls UnmuteChat rather than muting for zero seconds, and is worded as a duration only because every label there names the resulting state rather than the action. With no state left to draw, the row keeps one piece of state instead of three. --- .../Core/Screens/Profile/ChatMuteRow.swift | 115 +++--------------- .../Screens/Profile/ChatMuteStatusLabel.swift | 74 +++++++++++ .../Screens/Profile/ChatProfileScreen.swift | 2 + .../Core/Screens/Profile/MuteChatSheet.swift | 100 +++++++++++---- .../Screens/Profile/UserProfileScreen.swift | 4 + .../FlipcashUI/Theme/Image+Symbols.swift | 3 +- 6 files changed, 172 insertions(+), 126 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift diff --git a/Flipcash/Core/Screens/Profile/ChatMuteRow.swift b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift index ae6d9a088..d24cecc3b 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteRow.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift @@ -9,12 +9,17 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The mute control as both chat settings surfaces draw it: one row that opens the duration picker -/// while the chat is audible, and unmutes on the tap once it isn't. +/// The mute control as both chat settings surfaces draw it: one row that opens the duration picker. /// /// A named unit rather than a copy per screen, because the two have to agree. A DM and a group are -/// the same mute — the contract doesn't distinguish them — so a row that read its state or drew its -/// deadline differently on one would be a bug neither screen could catch from the other. +/// the same mute — the contract doesn't distinguish them — so a row that wired its sheet differently +/// on one would be a bug neither screen could catch from the other. +/// +/// The row says nothing about the current mute. It reads the same muted or not, and the state it +/// would have reported lives in ``ChatMuteStatusLabel`` under the title instead. A row whose label +/// changed under the tap that changed it read as a missed tap, and the deadline it drew alongside +/// left nothing for the label — the two longest strings appeared together, because the wording that +/// grew was the muted one. struct ChatMuteRow: View { /// How the trailing chevron draws. The two hosts style it differently and each is right on its @@ -30,115 +35,29 @@ struct ChatMuteRow: View { let insets: EdgeInsets var chevron: Chevron = .standard - @Environment(ConversationController.self) private var conversationController - @Environment(SessionContainer.self) private var sessionContainer - @State private var isPickingDuration = false - @State private var isUnmuting = false - - /// The instant the row is evaluated against, re-read when a timed mute lapses. - /// - /// A timed mute expires with nothing sent from the server, so no event can invalidate the row; - /// it has to notice on its own. See ``muteExpiry``. - @State private var now = Date.now - - private var conversation: Conversation? { - conversationController.conversation(withID: conversationID) - } - - private var isMuted: Bool { - conversation?.isMuted(at: now) ?? false - } - - /// The mute as the row reports it on the right — "Muted until 5:56 PM" for a timed mute, plain - /// "Muted" for an indefinite one. Nil when the chat isn't muted. - /// - /// Stating the deadline is what makes a timed mute trustworthy: without it the row can't be - /// told apart from an indefinite one, and the user has no way to know when the chat comes back. - private var muteDetail: String? { - guard isMuted, let mute = conversation?.viewerState?.mute else { return nil } - switch mute { - case .until(let expiry): - return "Muted until \(expiry.formattedRelatively(useTimeForToday: true))" - case .forever: - return "Muted" - } - } - - /// When the current mute lapses, or nil when it is indefinite or absent — the deadline the - /// refresh below waits on. - private var muteExpiry: Date? { - guard case .until(let expiry) = conversation?.viewerState?.mute else { return nil } - return expiry - } - - /// The trailing accessory: the loader while unmuting, else ``Row``'s chevron for the standard - /// style. The secondary style draws its own inside the content, so it takes no accessory. - private var accessory: RowAccessory? { - if isUnmuting { return .loader(.textMain) } - return chevron == .standard ? .chevron : nil - } var body: some View { - Row(insets: insets, disabled: isUnmuting, accessory: accessory) { - Image(systemName: isMuted ? "bell.slash" : "bell") + Row(insets: insets, accessory: chevron == .standard ? .chevron : nil) { + Image(systemName: "bell.slash") .frame(minWidth: 45) - Text(isMuted ? "Unmute Notifications" : "Mute Notifications") + Text("Mute Notifications") .foregroundStyle(.textMain) - if let muteDetail { - Text(muteDetail) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - .frame(maxWidth: .infinity, alignment: .trailing) - } - - if chevron == .secondary, !isUnmuting { - // Nothing has claimed the slack when there is no deadline to print, so the chevron - // needs the spacer the accessory would have brought. - if muteDetail == nil { - Spacer() - } + if chevron == .secondary { + // Nothing has claimed the slack, so the chevron needs the spacer the accessory + // would have brought. + Spacer() Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(.textSecondary) } } action: { - // Muting asks for how long; unmuting has nothing to ask, so it is the tap itself rather - // than a second sheet with one row on it. - if isMuted { - Task { await unmute() } - } else { - isPickingDuration = true - } + isPickingDuration = true } .accessibilityIdentifier("chat-mute") .sheet(isPresented: $isPickingDuration) { MuteChatSheet(conversationID: conversationID, isPresented: $isPickingDuration) } - // Flip the row back to "Mute" the moment a timed mute lapses. Re-run whenever the expiry - // changes, so muting again while the screen is open re-arms it; cancelled with the screen. - .task(id: muteExpiry) { - guard let muteExpiry, muteExpiry > .now else { return } - try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) - guard !Task.isCancelled else { return } - now = .now - } - } - - /// Unmutes, staying on the screen — unlike leaving, there is nothing to unwind to. - private func unmute() async { - isUnmuting = true - defer { isUnmuting = false } - do { - try await conversationController.unmute(conversationID: conversationID) - now = .now - } catch { - sessionContainer.session.dialogItem = .error( - title: "Something Went Wrong", - subtitle: "We were unable to unmute this chat. Please try again" - ) - ErrorReporting.captureError(error, reason: "Failed to unmute chat") - } } } diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift new file mode 100644 index 000000000..b4298380c --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -0,0 +1,74 @@ +// +// ChatMuteStatusLabel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What the chat's mute currently is, drawn under the title on both chat settings surfaces — +/// "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one, and nothing at all +/// while the chat is audible. +/// +/// Stating the deadline is what makes a timed mute trustworthy: without it a timed mute can't be +/// told apart from an indefinite one, and the user has no way to know when the chat comes back. +/// It sits here rather than on ``ChatMuteRow`` because the row has no room for it and because the +/// mute is a fact about the chat, like the member count it follows. +struct ChatMuteStatusLabel: View { + + let conversationID: ConversationID + + @Environment(ConversationController.self) private var conversationController + + /// The instant the label is evaluated against, re-read when a timed mute lapses. + /// + /// A timed mute expires with nothing sent from the server, so no event can invalidate the + /// label; it has to notice on its own. See ``muteExpiry``. + @State private var now = Date.now + + private var conversation: Conversation? { + conversationController.conversation(withID: conversationID) + } + + private var text: String? { + guard conversation?.isMuted(at: now) == true, let mute = conversation?.viewerState?.mute else { + return nil + } + switch mute { + case .until(let expiry): + return "Muted until \(expiry.formattedRelatively(useTimeForToday: true))" + case .forever: + return "Muted" + } + } + + /// When the current mute lapses, or nil when it is indefinite or absent — the deadline the + /// refresh below waits on. + private var muteExpiry: Date? { + guard case .until(let expiry) = conversation?.viewerState?.mute else { return nil } + return expiry + } + + var body: some View { + if let text { + HStack(spacing: 4) { + Image(systemName: "bell.slash") + Text(text) + } + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + .accessibilityIdentifier("chat-mute-status") + // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so + // muting again while the screen is open re-arms it; cancelled with the screen. + .task(id: muteExpiry) { + guard let muteExpiry, muteExpiry > .now else { return } + try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) + guard !Task.isCancelled else { return } + now = .now + } + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 7a735b0ad..78248ce89 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -75,6 +75,8 @@ struct ChatProfileScreen: View { .font(.appTextSmall) .foregroundStyle(.textSecondary) } + + ChatMuteStatusLabel(conversationID: conversationID) } if isMember { diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 7fc2a9978..3ce43b970 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -15,8 +15,9 @@ import FlipcashUI /// /// The durations are the client's choice; the contract admits only two mute shapes — until a /// timestamp, or forever — so every timed option is the same shape with a different offset. -/// Unmuting is not on this sheet: it is its own RPC, not a mute of zero length, and the row that -/// opens this sheet becomes the unmute once a chat is muted. +/// "Never" is the unmute, and is still its own RPC rather than a mute of zero length: it is worded +/// as a duration only because every label here names the resulting state rather than the action, +/// and it appears only once there is a mute to clear. struct MuteChatSheet: View { let conversationID: ConversationID @@ -26,20 +27,39 @@ struct MuteChatSheet: View { @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer - /// The offered durations, longest last. `nil` is the indefinite option. + /// What a row on this sheet does. + private enum Option: Hashable { + /// Mute until now plus this offset. + case until(TimeInterval) + /// Mute with no end. + case forever + /// Clear the mute. + case never + } + + /// The offered durations, longest last. The labels double as the state readout, so they are + /// named for the resulting state rather than the action — "1 Week", not "Mute for 1 week". /// /// The set is WhatsApp's (8 hours, 1 week, Always) plus Telegram's 1 hour, which is the shortest - /// option either offers and the one a noisy group most often wants. The labels double as the - /// state readout on the row that opens this sheet, so they are named for the resulting state - /// rather than the action — "1 Week", not "Mute for 1 week". - private static let options: [(title: String, duration: TimeInterval?)] = [ - ("1 Hour", 60 * 60), - ("8 Hours", 8 * 60 * 60), - ("1 Week", 7 * 24 * 60 * 60), - ("Always", nil), + /// option either offers and the one a noisy group most often wants. + private static let durations: [(title: String, option: Option)] = [ + ("1 Hour", .until(60 * 60)), + ("8 Hours", .until(8 * 60 * 60)), + ("1 Week", .until(7 * 24 * 60 * 60)), + ("Always", .forever), ] - @State private var isMuting = false + @State private var isWorking = false + + private var isMuted: Bool { + conversationController.conversation(withID: conversationID)?.isMuted(at: .now) ?? false + } + + /// The rows to draw. Unmuting leads when there is a mute to clear, and is left off entirely when + /// there isn't — on an audible chat it is a row that would do nothing. + private var options: [(title: String, option: Option)] { + isMuted ? [("Never", .never)] + Self.durations : Self.durations + } var body: some View { PartialSheet(background: .backgroundMain) { @@ -56,14 +76,14 @@ struct MuteChatSheet: View { .frame(maxWidth: .infinity) VStack(spacing: 12) { - ForEach(Self.options, id: \.title) { option in + ForEach(options, id: \.title) { row in ChatActionRow( - icon: option.duration == nil ? .system(.bellSlash) : .system(.clock), - title: option.title, - accessibilityIdentifier: "mute-chat-\(option.duration.map { "\(Int($0))" } ?? "forever")" + icon: .system(icon(for: row.option)), + title: row.title, + accessibilityIdentifier: "mute-chat-\(identifier(for: row.option))" ) { - guard !isMuting else { return } - Task { await mute(option.duration) } + guard !isWorking else { return } + Task { await apply(row.option) } } } } @@ -97,22 +117,48 @@ struct MuteChatSheet: View { return max(0, 16 - reserved) } - /// Mutes, then dismisses. The expiry is computed here, at the tap, rather than carried as a - /// duration: the server stores an instant, and the client owns the countdown against it. - private func mute(_ duration: TimeInterval?) async { - isMuting = true - defer { isMuting = false } - let mute: ConversationMuteState = duration.map { .until(.now.addingTimeInterval($0)) } ?? .forever + private func icon(for option: Option) -> SystemSymbol { + switch option { + case .until: .clock + case .forever: .bellSlash + case .never: .bell + } + } + + private func identifier(for option: Option) -> String { + switch option { + case .until(let duration): "\(Int(duration))" + case .forever: "forever" + case .never: "never" + } + } + + /// Applies the choice, then dismisses. A timed mute's expiry is computed here, at the tap, + /// rather than carried as a duration: the server stores an instant, and the client owns the + /// countdown against it. + private func apply(_ option: Option) async { + isWorking = true + defer { isWorking = false } do { - try await conversationController.mute(conversationID: conversationID, mute) + switch option { + case .until(let duration): + try await conversationController.mute(conversationID: conversationID, .until(.now.addingTimeInterval(duration))) + case .forever: + try await conversationController.mute(conversationID: conversationID, .forever) + case .never: + try await conversationController.unmute(conversationID: conversationID) + } isPresented = false } catch { isPresented = false + let unmuting = option == .never sessionContainer.session.dialogItem = .error( title: "Something Went Wrong", - subtitle: "We were unable to mute this chat. Please try again" + subtitle: unmuting + ? "We were unable to unmute this chat. Please try again" + : "We were unable to mute this chat. Please try again" ) - ErrorReporting.captureError(error, reason: "Failed to mute chat") + ErrorReporting.captureError(error, reason: unmuting ? "Failed to unmute chat" : "Failed to mute chat") } } } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 2d821e5ef..ab72b33bb 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -85,6 +85,10 @@ private struct UserProfileContent: View { .font(.appTextSmall) .foregroundStyle(.textSecondary) } + + if let conversationID { + ChatMuteStatusLabel(conversationID: conversationID) + } } VStack(spacing: 0) { diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index f336cd043..c7891d5e0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -66,9 +66,10 @@ public enum SystemSymbol: String { case faceID = "faceid" case trophy = "trophy.fill" - /// Timed and indefinite mute, on the mute-duration picker. + /// Timed mute, indefinite mute, and the unmute that clears either — on the mute picker. case clock = "clock" case bellSlash = "bell.slash" + case bell = "bell" case chevronDown = "chevron.down" case chevronRight = "chevron.right" From 7b341eff63299a2094ad3e06d164255f8ec85238 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 23:24:26 -0400 Subject: [PATCH 05/13] fix(chat): hold the mute line's height and cross-fade it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line was absent while a chat was audible, so the rows below it jumped up the moment a mute was cleared or lapsed — the whole screen appeared to re-lay itself out rather than one fact going away. It now always renders, falling back to a placeholder it never shows, and carries its visibility on opacity with a 0.25s ease. Nothing moves, and the line is hidden from accessibility while it is invisible. --- .../Screens/Profile/ChatMuteStatusLabel.swift | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index b4298380c..e37fc9efe 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -53,22 +53,27 @@ struct ChatMuteStatusLabel: View { } var body: some View { - if let text { - HStack(spacing: 4) { - Image(systemName: "bell.slash") - Text(text) - } - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - .accessibilityIdentifier("chat-mute-status") - // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so - // muting again while the screen is open re-arms it; cancelled with the screen. - .task(id: muteExpiry) { - guard let muteExpiry, muteExpiry > .now else { return } - try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) - guard !Task.isCancelled else { return } - now = .now - } + HStack(spacing: 4) { + Image(systemName: "bell.slash") + // The fallback never shows — it is there so the line claims its height whether or not + // the chat is muted. Without it the row block below jumps up the moment a mute lapses + // or is cleared, which reads as the screen re-laying itself out rather than one fact + // going away. + Text(text ?? "Muted") + } + .font(.appTextSmall) + .foregroundStyle(.textSecondary) + .opacity(text == nil ? 0 : 1) + .animation(.easeInOut(duration: 0.25), value: text) + .accessibilityHidden(text == nil) + .accessibilityIdentifier("chat-mute-status") + // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so + // muting again while the screen is open re-arms it; cancelled with the screen. + .task(id: muteExpiry) { + guard let muteExpiry, muteExpiry > .now else { return } + try? await Task.sleep(for: .seconds(muteExpiry.timeIntervalSinceNow)) + guard !Task.isCancelled else { return } + now = .now } } } From b3b6855563944ed07a4cd1ad64bd18b7a2393fcf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 23:29:58 -0400 Subject: [PATCH 06/13] feat(chat): draw the mute status as a chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the chat title sit the chat's own facts — its name, its size, when someone joined. The mute is the viewer's setting, and given the same secondary styling it read as a fourth fact about the chat; on a counterpart's profile it was the fourth such line in a row. chip(.standard) is the existing treatment for a non-interactive affordance in this flow, and its fill gives the line its own ground without adding a colour. --- .../Core/Screens/Profile/ChatMuteStatusLabel.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index e37fc9efe..7ac0e19af 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -15,8 +15,12 @@ import FlipcashUI /// /// Stating the deadline is what makes a timed mute trustworthy: without it a timed mute can't be /// told apart from an indefinite one, and the user has no way to know when the chat comes back. -/// It sits here rather than on ``ChatMuteRow`` because the row has no room for it and because the -/// mute is a fact about the chat, like the member count it follows. +/// It sits here rather than on ``ChatMuteRow`` because the row has no room for it. +/// +/// Drawn as a chip rather than as another line of secondary text: the lines above it are the chat's +/// own identity — its name, its size, when someone joined — and this is the viewer's setting. Given +/// their styling it read as a fourth fact about the chat, and on a counterpart's profile it was the +/// fourth such line in a row. The fill gives it its own ground instead. struct ChatMuteStatusLabel: View { let conversationID: ConversationID @@ -61,8 +65,8 @@ struct ChatMuteStatusLabel: View { // going away. Text(text ?? "Muted") } - .font(.appTextSmall) - .foregroundStyle(.textSecondary) + .chip(.standard) + .padding(.top, 3) .opacity(text == nil ? 0 : 1) .animation(.easeInOut(duration: 0.25), value: text) .accessibilityHidden(text == nil) From 24a9af424f142663c24856cc206dc34cacc678f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 23:38:48 -0400 Subject: [PATCH 07/13] feat(chat): spring the mute chip in, fade it out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arriving is the event worth seeing — the user has just chosen it — so the chip scales up from 0.85 with a little overshoot. Going away is either their unmute or a deadline passing quietly, and neither wants attention drawn to it, so it leaves on a plain ease. The animation is read against the new state, which is what lets one value drive two curves. --- .../Core/Screens/Profile/ChatMuteStatusLabel.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 7ac0e19af..938a54835 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -68,7 +68,17 @@ struct ChatMuteStatusLabel: View { .chip(.standard) .padding(.top, 3) .opacity(text == nil ? 0 : 1) - .animation(.easeInOut(duration: 0.25), value: text) + .scaleEffect(text == nil ? 0.85 : 1) + // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — + // so it gets the overshoot; going away is either their unmute or a deadline passing, and + // neither wants drawing attention to. Read against the new state, so each direction picks + // its own curve. + .animation( + text == nil + ? .easeOut(duration: 0.2) + : .spring(response: 0.34, dampingFraction: 0.62), + value: text + ) .accessibilityHidden(text == nil) .accessibilityIdentifier("chat-mute-status") // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so From efeb06b3f83fde71b870e8d724b8e84e74850507 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 23:55:54 -0400 Subject: [PATCH 08/13] feat(chat): tint the mute chip amber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mute is the one thing on a profile screen that stops being true on its own, and white on the row fill gave it the same weight as the chat's name and size. Amber separates it, and the tint keeps it under the title — at full strength on the row fill it out-shouted the title instead. ChipStyle becomes a struct of statics so `tinted(_:on:)` can name its colours. The enum had no references outside the file and `chip()` had one call site, so a case would have cost a new name and two switch arms for a single use. The style now carries the shape as well: a status chip draws as a capsule rather than at `Metrics.buttonRadius`, which is the corner every button on these screens uses and invites a tap the chip has nothing to do with. `warningSecondary` is amber at a tenth of its strength over `backgroundMain`, baked flat the way the sentiment pair secondaries are. --- .../Screens/Profile/ChatMuteStatusLabel.swift | 6 +- .../Sources/FlipcashUI/Theme/Colors.swift | 4 ++ .../FlipcashUI/Views/ButtonStyles/Chip.swift | 68 +++++++++++++------ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 938a54835..f4166769f 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -21,6 +21,10 @@ import FlipcashUI /// own identity — its name, its size, when someone joined — and this is the viewer's setting. Given /// their styling it read as a fourth fact about the chat, and on a counterpart's profile it was the /// fourth such line in a row. The fill gives it its own ground instead. +/// +/// Amber, because a mute is the one thing on the screen that will stop being true on its own, and +/// the colour is what separates it from the settled facts above. Tinted rather than solid so it +/// stays under the title — see ``ChipStyle/tinted(_:on:)``. struct ChatMuteStatusLabel: View { let conversationID: ConversationID @@ -65,7 +69,7 @@ struct ChatMuteStatusLabel: View { // going away. Text(text ?? "Muted") } - .chip(.standard) + .chip(.tinted(.warning, on: .warningSecondary)) .padding(.top, 3) .opacity(text == nil ? 0 : 1) .scaleEffect(text == nil ? 0.85 : 1) diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index 359b8907d..dfa0be46f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -22,6 +22,10 @@ extension ShapeStyle where Self == Color { public static var textSuccess: Color { Color(r: 73 , g: 213, b: 23) } public static var textWarning: Color { Color(r: 255, g: 243, b: 131) } public static var warning: Color { Color(r: 255, g: 169, b: 57) } + /// The ground `warning` sits on when it labels a state rather than raising an alarm — amber at + /// a tenth of its strength over ``backgroundMain``, baked flat the way the sentiment pair + /// secondaries are. Only true against the main background; it is not a general wash. + public static var warningSecondary: Color { Color(r: 48, g: 39, b: 29) } public static var action: Color { Color.white } public static var actionDisabled: Color { Color(r: 30, g: 30, b: 30) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift index 31431449d..1a35c316f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift @@ -5,32 +5,59 @@ import SwiftUI -/// Visual style for a compact chip label (small text on a rounded-rect fill). -public enum ChipStyle { - /// Subtle row-fill background — e.g. the "Invite" affordance. - case standard - /// Solid white fill — e.g. the "Settings" affordance. - case prominent +/// Visual style for a compact chip label: what colour its text and fill take, and what shape the +/// fill draws. +/// +/// A struct of statics rather than an enum so a caller can name a colour the theme doesn't have a +/// chip token for. ``tinted(_:)`` is one line where a case would have been a new name, a new colour +/// constant, and a switch arm in two places — all for a single use. +public struct ChipStyle { - var textColor: Color { - switch self { - case .standard: .textMain - case .prominent: .backgroundMain - } + let textColor: Color + let fillColor: Color + let shape: AnyShape + + private init(textColor: Color, fillColor: Color, shape: some Shape) { + self.textColor = textColor + self.fillColor = fillColor + self.shape = AnyShape(shape) } - var fillColor: Color { - switch self { - case .standard: .backgroundRow - case .prominent: .textMain - } + /// Subtle row-fill background — e.g. the "Invite" affordance. + public static let standard = ChipStyle( + textColor: .textMain, + fillColor: .backgroundRow, + shape: RoundedRectangle(cornerRadius: Metrics.buttonRadius) + ) + + /// Solid white fill — e.g. the "Settings" affordance. + public static let prominent = ChipStyle( + textColor: .backgroundMain, + fillColor: .textMain, + shape: RoundedRectangle(cornerRadius: Metrics.buttonRadius) + ) + + /// `color` on its own muted ground, in a capsule — for a chip that reports state rather than + /// offering an action. + /// + /// The colour lands in the fill rather than only in the text: at full strength on the row fill + /// a tinted label out-shouts the title above it, which inverts the hierarchy on a screen whose + /// subject is the chat, not the viewer's settings. The capsule is the other half of that — + /// ``Metrics/buttonRadius`` is the corner every button on the screen uses, so a rounded rect + /// invites a tap this chip has nothing to do with. + /// + /// `fill` is named rather than derived here because the theme owns what a colour's muted ground + /// is — see ``ShapeStyle/warningSecondary`` and the sentiment pair secondaries, which are flat + /// values rather than an opacity on their main colour. + public static func tinted(_ color: Color, on fill: Color) -> ChipStyle { + ChipStyle(textColor: color, fillColor: fill, shape: Capsule()) } } public extension View { - /// Styles the receiver as a compact rounded-rect chip. Used for the - /// non-interactive affordance inside a tappable row (the whole row is the - /// tap target), e.g. "Invite" and "Settings". + /// Styles the receiver as a compact chip: a small bold label on its own fill. Used for the + /// non-interactive affordance inside a tappable row (the whole row is the tap target), e.g. + /// "Invite" and "Settings", and for a standalone status chip via ``ChipStyle/tinted(_:on:)``. func chip(_ style: ChipStyle) -> some View { self .font(.appTextSmall) @@ -38,8 +65,7 @@ public extension View { .padding(.horizontal, 10) .padding(.vertical, 6) .background { - RoundedRectangle(cornerRadius: Metrics.buttonRadius) - .fill(style.fillColor) + style.shape.fill(style.fillColor) } } } From 61d5014d32bc41d324c3dbf7142c59135ff58ffc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 07:39:45 -0400 Subject: [PATCH 09/13] fix(chat): settle the mute chip's width and the sheet's row list Three things visible on device: The chip drew its text outside the capsule for the length of the spring. Geometry interpolates and glyphs don't, so the fill grew from the old string's width while the new string was already drawn in full. The chip now takes its width at once inside the spring, which still carries the scale and the fade. Fading out, the label changed what it said on the way: the text is nil the instant a mute is cleared, so the placeholder it fell back to replaced the deadline mid-fade. It now falls back to what it last said, which reads as the chip going quiet rather than correcting itself. "Never" appeared on the sheet while the sheet was closing. The store converges before the dismissal finishes, so a list read live gained its first row on the way out. `apply` pins the row's presence; left idle the list is still live, so a mute lapsing under an open sheet drops it. Also drops the trailing period from the sheet's caption, matching the copy elsewhere. --- .../Screens/Profile/ChatMuteStatusLabel.swift | 24 +++++++++++++++---- .../Core/Screens/Profile/MuteChatSheet.swift | 20 ++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index f4166769f..e82a224ef 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -37,6 +37,13 @@ struct ChatMuteStatusLabel: View { /// label; it has to notice on its own. See ``muteExpiry``. @State private var now = Date.now + /// What the chip last said, so a fade-out goes quiet on its own wording. + /// + /// The text is nil the instant a mute is cleared, but the chip is still on screen fading. Drawn + /// straight from a placeholder it would change what it said on the way out, which reads as a + /// correction rather than a dismissal. + @State private var lastText = "Muted" + private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } @@ -63,13 +70,17 @@ struct ChatMuteStatusLabel: View { var body: some View { HStack(spacing: 4) { Image(systemName: "bell.slash") - // The fallback never shows — it is there so the line claims its height whether or not - // the chat is muted. Without it the row block below jumps up the moment a mute lapses - // or is cleared, which reads as the screen re-laying itself out rather than one fact - // going away. - Text(text ?? "Muted") + // Drawn even when there is no mute, so the line claims its height either way — without + // it the row block below jumps up the moment a mute lapses or is cleared, which reads + // as the screen re-laying itself out rather than one fact going away. Invisible except + // during a fade-out, which is the one moment the fallback is what the user sees. + Text(text ?? lastText) } .chip(.tinted(.warning, on: .warningSecondary)) + // The chip takes its new width at once, inside the spring below. Geometry interpolates and + // glyphs don't: the fill grew from the old string's width while the new string was already + // drawn in full, so the text sat outside the capsule for the length of the animation. + .animation(nil, value: text) .padding(.top, 3) .opacity(text == nil ? 0 : 1) .scaleEffect(text == nil ? 0.85 : 1) @@ -85,6 +96,9 @@ struct ChatMuteStatusLabel: View { ) .accessibilityHidden(text == nil) .accessibilityIdentifier("chat-mute-status") + .onChange(of: text) { _, text in + if let text { lastText = text } + } // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so // muting again while the screen is open re-arms it; cancelled with the screen. .task(id: muteExpiry) { diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 3ce43b970..222ac4bd0 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -51,14 +51,25 @@ struct MuteChatSheet: View { @State private var isWorking = false + /// The unmute row's presence, fixed for the dismissal by ``apply(_:)``. + /// + /// Nil while the sheet is idle, so a mute that lapses under an open sheet still drops the row. + /// Applying a choice pins it, because the store converges before the sheet finishes animating + /// away: read live, the list gained or lost its first row on the way out. + @State private var pinnedOffersUnmute: Bool? + private var isMuted: Bool { conversationController.conversation(withID: conversationID)?.isMuted(at: .now) ?? false } - /// The rows to draw. Unmuting leads when there is a mute to clear, and is left off entirely when - /// there isn't — on an audible chat it is a row that would do nothing. + /// Whether to offer the unmute at all — on an audible chat it is a row that would do nothing. + private var offersUnmute: Bool { + pinnedOffersUnmute ?? isMuted + } + + /// The rows to draw, unmuting first when it is offered. private var options: [(title: String, option: Option)] { - isMuted ? [("Never", .never)] + Self.durations : Self.durations + offersUnmute ? [("Never", .never)] + Self.durations : Self.durations } var body: some View { @@ -69,7 +80,7 @@ struct MuteChatSheet: View { // What muting does and doesn't do, the way WhatsApp captions the same sheet. Worth // saying here because the answer isn't obvious and is easy to get wrong: a muted // chat still delivers, still stores, and still counts unread. - Text("You'll still receive messages and see unread counts. Only notifications are silenced.") + Text("You'll still receive messages and see unread counts. Only notifications are silenced") .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) @@ -137,6 +148,7 @@ struct MuteChatSheet: View { /// rather than carried as a duration: the server stores an instant, and the client owns the /// countdown against it. private func apply(_ option: Option) async { + pinnedOffersUnmute = offersUnmute isWorking = true defer { isWorking = false } do { From 5ddc6996e27fe975b962c04a4eed82a2e0f8bc52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 08:02:27 -0400 Subject: [PATCH 10/13] fix(chat): replace the mute chip instead of resizing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip slid sideways into place on every text change. Frame-by-frame, its glyph box travelled 63px right-to-left over the spring while its width barely moved: the string had snapped to full size, but the frame was still interpolating from the old string's width, and the content sits at that frame's leading edge. Suppressing the glyph animation only swapped one symptom for the other — before it the text spilled out of the capsule, after it the whole chip slid. Anything that keeps one view and changes its string interpolates the width. Key the chip's identity on the string instead, so a change is an insert and a remove with a scale-and-fade transition. There is no width to interpolate, and the scale is anchored at the centre. A hidden placeholder holds the line's height, and the outgoing copy keeps its own wording as it fades, which drops the `lastText` cache that was doing that by hand. --- .../Screens/Profile/ChatMuteStatusLabel.swift | 53 ++++++++++--------- .../FlipcashUI/Views/ButtonStyles/Chip.swift | 2 +- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index e82a224ef..240d5e337 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -37,13 +37,6 @@ struct ChatMuteStatusLabel: View { /// label; it has to notice on its own. See ``muteExpiry``. @State private var now = Date.now - /// What the chip last said, so a fade-out goes quiet on its own wording. - /// - /// The text is nil the instant a mute is cleared, but the chip is still on screen fading. Drawn - /// straight from a placeholder it would change what it said on the way out, which reads as a - /// correction rather than a dismissal. - @State private var lastText = "Muted" - private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } @@ -68,22 +61,27 @@ struct ChatMuteStatusLabel: View { } var body: some View { - HStack(spacing: 4) { - Image(systemName: "bell.slash") - // Drawn even when there is no mute, so the line claims its height either way — without - // it the row block below jumps up the moment a mute lapses or is cleared, which reads - // as the screen re-laying itself out rather than one fact going away. Invisible except - // during a fade-out, which is the one moment the fallback is what the user sees. - Text(text ?? lastText) + ZStack { + // Holds the line's height whether or not there is a mute, so the rows below don't move + // the moment one lapses or is cleared — that reads as the screen re-laying itself out + // rather than one fact going away. Never drawn, never read aloud. + chipLabel("Muted") + .hidden() + + if let text { + chipLabel(text) + .accessibilityIdentifier("chat-mute-status") + // Replaced rather than resized. A chip that keeps its identity across a text + // change interpolates its width, and the glyphs inside don't interpolate with + // it: they are drawn at the new string's size against a frame still growing + // from the old one's, so the label either spills out of the capsule or slides + // sideways into place. Keying identity on the string makes every change an + // insert and a remove, which has no width to interpolate. + .transition(.scale(scale: 0.85).combined(with: .opacity)) + .id(text) + } } - .chip(.tinted(.warning, on: .warningSecondary)) - // The chip takes its new width at once, inside the spring below. Geometry interpolates and - // glyphs don't: the fill grew from the old string's width while the new string was already - // drawn in full, so the text sat outside the capsule for the length of the animation. - .animation(nil, value: text) .padding(.top, 3) - .opacity(text == nil ? 0 : 1) - .scaleEffect(text == nil ? 0.85 : 1) // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — // so it gets the overshoot; going away is either their unmute or a deadline passing, and // neither wants drawing attention to. Read against the new state, so each direction picks @@ -94,11 +92,6 @@ struct ChatMuteStatusLabel: View { : .spring(response: 0.34, dampingFraction: 0.62), value: text ) - .accessibilityHidden(text == nil) - .accessibilityIdentifier("chat-mute-status") - .onChange(of: text) { _, text in - if let text { lastText = text } - } // Drop the label the moment a timed mute lapses. Re-run whenever the expiry changes, so // muting again while the screen is open re-arms it; cancelled with the screen. .task(id: muteExpiry) { @@ -108,4 +101,12 @@ struct ChatMuteStatusLabel: View { now = .now } } + + private func chipLabel(_ string: String) -> some View { + HStack(spacing: 4) { + Image(systemName: "bell.slash") + Text(string) + } + .chip(.tinted(.warning, on: .warningSecondary)) + } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift index 1a35c316f..0f9b4f444 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift @@ -9,7 +9,7 @@ import SwiftUI /// fill draws. /// /// A struct of statics rather than an enum so a caller can name a colour the theme doesn't have a -/// chip token for. ``tinted(_:)`` is one line where a case would have been a new name, a new colour +/// chip token for. ``tinted(_:on:)`` is one line where a case would have been a new name, a new colour /// constant, and a switch arm in two places — all for a single use. public struct ChipStyle { From 4463c299f5c9d5d56610be78b72acdc68f88cfc1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 09:39:54 -0400 Subject: [PATCH 11/13] feat(chat): show a bell where a chat is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amber chip only exists on the chat's own profile, so a muted chat was indistinguishable from an audible one everywhere the user actually looks — the chats list, the transcript's title bar, the card at the head of the transcript. `MuteBell` draws `bell.slash.fill` at caption size in `textSecondary`, the weight the chevron and the timestamp beside it already carry. It takes the mute rather than a muted flag and keeps its own clock, for the same reason the status label does: a timed mute lapses with nothing sent from the server, so a bell handed a boolean stays lit until something unrelated redraws its host. Both head cards are configured from `Codable` structs in FlipcashCore, so the mute travels on the card. On the chat list row it sits at the trailing end of the subtitle line, past a spacer — it belongs to the chat, not to the message the preview quotes. On the head cards it goes before the chevron, so the disclosure indicator stays last. The row and the title button both collapse to a single accessibility element, which discards the bell's own label; both now append "muted" to the container label from a plain read. --- .../Conversation/ConversationScreen.swift | 50 +++++++++++++---- .../Core/Screens/Main/Tips/RecipientRow.swift | 30 +++++++--- .../Main/Tips/TipConversationsScreen.swift | 14 ++++- .../Models/Chat/ChatGroupCard.swift | 8 ++- .../Models/Chat/ChatProfileCard.swift | 8 ++- .../FlipcashUI/Chat/ChatGroupCardCell.swift | 3 + .../FlipcashUI/Chat/ChatProfileCardCell.swift | 3 + .../Sources/FlipcashUI/Views/MuteBell.swift | 56 +++++++++++++++++++ 8 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 974955830..78758b064 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -273,6 +273,12 @@ struct ConversationScreen: View { /// This chat when it is a group, else nil — the single test every group surface on this screen /// branches on, so none of them can disagree about what a group is. + /// The viewer's mute on this chat, or nil while it is audible or before the chat exists locally. + private var viewerMute: ConversationMuteState? { + guard let conversationID else { return nil } + return conversationController.conversation(withID: conversationID)?.viewerState?.mute + } + private var groupConversation: Conversation? { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID), @@ -352,7 +358,8 @@ struct ConversationScreen: View { imageData: groupAvatarSubject.flatMap { sessionContainer.profileAvatars.data(for: $0) }, blurhash: group.picture?.thumbnailBlurhash, requirement: groupCardRequirement, - showsInvite: showsGroupInvite + showsInvite: showsGroupInvite, + mute: group.viewerState?.mute ) } @@ -528,6 +535,7 @@ struct ConversationScreen: View { blurhash: groupConversation.map { $0.picture?.thumbnailBlurhash } ?? tipCounterpart?.profilePicture?.thumbnailBlurhash, width: max(navBarWidth - Self.titleSideInset * 2, 0), + mute: viewerMute, onTap: titleTapAction, opensProfile: profileTapAction != nil ) @@ -1002,6 +1010,9 @@ struct ConversationScreen: View { profileAvatars: ProfileAvatarStore, fallbackCounterpart: ConversationMember? = nil ) -> ChatProfileCard { + // Carried on the card rather than read where it's drawn: the cell lives in FlipcashUI and is + // configured from this struct, so the mute has to travel with the rest of the card's facts. + let mute = controller.conversation(withID: conversationID)?.viewerState?.mute if let conversation = controller.conversation(withID: conversationID), conversation.type == .tipDm { let counterpart = conversation.counterpart(excluding: controller.selfUserID) @@ -1010,7 +1021,8 @@ struct ConversationScreen: View { avatarID: counterpart?.userID?.uuidString ?? conversationID.description, imageData: profileAvatars.data(for: counterpart?.userID), blurhash: counterpart?.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart) + counterpart: Self.tipDMCounterpart(counterpart), + mute: mute ) } // No conversation record yet — the same card, from the cached profile. @@ -1020,7 +1032,8 @@ struct ConversationScreen: View { avatarID: counterpart.userID?.uuidString ?? conversationID.description, imageData: profileAvatars.data(for: counterpart.userID), blurhash: counterpart.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart) + counterpart: Self.tipDMCounterpart(counterpart), + mute: mute ) } if let contact = context.resolvedContact(in: directory) { @@ -1028,14 +1041,16 @@ struct ConversationScreen: View { name: contact.displayName, avatarID: contact.contactId, imageData: contact.imageData, - counterpart: .contact(phone: contact.nationalPhone) + counterpart: .contact(phone: contact.nationalPhone), + mute: mute ) } return ChatProfileCard( name: controller.displayName(forConversationID: conversationID), avatarID: conversationID.description, imageData: nil, - counterpart: .unknown + counterpart: .unknown, + mute: mute ) } @@ -1058,6 +1073,7 @@ private struct ConversationTitleItem: View { let imageData: Data? let blurhash: String? let width: CGFloat + var mute: ConversationMuteState? = nil let onTap: (() -> Void)? let opensProfile: Bool @@ -1069,13 +1085,21 @@ private struct ConversationTitleItem: View { conversationID: conversationID, imageData: imageData, blurhash: blurhash, - width: width + width: width, + mute: mute ) if let onTap { let hint = opensProfile ? "Opens profile" : (contact != nil ? "Opens contact card" : "Adds to Contacts") Button(action: onTap) { label } .buttonStyle(.plain) - .accessibilityLabel(subtitle.map { "\(title), \($0)" } ?? title) + // The button reads as one element, so the bell's own label is discarded. Read from + // the mute here rather than kept as a flag, and a moment stale at the instant a + // timed one lapses — this redraws on store changes, not on the bell's clock. + .accessibilityLabel( + [title, subtitle, mute?.isActive() == true ? "muted" : nil] + .compactMap { $0 } + .joined(separator: ", ") + ) .accessibilityHint(hint) } else { label @@ -1092,6 +1116,7 @@ private struct ConversationTitleLabel: View { let imageData: Data? let blurhash: String? let width: CGFloat + var mute: ConversationMuteState? = nil var body: some View { HStack(spacing: 12) { @@ -1106,10 +1131,13 @@ private struct ConversationTitleLabel: View { // The 44pt avatar already sets the bar's height, so the second line costs nothing and a // titled-only DM keeps the layout it has (nodes 10125:19191-19194). VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.appBarButton) - .foregroundStyle(Color.textMain) - .lineLimit(1) + HStack(spacing: 4) { + Text(title) + .font(.appBarButton) + .foregroundStyle(Color.textMain) + .lineLimit(1) + MuteBell(mute) + } if let subtitle { Text(subtitle) .font(.default(size: 13, weight: .medium)) diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index 1ec7bb5b5..fc989a94b 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -27,6 +27,9 @@ struct RecipientRowScaffold: View { let imageData: Data? var blurhash: String? = nil var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn + /// The viewer's mute on this chat, drawn at the end of the subtitle line. The mute rather than a + /// muted flag — ``MuteBell`` owns the countdown, because a timed one lapses with nothing sent. + var mute: ConversationMuteState? = nil let accessibilityLabel: String let onTap: () -> Void @ViewBuilder let trailing: Trailing @@ -39,7 +42,8 @@ struct RecipientRowScaffold: View { subtitle: subtitle, imageData: imageData, blurhash: blurhash, - accessoryPlacement: accessoryPlacement + accessoryPlacement: accessoryPlacement, + mute: mute ) { trailing } @@ -58,6 +62,7 @@ struct RecipientRowBody: View { let imageData: Data? var blurhash: String? = nil var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn + var mute: ConversationMuteState? = nil @ViewBuilder let trailing: Trailing var body: some View { @@ -80,13 +85,22 @@ struct RecipientRowBody: View { trailing } } - if let subtitle { - Text(subtitle) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .lineLimit(1) - .contentTransition(.opacity) - .transition(.opacity) + if subtitle != nil || mute != nil { + HStack(spacing: 6) { + if let subtitle { + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .contentTransition(.opacity) + .transition(.opacity) + } + // Trailing, against the row's edge rather than beside the preview: the mute + // belongs to the chat, not to the message the preview quotes, and a preview + // runs to whatever length it runs to. + Spacer(minLength: 12) + MuteBell(mute) + } } } // Cross-fade the subtitle when it changes — notably the "Typing…" ⇄ last-message swap, but diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 1b0a3cc4e..bde8bef83 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -172,6 +172,10 @@ private struct TipConversationRow: View { session.balance(for: $0)?.name } let hasUnread = conversation.hasUnread(for: conversationController.selfUserID) + // Read once, for the label only — the bell keeps its own clock. A label can't: VoiceOver + // reads it when it lands on the row, and this view redraws on store changes rather than at + // the instant a timed mute lapses, so it can be a moment stale. + let isMuted = conversation.isMuted(at: .now) RecipientRowScaffold( avatarID: conversation.type == .group @@ -182,7 +186,8 @@ private struct TipConversationRow: View { imageData: sessionContainer.profileAvatars.data(for: avatarSubject), blurhash: avatarPicture?.thumbnailBlurhash, accessoryPlacement: .titleLine, - accessibilityLabel: hasUnread ? "\(title), unread messages" : title, + mute: conversation.viewerState?.mute, + accessibilityLabel: accessibilityLabel(title: title, hasUnread: hasUnread, isMuted: isMuted), onTap: onTap ) { RecipientRowAccessory( @@ -195,4 +200,11 @@ private struct TipConversationRow: View { await sessionContainer.profileAvatars.load(avatarSubject, picture: avatarPicture) } } + + /// The row reads as one element, so the bell's own label is discarded — it has to be said here. + private func accessibilityLabel(title: String, hasUnread: Bool, isMuted: Bool) -> String { + [title, hasUnread ? "unread messages" : nil, isMuted ? "muted" : nil] + .compactMap { $0 } + .joined(separator: ", ") + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift index e34a8e6b6..3a3b275dd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift @@ -35,12 +35,18 @@ public struct ChatGroupCard: Hashable, Sendable, Codable { /// way into a group, so an empty one needs its own way to hand that link out. public var showsInvite: Bool - public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false) { + /// The viewer's mute on this chat, or nil when it is audible — the card reports it beside the + /// title. The mute itself rather than a muted flag, because a timed one lapses with nothing sent + /// from the server and the card would otherwise keep reporting it. See `MuteBell`. + public var mute: ConversationMuteState? + + public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false, mute: ConversationMuteState? = nil) { self.title = title self.avatarID = avatarID self.imageData = imageData self.blurhash = blurhash self.requirement = requirement self.showsInvite = showsInvite + self.mute = mute } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift index ee1ea514c..3203a3d86 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift @@ -21,6 +21,11 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { public var blurhash: String? public var counterpart: Counterpart + /// The viewer's mute on this chat, or nil when it is audible — the card reports it beside the + /// name. The mute itself rather than a muted flag, because a timed one lapses with nothing sent + /// from the server and the card would otherwise keep reporting it. See `MuteBell`. + public var mute: ConversationMuteState? + /// What the card's subtitle line says about the counterpart, and whether it /// offers a contact action. public enum Counterpart: Hashable, Sendable, Codable { @@ -36,11 +41,12 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { case none } - public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart) { + public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart, mute: ConversationMuteState? = nil) { self.name = name self.avatarID = avatarID self.imageData = imageData self.blurhash = blurhash self.counterpart = counterpart + self.mute = mute } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift index 818134b5b..62519a26a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift @@ -104,6 +104,9 @@ private struct GroupCardView: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .lineLimit(1) + // Before the chevron, not after it: the chevron says the card opens something, and + // a disclosure indicator that isn't last stops reading as one. + MuteBell(card.mute) if onTap != nil { Image(systemName: "chevron.right") .font(.caption2) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift index 25be7da2e..8edd42cd5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift @@ -80,6 +80,9 @@ private struct ProfileCardView: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .lineLimit(1) + // Before the chevron, not after it: the chevron says the card opens something, and + // a disclosure indicator that isn't last stops reading as one. + MuteBell(card.mute) if onProfileTap != nil { Image(systemName: "chevron.right") .font(.caption2) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift new file mode 100644 index 000000000..db413aa6c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift @@ -0,0 +1,56 @@ +// +// MuteBell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore + +/// The bell that says a chat is silenced, for the surfaces that report it in passing — a row in the +/// chat list, the transcript's title bar, the head card above it. +/// +/// A glyph rather than the amber chip the chat's own profile draws. Those surfaces are about the +/// chat, and the mute is one attribute among several, so it carries the weight of the chevron and +/// the timestamp beside it rather than taking a ground of its own. +/// +/// Takes the mute and compares it against its own clock rather than taking a boolean. A timed mute +/// lapses with nothing sent from the server, so a bell handed a boolean stays lit until something +/// unrelated happens to redraw its host. +public struct MuteBell: View { + + private let mute: ConversationMuteState? + + /// The instant the mute is read against, re-read when a timed one lapses. + @State private var now = Date.now + + public init(_ mute: ConversationMuteState?) { + self.mute = mute + } + + /// When the current mute lapses, or nil when it is indefinite or absent. + private var expiry: Date? { + guard case .until(let expiry) = mute else { return nil } + return expiry + } + + public var body: some View { + Group { + if mute?.isActive(at: now) == true { + Image(systemName: "bell.slash.fill") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .accessibilityLabel("Muted") + } + } + // Drop the bell the moment a timed mute lapses. Re-armed whenever the expiry changes, so + // muting again under an open screen re-arms it; cancelled with the view. + .task(id: expiry) { + guard let expiry, expiry > .now else { return } + try? await Task.sleep(for: .seconds(expiry.timeIntervalSinceNow)) + guard !Task.isCancelled else { return } + now = .now + } + } +} From adf139573b040c69210da47e25946420f7f1c65f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 11:10:38 -0400 Subject: [PATCH 12/13] fix(chat): drop the mute bell from the head card The bell stays on the two surfaces that list or title a chat: the chats row and the transcript's title bar. The head card sits directly under that title bar, so it was repeating the bell a few points below it. The `mute` field comes off `ChatProfileCard` and `ChatGroupCard` with it. It existed only because the cells are hosted from those structs, and `ChatProfileCard` rides in the byte-compared `ConversationLoadCoordinator.Inputs`, so a mute change was re-mapping the transcript to draw a glyph the title bar already carried. --- .../Conversation/ConversationScreen.swift | 18 +++++------------- .../Models/Chat/ChatGroupCard.swift | 8 +------- .../Models/Chat/ChatProfileCard.swift | 8 +------- .../FlipcashUI/Chat/ChatGroupCardCell.swift | 3 --- .../FlipcashUI/Chat/ChatProfileCardCell.swift | 3 --- 5 files changed, 7 insertions(+), 33 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 78758b064..8f166e559 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -358,8 +358,7 @@ struct ConversationScreen: View { imageData: groupAvatarSubject.flatMap { sessionContainer.profileAvatars.data(for: $0) }, blurhash: group.picture?.thumbnailBlurhash, requirement: groupCardRequirement, - showsInvite: showsGroupInvite, - mute: group.viewerState?.mute + showsInvite: showsGroupInvite ) } @@ -1010,9 +1009,6 @@ struct ConversationScreen: View { profileAvatars: ProfileAvatarStore, fallbackCounterpart: ConversationMember? = nil ) -> ChatProfileCard { - // Carried on the card rather than read where it's drawn: the cell lives in FlipcashUI and is - // configured from this struct, so the mute has to travel with the rest of the card's facts. - let mute = controller.conversation(withID: conversationID)?.viewerState?.mute if let conversation = controller.conversation(withID: conversationID), conversation.type == .tipDm { let counterpart = conversation.counterpart(excluding: controller.selfUserID) @@ -1021,8 +1017,7 @@ struct ConversationScreen: View { avatarID: counterpart?.userID?.uuidString ?? conversationID.description, imageData: profileAvatars.data(for: counterpart?.userID), blurhash: counterpart?.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart), - mute: mute + counterpart: Self.tipDMCounterpart(counterpart) ) } // No conversation record yet — the same card, from the cached profile. @@ -1032,8 +1027,7 @@ struct ConversationScreen: View { avatarID: counterpart.userID?.uuidString ?? conversationID.description, imageData: profileAvatars.data(for: counterpart.userID), blurhash: counterpart.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart), - mute: mute + counterpart: Self.tipDMCounterpart(counterpart) ) } if let contact = context.resolvedContact(in: directory) { @@ -1041,16 +1035,14 @@ struct ConversationScreen: View { name: contact.displayName, avatarID: contact.contactId, imageData: contact.imageData, - counterpart: .contact(phone: contact.nationalPhone), - mute: mute + counterpart: .contact(phone: contact.nationalPhone) ) } return ChatProfileCard( name: controller.displayName(forConversationID: conversationID), avatarID: conversationID.description, imageData: nil, - counterpart: .unknown, - mute: mute + counterpart: .unknown ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift index 3a3b275dd..e34a8e6b6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift @@ -35,18 +35,12 @@ public struct ChatGroupCard: Hashable, Sendable, Codable { /// way into a group, so an empty one needs its own way to hand that link out. public var showsInvite: Bool - /// The viewer's mute on this chat, or nil when it is audible — the card reports it beside the - /// title. The mute itself rather than a muted flag, because a timed one lapses with nothing sent - /// from the server and the card would otherwise keep reporting it. See `MuteBell`. - public var mute: ConversationMuteState? - - public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false, mute: ConversationMuteState? = nil) { + public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false) { self.title = title self.avatarID = avatarID self.imageData = imageData self.blurhash = blurhash self.requirement = requirement self.showsInvite = showsInvite - self.mute = mute } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift index 3203a3d86..ee1ea514c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift @@ -21,11 +21,6 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { public var blurhash: String? public var counterpart: Counterpart - /// The viewer's mute on this chat, or nil when it is audible — the card reports it beside the - /// name. The mute itself rather than a muted flag, because a timed one lapses with nothing sent - /// from the server and the card would otherwise keep reporting it. See `MuteBell`. - public var mute: ConversationMuteState? - /// What the card's subtitle line says about the counterpart, and whether it /// offers a contact action. public enum Counterpart: Hashable, Sendable, Codable { @@ -41,12 +36,11 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { case none } - public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart, mute: ConversationMuteState? = nil) { + public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart) { self.name = name self.avatarID = avatarID self.imageData = imageData self.blurhash = blurhash self.counterpart = counterpart - self.mute = mute } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift index 62519a26a..818134b5b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift @@ -104,9 +104,6 @@ private struct GroupCardView: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .lineLimit(1) - // Before the chevron, not after it: the chevron says the card opens something, and - // a disclosure indicator that isn't last stops reading as one. - MuteBell(card.mute) if onTap != nil { Image(systemName: "chevron.right") .font(.caption2) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift index 8edd42cd5..25be7da2e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift @@ -80,9 +80,6 @@ private struct ProfileCardView: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .lineLimit(1) - // Before the chevron, not after it: the chevron says the card opens something, and - // a disclosure indicator that isn't last stops reading as one. - MuteBell(card.mute) if onProfileTap != nil { Image(systemName: "chevron.right") .font(.caption2) From 9c4d828c85c547988ecb1d35d6d20a306acd919d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 11:52:03 -0400 Subject: [PATCH 13/13] fix(chat): stop reserving preview width for an absent bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bell sat behind `Spacer(minLength: 12)` in an `HStack(spacing: 6)`, and both are unconditional: an audible chat drew no bell but still paid 18pt off the end of its message preview. Rendered at a 200pt width, the subtitle's ink stopped at 174pt where it now reaches 197pt. The gap moves inside `MuteBell`, behind the same condition as the glyph, so it appears and disappears with it. `leadingGap` defaults to zero, which is what the transcript title bar wants — its stack spacing is already right and its title is not competing with the bell for width. --- Flipcash/Core/Screens/Main/Tips/RecipientRow.swift | 10 +++++++--- FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift | 12 +++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift index fc989a94b..7bc754c55 100644 --- a/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/RecipientRow.swift @@ -86,7 +86,7 @@ struct RecipientRowBody: View { } } if subtitle != nil || mute != nil { - HStack(spacing: 6) { + HStack(spacing: 0) { if let subtitle { Text(subtitle) .font(.appTextSmall) @@ -98,8 +98,12 @@ struct RecipientRowBody: View { // Trailing, against the row's edge rather than beside the preview: the mute // belongs to the chat, not to the message the preview quotes, and a preview // runs to whatever length it runs to. - Spacer(minLength: 12) - MuteBell(mute) + // + // Nothing reserved for it: an audible chat draws no bell, and a minimum + // length or stack spacing held for one would shorten every preview in the + // list. The bell brings its own gap when it appears. + Spacer(minLength: 0) + MuteBell(mute, leadingGap: 12) } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift index db413aa6c..1f3e677d7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift @@ -8,8 +8,8 @@ import SwiftUI import FlipcashCore -/// The bell that says a chat is silenced, for the surfaces that report it in passing — a row in the -/// chat list, the transcript's title bar, the head card above it. +/// The bell that says a chat is silenced, for the surfaces that name a chat in passing — a row in +/// the chat list, the transcript's title bar. /// /// A glyph rather than the amber chip the chat's own profile draws. Those surfaces are about the /// chat, and the mute is one attribute among several, so it carries the weight of the chevron and @@ -21,12 +21,17 @@ import FlipcashCore public struct MuteBell: View { private let mute: ConversationMuteState? + private let leadingGap: CGFloat /// The instant the mute is read against, re-read when a timed one lapses. @State private var now = Date.now - public init(_ mute: ConversationMuteState?) { + /// - Parameter leadingGap: space before the glyph, drawn only when the glyph is. The gap + /// belongs to the bell rather than to the host's stack spacing because it has to disappear + /// with the bell — a host reserving it would shorten its own content on every audible chat. + public init(_ mute: ConversationMuteState?, leadingGap: CGFloat = 0) { self.mute = mute + self.leadingGap = leadingGap } /// When the current mute lapses, or nil when it is indefinite or absent. @@ -42,6 +47,7 @@ public struct MuteBell: View { .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .accessibilityLabel("Muted") + .padding(.leading, leadingGap) } } // Drop the bell the moment a timed mute lapses. Re-armed whenever the expiry changes, so