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
57 changes: 51 additions & 6 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ final class ConversationController {

/// A marked chat with nothing unread is the no-count unread state; a known count wins over the mark.
private func chatListUnread(for conversation: Conversation, isMarked: Bool) -> ChatListUnread {
guard conversation.hasUnread(for: selfUserID) else { return isMarked ? .unknown : .count(0) }
guard hasUnreadMessages(conversation) else { return isMarked ? .unknown : .count(0) }
return unreadCount(for: conversation).map(ChatListUnread.count) ?? .unknown
}

Expand Down Expand Up @@ -197,7 +197,7 @@ final class ConversationController {
/// Unread messages or a mark: what decides between Mark as Read and Mark as Unread. A pending
/// join request doesn't count; it is not something reading the chat clears.
func isUnread(_ conversation: Conversation) -> Bool {
conversation.hasUnread(for: selfUserID) || isMarkedUnread(conversation.id)
hasUnreadMessages(conversation) || isMarkedUnread(conversation.id)
}

/// The read action a chat's swipe, menu and selection offer; nil for a chat the user hasn't
Expand Down Expand Up @@ -232,7 +232,7 @@ final class ConversationController {
func markRead(_ id: ConversationID, source: ChatReadSource) {
chatMarkedUnread?.clear(id)
guard let conversation = conversation(withID: id),
conversation.hasUnread(for: selfUserID),
hasUnreadMessages(conversation),
let newest = conversation.lastMessage?.id
else { return }
advanceReadPointer(to: newest, in: id)
Expand Down Expand Up @@ -287,16 +287,48 @@ final class ConversationController {
chatMarkedUnread?.clear(id)
}

/// Whether `conversation` holds a message that is unread for the viewer. A share payment is
/// unread only for the member it paid, so one to someone else is looked past.
func hasUnreadMessages(_ conversation: Conversation) -> Bool {
conversation.hasUnread(for: selfUserID, countedThrough: newestCountingAsUnread(in: conversation))
}

/// The unread count a conversation row shows; nil when the chat is unread but its READ
/// watermark's message is neither stored nor fetched yet. See
/// ``Conversation/unreadCount(for:unreadSeqAt:)`` and ``resolveUnreadCount(for:)``.
/// ``Conversation/unreadCount(for:countedThrough:uncounted:unreadSeqAt:)`` and
/// ``resolveUnreadCount(for:)``.
func unreadCount(for conversation: Conversation) -> Int? {
conversation.unreadCount(for: selfUserID) { pointer in
let newest = newestCountingAsUnread(in: conversation)
return conversation.unreadCount(
for: selfUserID,
countedThrough: newest,
uncounted: sharePaymentsToOthers(in: conversation, through: newest)
) { pointer in
storedUnreadSeq(of: pointer, in: conversation.id)
?? watermarkStamps.stamp(for: .init(conversationID: conversation.id, messageID: pointer))
}
}

/// The newest message that can be unread for the viewer: the chat's latest, or the newest
/// stored one under it when the latest is a share payment to someone else.
private func newestCountingAsUnread(in conversation: Conversation) -> ConversationMessage? {
guard let latest = conversation.lastMessage else { return nil }
if latest.countsAsUnread(for: selfUserID) { return latest }
_ = messageRevision // observe: the message under the payment can land after the row draws
return (try? database.newestMessageCountingAsUnread(conversationID: conversation.id, for: selfUserID)) ?? nil
}

/// The stored share payments to someone else between the viewer's READ watermark and `newest`.
/// Only a group holds any, so a DM skips the read.
private func sharePaymentsToOthers(in conversation: Conversation, through newest: ConversationMessage?) -> Int {
guard conversation.type == .group, let newest else { return 0 }
_ = messageRevision
let after = conversation.selfReadPointer(for: selfUserID) ?? MessageID(value: 0)
return (try? database.sharePaymentsToOthersCount(
conversationID: conversation.id, after: after, through: newest.id, for: selfUserID
)) ?? 0
}

/// Fetches the READ watermark's message when ``unreadCount(for:)`` can't be known without it,
/// so the row's count fills in. A no-op for a chat whose count is already known.
func resolveUnreadCount(for conversation: Conversation) async {
Expand Down Expand Up @@ -1300,7 +1332,7 @@ final class ConversationController {
/// ``FeedReconcileTiming/unreadCap``; the lookups keep going and fill their rows in as they land.
private func resolveUnreadCounts(in conversations: [Conversation]) async {
unresolvedUnreadQueue = conversations
.filter { $0.hasUnread(for: selfUserID) && unreadCount(for: $0) == nil }
.filter { hasUnreadMessages($0) && unreadCount(for: $0) == nil }
guard !unresolvedUnreadQueue.isEmpty else { return }
watermarkStamps.hold()
let wait = BoundedWait(expecting: unresolvedUnreadQueue.count)
Expand Down Expand Up @@ -1991,6 +2023,19 @@ final class ConversationController {
guard let senderName else { return text }
return "\(senderName): \(text)"

case .cash(let amount) where message.isSharePayment:
// A share of a request: who paid whom, with the request's title when it can be read.
let paid = viewerAmount(amount).formattedDroppingZeroFraction()
let subject = message.paymentRequestID.flatMap(requestTitle).map { " for \($0)" } ?? ""
if isFromSelf { return "You paid \(paid)\(subject)" }
let payer = message.senderID.flatMap { noticeUserName(for: $0, in: conversation, knownAuthors: knownAuthors) } ?? "Someone"
// No receiver means no creator to check the request against, so no title either.
guard let receiver = message.cashReceiverID else { return "\(payer) paid \(paid)" }
let creator = receiver == selfUserID
? "you"
: noticeUserName(for: receiver, in: conversation, knownAuthors: knownAuthors) ?? "Someone"
return "\(payer) paid \(creator) \(paid)\(subject)"

case .cash(let amount):
let formatted = viewerAmount(amount).formatted()
// The reserve would read "$1.00 of Dollars" — the amount alone already says it.
Expand Down
111 changes: 105 additions & 6 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ extension ChatItem {
paymentRequest: (ConversationMessage) -> PaymentRequestCardState = { _ in .loading },
/// The Send Back row under a cash message; nil draws none. See ``SendBackRow/resolve``.
sendBack: (ConversationMessage) -> ChatCashContent.SendBack? = { _ in nil },
/// The chat's stored, readable payment requests, resolved by the caller from the request
/// store. They title a share payment's row and are the source of the marked-paid and
/// closed rows, which have no message behind them.
requests: [PaymentRequestID: RequestTimeline] = [:],
/// Where the viewer's unread messages began at open; the divider heads the first message
/// after it that someone else sent.
unreadBoundary: UnreadBoundary = .none,
Expand Down Expand Up @@ -202,9 +206,14 @@ extension ChatItem {
func isSystem(_ message: ConversationMessage) -> Bool {
if case .system = message.content { true } else { false }
}
// A share payment draws as a centred line too, with no sender to group a bubble under.
func isNotice(_ message: ConversationMessage) -> Bool {
isSystem(message) || message.isSharePayment
}
let dividerHeadID: String? = {
var older: ConversationMessage?
for message in messages where !isSystem(message) {
// A share payment paid to someone else is never unread, so the divider passes it too.
for message in messages where !isSystem(message) && message.countsAsUnread(for: selfUserID) {
if let older, unreadBoundary.dividerBetween(newer: message, older: older, selfUserID: selfUserID) {
return message.stableID
}
Expand Down Expand Up @@ -261,14 +270,50 @@ extension ChatItem {
}
}

// The marked-paid and closed rows, each placed after the last message at or before its
// time. One older than the oldest loaded message waits for paging to reach it.
var storedAfter: [Int: [RequestOccurrence]] = [:]
if let oldest = messages.first?.date {
for occurrence in RequestOccurrence.stored(in: requests) where occurrence.date >= oldest {
guard let index = messages.lastIndex(where: { $0.date <= occurrence.date }) else { continue }
storedAfter[index, default: []].append(occurrence)
}
}
// What each request row was built from, so a run of them can collapse once every row is placed.
var occurrences: [String: RequestOccurrence] = [:]
// The date of the row drawn last. It is not always the previous message's: a stored row
// sits between two messages at a time of its own.
var lastDrawn: Date?
func separates(_ date: Date, fromDrawn earlier: Date) -> Bool {
date.timeIntervalSince(earlier) > gap || !calendar.isDate(date, inSameDayAs: earlier)
}

var items: [ChatItem] = []
func appendRequestRow(_ occurrence: RequestOccurrence, showsSeparator: Bool) {
guard let row = RequestOccurrence.row(for: [occurrence], request: requests[occurrence.requestID], selfUserID: selfUserID, nameOf: nameOf) else { return }
if showsSeparator {
items.append(.dateSeparator(id: "sep-\(row.id)", text: occurrence.date.formattedChatSeparator()))
}
occurrences[row.id] = occurrence
items.append(.requestEvent(row))
lastDrawn = occurrence.date
}
for (index, message) in messages.enumerated() {
let isFromSelf = message.isFromSelf(selfUserID)
let previous = index > 0 ? messages[index - 1] : nil
let next = index + 1 < messages.count ? messages[index + 1] : nil
// Rows with no message behind them, drawn between this message and its neighbours.
let followsStored = index > 0 && storedAfter[index - 1] != nil
let stored = storedAfter[index] ?? []
defer {
lastDrawn = message.date
for occurrence in stored {
appendRequestRow(occurrence, showsSeparator: lastDrawn.map { separates(occurrence.date, fromDrawn: $0) } ?? true)
}
}

// A separator opens the transcript and breaks any run longer than the gap.
let showsSeparator = previous.map { separates(message, from: $0) } ?? true
let showsSeparator = lastDrawn.map { separates(message.date, fromDrawn: $0) } ?? true
let showsMarker = index == markerIndex
if showsMarker {
items.append(.encryptionMarker)
Expand All @@ -292,11 +337,12 @@ extension ChatItem {
// separator is already the heading of what follows it, so a second, shorter threshold
// would flatten runs the transcript still draws as continuous.
let groupedAbove = previous.map {
$0.senderID == message.senderID && !isSystem($0) && !showsSeparator && !showsDivider && !showsMarker
$0.senderID == message.senderID && !isNotice($0) && !showsSeparator && !showsDivider && !showsMarker
&& !followsStored
} ?? false
let groupedBelow = next.map {
$0.senderID == message.senderID && !isSystem($0) && !separates($0, from: message) && !divides($0, from: message)
&& index + 1 != markerIndex
$0.senderID == message.senderID && !isNotice($0) && !separates($0, from: message) && !divides($0, from: message)
&& index + 1 != markerIndex && stored.isEmpty
} ?? false

// The bubble run, which is not the author run. A bubble stacked above a bare emoji would
Expand Down Expand Up @@ -324,6 +370,21 @@ extension ChatItem {
continue
case .text(let text):
content = .text(text)
case .cash(let fiat) where message.isSharePayment:
guard let requestID = message.paymentRequestID else { continue }
// The separator above already went in with the loop's own; the row adds none.
appendRequestRow(
RequestOccurrence(
kind: .sharePayment(message: message, amount: viewerAmount(fiat)),
requestID: requestID,
// The payment names who it paid, so the row reads right before the request loads.
creator: message.cashReceiverID ?? requests[requestID]?.request.creator,
member: message.senderID,
date: message.date
),
showsSeparator: false
)
continue
case .cash(let fiat):
let branding = cashBranding(fiat)
let isTip = message.cashAction == .tipped
Expand Down Expand Up @@ -524,7 +585,45 @@ extension ChatItem {
)))
}
}
return items
return collapsingRequestRows(items, occurrences: occurrences, requests: requests, selfUserID: selfUserID, nameOf: nameOf)
}

/// Joins request rows that sit directly under one another, for one request and of one kind,
/// into a single row. Anything drawn between two of them, a date header included, keeps them apart.
nonisolated private static func collapsingRequestRows(
_ items: [ChatItem],
occurrences: [String: RequestOccurrence],
requests: [PaymentRequestID: RequestTimeline],
selfUserID: UserID,
nameOf: (UserID) -> String?
) -> [ChatItem] {
guard !occurrences.isEmpty else { return items }
var collapsed: [ChatItem] = []
var run: [RequestOccurrence] = []
func flush() {
guard let first = run.first else { return }
if let row = RequestOccurrence.row(for: run, request: requests[first.requestID], selfUserID: selfUserID, nameOf: nameOf) {
collapsed.append(.requestEvent(row))
}
run = []
}
for item in items {
switch item {
case .requestEvent(let event):
guard let occurrence = occurrences[event.id] else {
flush()
collapsed.append(item)
continue
}
if let last = run.last, !last.collapses(with: occurrence) { flush() }
run.append(occurrence)
case .message, .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard:
flush()
collapsed.append(item)
}
}
flush()
return collapsed
}

/// The pin bar's quote of a pinned `message`: the same author, snippet and kind a reply's quote
Expand Down
Loading
Loading