diff --git a/Flipcash/Core/Controllers/ReactionAlertController.swift b/Flipcash/Core/Controllers/ReactionAlertController.swift index 387b1ede7..1697699f4 100644 --- a/Flipcash/Core/Controllers/ReactionAlertController.swift +++ b/Flipcash/Core/Controllers/ReactionAlertController.swift @@ -40,12 +40,6 @@ final class ReactionAlertController { /// The display currency amounts are shown in. var displayCurrency: () -> DisplayCurrency = { DisplayCurrency(currency: .usd, rates: [:]) } - /// Set by the open chat: the ids of the messages on screen. - var visibleMessageIDs: () -> Set = { [] } - - /// Set by the open chat: scrolls to a message of this chat and pulses it. - var scrollToMessage: ((MessageID) -> Void)? - /// Names a user the chat's own roster leaves out, fetching their profile when nothing on the /// device has a name for them. A large group embeds only part of its roster. var knownName: (UserID) async -> String? = { _ in nil } @@ -82,6 +76,15 @@ final class ReactionAlertController { pill.cancel() } + /// Clears a chat's pills for the chat being opened: drops the ones waiting, then closes the one + /// on screen if it is that chat's, which promotes the next. + func conversationOpened(_ conversationID: ConversationID) { + queue.removeWaiting(for: conversationID) + if queue.current?.conversationID == conversationID { + pill.cancel() + } + } + // MARK: - Updates - /// Considers each update of one stream event, in order. @@ -112,13 +115,11 @@ final class ReactionAlertController { let placement = ReactionAlertPlacement.decide( alertConversation: conversationID, - openConversation: conversations.visibleConversationID, - messageOnScreen: visibleMessageIDs().contains(String(update.messageID.value)) + openConversation: conversations.visibleConversationID ) let topRow: String switch placement { case .bubbleOnly: return - case .pill(.thisChat): topRow = "This chat ↓" case .pill(.chatName): topRow = conversations.displayName(forConversationID: conversationID) } @@ -216,11 +217,7 @@ final class ReactionAlertController { } private func tapped(conversationID: ConversationID, messageID: MessageID) { - if conversations?.visibleConversationID == conversationID, let scrollToMessage { - scrollToMessage(messageID) - } else { - openMessage(conversationID, messageID) - } + openMessage(conversationID, messageID) } private func pillFinished() { diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 05f9433d9..c629fb603 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -224,9 +224,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.reportsReads = reportsReads perform(messageJump, on: screen, coordinator: context.coordinator) screen.update(items: items) - // The alert pill asks which messages are in front of the reader, so a reaction on one of - // them animates its chip instead of raising a pill. Weak, so a closed chat answers nothing. - conversationController.reactionAlerts?.visibleMessageIDs = { [weak screen] in screen?.visibleMessageIDs ?? [] } context.coordinator.barHost = barHost context.coordinator.screen = screen context.coordinator.lastMessageID = lastMessageID(of: items) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 1a4dc4d42..a1b426282 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1182,7 +1182,6 @@ struct ConversationScreen: View { ]) if matched { conversationController.visibleConversationID = nil - conversationController.reactionAlerts?.scrollToMessage = nil } } // Donate the open chat for Siri prediction, Handoff, and Spotlight. @@ -1227,8 +1226,7 @@ struct ConversationScreen: View { "conversationID": id.map { "\($0)" } ?? "nil", ]) conversationController.visibleConversationID = id - // A tapped alert pill for this chat scrolls to its message and pulses it. - conversationController.reactionAlerts?.scrollToMessage = id == nil ? nil : { messageID in land(on: messageID) } + if let id { conversationController.reactionAlerts?.conversationOpened(id) } } /// Opens the native iOS contact card for the counterpart: their diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ReactionAlert.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ReactionAlert.swift index 5f736d910..d734fc5b7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ReactionAlert.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ReactionAlert.swift @@ -85,23 +85,20 @@ public enum ReactionAlertPlacement: Hashable, Sendable { /// What the pill's top row reads. public enum TopRow: Hashable, Sendable { - /// The message is in the open chat but scrolled off: "This chat". - case thisChat /// Any other screen: the chat's name. case chatName } - /// The bubble's own animation covers it; no pill. + /// The message is in the open chat: its bubble's own animation covers it, and an off-screen + /// reaction waits in the transcript. No pill. case bubbleOnly case pill(TopRow) public static func decide( alertConversation: ConversationID, - openConversation: ConversationID?, - messageOnScreen: Bool + openConversation: ConversationID? ) -> ReactionAlertPlacement { - guard alertConversation == openConversation else { return .pill(.chatName) } - return messageOnScreen ? .bubbleOnly : .pill(.thisChat) + alertConversation == openConversation ? .bubbleOnly : .pill(.chatName) } } @@ -117,7 +114,7 @@ public struct ReactionAlertEvent: Hashable, Sendable { public let boostDeltaCents: UInt64 /// The quoted message text. public let snippet: String - /// The top row: "This chat" or the chat's name. + /// The top row: the chat's name. public let topRow: String public init( @@ -250,6 +247,12 @@ public struct ReactionAlertQueue: Sendable { return current } + /// Drops the pills waiting for a chat, for that chat being opened. The pill on screen is left to + /// the caller to close, which promotes the next one. + public mutating func removeWaiting(for conversationID: ConversationID) { + waiting.removeAll { $0.conversationID == conversationID } + } + /// Drops everything, for a sign-out or a trip to the background. public mutating func removeAll() { current = nil diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ReactionAlertTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ReactionAlertTests.swift index 7f6ccf7c0..c17e68777 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ReactionAlertTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ReactionAlertTests.swift @@ -115,24 +115,24 @@ struct ReactionAlertPlacementTests { private static let chat = ConversationID(data: Data(repeating: 1, count: 32)) private static let elsewhere = ConversationID(data: Data(repeating: 2, count: 32)) - @Test("Open chat, message on screen: bubble only") - func onScreen() { - #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: Self.chat, messageOnScreen: true) == .bubbleOnly) + // Rows 1-2: the open chat raises no pill. `decide` takes no on-screen flag, so a message + // scrolled off the screen gets the same answer as one in view. + @Test("Open chat: bubble only, whether or not the message is on screen") + func openChat() { + #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: Self.chat) == .bubbleOnly) } - @Test("Open chat, message scrolled off: pill reading This chat") - func scrolledOff() { - #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: Self.chat, messageOnScreen: false) == .pill(.thisChat)) - } - - @Test("Another chat open: pill with the chat name, whether or not the id is on screen there") + // Rows 3 and 8: a reaction or a boost in another chat takes the pill. The controller sends both + // through `decide`; only the event's content differs. + @Test("Another chat open: pill with the chat name") func otherChat() { - #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: Self.elsewhere, messageOnScreen: true) == .pill(.chatName)) + #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: Self.elsewhere) == .pill(.chatName)) } + // Row 4. @Test("No chat open: pill with the chat name") func noChat() { - #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: nil, messageOnScreen: false) == .pill(.chatName)) + #expect(ReactionAlertPlacement.decide(alertConversation: Self.chat, openConversation: nil) == .pill(.chatName)) } } @@ -140,8 +140,10 @@ struct ReactionAlertPlacementTests { struct ReactionAlertQueueTests { private static let chat = ConversationID(data: Data(repeating: 1, count: 32)) + private static let other = ConversationID(data: Data(repeating: 2, count: 32)) private static func event( + conversation: ConversationID = chat, message: UInt64 = 1, emoji: String = "🔥", actor: UserID = UUID(), @@ -149,11 +151,37 @@ struct ReactionAlertQueueTests { boost: UInt64? = nil ) -> ReactionAlertEvent { ReactionAlertEvent( - conversationID: chat, messageID: MessageID(value: message), emoji: emoji, actor: actor, actorName: name, + conversationID: conversation, messageID: MessageID(value: message), emoji: emoji, actor: actor, actorName: name, isBoost: boost != nil, boostDeltaCents: boost ?? 0, snippet: "hello", topRow: "Moony" ) } + // Row 5: the pill on screen is the opened chat's, so the caller closes it and the queue moves on. + @Test("Opening a chat leaves its showing pill to be closed, then advance promotes the next") + func openedChatShowingPill() { + var queue = ReactionAlertQueue() + queue.enqueue(Self.event(conversation: Self.chat)) + queue.enqueue(Self.event(conversation: Self.other, message: 2)) + queue.removeWaiting(for: Self.chat) + #expect(queue.current?.conversationID == Self.chat) + #expect(queue.advance()?.conversationID == Self.other) + } + + // Row 6. + @Test("Opening a chat drops its waiting pills and keeps the others in order") + func openedChatWaitingDropped() { + var queue = ReactionAlertQueue() + queue.enqueue(Self.event(conversation: Self.other, message: 9)) + queue.enqueue(Self.event(conversation: Self.chat, message: 1)) + queue.enqueue(Self.event(conversation: Self.other, message: 2)) + queue.enqueue(Self.event(conversation: Self.chat, message: 3, boost: 500)) + queue.removeWaiting(for: Self.chat) + #expect(queue.current?.messageID == MessageID(value: 9)) + #expect(queue.waiting.map(\.messageID) == [MessageID(value: 2)]) + #expect(queue.advance()?.conversationID == Self.other) + #expect(queue.advance() == nil) + } + @Test("The first event starts a pill") func firstStarts() { var queue = ReactionAlertQueue()