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
25 changes: 11 additions & 14 deletions Flipcash/Core/Controllers/ReactionAlertController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = { [] }

/// 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 }
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 11 additions & 8 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/ReactionAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
52 changes: 40 additions & 12 deletions FlipcashCore/Tests/FlipcashCoreTests/ReactionAlertTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,45 +115,73 @@ 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))
}
}

@Suite("ReactionAlertQueue")
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(),
name: String = "Chloe",
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()
Expand Down
Loading