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
15 changes: 13 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ final class ConversationController {
do {
let conversation = try await fetching.getChat(owner: owner, conversationID: conversationID)
store.apply(.metadataRefresh(conversation))
persistConversation(conversationID)
// The server copy, not the store's: the store drops a tombstone preview, and the
// database wants the row so the repair below can tell the newest message was deleted.
persistConversation(conversation)
refreshFeedPreview(for: conversationID)
return conversation
} catch {
logger.error("Failed to hydrate conversation on demand", metadata: [
Expand Down Expand Up @@ -465,7 +468,8 @@ final class ConversationController {
do {
let conversation = try await fetching.getChat(owner: owner, conversationID: conversationID)
store.apply(.metadataRefresh(conversation))
persistConversation(conversationID)
persistConversation(conversation)
refreshFeedPreview(for: conversationID)
} catch {
logger.error("Failed to hydrate conversation referenced by the event stream", metadata: [
"conversationID": "\(conversationID)",
Expand Down Expand Up @@ -504,6 +508,13 @@ final class ConversationController {
store.setFeed(conversations, type: type)
reconcileHidden()
persist(operation: "replace-feed") { try database.replaceConversationFeed(conversations, type: type) }
// The store refuses a tombstone as a preview, so a chat whose newest message is deleted
// seats blank here. Fill it from the newest visible message already cached — the feed
// reloads on every launch and foreground, so without this the row stays blank until the
// transcript is opened.
for conversation in conversations where conversation.lastMessage?.isDeleted == true {
refreshFeedPreview(for: conversation.id)
}
return conversations
} catch {
logger.error("Failed to load conversation feed", metadata: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public struct ConversationStore: Sendable {

/// Replace the feed from a paged load, sorted most-recent-activity first.
public mutating func setFeed(_ conversations: [Conversation]) {
self.conversations = conversations.sorted { $0.lastActivity > $1.lastActivity }
self.conversations = conversations
.map { seated($0) }
.sorted { $0.lastActivity > $1.lastActivity }
}

/// Replace one type's conversations from that type's paged feed load,
Expand Down Expand Up @@ -380,6 +382,7 @@ public struct ConversationStore: Sendable {
}

private mutating func upsert(_ conversation: Conversation) {
let conversation = seated(conversation)
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) {
conversations[index] = conversation
} else {
Expand All @@ -388,6 +391,24 @@ public struct ConversationStore: Sendable {
sort()
}

/// `conversation` with a feed preview the row can actually draw: the newest message that still
/// has content. Server metadata reports the newest message whatever its state, so a delete
/// arrives here as a tombstone — which renders as a blank row with an unread splat beside it.
/// It falls back to the visible preview the row already carries, and to nothing when there is
/// none; ``setFeedPreview(_:in:force:)`` then fills it from the database.
///
/// The fallback is skipped when the tombstone replaces the very message the row was previewing,
/// which would leave deleted content on screen.
private func seated(_ conversation: Conversation) -> Conversation {
guard let tombstone = conversation.lastMessage, tombstone.isDeleted else { return conversation }
var conversation = conversation
let current = conversations.first { $0.id == conversation.id }?.lastMessage
conversation.lastMessage = current.flatMap { current in
!current.isDeleted && current.id != tombstone.id ? current : nil
}
return conversation
}

private mutating func sort() {
conversations.sort { $0.lastActivity > $1.lastActivity }
}
Expand Down
60 changes: 60 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ struct ConversationStoreTests {
ConversationMessage(id: MessageID(value: id), senderID: nil, content: .text(text), date: Date(timeIntervalSince1970: at), unreadSeq: 0, eventSequence: eventSequence)
}

private func tombstone(_ id: UInt64, at: TimeInterval = 0, eventSequence: UInt64 = 0) -> ConversationMessage {
ConversationMessage(
id: MessageID(value: id), senderID: nil,
content: .deleted(.init(deletedBy: nil, deletedAt: Date(timeIntervalSince1970: at))),
date: Date(timeIntervalSince1970: at), unreadSeq: 0, eventSequence: eventSequence
)
}

private func pending(_ clientID: UUID, _ text: String, sender: UserID? = nil, at: TimeInterval = 0, status: SendStatus = .sending) -> ConversationMessage {
ConversationMessage(id: MessageID(value: .max), senderID: sender, content: .text(text), date: Date(timeIntervalSince1970: at), unreadSeq: 0, status: status, clientMessageID: clientID)
}
Expand Down Expand Up @@ -110,6 +118,58 @@ struct ConversationStoreTests {
#expect(store.conversations.first?.lastMessage?.id.value == 9)
}

// MARK: - Deleted-message previews

@Test("A metadata refresh whose newest message was deleted keeps the visible preview the row shows")
func metadataRefreshDoesNotBlankTheRowWithATombstone() {
var store = ConversationStore()
store.setFeed([conversation(1, lastActivity: 100)])
store.setFeedPreview(message(9, "still here"), in: conversationID(1))

// The server reports the newest message whatever its state, so a delete arrives as a
// tombstone at a *higher* id than the visible message the row falls back to.
store.apply(.metadataRefresh(Conversation(
id: conversationID(1), members: [], lastMessage: tombstone(10),
lastActivity: Date(timeIntervalSince1970: 100)
)))

#expect(store.conversations.first?.lastMessage?.id.value == 9)
}

@Test("A feed load previewing a tombstone seats no preview, so no unread splat sits beside a blank row")
func feedLoadDropsATombstonePreview() {
let me = UUID()
var store = ConversationStore()
// Nothing cached to fall back to: the only message in the chat was deleted.
store.setFeed([Conversation(
id: conversationID(1),
members: [ConversationMember(userID: me, displayName: "", readPointer: MessageID(value: 1))],
lastMessage: tombstone(10),
lastActivity: Date(timeIntervalSince1970: 100)
)])

#expect(store.conversations.first?.lastMessage == nil)
#expect(store.conversations.first?.hasUnread(for: me) == false)
}

@Test("A tombstone for the previewed message drops it rather than leaving deleted content on the row")
func deletingThePreviewedMessageClearsIt() {
var store = ConversationStore()
store.setFeed([conversation(1, lastActivity: 100)])
store.setFeedPreview(message(10, "about to go"), in: conversationID(1))

store.apply(.metadataRefresh(Conversation(
id: conversationID(1), members: [], lastMessage: tombstone(10),
lastActivity: Date(timeIntervalSince1970: 100)
)))
#expect(store.conversations.first?.lastMessage == nil)

// The database repair then supplies the message before it — reachable now that the guard has
// no newer preview to compare against.
store.setFeedPreview(message(9, "still here"), in: conversationID(1))
#expect(store.conversations.first?.lastMessage?.id.value == 9)
}

@Test("setFeedPreview does not re-sort the feed — activity stays feed-owned")
func previewDoesNotResort() {
var store = ConversationStore()
Expand Down
25 changes: 25 additions & 0 deletions FlipcashTests/ConversationControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,31 @@ struct ConversationControllerTests {
#expect(controller.messages(for: ConversationID.test(1)).map(\.id.value) == [3, 4])
}

@Test("a feed reporting a deleted newest message previews the message before it, not a blank row")
func feedLoadFallsBackPastATombstone() async throws {
let mock = MockConversations()
let visible = ConversationMessage(id: MessageID(value: 9), senderID: nil, content: .text("hi"), date: Date(timeIntervalSince1970: 90), unreadSeq: 9, eventSequence: 9)
mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: visible, lastActivity: Date(timeIntervalSince1970: 100))]
mock.messages = [visible]
let controller = makeController(mock)
await controller.loadFeed()

// The counterpart's newest message is deleted while the chat is closed, so the client never
// sees the event — the delete reaches it only as feed metadata, whose `lastMessage` is the
// tombstone. Seating that verbatim leaves the row blank until the transcript is opened.
let tombstone = ConversationMessage(
id: MessageID(value: 10), senderID: nil,
content: .deleted(.init(deletedBy: nil, deletedAt: Date(timeIntervalSince1970: 100))),
date: Date(timeIntervalSince1970: 100), unreadSeq: 10, eventSequence: 10
)
mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: tombstone, lastActivity: Date(timeIntervalSince1970: 100))]
await controller.loadFeed()

let conversation = try #require(controller.conversation(withID: .test(1)))
#expect(conversation.lastMessage?.id.value == 9)
#expect(controller.lastMessagePreview(for: conversation) { _ in nil } == "hi")
}

@Test("a second feed load leaves an already-cached transcript alone")
func loadFeedSkipsCachedConversation() async {
let mock = MockConversations()
Expand Down
Loading