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
8 changes: 8 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -415,6 +417,8 @@
AC8F31A0EA9ADA8C6827A16C /* APIClient+AppSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+AppSettings.swift"; sourceTree = "<group>"; };
AE452BC01A7461AF5199FFE6 /* AIJSON.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSON.swift; sourceTree = "<group>"; };
B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = "<group>"; };
FEED105A0000000000000002 /* FeedMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMerge.swift; sourceTree = "<group>"; };
FEED105A0000000000000004 /* FeedMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMergeTests.swift; sourceTree = "<group>"; };
B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = "<group>"; };
B1C1D1E1F1A10006 /* SkeletonBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBlock.swift; sourceTree = "<group>"; };
B1C1D1E1F1A10008 /* FeedSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSkeletonView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
20 changes: 19 additions & 1 deletion InterlinedList/Services/AppDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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.
///
Expand Down
37 changes: 37 additions & 0 deletions InterlinedList/Services/FeedMerge.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 8 additions & 7 deletions InterlinedList/Views/FeedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
91 changes: 89 additions & 2 deletions InterlinedListTests/ServiceTests/AppDataStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading