eleven / messenger Public

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>
Paul Campbell 05bf1616f453d103769766754ffda337e13737db parent 8a2db71
4 files changed, +105 −8
  • ios/LChat/LChatApp.swift +14 −3
  • ios/LChatCore/Sources/LChatCore/HomeVault.swift +27 −0
  • ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift +42 −2
  • ios/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()
}