Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 37 additions & 1 deletion Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {}
23 changes: 14 additions & 9 deletions Flipcash/Core/Controllers/PushController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 26 additions & 6 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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))
Expand Down
34 changes: 26 additions & 8 deletions Flipcash/Core/Screens/Main/Tips/RecipientRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ struct RecipientRowScaffold<Trailing: View>: 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
Expand All @@ -39,7 +42,8 @@ struct RecipientRowScaffold<Trailing: View>: View {
subtitle: subtitle,
imageData: imageData,
blurhash: blurhash,
accessoryPlacement: accessoryPlacement
accessoryPlacement: accessoryPlacement,
mute: mute
) {
trailing
}
Expand All @@ -58,6 +62,7 @@ struct RecipientRowBody<Trailing: View>: View {
let imageData: Data?
var blurhash: String? = nil
var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn
var mute: ConversationMuteState? = nil
@ViewBuilder let trailing: Trailing

var body: some View {
Expand All @@ -80,13 +85,26 @@ struct RecipientRowBody<Trailing: View>: 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
Expand Down
14 changes: 13 additions & 1 deletion Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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: ", ")
}
}
Loading
Loading