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
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ public struct AgentSessionShare: Decodable, Sendable, Equatable, Identifiable {
public var usesLeft: UInt32
/// The hosted room's origin, nil without one.
public var room: String?
/// The link invitees open (`<room>/i/<share_id>`, no secret in it); nil without a room, and from a
/// daemon older than invite links.
public var inviteUrl: String?
public var guests: [AgentSessionShareGuest]
/// The cmux accounts invited to the share; empty from a daemon older than invites.
public var invitees: [AgentSessionShareInvitee]
Expand All @@ -193,10 +196,11 @@ public struct AgentSessionShare: Decodable, Sendable, Equatable, Identifiable {
case expiresAt = "expires_at"
case expiresInSecs = "expires_in_secs"
case usesLeft = "uses_left"
case inviteUrl = "invite_url"
}

public init(shareId: String, state: String, session: String, surface: UInt64, createdAt: UInt64 = 0, createId: String? = nil,
expiresAt: UInt64 = 0, expiresInSecs: UInt64 = 0, usesLeft: UInt32 = 0, room: String? = nil,
expiresAt: UInt64 = 0, expiresInSecs: UInt64 = 0, usesLeft: UInt32 = 0, room: String? = nil, inviteUrl: String? = nil,
guests: [AgentSessionShareGuest] = [], invitees: [AgentSessionShareInvitee] = []) {
self.shareId = shareId
self.state = state
Expand All @@ -208,6 +212,7 @@ public struct AgentSessionShare: Decodable, Sendable, Equatable, Identifiable {
self.expiresInSecs = expiresInSecs
self.usesLeft = usesLeft
self.room = room
self.inviteUrl = inviteUrl
self.guests = guests
self.invitees = invitees
}
Expand All @@ -224,6 +229,7 @@ public struct AgentSessionShare: Decodable, Sendable, Equatable, Identifiable {
expiresInSecs = try c.decode(UInt64.self, forKey: .expiresInSecs)
usesLeft = try c.decode(UInt32.self, forKey: .usesLeft)
room = try c.decodeIfPresent(String.self, forKey: .room)
inviteUrl = try c.decodeIfPresent(String.self, forKey: .inviteUrl)
guests = try c.decode([AgentSessionShareGuest].self, forKey: .guests)
invitees = try c.decodeIfPresent([AgentSessionShareInvitee].self, forKey: .invitees) ?? []
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
public import CmuxNextDaemon
import Foundation
public import Observation
import CmuxNextWakeups

/// The Invite People sheet's state (design chat-multiplayer.md 2, "Invite people"): who is invited to
/// one chat, an account field and a role, and the work the sheet runs. Invite and Remove are the same
/// work `agent.share.invite` and `agent.share.uninvite` run (``AgentShareActions/inviteWork(_:account:role:ttl:host:)``),
/// so the sheet holds no share logic of its own: it reads the chat's shares again after each change.
/// one chat, an account field and a role, the chat's invite link, and the work the sheet runs. Invite
/// and Remove are the same work `agent.share.invite` and `agent.share.uninvite` run
/// (``AgentShareActions/inviteWork(_:account:role:ttl:host:)``), so the sheet holds no share logic of
/// its own: it reads the chat's shares again after each change.
@MainActor @Observable
public final class AgentShareInviteSheetModel {
/// One invited account.
Expand Down Expand Up @@ -34,13 +36,17 @@ public final class AgentShareInviteSheetModel {
public var invite: @MainActor (_ account: String, _ role: String) async throws -> Void
/// Removes an invited account from every share of the chat.
public var remove: @MainActor (_ account: String) async throws -> Void
/// Puts the invite link on the pasteboard.
public var copy: @MainActor (_ text: String) -> Void

public init(list: @escaping @MainActor () async throws -> [AgentSessionShare],
invite: @escaping @MainActor (_ account: String, _ role: String) async throws -> Void,
remove: @escaping @MainActor (_ account: String) async throws -> Void) {
remove: @escaping @MainActor (_ account: String) async throws -> Void,
copy: @escaping @MainActor (_ text: String) -> Void = { _ in }) {
self.list = list
self.invite = invite
self.remove = remove
self.copy = copy
}
}

Expand All @@ -52,15 +58,26 @@ public final class AgentShareInviteSheetModel {
public var role: String
/// The invited accounts, oldest first.
public private(set) var rows: [Row] = []
/// The link invitees open (`<room>/i/<share>`, of the chat's invite share); nil until someone is
/// invited, and without a room.
public private(set) var inviteLink: String?
/// Copy Invite Link just ran: it reads Copied for ``copiedFor``, then goes back.
public private(set) var copied = false
/// How long Copy Invite Link reads Copied.
public var copiedFor: Duration = .milliseconds(1500)
/// Why the last step did not finish; nil when it did.
public private(set) var status: String?
/// A step runs: Invite and Remove wait for it.
/// An Invite runs or waits its turn: Invite is off meanwhile.
public private(set) var isWorking = false
/// The accounts a Remove is running for.
/// The accounts a Remove is running or waiting for.
public private(set) var removing: Set<String> = []

private let session: String
private let work: Work
/// The last Invite or Remove: the next one runs after it.
@ObservationIgnored private var lastStep: Task<Void, Never>?
/// Copied goes back to Copy Invite Link at this deadline; the next copy pushes it back.
@ObservationIgnored private let copiedReset = DemandTimer(owner: "agentShare.copiedLink")

public init(chat: String, session: String, role: String = "talk", work: Work) {
self.chat = chat
Expand All @@ -69,13 +86,15 @@ public final class AgentShareInviteSheetModel {
self.work = work
}

/// Invite is on once the field names someone and nothing else runs.
/// Invite is on once the field names someone and no Invite runs.
public var canInvite: Bool { !isWorking && !account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }

/// Reads who is invited (when the sheet opens, and after every change).
/// Reads who is invited and the invite link (when the sheet opens, and after every change).
public func load() async {
do {
rows = AgentShareInvites.rows(of: session, in: try await work.list())
let shares = try await work.list()
rows = AgentShareInvites.rows(of: session, in: shares)
inviteLink = AgentShareInvites.inviteLink(of: session, in: shares)
} catch {
status = AgentShareStrings.failure(error).message
}
Expand All @@ -86,30 +105,58 @@ public final class AgentShareInviteSheetModel {
public func invite() async {
let account = account.trimmingCharacters(in: .whitespacesAndNewlines)
guard canInvite else { return }
let role = role
isWorking = true
defer { isWorking = false }
do {
try await work.invite(account, role)
status = nil
// Typing a next account while the invite ran keeps it.
if self.account.trimmingCharacters(in: .whitespacesAndNewlines) == account { self.account = "" }
} catch {
status = AgentShareStrings.failure(error).message
await inTurn {
do {
try await self.work.invite(account, role)
self.status = nil
// Typing a next account while the invite ran keeps it.
if self.account.trimmingCharacters(in: .whitespacesAndNewlines) == account { self.account = "" }
} catch {
self.status = AgentShareStrings.failure(error).message
}
await self.load()
}
await load()
}

/// Removes `account`: it joins no more, and every device it joined from leaves.
public func remove(_ account: String) async {
guard !removing.contains(account) else { return }
removing.insert(account)
defer { removing.remove(account) }
do {
try await work.remove(account)
status = nil
} catch {
status = AgentShareStrings.failure(error).message
await inTurn {
do {
try await self.work.remove(account)
self.status = nil
} catch {
self.status = AgentShareStrings.failure(error).message
}
await self.load()
}
}

/// Puts the invite link on the pasteboard; Copy Invite Link reads Copied for ``copiedFor``.
public func copyLink() {
guard let inviteLink else { return }
work.copy(inviteLink)
copied = true
copiedReset.schedule(after: copiedFor) { @MainActor [weak self] in
self?.copied = false
}
}

/// Runs `step` after every Invite and Remove pressed before it, so two steps for one account (an
/// Invite, then a Remove) land in the order pressed.
private func inTurn(_ step: @escaping @MainActor () async -> Void) async {
let before = lastStep
// task-owner: one Invite or Remove, after the one before it; ends with the step
let mine = Task { @MainActor in
await before?.value
await step()
}
await load()
lastStep = mine
await mine.value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ extension AgentShareActions {
AgentShareInviteSheetModel(chat: chat.title, session: chat.session, role: role, work: AgentShareInviteSheetModel.Work(
list: { try await Self.connected(chat.connection).request(AgentSessionShareListRequest(surface: chat.surface)).shares },
invite: { account, role in _ = try await Self.inviteWork(chat, account: account, role: role, ttl: nil, host: host) },
remove: { account in _ = try await Self.uninviteWork(account: account, shareID: nil, chat: chat, connection: chat.connection) }
remove: { account in _ = try await Self.uninviteWork(account: account, shareID: nil, chat: chat, connection: chat.connection) },
copy: { host.shareCopy($0) }
))
}
}
Expand All @@ -150,7 +151,17 @@ enum AgentShareInvites {
/// invitees. A guest link without any is left alone (its 24 hours and single use are the link's),
/// so nil makes a new share for invites.
static func inviteShare(of session: String, in shares: [AgentSessionShare]) -> String? {
shares.filter { $0.session == session && $0.isLive && !$0.invitees.isEmpty }.max { $0.createdAt < $1.createdAt }?.shareId
inviteRecord(of: session, in: shares)?.shareId
}

/// The link of the share invites to `session`'s chat go on (``inviteShare(of:in:)``); nil with none,
/// or while that share has no room.
static func inviteLink(of session: String, in shares: [AgentSessionShare]) -> String? {
inviteRecord(of: session, in: shares)?.inviteUrl
}

private static func inviteRecord(of session: String, in shares: [AgentSessionShare]) -> AgentSessionShare? {
shares.filter { $0.session == session && $0.isLive && !$0.invitees.isEmpty }.max { $0.createdAt < $1.createdAt }
}

/// Every share of `session`'s chat on which `account` is still invited.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ enum AgentShareResults {
"surface": .int(Int(clamping: share.surface)), "created_at": .int(Int(clamping: share.createdAt)),
"expires_at": .int(Int(clamping: share.expiresAt)), "expires_in_secs": .int(Int(clamping: share.expiresInSecs)),
"uses_left": .int(Int(share.usesLeft)), "room": share.room.map(ActionResultValue.string) ?? .null,
"invite_url": share.inviteUrl.map(ActionResultValue.string) ?? .null,
"guests": .array(share.guests.map(guest)),
"invitees": .array(share.invitees.map { invitee in
.object(["account": .string(invitee.account), "role": .string(invitee.role),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,13 @@ public extension AgentShareInviteSheetModel {
var closeTitle: String {
String(localized: "agentShare.sheet.close", defaultValue: "Close", table: "AgentShare", bundle: .module)
}

var copyLinkTitle: String {
String(localized: "agentShare.sheet.copyLink", defaultValue: "Copy Invite Link", table: "AgentShare", bundle: .module)
}

/// Copy Invite Link's title for a moment after a press.
var copiedTitle: String {
String(localized: "agentShare.sheet.copied", defaultValue: "Copied", table: "AgentShare", bundle: .module)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6812,6 +6812,124 @@
}
}
}
},
"agentShare.sheet.copyLink": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy Invite Link"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Einladungslink kopieren"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Copier le lien d’invitation"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "نسخ رابط الدعوة"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Copiar enlace de invitación"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "拷貝邀請連結"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "拷贝邀请链接"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "초대 링크 복사"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "招待リンクをコピー"
}
}
}
},
"agentShare.sheet.copied": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copied"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Kopiert"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Copié"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تم النسخ"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Copiado"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "已拷貝"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "已拷贝"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "복사됨"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "コピーしました"
}
}
}
}
},
"version": "1.0"
Expand Down
Loading
Loading