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
156 changes: 155 additions & 1 deletion apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
private var latestServerConfig: ServerConfigSnapshot?
private var serverConfigsByEnvironmentID: [String: ServerConfigSnapshot] = [:]
private var latestSnapshot: FeatureSnapshot?
/// One reorder at a time: a second move planned from the same keys would
/// land a conflicting write (React Native holds a pending order for this).
private var threadMoveInFlight = false
private var activeThreadID: String?
private var activeThreadEnvironmentID: String?
private var latestDetails: [String: FeatureThreadDetail] = [:]
Expand Down Expand Up @@ -1687,10 +1690,133 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,

func setThreadPinned(id: String, pinned: Bool) async throws {
let route = try threadRoute(for: id)
_ = try await route.client.pin(threadID: route.wireID, pinned: pinned)
// Same placement as web and React Native: a fresh pin takes the top of
// the arranged run. Servers that predate reordering get the bare pin
// (keyless, sorted by creation order below keyed threads).
var orderKey: String? = nil
if pinned, cachedThread(id: route.uiID)?.supportsPinReorder == true {
let firstKey = latestSnapshot?.threads
.filter { $0.pinnedAt != nil }
.compactMap(\.pinOrderKey)
.min()
orderKey = ThreadOrderPlanner.orderKeyBetween(before: nil, after: firstKey)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
_ = try await route.client.pin(threadID: route.wireID, pinned: pinned, orderKey: orderKey)
try? await refresh(client: route.client)
}

/// Saves a full-section order. Cross-section moves first clear the source
/// lifecycle state. Every key write goes to the thread's owning server.
@discardableResult
func reorderThread(
id: String,
section: FeatureThreadOrderSection,
orderedIDs: [String]
) async throws -> [FeatureThreadOrderAssignment] {
guard !threadMoveInFlight else { return [] }
guard let snapshot = latestSnapshot else {
throw NativeFeatureClientError.threadNotFound
}
// Only currently-connected environments are writable; a disconnected
// server's rows keep their stale keys as anchors but never receive
// writes, so a spread rewrite cannot half-land on a dead client.
let connectedEnvironmentIDs = Set(
snapshot.environments
.filter { $0.isEnabled && environmentConnectionStates[$0.id] == .connected }
.map(\.id)
)
let threadsByID = Dictionary(
snapshot.threads.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
let now = Date.now
let canonical = DailyUXSidebarIndex.orderedSection(snapshot.threads, section: section, now: now)
guard let moved = threadsByID[id],
ThreadArrangementPlanner.canEnter(moved, section: section, now: now),
Set(orderedIDs).count == orderedIDs.count,
Set(orderedIDs) == Set(canonical.map(\.id)).union([id]),
let assignments = ThreadOrderPlanner.planDrop(
ordered: orderedIDs.compactMap { threadsByID[$0] },
all: snapshot.threads,
section: section,
connectedEnvironmentIDs: connectedEnvironmentIDs,
movedID: id
) else {
return []
}

threadMoveInFlight = true
defer { threadMoveInFlight = false }

var confirmed: [FeatureThreadOrderAssignment] = []
var touchedEnvironmentIDs = Set<String>()
var firstError: Error?
let crossesSection = !canonical.contains(where: { $0.id == id })
if crossesSection {
do {
let route = try threadRoute(for: id)
touchedEnvironmentIDs.insert(route.environmentID)
for action in ThreadArrangementPlanner.lifecycle(moved, section: section, now: now) {
switch action {
case .pin:
let key = assignments.first(where: { $0.threadID == id })?.orderKey
_ = try await route.client.pin(threadID: route.wireID, pinned: true, orderKey: key)
case .unpin:
_ = try await route.client.pin(threadID: route.wireID, pinned: false)
case .unsettle:
_ = try await route.client.settle(threadID: route.wireID, settled: false)
case .unsnooze:
_ = try await route.client.snooze(threadID: route.wireID, until: nil)
}
}
} catch {
firstError = error
}
}
for assignment in assignments {
guard firstError == nil else { break }
do {
let route = try threadRoute(for: assignment.threadID)
// Refresh even when the transport lost a receipt after the
// server accepted the write.
touchedEnvironmentIDs.insert(route.environmentID)
switch section {
case .pinned:
_ = try await route.client.reorderPinnedThread(
threadID: route.wireID,
orderKey: assignment.orderKey
)
case .active:
_ = try await route.client.reorderActiveThread(
threadID: route.wireID,
orderKey: assignment.orderKey
)
}
confirmed.append(assignment)
} catch {
// Confirmed writes stand: a later environment rejecting its
// write leaves the earlier arrangement in place.
firstError = error
break
}
}
for environmentID in touchedEnvironmentIDs {
if let client = environmentClients[environmentID] {
try? await refresh(client: client)
}
}
if let firstError {
guard confirmed.isEmpty else {
throw FeatureThreadMovePartialError(
confirmed: confirmed,
underlying: firstError
)
}
throw firstError
}
return confirmed
}

func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {
let route = try threadRoute(for: id)
_ = try await route.client.setRuntimeMode(
Expand Down Expand Up @@ -5215,6 +5341,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSettlement: capabilities?.threadSettlement,
supportsSnooze: capabilities?.threadSnooze,
supportsPinning: capabilities?.threadPinning,
supportsPinReorder: capabilities?.threadPinReorder,
supportsActiveReorder: capabilities?.threadActiveReorder,
supportsTitleRegeneration: capabilities?.threadTitleRegeneration,
supportsPullRequestLinking: capabilities?.threadPullRequestLinking,
isRegeneratingTitle: thread.titleRegeneration != nil,
Expand Down Expand Up @@ -5302,6 +5430,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSettlement: capabilities?.threadSettlement,
supportsSnooze: capabilities?.threadSnooze,
supportsPinning: capabilities?.threadPinning,
supportsPinReorder: capabilities?.threadPinReorder,
supportsActiveReorder: capabilities?.threadActiveReorder,
supportsTitleRegeneration: capabilities?.threadTitleRegeneration,
supportsPullRequestLinking: capabilities?.threadPullRequestLinking,
isRegeneratingTitle: thread.titleRegeneration != nil,
Expand Down Expand Up @@ -5611,6 +5741,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
snoozedUntil: loaded.snoozedUntil,
snoozedAt: loaded.snoozedAt,
pinnedAt: loaded.pinnedAt,
pinOrderKey: loaded.pinOrderKey,
titleRegeneration: loaded.titleRegeneration,
deletedAt: loaded.deletedAt,
messages: prependByID(older.messages, loaded.messages),
Expand Down Expand Up @@ -7133,6 +7264,12 @@ enum NativeThreadDetailReducer {
result = reduceUnsettled(payload: payload, thread: thread)
case "thread.meta-updated":
result = reduceMetadata(payload: payload, occurredAt: occurredAt, thread: thread)
case "thread.pin-reordered":
result = reducePinReordered(
payload: payload,
occurredAt: occurredAt,
thread: thread
)
case "thread.message-sent":
result = reduceMessage(
payload: payload,
Expand Down Expand Up @@ -7267,6 +7404,23 @@ enum NativeThreadDetailReducer {
return .updated(updated)
}

private static func reducePinReordered(
payload: JSONValue,
occurredAt: String,
thread: OrchestrationThread
) -> NativeThreadDetailReductionResult {
guard let orderKey = payload["orderKey"]?.stringValue,
!orderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .refresh
}
var updated = replacing(
thread,
updatedAt: payload["updatedAt"]?.stringValue ?? occurredAt
)
updated.pinOrderKey = orderKey
return .updated(updated)
}

private static func reduceMessage(
payload: JSONValue,
occurredAt: String,
Expand Down
9 changes: 9 additions & 0 deletions apps/swift-ios/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable {
public var threadRestartContinuation: Bool? = nil
public let threadSnooze: Bool?
public let threadPinning: Bool?
public let threadPinReorder: Bool?
public let threadActiveReorder: Bool?
public let threadTitleRegeneration: Bool?
public let threadPullRequestLinking: Bool?
public let serverSelfUpdate: String?
Expand All @@ -131,6 +133,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable {
case threadRestartContinuation
case threadSnooze
case threadPinning
case threadPinReorder
case threadActiveReorder
case threadTitleRegeneration
case threadPullRequestLinking
case serverSelfUpdate
Expand Down Expand Up @@ -165,6 +169,11 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable {
)
threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze)
threadPinning = try container.decodeIfPresent(Bool.self, forKey: .threadPinning)
threadPinReorder = try container.decodeIfPresent(Bool.self, forKey: .threadPinReorder)
threadActiveReorder = try container.decodeIfPresent(
Bool.self,
forKey: .threadActiveReorder
)
threadTitleRegeneration = try container.decodeIfPresent(
Bool.self,
forKey: .threadTitleRegeneration
Expand Down
83 changes: 76 additions & 7 deletions apps/swift-ios/Core/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -873,8 +873,34 @@ public actor T3Client {
}

@discardableResult
public func pin(threadID: String, pinned: Bool) async throws -> DispatchResult {
try await dispatch(OrchestrationCommands.pin(threadID: threadID, pinned: pinned))
public func pin(
threadID: String,
pinned: Bool,
orderKey: String? = nil
) async throws -> DispatchResult {
try await dispatch(
OrchestrationCommands.pin(threadID: threadID, pinned: pinned, orderKey: orderKey)
)
}

@discardableResult
public func reorderPinnedThread(
threadID: String,
orderKey: String
) async throws -> DispatchResult {
try await dispatch(
OrchestrationCommands.reorderPinned(threadID: threadID, orderKey: orderKey)
)
}

@discardableResult
public func reorderActiveThread(
threadID: String,
orderKey: String
) async throws -> DispatchResult {
try await dispatch(
OrchestrationCommands.reorderActive(threadID: threadID, orderKey: orderKey)
)
}

@discardableResult
Expand Down Expand Up @@ -2278,13 +2304,56 @@ public enum OrchestrationCommands {
public static func pin(
threadID: String,
pinned: Bool,
orderKey: String? = nil,
commandID: String = UUID().uuidString
) -> JSONValue {
basic(
type: pinned ? "thread.pin" : "thread.unpin",
threadID: threadID,
commandID: commandID
)
guard pinned, let orderKey else {
return basic(
type: pinned ? "thread.pin" : "thread.unpin",
threadID: threadID,
commandID: commandID
)
}
return .object([
"type": .string("thread.pin"),
"commandId": .string(commandID),
"threadId": .string(threadID),
"orderKey": .string(orderKey),
])
}

/// Fractional-index reorder: pinned threads sort by `pinOrderKey`, so a
/// move writes one key on one thread (see `thread.pin.reorder` in
/// packages/contracts). `thread.active.reorder` does the same for the
/// active section's `activeOrderKey`.
public static func reorderPinned(
threadID: String,
orderKey: String,
commandID: String = UUID().uuidString
) -> JSONValue {
reorder(type: "thread.pin.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID)
}

public static func reorderActive(
threadID: String,
orderKey: String,
commandID: String = UUID().uuidString
) -> JSONValue {
reorder(type: "thread.active.reorder", threadID: threadID, orderKey: orderKey, commandID: commandID)
}

private static func reorder(
type: String,
threadID: String,
orderKey: String,
commandID: String
) -> JSONValue {
.object([
"type": .string(type),
"commandId": .string(commandID),
"threadId": .string(threadID),
"orderKey": .string(orderKey),
])
}

public static func setRuntimeMode(
Expand Down
Loading
Loading