ios+mac: archive repositions in lockstep with home — unarchive lands at the bottom (#473)
The native toggles flipped the archived flag in place, diverging from the web contract (serverlist.js setArchived, PR #445): archiving drops the star and lands at the TOP of the archived group; unarchiving lands at the BOTTOM of the active list. Both are the active/archived boundary — one insert. - HomeVault gains partitionArchived + setArchived, mirroring serverlist.js (HomeVaultTests ports its tests, matching the toggleStar pattern). - iOS toggleArchive and Mac toggleArchive (instances + the chats rail mirror, like toggleStar) now reposition with the same semantics, so the order HomeSync pushes to the vault matches what home would have written. No changelog entry — native change, waits for the release that ships it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+105
−8
ios/LChat/LChatApp.swift+14 −3ios/LChatCore/Sources/LChatCore/HomeVault.swift+27 −0ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift+42 −2ios/LChatMac/LChatMacApp.swift+22 −3
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index ca2427d..253301e 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -161,10 +161,21 @@ final class AppState: ObservableObject { |
| } |
| /// Archive or unarchive a space: hidden from the drawer's main lists, |
| - /// grouped under Archived. No reordering — position is kept for unarchive. |
| + /// grouped under Archived. Repositions with the home vault's setArchived |
| + /// semantics so the array order stays in lockstep: archiving drops the star |
| + /// and lands at the TOP of the archived group; unarchiving lands at the |
| + /// BOTTOM of the active list. Both are the active/archived boundary — one |
| + /// insert. |
| func toggleArchive(_ inst: SavedInstance) { |
| - guard let i = instances.firstIndex(where: { $0.id == inst.id }) else { return } |
| - instances[i].archived = instances[i].isArchived ? nil : true |
| + guard let target = instances.first(where: { $0.id == inst.id }) else { return } |
| + var updated = target |
| + let on = !target.isArchived |
| + updated.archived = on ? true : nil |
| + if on { updated.starred = nil } // leaving the rail |
| + let starred = instances.filter { $0.id != inst.id && $0.isStarred && !$0.isArchived } |
| + let others = instances.filter { $0.id != inst.id && !$0.isStarred && !$0.isArchived } |
| + let archived = instances.filter { $0.id != inst.id && $0.isArchived } |
| + instances = starred + others + [updated] + archived |
| InstancesStore.save(instances) |
| HomeSync.pushSoon(instances: instances) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeVault.swift b/ios/LChatCore/Sources/LChatCore/HomeVault.swift| index b3bf95c..b755804 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| @@ -83,6 +83,33 @@ public struct HomeVault { |
| return starred + [updated] + others |
| } |
| + /// Split into (active, archived), each preserving its own array order — the |
| + /// first cut rendering makes, before active splits into starred/others. |
| + public static func partitionArchived(_ servers: [Server]) -> (active: [Server], archived: [Server]) { |
| + var active: [Server] = [], archived: [Server] = [] |
| + for s in servers { if s.isArchived { archived.append(s) } else { active.append(s) } } |
| + return (active, archived) |
| + } |
| + |
| + /// Set a server's archived flag AND reposition it, keeping the master order |
| + /// active-first-then-archived (mirrors serverlist.js setArchived): |
| + /// archiving drops `starred` (an archived space leaves the rail) and lands |
| + /// at the TOP of the archived group; unarchiving lands at the BOTTOM of the |
| + /// active list — a returning space rejoins at the back, not ahead of the |
| + /// spaces you kept. Both are the boundary between the two groups, so the |
| + /// move is a single insert there. Returns a NEW array; an unknown url |
| + /// passes through unchanged. |
| + public static func setArchived(_ servers: [Server], url: String, _ on: Bool) -> [Server] { |
| + guard let target = servers.first(where: { $0.url == url }) else { return servers } |
| + var updated = target |
| + updated.archived = on ? true : nil // nil ⇒ omitted, like the web's drop |
| + if on { updated.starred = nil } // leaving the rail |
| + let starred = servers.filter { $0.url != url && $0.isStarred && !$0.isArchived } |
| + let others = servers.filter { $0.url != url && !$0.isStarred && !$0.isArchived } |
| + let archived = servers.filter { $0.url != url && $0.isArchived } |
| + return starred + others + [updated] + archived |
| + } |
| + |
| /// Swap the entry at `url` with its nearest SAME-GROUP neighbour in `dir` |
| /// (-1 = up, +1 = down), skipping the other group; unchanged if it's already |
| /// at an end of its list. The keyboard-reorder fallback. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift| index f2ffb24..ba020f3 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift |
| @@ -6,8 +6,9 @@ import XCTest |
| // the native rail sorts a member's spaces exactly like the home page (same star |
| // + independent-sort contract). |
| final class HomeVaultTests: XCTestCase { |
| - private func srv(_ url: String, starred: Bool? = nil, name: String? = nil) -> HomeVault.Server { |
| - HomeVault.Server(url: url, name: name, starred: starred, |
| + private func srv(_ url: String, starred: Bool? = nil, archived: Bool? = nil, |
| + name: String? = nil) -> HomeVault.Server { |
| + HomeVault.Server(url: url, name: name, starred: starred, archived: archived, |
| credentials: .init(token: "t", userPub: "p", userPriv: "k", name: nil)) |
| } |
| private func urls(_ list: [HomeVault.Server]) -> [String] { list.map(\.url) } |
| @@ -73,6 +74,45 @@ final class HomeVaultTests: XCTestCase { |
| XCTAssertEqual(out.first(where: { $0.url == "a" })?.starred, true, "flag preserved") |
| } |
| + func testPartitionArchivedKeepsArrayOrder() { |
| + let servers = [srv("a", starred: true), srv("b", archived: true), srv("c"), srv("d", archived: true)] |
| + let (active, archived) = HomeVault.partitionArchived(servers) |
| + XCTAssertEqual(urls(active), ["a", "c"]) |
| + XCTAssertEqual(urls(archived), ["b", "d"]) |
| + } |
| + |
| + func testSetArchivedDropsStarWithoutMutating() { |
| + let servers = [srv("a", starred: true), srv("b")] |
| + let next = HomeVault.setArchived(servers, url: "a", true) |
| + let entry = next.first { $0.url == "a" } |
| + XCTAssertEqual(entry?.archived, true) |
| + XCTAssertNil(entry?.starred, "an archived space leaves the rail") |
| + XCTAssertEqual(servers[0].starred, true, "input must be untouched") |
| + XCTAssertNil(servers[0].archived, "input must be untouched") |
| + } |
| + |
| + func testArchivingSendsToTopOfArchive() { |
| + // x active, y already archived; archive 'x' → it lands above y. |
| + let servers = [srv("s", starred: true), srv("x"), srv("z"), srv("y", archived: true)] |
| + let (active, archived) = HomeVault.partitionArchived(HomeVault.setArchived(servers, url: "x", true)) |
| + XCTAssertEqual(urls(active), ["s", "z"], "active keeps order minus x") |
| + XCTAssertEqual(urls(archived), ["x", "y"], "x is the top of the archive") |
| + } |
| + |
| + func testUnarchivingSendsToBottomOfActive() { |
| + let servers = [srv("s", starred: true), srv("a"), srv("z", archived: true), srv("y", archived: true)] |
| + let next = HomeVault.setArchived(servers, url: "z", false) |
| + XCTAssertNil(next.first { $0.url == "z" }?.archived, "unarchived entry carries no flag") |
| + let (active, archived) = HomeVault.partitionArchived(next) |
| + XCTAssertEqual(urls(active), ["s", "a", "z"], "z rejoins at the bottom, after kept spaces") |
| + XCTAssertEqual(urls(archived), ["y"], "y stays archived") |
| + } |
| + |
| + func testSetArchivedUnknownUrlUnchanged() { |
| + let servers = [srv("a", name: "A")] |
| + XCTAssertEqual(HomeVault.setArchived(servers, url: "zzz", true), servers) |
| + } |
| + |
| // `starred: nil` must serialise as an ABSENT key (like the web's drop), not |
| // `"starred": null` — otherwise the two impls' blobs would diverge. |
| func testNilStarredOmittedFromJSON() throws { |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index bd3d7dd..cae76f2 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -466,18 +466,37 @@ final class MacAppState: ObservableObject { |
| /// Archived: hidden from the rail and the overflow list, reachable via the |
| /// "…" menu's Archived group. Same identity-flag shape as starring (and |
| - /// synced to the vault the same way); no reordering — position is kept for |
| - /// when it's unarchived. |
| + /// synced to the vault the same way). |
| func isArchived(_ chat: SavedChat) -> Bool { |
| guard let origin = chat.origin?.absoluteString else { return false } |
| return instances.first { $0.origin == origin }?.isArchived ?? false |
| } |
| + /// Archive or unarchive a chat, repositioning BOTH lists with the vault's |
| + /// setArchived semantics (archive → drop the star, top of the archived |
| + /// group; unarchive → bottom of the active list — both the active/archived |
| + /// boundary), so the rail, iOS, and home stay in step. |
| func toggleArchive(_ chat: SavedChat) { |
| guard let origin = chat.origin?.absoluteString, |
| let i = instances.firstIndex(where: { $0.origin == origin }) else { return } |
| - instances[i].archived = instances[i].isArchived ? nil : true |
| + var flipped = instances[i] |
| + let on = !flipped.isArchived |
| + flipped.archived = on ? true : nil |
| + if on { flipped.starred = nil } // leaving the rail |
| + let starredInst = instances.filter { $0.origin != origin && $0.isStarred && !$0.isArchived } |
| + let otherInst = instances.filter { $0.origin != origin && !$0.isStarred && !$0.isArchived } |
| + let archivedInst = instances.filter { $0.origin != origin && $0.isArchived } |
| + instances = starredInst + otherInst + [flipped] + archivedInst |
| InstancesStore.save(instances) |
| + // The rail renders from `chats` — mirror the same boundary move there. |
| + // (isArchived reads the just-updated instances, so partitioning is fresh.) |
| + if let j = chats.firstIndex(where: { $0.id == chat.id }) { |
| + let moved = chats.remove(at: j) |
| + let activeChats = chats.filter { !isArchived($0) } |
| + let archivedChats = chats.filter { isArchived($0) } |
| + chats = activeChats + [moved] + archivedChats |
| + MacChatsStore.save(chats) |
| + } |
| syncHome() |
| } |