Merge nucleic/lucid-coral-otter-x7ib into dev
This commit is contained in:
@@ -432,35 +432,38 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
// MARK: - Live composer streaming (the "composer.typing" channel)
|
||||
|
||||
/// The live remote typer per session (mesh composer streaming) — folded from
|
||||
/// `composer.typing` casts, *other* devices only (this phone's own typing, republished by
|
||||
/// the session's host, is skipped so the typer never locks itself). The session detail
|
||||
/// locks its composer and renders the streamed draft while an entry is present;
|
||||
/// expiry-guarded locally so a typer that vanished without its tombstone can't leave the
|
||||
/// composer locked.
|
||||
/// The session's shared composer draft as other devices left it (mesh composer streaming) —
|
||||
/// folded from `composer.typing` casts, *other* devices only (this phone's own typing,
|
||||
/// republished by the session's host, is skipped so it never locks itself or re-adopts its
|
||||
/// own text). An `editing` entry locks the session detail's composer and renders the
|
||||
/// incoming draft above the field; a settled one moves into it. Settle-guarded locally too,
|
||||
/// so an editor that vanished can't leave the composer locked — and its text is kept.
|
||||
@Published private(set) var composerTypingBySession: [SessionID: ComposerTypingState] = [:]
|
||||
/// Watcher-side expiry timers for `composerTypingBySession` (the crash guard).
|
||||
/// Watcher-side settle timers for `composerTypingBySession` (the crash guard).
|
||||
private var composerExpiryTasks: [SessionID: Task<Void, Never>] = [:]
|
||||
/// Sender-side throttle + idle bookkeeping for streaming THIS device's drafts: the
|
||||
/// trailing-edge send, the last send time, the newest not-yet-sent draft, the idle-stop
|
||||
/// timer, and which sessions have an active report out (so `ended` sends exactly one stop).
|
||||
/// trailing-edge send, the last send time, the newest not-yet-sent draft, the newest draft
|
||||
/// known (retained past the send, so the idle settle can report the final text), the idle
|
||||
/// timer, and which sessions have an active report out (so a settle reports exactly once).
|
||||
private var composerSendTasks: [SessionID: Task<Void, Never>] = [:]
|
||||
private var composerLastSentAt: [SessionID: Date] = [:]
|
||||
private var composerPendingText: [SessionID: String] = [:]
|
||||
private var composerLatestText: [SessionID: String] = [:]
|
||||
private var composerIdleTasks: [SessionID: Task<Void, Never>] = [:]
|
||||
private var composerStreamingSessions: Set<SessionID> = []
|
||||
|
||||
/// The composer's draft for `sessionID` changed on this phone — report it (throttled) to
|
||||
/// the session's host, which republishes it on the `composer.typing` channel for the rest
|
||||
/// of the mesh, and (re)arm the idle stop. An emptied field ends the typing immediately.
|
||||
/// A no-op toward a host that never advertised `canStreamComposer` (an older host would
|
||||
/// throw on the unknown tag).
|
||||
/// of the mesh, and (re)arm the idle settle. An emptied field clears the shared draft
|
||||
/// mesh-wide immediately. A no-op toward a host that never advertised `canStreamComposer`
|
||||
/// (an older host would throw on the unknown tag).
|
||||
func composerDraftChanged(_ sessionID: SessionID, text: String) {
|
||||
guard !demoMode else { return }
|
||||
guard !text.isEmpty else { composerDraftEnded(sessionID); return }
|
||||
guard !text.isEmpty else { composerDraftCleared(sessionID); return }
|
||||
guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive,
|
||||
conn.capabilities.canStreamComposer else { return }
|
||||
composerPendingText[sessionID] = text
|
||||
composerLatestText[sessionID] = text
|
||||
scheduleComposerIdleStop(sessionID)
|
||||
guard composerSendTasks[sessionID] == nil else { return } // trailing edge armed
|
||||
let elapsed = Date().timeIntervalSince(composerLastSentAt[sessionID] ?? .distantPast)
|
||||
@@ -472,19 +475,43 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing for `sessionID` finished on this phone — sent, cleared, or the detail closed.
|
||||
/// Sends the stop that tombstones the session's entry (the host also idle-stops on its own
|
||||
/// after `idleTimeout`, so a phone that vanishes still unlocks everyone).
|
||||
/// Editing stopped on this phone but the text stands — it went idle, or the detail closed.
|
||||
/// Reports the final draft with `active: false`, which settles the session's entry: every
|
||||
/// composer keeps that text and unlocks. (The host also idle-settles on its own after
|
||||
/// `idleTimeout`, so a phone that vanishes still unlocks everyone.)
|
||||
///
|
||||
/// A no-op when this phone never claimed the editing lock — a chat whose shared draft it
|
||||
/// merely adopted is left exactly as it found it, not settled *or* cleared.
|
||||
func composerDraftEnded(_ sessionID: SessionID) {
|
||||
composerSendTasks[sessionID]?.cancel()
|
||||
composerSendTasks[sessionID] = nil
|
||||
composerPendingText[sessionID] = nil
|
||||
composerIdleTasks[sessionID]?.cancel()
|
||||
composerIdleTasks[sessionID] = nil
|
||||
composerLastSentAt[sessionID] = nil
|
||||
cancelComposerSendWork(sessionID)
|
||||
let text = composerLatestText.removeValue(forKey: sessionID) ?? ""
|
||||
guard composerStreamingSessions.remove(sessionID) != nil else { return }
|
||||
connection(owningSession: sessionID)?.send(
|
||||
.composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false)))
|
||||
.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: false)))
|
||||
}
|
||||
|
||||
/// The shared draft was consumed on this phone — sent, or the field emptied by hand. Clears
|
||||
/// the session's entry mesh-wide so every other composer empties too. Reported even when
|
||||
/// this phone never streamed anything: it may be consuming a draft it merely adopted.
|
||||
func composerDraftCleared(_ sessionID: SessionID) {
|
||||
guard !demoMode else { return }
|
||||
cancelComposerSendWork(sessionID)
|
||||
composerLatestText[sessionID] = nil
|
||||
let held = composerTypingBySession.removeValue(forKey: sessionID) != nil
|
||||
composerExpiryTasks.removeValue(forKey: sessionID)?.cancel()
|
||||
let streamed = composerStreamingSessions.remove(sessionID) != nil
|
||||
guard streamed || held else { return }
|
||||
guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive,
|
||||
conn.capabilities.canStreamComposer else { return }
|
||||
conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false)))
|
||||
}
|
||||
|
||||
/// Drop the throttle's in-flight work for a session (shared by settle and clear).
|
||||
private func cancelComposerSendWork(_ sessionID: SessionID) {
|
||||
composerSendTasks.removeValue(forKey: sessionID)?.cancel()
|
||||
composerPendingText[sessionID] = nil
|
||||
composerIdleTasks.removeValue(forKey: sessionID)?.cancel()
|
||||
composerLastSentAt[sessionID] = nil
|
||||
}
|
||||
|
||||
/// One throttle-window flush: report the newest pending draft to the session's host.
|
||||
@@ -498,7 +525,8 @@ final class RemoteStore: ObservableObject {
|
||||
conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: true)))
|
||||
}
|
||||
|
||||
/// Re-arm the typer-side idle stop: `idleTimeout` with no keystrokes ends the typing.
|
||||
/// Re-arm the editor-side idle settle: `idleTimeout` with no keystrokes hands the text to
|
||||
/// every composer and unlocks them.
|
||||
private func scheduleComposerIdleStop(_ sessionID: SessionID) {
|
||||
composerIdleTasks[sessionID]?.cancel()
|
||||
composerIdleTasks[sessionID] = Task { [weak self] in
|
||||
@@ -508,31 +536,37 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold composer-typing casts into the watcher projection: fresh entries from other
|
||||
/// devices lock (and render into) that session's composer; tombstones — and local expiry,
|
||||
/// for a typer that vanished without one — unlock it.
|
||||
/// Fold composer-typing casts into the watcher projection: a fresh `editing` entry from
|
||||
/// another device locks (and renders above) that session's composer; a settled one hands
|
||||
/// its text to the composer and unlocks; a tombstone empties it.
|
||||
private func updateComposerTyping(_ casts: [WireCast]) {
|
||||
for cast in casts where cast.channel == MeshCastChannel.composerTyping {
|
||||
guard let state = ComposerTypingState.decode(cast) else { continue }
|
||||
if cast.deleted {
|
||||
guard composerTypingBySession[state.sessionID]?.deviceID == state.deviceID
|
||||
else { continue }
|
||||
// This phone's own entries (republished by the session's host under its deviceID)
|
||||
// never touch its own composer — it already holds what it typed.
|
||||
guard state.deviceID != IdentityStore.deviceID() else { continue }
|
||||
guard !cast.deleted else {
|
||||
// The draft was consumed or emptied somewhere in the mesh. Authoritative from
|
||||
// whichever device did it — any holder may consume a shared draft.
|
||||
composerTypingBySession[state.sessionID] = nil
|
||||
composerExpiryTasks[state.sessionID]?.cancel()
|
||||
composerExpiryTasks[state.sessionID] = nil
|
||||
composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel()
|
||||
continue
|
||||
}
|
||||
// Own typing (republished by the session's host under this phone's deviceID)
|
||||
// never locks this composer; an entry already past its window (a catch-up
|
||||
// replay) never locks at all.
|
||||
guard state.deviceID != IdentityStore.deviceID(),
|
||||
state.isFresh(at: Date()) else { continue }
|
||||
composerTypingBySession[state.sessionID] = state
|
||||
scheduleComposerExpiry(state.sessionID, publishedAt: state.at)
|
||||
if state.locksComposers(at: Date()) {
|
||||
composerTypingBySession[state.sessionID] = state
|
||||
scheduleComposerExpiry(state.sessionID, publishedAt: state.at)
|
||||
} else {
|
||||
// Settled — or an `editing` entry so old it's plainly abandoned (a catch-up
|
||||
// replay), which is the same thing: keep the text, lock nobody.
|
||||
composerTypingBySession[state.sessionID] = state.settled()
|
||||
composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm (or re-arm) the watcher-side expiry for a session's typing entry.
|
||||
/// Arm (or re-arm) the watcher-side settle for a session's editing entry — the crash guard
|
||||
/// for an editor that went silent without publishing its own settle. Its text is kept (it
|
||||
/// moves into the composer, as a published settle would); only the lock is released.
|
||||
private func scheduleComposerExpiry(_ sessionID: SessionID, publishedAt: Date) {
|
||||
composerExpiryTasks[sessionID]?.cancel()
|
||||
let deadline = publishedAt.addingTimeInterval(ComposerTypingState.staleTimeout)
|
||||
@@ -541,8 +575,8 @@ final class RemoteStore: ObservableObject {
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
guard let entry = self.composerTypingBySession[sessionID],
|
||||
!entry.isFresh(at: Date()) else { return }
|
||||
self.composerTypingBySession[sessionID] = nil
|
||||
entry.editing, !entry.isFresh(at: Date()) else { return }
|
||||
self.composerTypingBySession[sessionID] = entry.settled()
|
||||
self.composerExpiryTasks[sessionID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ struct SessionDetailView: View {
|
||||
// matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
|
||||
@State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
|
||||
@State private var draft = ""
|
||||
// Mesh composer streaming bookkeeping for `draft`: the text this composer last took from the
|
||||
// mesh (so a draft consumed on another device clears only what came from there, never what
|
||||
// has been typed here since), and the same text held for one update as the echo to swallow —
|
||||
// adopting the shared draft must not stream it straight back out and lock every other device.
|
||||
@State private var meshDraft: String?
|
||||
@State private var meshDraftEcho: String?
|
||||
// Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and
|
||||
// materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick
|
||||
// refused for exceeding the per-message size cap.
|
||||
@@ -46,13 +52,49 @@ struct SessionDetailView: View {
|
||||
store.sessions.first { $0.sessionID == sessionID }
|
||||
}
|
||||
|
||||
/// Another device's live typing in this chat's composer (mesh composer streaming). While
|
||||
/// present, this composer is locked and renders the streamed draft; it unlocks when the
|
||||
/// typer sends or goes idle (their tombstone — or the local expiry guard — clears it).
|
||||
private var remoteTyping: ComposerTypingState? {
|
||||
/// This chat's shared composer draft as another device left it (mesh composer streaming) —
|
||||
/// being edited there right now, or settled and waiting to be picked up here.
|
||||
private var sharedComposerDraft: ComposerTypingState? {
|
||||
store.composerTypingBySession[sessionID]
|
||||
}
|
||||
|
||||
/// Another device is editing this chat's composer right now. While present, this composer is
|
||||
/// locked and renders the incoming draft above the field; it unlocks when that device stops
|
||||
/// — and the text it left settles into the field (`sharedComposerDraftChanged`).
|
||||
private var remoteTyping: ComposerTypingState? {
|
||||
sharedComposerDraft.flatMap { $0.editing ? $0 : nil }
|
||||
}
|
||||
|
||||
/// This chat's shared draft changed on another device — apply it to the field.
|
||||
///
|
||||
/// A *settled* entry (the other device stopped typing) moves into the composer: the text
|
||||
/// carries on where they left off, on every device, instead of vanishing with the lock. A
|
||||
/// tombstone means the draft was consumed there (sent) or emptied, so this field empties too
|
||||
/// — but only the part that came from the mesh; anything typed here since stands, and is
|
||||
/// itself the newer shared draft. An `editing` entry applies nothing: it renders live in the
|
||||
/// row above the (locked) field until it settles.
|
||||
private func sharedComposerDraftChanged(
|
||||
from old: ComposerTypingState?, to new: ComposerTypingState?
|
||||
) {
|
||||
guard let new else {
|
||||
guard old != nil, draft == meshDraft else { return }
|
||||
meshDraft = nil
|
||||
applyMeshDraft("")
|
||||
return
|
||||
}
|
||||
guard !new.editing else { return }
|
||||
meshDraft = new.text
|
||||
applyMeshDraft(new.text)
|
||||
}
|
||||
|
||||
/// Put mesh-sourced text in the composer without streaming it back out (see the `draft`
|
||||
/// `onChange` — `meshDraftEcho` is the one update it swallows).
|
||||
private func applyMeshDraft(_ text: String) {
|
||||
guard draft != text else { return }
|
||||
meshDraftEcho = text
|
||||
draft = text
|
||||
}
|
||||
|
||||
/// The friendly label for a remote typer, never blank.
|
||||
private func typerName(_ typing: ComposerTypingState) -> String {
|
||||
typing.deviceName.isEmpty ? "Another device" : typing.deviceName
|
||||
@@ -109,8 +151,17 @@ struct SessionDetailView: View {
|
||||
.onAppear {
|
||||
openToken = store.open(sessionID)
|
||||
// Restore any unsent follow-up — the composer keeps what was typed across
|
||||
// backgrounding, locking, or the view being torn down and remounted.
|
||||
draft = store.sessionDraft(sessionID)
|
||||
// backgrounding, locking, or the view being torn down and remounted. With
|
||||
// nothing kept here, pick up the chat's shared draft instead: another device
|
||||
// may have left one settled while this phone was away.
|
||||
let kept = store.sessionDraft(sessionID)
|
||||
if kept.isEmpty, let shared = sharedComposerDraft, !shared.editing,
|
||||
!shared.text.isEmpty {
|
||||
meshDraft = shared.text
|
||||
applyMeshDraft(shared.text)
|
||||
} else {
|
||||
draft = kept
|
||||
}
|
||||
}
|
||||
// Pass our own id + token so an iPad split-view A→B switch (which may mount B
|
||||
// before A disappears) unsubscribes A without tearing down B's just-opened state —
|
||||
@@ -126,14 +177,29 @@ struct SessionDetailView: View {
|
||||
}
|
||||
// Stream this composer's draft to the session's host (throttled in the store)
|
||||
// so every other device viewing this chat sees it live and locks its own
|
||||
// composer; an emptied field — including the clear on send — ends the typing
|
||||
// and unlocks them. Leaving the detail ends it too.
|
||||
// composer; an emptied field — including the clear on send — consumes the
|
||||
// shared draft and empties theirs too. Leaving the detail releases the lock
|
||||
// while leaving the text as the chat's shared draft.
|
||||
.onChange(of: draft) { _, text in
|
||||
store.composerDraftChanged(sessionID, text: text)
|
||||
// Text this composer just took from the mesh is already the shared draft —
|
||||
// echoing it back out would claim the editing lock on every other device
|
||||
// for a change the user never made.
|
||||
if meshDraftEcho == text {
|
||||
meshDraftEcho = nil
|
||||
} else {
|
||||
meshDraftEcho = nil
|
||||
store.composerDraftChanged(sessionID, text: text)
|
||||
}
|
||||
// Durably save the draft (cleared once the field empties, e.g. on send) so it
|
||||
// survives leaving the app — see `RemoteStore.setSessionDraft`.
|
||||
store.setSessionDraft(sessionID, text)
|
||||
}
|
||||
// The other device stopped typing (or its draft was consumed): move its text
|
||||
// down into this field, or clear it — the composer's text is one draft shared
|
||||
// across the mesh.
|
||||
.onChange(of: sharedComposerDraft) { old, new in
|
||||
sharedComposerDraftChanged(from: old, to: new)
|
||||
}
|
||||
.onDisappear { store.composerDraftEnded(sessionID) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user