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" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 883bc0872..204076fec 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 } @@ -1025,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/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/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 974955830..8f166e559 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), @@ -528,6 +534,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 ) @@ -1058,6 +1065,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 +1077,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 +1108,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 +1123,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..7bc754c55 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,26 @@ 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: 0) { + 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. + // + // 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) + } } } // 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/Flipcash/Core/Screens/Profile/ChatMuteRow.swift b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift new file mode 100644 index 000000000..d24cecc3b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift @@ -0,0 +1,63 @@ +// +// 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. +/// +/// 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 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 + /// 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 + + @State private var isPickingDuration = false + + var body: some View { + Row(insets: insets, accessory: chevron == .standard ? .chevron : nil) { + Image(systemName: "bell.slash") + .frame(minWidth: 45) + Text("Mute Notifications") + .foregroundStyle(.textMain) + + 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: { + isPickingDuration = true + } + .accessibilityIdentifier("chat-mute") + .sheet(isPresented: $isPickingDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingDuration) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift new file mode 100644 index 000000000..240d5e337 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -0,0 +1,112 @@ +// +// 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. +/// +/// 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. +/// +/// 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 + + @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 { + 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) + } + } + .padding(.top, 3) + // 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 + ) + // 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 + } + } + + private func chipLabel(_ string: String) -> some View { + HStack(spacing: 4) { + Image(systemName: "bell.slash") + Text(string) + } + .chip(.tinted(.warning, on: .warningSecondary)) + } +} diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index e4e2d366e..78248ce89 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 @@ -75,6 +75,8 @@ struct ChatProfileScreen: View { .font(.appTextSmall) .foregroundStyle(.textSecondary) } + + ChatMuteStatusLabel(conversationID: conversationID) } if isMember { @@ -89,6 +91,8 @@ struct ChatProfileScreen: View { } .accessibilityIdentifier("chat-profile-invite") + ChatMuteRow(conversationID: conversationID, insets: rowInsets) + Row( insets: rowInsets, disabled: isLeaving, diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift new file mode 100644 index 000000000..222ac4bd0 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -0,0 +1,176 @@ +// +// 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. +/// "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 + + @Binding var isPresented: Bool + + @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer + + /// 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. + 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 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 + } + + /// 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)] { + offersUnmute ? [("Never", .never)] + Self.durations : Self.durations + } + + 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(options, id: \.title) { row in + ChatActionRow( + icon: .system(icon(for: row.option)), + title: row.title, + accessibilityIdentifier: "mute-chat-\(identifier(for: row.option))" + ) { + guard !isWorking else { return } + Task { await apply(row.option) } + } + } + } + } + .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) + } + + 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 { + pinnedOffersUnmute = offersUnmute + isWorking = true + defer { isWorking = false } + do { + 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: 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: 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 8c830d2a3..ab72b33bb 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) } @@ -73,20 +85,32 @@ private struct UserProfileContent: View { .font(.appTextSmall) .foregroundStyle(.textSecondary) } + + if let conversationID { + ChatMuteStatusLabel(conversationID: conversationID) + } } - 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 +125,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/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..37afa7302 --- /dev/null +++ b/FlipcashTests/Chat/ConversationMuteWiringTests.swift @@ -0,0 +1,211 @@ +// +// 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, + selfUserID: UserID = UUID(), + database: Database + ) -> ConversationController { + ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + 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 + ) + } + + 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)) + } + + // 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) + } +} 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/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/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index ec66f9bf8..c7891d5e0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -65,6 +65,11 @@ public enum SystemSymbol: String { case touchID = "touchid" case faceID = "faceid" case trophy = "trophy.fill" + + /// 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" diff --git a/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift b/FlipcashUI/Sources/FlipcashUI/Views/ButtonStyles/Chip.swift index 31431449d..0f9b4f444 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(_: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 { - 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) } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift new file mode 100644 index 000000000..1f3e677d7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Views/MuteBell.swift @@ -0,0 +1,62 @@ +// +// 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 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 +/// 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? + private let leadingGap: CGFloat + + /// The instant the mute is read against, re-read when a timed one lapses. + @State private var now = Date.now + + /// - 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. + 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") + .padding(.leading, leadingGap) + } + } + // 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 + } + } +} 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