diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index a3d5714..39f5a43 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -136,6 +136,8 @@ A5C47F6A46E3CE8708789CB2 /* PublicBrowse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */; }; ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */; }; B1C1D1E1F1A10001 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10002 /* DataCache.swift */; }; + FEED105A0000000000000001 /* FeedMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED105A0000000000000002 /* FeedMerge.swift */; }; + FEED105A0000000000000003 /* FeedMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED105A0000000000000004 /* FeedMergeTests.swift */; }; B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */; }; B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; }; @@ -415,6 +417,8 @@ AC8F31A0EA9ADA8C6827A16C /* APIClient+AppSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+AppSettings.swift"; sourceTree = ""; }; AE452BC01A7461AF5199FFE6 /* AIJSON.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSON.swift; sourceTree = ""; }; B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; + FEED105A0000000000000002 /* FeedMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMerge.swift; sourceTree = ""; }; + FEED105A0000000000000004 /* FeedMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMergeTests.swift; sourceTree = ""; }; B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = ""; }; B1C1D1E1F1A10006 /* SkeletonBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBlock.swift; sourceTree = ""; }; B1C1D1E1F1A10008 /* FeedSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSkeletonView.swift; sourceTree = ""; }; @@ -640,6 +644,7 @@ 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */, A3C3D3E3F3A30056 /* URLSessionProtocol.swift */, B1C1D1E1F1A10002 /* DataCache.swift */, + FEED105A0000000000000002 /* FeedMerge.swift */, B1C1D1E1F1A10004 /* AppDataStore.swift */, D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, @@ -868,6 +873,7 @@ BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */, AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */, 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, + FEED105A0000000000000004 /* FeedMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */, @@ -1015,6 +1021,7 @@ A3C3D3E3F3A30053 /* DocumentsView.swift in Sources */, A3C3D3E3F3A30055 /* URLSessionProtocol.swift in Sources */, B1C1D1E1F1A10001 /* DataCache.swift in Sources */, + FEED105A0000000000000001 /* FeedMerge.swift in Sources */, B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */, B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */, B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */, @@ -1199,6 +1206,7 @@ C8AFF749A154E5F96FB77DE2 /* APIClientSessionsTests.swift in Sources */, FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */, 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */, + FEED105A0000000000000003 /* FeedMergeTests.swift in Sources */, 1C47F69DF0848EF0C45C51EE /* DocumentSyncModelTests.swift in Sources */, 213A17C03007A464190DB5D6 /* APIClientMessageByIdTests.swift in Sources */, C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */, diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 4179310..c77db17 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -7,7 +7,16 @@ import Foundation @MainActor final class AppDataStore: ObservableObject { - @Published private(set) var feedMessages: [Message] = [] + @Published private(set) var feedMessages: [Message] = [] { + didSet { feedRevision &+= 1 } + } + + /// Bumped on every mutation of `feedMessages`, in-place row edits included. + /// `Message`'s `Equatable` is identity-based (`id` only), so neither the array + /// nor its `count` moves when a row's content is replaced — a view mirroring the + /// feed has nothing else to observe. + @Published private(set) var feedRevision = 0 + @Published private(set) var feedLoading = true @Published private(set) var feedError: String? @@ -483,6 +492,15 @@ final class AppDataStore: ObservableObject { saveFeedCache() } + /// Replaces an already-loaded row with a newer copy of itself. Unknown id: no-op — + /// the feed page is a window, not the whole timeline, so a message that has scrolled + /// out of it is not an error. + func updateFeedMessage(_ message: Message) { + guard let index = feedMessages.firstIndex(where: { $0.id == message.id }) else { return } + feedMessages[index] = message + saveFeedCache() + } + /// Replaces the link previews on one already-inserted feed message, so the row /// a publish just added can show its card as soon as the metadata refresh lands. /// diff --git a/InterlinedList/Services/FeedMerge.swift b/InterlinedList/Services/FeedMerge.swift new file mode 100644 index 0000000..7dc9d09 --- /dev/null +++ b/InterlinedList/Services/FeedMerge.swift @@ -0,0 +1,37 @@ +// +// FeedMerge.swift +// InterlinedList +// + +import Foundation + +/// Reconciles the store's feed page into a view's working copy of the feed. +/// +/// The working copy is not a plain mirror of `AppDataStore.feedMessages`: pagination +/// appends pages the store never sees, so the store's page has to be merged into it +/// rather than replace it. Rows already held keep their position (and with it the +/// scroll position); a row the store has re-sent replaces the held copy in place, so +/// an edit to an existing row is visible without a reload. +enum FeedMerge { + struct Result { + let messages: [Message] + /// Rows this merge inserted or replaced — the ones whose derived per-row + /// state (dig counts) needs re-seeding. Empty means nothing moved. + let changed: [Message] + } + + static func merge(existing: [Message], incoming: [Message]) -> Result { + let incomingById = Dictionary(incoming.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + var merged = existing + var replaced: [Message] = [] + for index in merged.indices { + guard let updated = incomingById[merged[index].id] else { continue } + merged[index] = updated + replaced.append(updated) + } + let existingIds = Set(existing.map(\.id)) + let inserted = incoming.filter { !existingIds.contains($0.id) } + merged.insert(contentsOf: inserted, at: 0) + return Result(messages: merged, changed: inserted + replaced) + } +} diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 95c8954..e16c645 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -325,6 +325,9 @@ struct FeedView: View { if let index = messages.firstIndex(where: { $0.id == updated.id }) { messages[index] = updated } + // The working copy can hold rows the store's page never saw, so the + // local write above stays; this keeps the store and its cache in step. + store.updateFeedMessage(updated) } } .alert("Delete message?", isPresented: Binding( @@ -392,7 +395,7 @@ struct FeedView: View { if authState.isLoggedIn { Task { await loadMessages() } } } return nav - .onChange(of: store.feedMessages.count) { _, _ in + .onChange(of: store.feedRevision) { _, _ in guard !showOnlyMine && tagFilter == nil else { return } if !syncedFromStore { let msgs = store.feedMessages @@ -401,12 +404,10 @@ struct FeedView: View { isLoading = false syncedFromStore = !msgs.isEmpty } else { - let existingIds = Set(messages.map { $0.id }) - let newMessages = store.feedMessages.filter { !existingIds.contains($0.id) } - if !newMessages.isEmpty { - messages.insert(contentsOf: newMessages, at: 0) - initDigStates(from: newMessages) - } + let result = FeedMerge.merge(existing: messages, incoming: store.feedMessages) + guard !result.changed.isEmpty else { return } + messages = result.messages + initDigStates(from: result.changed) } } .onChange(of: store.feedLoading) { _, loading in diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index bd24488..7acc7c7 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -82,6 +82,92 @@ final class AppDataStoreTests: XCTestCase { wait(for: [countIncreased], timeout: 1.0) } + // MARK: - updateFeedMessage + + func test_updateFeedMessage_replacesTheRowContent() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + XCTAssertEqual(sut.feedMessages.first?.content, "after") + } + + func test_updateFeedMessage_keepsCountAndOrder() { + sut.insertFeedMessage(makeMessage(id: "a")) + sut.insertFeedMessage(makeMessage(id: "b")) + sut.updateFeedMessage(makeMessage(id: "a", content: "edited")) + XCTAssertEqual(sut.feedMessages.map(\.id), ["b", "a"]) + XCTAssertEqual(sut.feedMessages.last?.content, "edited") + } + + func test_updateFeedMessage_leavesOtherRowsAlone() { + sut.insertFeedMessage(makeMessage(id: "a", content: "a-content")) + sut.insertFeedMessage(makeMessage(id: "b", content: "b-content")) + sut.updateFeedMessage(makeMessage(id: "b", content: "edited")) + XCTAssertEqual(sut.feedMessages.last?.content, "a-content") + } + + func test_updateFeedMessage_unknownId_isANoOp() { + sut.insertFeedMessage(makeMessage(id: "a", content: "kept")) + sut.updateFeedMessage(makeMessage(id: "ghost", content: "ignored")) + XCTAssertEqual(sut.feedMessages.map(\.id), ["a"]) + XCTAssertEqual(sut.feedMessages.first?.content, "kept") + } + + // MARK: - feedRevision + + /// The whole point of the revision: `Message` compares by id, so neither the + /// array nor its count moves when a row is edited in place. + func test_feedRevision_movesWhenARowIsEditedInPlace() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + let before = sut.feedRevision + let countBefore = sut.feedMessages.count + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + XCTAssertEqual(sut.feedMessages.count, countBefore) + XCTAssertGreaterThan(sut.feedRevision, before) + } + + func test_feedRevision_movesWhenARowIsInserted() { + let before = sut.feedRevision + sut.insertFeedMessage(makeMessage(id: "a")) + XCTAssertGreaterThan(sut.feedRevision, before) + } + + func test_feedRevision_doesNotMoveForAnUnknownId() { + sut.insertFeedMessage(makeMessage(id: "a")) + let before = sut.feedRevision + sut.updateFeedMessage(makeMessage(id: "ghost")) + XCTAssertEqual(sut.feedRevision, before) + } + + // MARK: - store mutation reaching the feed's working copy + + /// End of the chain the bug broke: the store edits a row, and the copy `FeedView` + /// renders from picks the edit up through `FeedMerge` without a reload. + func test_storeRowEdit_reachesTheViewsWorkingCopy() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + let viewCopy = sut.feedMessages + let revisionBefore = sut.feedRevision + + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + + XCTAssertGreaterThan(sut.feedRevision, revisionBefore) + let merged = FeedMerge.merge(existing: viewCopy, incoming: sut.feedMessages) + XCTAssertEqual(merged.messages.count, 1) + XCTAssertEqual(merged.messages.first?.content, "after") + } + + /// The view's copy runs ahead of the store once pagination has appended a page; + /// a store edit must not drop those rows. + func test_storeRowEdit_keepsPaginatedRowsTheStoreNeverSaw() { + sut.insertFeedMessage(makeMessage(id: "p1", content: "before")) + let viewCopy = sut.feedMessages + [makeMessage(id: "p2"), makeMessage(id: "p3")] + + sut.updateFeedMessage(makeMessage(id: "p1", content: "after")) + + let merged = FeedMerge.merge(existing: viewCopy, incoming: sut.feedMessages) + XCTAssertEqual(merged.messages.map(\.id), ["p1", "p2", "p3"]) + XCTAssertEqual(merged.messages.first?.content, "after") + } + // MARK: - reset func test_reset_clearsFeedMessages() { @@ -352,8 +438,9 @@ final class AppDataStoreTests: XCTestCase { // MARK: - Helpers - private func makeMessage(id: String, linkMetadata: LinkMetadata? = nil) -> Message { - Message(id: id, content: "test", publiclyVisible: true, + private func makeMessage(id: String, content: String = "test", + linkMetadata: LinkMetadata? = nil) -> Message { + Message(id: id, content: content, publiclyVisible: true, userId: "u1", createdAt: "2026-01-01T00:00:00Z", updatedAt: nil, user: nil, imageUrls: nil, videoUrls: nil, linkMetadata: linkMetadata, parentId: nil, scheduledAt: nil, diff --git a/InterlinedListTests/ServiceTests/FeedMergeTests.swift b/InterlinedListTests/ServiceTests/FeedMergeTests.swift new file mode 100644 index 0000000..2067e34 --- /dev/null +++ b/InterlinedListTests/ServiceTests/FeedMergeTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import InterlinedList + +/// `Message`'s `==` is identity-based (id only), so every assertion here compares +/// content fields explicitly — comparing whole `Message` values would pass even when +/// the merge kept a stale row. +final class FeedMergeTests: XCTestCase { + + // MARK: - In-place updates + + func test_merge_existingRowWithNewContent_isReplacedInPlace() { + let existing = [makeMessage(id: "a", content: "before"), makeMessage(id: "b")] + let incoming = [makeMessage(id: "a", content: "after"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.messages.first?.content, "after") + } + + func test_merge_existingRowWithNewContent_isNotDuplicated() { + let existing = [makeMessage(id: "a", content: "before")] + let incoming = [makeMessage(id: "a", content: "after")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.count, 1) + } + + func test_merge_replacedRow_keepsItsPosition() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b", content: "before"), makeMessage(id: "c")] + let incoming = [makeMessage(id: "b", content: "after")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b", "c"]) + XCTAssertEqual(result.messages[1].content, "after") + } + + func test_merge_linkMetadataBackfill_isVisibleOnTheHeldRow() { + let existing = [makeMessage(id: "a")] + let backfilled = makeMessage( + id: "a", + linkMetadata: LinkMetadata(links: [ + LinkMetadataItem( + url: "https://example.com", + platform: nil, + metadata: LinkMetadataItemContent(thumbnail: nil, title: "Example", + description: nil, text: nil, type: nil), + fetchStatus: nil + ) + ]) + ) + + let result = FeedMerge.merge(existing: existing, incoming: [backfilled]) + + XCTAssertEqual(result.messages.first?.linkMetadata?.links.first?.metadata?.title, "Example") + } + + func test_merge_replacedRow_isReportedAsChanged() { + let result = FeedMerge.merge(existing: [makeMessage(id: "a", content: "before")], + incoming: [makeMessage(id: "a", content: "after")]) + + XCTAssertEqual(result.changed.map(\.id), ["a"]) + XCTAssertEqual(result.changed.first?.content, "after") + } + + // MARK: - Insertions + + func test_merge_unseenId_isPrependedNewestFirst() { + let existing = [makeMessage(id: "old")] + let incoming = [makeMessage(id: "new"), makeMessage(id: "old")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["new", "old"]) + } + + func test_merge_severalUnseenIds_keepIncomingOrder() { + let existing = [makeMessage(id: "old")] + let incoming = [makeMessage(id: "n1"), makeMessage(id: "n2"), makeMessage(id: "old")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["n1", "n2", "old"]) + } + + func test_merge_insertedRow_isReportedAsChanged() { + let result = FeedMerge.merge(existing: [makeMessage(id: "old")], + incoming: [makeMessage(id: "new")]) + + XCTAssertTrue(result.changed.contains { $0.id == "new" }) + } + + // MARK: - Rows the store does not hold + + func test_merge_paginatedRowsNotInIncoming_areKept() { + // Page 2+ is appended by the view and never reaches the store. + let existing = [makeMessage(id: "p1"), makeMessage(id: "p2"), makeMessage(id: "p3")] + let incoming = [makeMessage(id: "p1")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["p1", "p2", "p3"]) + } + + func test_merge_emptyIncoming_changesNothing() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: []) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertTrue(result.changed.isEmpty) + } + + func test_merge_emptyExisting_takesIncomingAsIs() { + let result = FeedMerge.merge(existing: [], incoming: [makeMessage(id: "a"), makeMessage(id: "b")]) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.changed.map(\.id), ["a", "b"]) + } + + func test_merge_identicalInput_reportsTheRowsItRefreshed() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: existing) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.changed.count, 2) + } + + // MARK: - Helpers + + private func makeMessage(id: String, + content: String = "test", + linkMetadata: LinkMetadata? = nil) -> Message { + Message(id: id, content: content, publiclyVisible: true, + userId: "u1", createdAt: "2026-01-01T00:00:00Z", + updatedAt: nil, user: nil, imageUrls: nil, videoUrls: nil, + linkMetadata: linkMetadata, parentId: nil, scheduledAt: nil, + tags: nil, digCount: 0, dugByMe: false, crossPostUrls: nil) + } +}