eleven / ocho Public

Make Ocho sending immediate and retryable across web and Apple apps

Match the web composer to Eleven, preserve encrypted pending sends across retries, add native keyboard and sidebar fixes, and cover delayed/lost uploads in real UI tests.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 3a34741a98f46f023842857c783cb506affce7a9 parent 6305a3c
31 files changed, +734 −50
  • apple/Infinity/AccountStore.swift +6 −3
  • apple/Infinity/MacMessageField.swift +72 −0
  • apple/Infinity/NativeConversationView.swift +19 −7
  • apple/Infinity/OchoRootView.swift +9 −5
  • apple/Infinity/OutgoingMessages.swift +91 −0
  • apple/InfinityMacUITests/NativeChatTests.swift +57 −0
  • apple/InfinityUITests/NativeChatTests.swift +1 −0
  • apple/OchoCore/Sources/OchoCore/Client.swift +3 −0
  • apple/OchoCore/Sources/OchoCore/Models.swift +4 −0
  • apple/README.md +4 −1
  • apple/project.yml +14 −1
  • docs/infinity/local-sending.md +13 −0
  • internal/infinity/app.go +1 −0
  • internal/infinity/app_test.go +4 −2
  • internal/infinity/outbox.go +14 −0
  • internal/infinity/static/chat.js +18 −11
  • internal/infinity/static/client.js +24 −9
  • internal/infinity/static/onboarding.js +1 −1
  • internal/infinity/static/outgoing.js +68 −0
  • internal/infinity/static/style.css +10 −2
  • mobile/client.go +1 −0
  • mobile/conversations.go +12 −5
  • mobile/outgoing.go +72 −0
  • mobile/outgoing_test.go +92 −0
  • mobile/projection.go +1 −0
  • test/infinity/home-browser.mjs +8 −2
  • test/infinity/native-send-proxy.py +37 −0
  • test/infinity/outgoing-browser.mjs +32 −0
  • test/infinity/outgoing.test.mjs +22 −0
  • tools/infinity/check +7 −1
  • tools/infinity/check-native-mac-ui +17 −0
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift
index a7b2650..867f875 100644
--- a/apple/Infinity/AccountStore.swift
+++ b/apple/Infinity/AccountStore.swift
@@ -12,7 +12,9 @@ import OchoCore
@Published var stage: String?
@Published var selected: String?
@Published var invitation: String = ""
- private var client: OchoClient?
+ var client: OchoClient?
+ @Published var outgoing: [String: PendingSend] = [:]
+ var sendingTask: Task<Void, Never>?
static let home = "https://ocho.chat"
init() {
#if DEBUG
@@ -34,6 +36,7 @@ import OchoCore
try KeychainIdentity.write(JSONEncoder().encode(id)); identity = id; client = try OchoClient(identity: id)
}
}
+ try restoreOutgoing()
} catch { self.error = error.localizedDescription }
}
func perform(_ operation: @escaping () async throws -> Void) async {
@@ -47,7 +50,7 @@ import OchoCore
let view = try await c.refresh()
try KeychainIdentity.write(JSONEncoder().encode(id))
if claim != nil { stage = "To infinity!"; try await Task.sleep(for: .milliseconds(800)) }
- client = c; identity = id; threads = view.threads; requests = view.requests
+ client = c; identity = id; threads = view.threads; requests = view.requests; try restoreOutgoing()
}
struct Pending: Codable { var identity: Identity; var claim: String }
func create(name: String) async {
@@ -91,7 +94,7 @@ import OchoCore
}
func refresh() async throws {
guard let client else { return }
- let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity
+ let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity; reconcileOutgoing()
}
func poll() async {
while !Task.isCancelled {
diff --git a/apple/Infinity/MacMessageField.swift b/apple/Infinity/MacMessageField.swift
new file mode 100644
index 0000000..76b790b
--- /dev/null
+++ b/apple/Infinity/MacMessageField.swift
@@ -0,0 +1,72 @@
+// 🤖 Native Mac editing: Return sends; modified Return inserts at the caret.
+#if os(macOS)
+import SwiftUI
+import AppKit
+
+struct MacMessageField: NSViewRepresentable {
+ @Binding var text: String
+ @Binding var height: CGFloat
+ var onSend: () -> Void
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+ func makeNSView(context: Context) -> NSScrollView {
+ let scroll = NSScrollView()
+ scroll.drawsBackground = false
+ scroll.hasVerticalScroller = true
+ scroll.autohidesScrollers = true
+ let editor = Editor(frame: NSRect(x: 0, y: 0, width: 300, height: 22))
+ editor.isRichText = false
+ editor.drawsBackground = false
+ editor.font = .systemFont(ofSize: NSFont.systemFontSize)
+ editor.textColor = .labelColor
+ editor.textContainerInset = .zero
+ editor.textContainer?.lineFragmentPadding = 0
+ editor.isVerticallyResizable = true
+ editor.isHorizontallyResizable = false
+ editor.autoresizingMask = [.width]
+ editor.textContainer?.widthTracksTextView = true
+ editor.textContainer?.containerSize = NSSize(width: 300, height: CGFloat.greatestFiniteMagnitude)
+ editor.setAccessibilityLabel("Message")
+ editor.delegate = context.coordinator
+ editor.send = { [weak coordinator = context.coordinator] in coordinator?.parent.onSend() }
+ scroll.documentView = editor
+ return scroll
+ }
+ func updateNSView(_ scroll: NSScrollView, context: Context) {
+ context.coordinator.parent = self
+ guard let editor = scroll.documentView as? Editor else { return }
+ if editor.string != text { editor.string = text }
+ context.coordinator.measure(editor)
+ }
+ final class Coordinator: NSObject, NSTextViewDelegate {
+ var parent: MacMessageField
+ init(_ parent: MacMessageField) { self.parent = parent }
+ func textDidChange(_ notification: Notification) {
+ guard let editor = notification.object as? Editor else { return }
+ parent.text = editor.string
+ measure(editor)
+ }
+ func measure(_ editor: NSTextView) {
+ guard let layout = editor.layoutManager, let container = editor.textContainer else { return }
+ layout.ensureLayout(for: container)
+ let height = min(132, max(22, ceil(layout.usedRect(for: container).height)))
+ DispatchQueue.main.async { [weak self] in
+ guard let self, self.parent.height != height else { return }
+ self.parent.height = height
+ }
+ }
+ }
+ final class Editor: NSTextView {
+ var send: (() -> Void)?
+ override func keyDown(with event: NSEvent) {
+ // Let the input method confirm marked text before treating Return as send.
+ if (event.keyCode == 36 || event.keyCode == 76) && !hasMarkedText() {
+ if !event.modifierFlags.intersection([.shift, .option, .control]).isEmpty {
+ insertText("\n", replacementRange: selectedRange())
+ } else { send?() }
+ return
+ }
+ super.keyDown(with: event)
+ }
+ }
+}
+#endif
diff --git a/apple/Infinity/NativeConversationView.swift b/apple/Infinity/NativeConversationView.swift
index aa88aba..ffa8916 100644
--- a/apple/Infinity/NativeConversationView.swift
+++ b/apple/Infinity/NativeConversationView.swift
@@ -6,7 +6,14 @@ struct NativeConversationView: View {
let thread: OchoCore.Thread
@State private var draft = ""
@State private var members = false
+ @State private var composerHeight: CGFloat = 22
@FocusState private var composerFocused: Bool
+ private var canSend: Bool { !thread.pending && !thread.left && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ private func send() {
+ guard canSend else { return }
+ do { try account.queueMessage(draft, thread: thread.id); draft = ""; composerFocused = true }
+ catch { account.error = error.localizedDescription }
+ }
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { scroll in
@@ -20,7 +27,7 @@ struct NativeConversationView: View {
Text(message.text).textSelection(.enabled).padding(.horizontal, 14).padding(.vertical, 10)
.foregroundStyle(message.mine ? Color.white : Color.primary)
.background(message.mine ? Color.accentColor : Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 18))
- HStack(spacing: 5) { Text(message.date, style: .time); if let delivery = message.delivery { Text(delivery) } }.font(.caption2).foregroundStyle(.secondary)
+ HStack(spacing: 5) { Text(message.date, style: .time); if let delivery = message.delivery { Text(delivery) }; if message.retry == true { Button { account.retryMessage(message.id) } label: { Text("Retry").underline() }.buttonStyle(.plain).accessibilityLabel("Retry sending message") } }.font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: 560, alignment: message.mine ? .trailing : .leading)
if !message.mine { Spacer(minLength: 48) }
}.id(message.id)
@@ -31,17 +38,22 @@ struct NativeConversationView: View {
.onChange(of: thread.messages.last?.id) { _, id in if let id { withAnimation { scroll.scrollTo(id, anchor: .bottom) } } }
.overlay { if thread.messages.isEmpty { ContentUnavailableView(thread.pending ? "Invitation sent" : "Say hello", systemImage: "bubble.left", description: Text(thread.pending ? "You can chat once they accept." : "This is the beginning of your conversation.")) } }
}
- Divider()
if thread.left { Text("You’re no longer in this group.").foregroundStyle(.secondary).padding() }
else {
HStack(alignment: .bottom, spacing: 12) {
+ #if os(macOS)
+ MacMessageField(text: $draft, height: $composerHeight, onSend: send)
+ .frame(height: composerHeight)
+ .overlay(alignment: .topLeading) { if draft.isEmpty { Text("Message").foregroundStyle(.placeholder).allowsHitTesting(false) } }
+ .padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius: 18))
+ #else
TextField("Message", text: $draft, axis: .vertical).lineLimit(1...6).textFieldStyle(.plain).padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius: 18)).focused($composerFocused)
- Button {
- let text = draft
- Task { await account.perform { try await account.send(text, thread: thread.id); if draft == text { draft = "" }; composerFocused = true } }
- } label: { Image(systemName: "arrow.up.circle.fill").font(.system(size: 32)) }
- .buttonStyle(.plain).foregroundStyle(.tint).disabled(account.busy || thread.pending || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityLabel("Send message")
+ #endif
+ if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Button(action: send) { Image(systemName: "arrow.up.circle.fill").font(.system(size: 32)) }
+ .buttonStyle(.plain).foregroundStyle(.tint).disabled(!canSend).accessibilityLabel("Send message")
.keyboardShortcut(.return, modifiers: .command)
+ }
}.padding()
}
}
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift
index 0f88d92..d110089 100644
--- a/apple/Infinity/OchoRootView.swift
+++ b/apple/Infinity/OchoRootView.swift
@@ -27,14 +27,18 @@ struct OchoRootView: View {
}
}
Section("Chats") {
- ForEach(account.threads.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) }) { thread in
- NavigationLink(value: thread.id) { ConversationRow(thread: thread) }
+ ForEach(account.conversationThreads.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) }) { thread in
+ NavigationLink(value: thread.id) { ConversationRow(thread: thread) }.accessibilityIdentifier("conversation-" + thread.id)
}
}
}
+ #if os(macOS)
+ .frame(minWidth: 260, idealWidth: 320, maxWidth: 420)
+ #endif
+ .navigationSplitViewColumnWidth(min: 260, ideal: 320, max: 420)
.navigationTitle("Ocho")
.searchable(text: $search, prompt: "Search chats")
- .overlay { if account.threads.isEmpty && account.requests.isEmpty { ContentUnavailableView("Your people, right here", systemImage: "bubble.left.and.bubble.right", description: Text("Share an invitation to start a conversation.")) } }
+ .overlay { if account.conversationThreads.isEmpty && account.requests.isEmpty { ContentUnavailableView("Your people, right here", systemImage: "bubble.left.and.bubble.right", description: Text("Share an invitation to start a conversation.")) } }
.toolbar {
ToolbarItem { Menu {
Button("Invite someone", systemImage: "square.and.arrow.up") { Task { await account.perform { shareURL = try await account.invite() } } }
@@ -47,7 +51,7 @@ struct OchoRootView: View {
} label: { Image(systemName: "square.and.pencil") }.disabled(account.busy).accessibilityLabel("New conversation") }
}
} detail: {
- if let thread = account.threads.first(where: { $0.id == account.selected }) { NativeConversationView(thread: thread).id(thread.id) }
+ if let thread = account.conversationThreads.first(where: { $0.id == account.selected }) { NativeConversationView(thread: thread).id(thread.id) }
else { ContentUnavailableView("Ocho", systemImage: "infinity", description: Text("Just you. And your people.")) }
}
}
@@ -78,7 +82,7 @@ struct ConversationRow: View {
HStack(spacing: 12) {
ZStack { Circle().fill(Color.accentColor.opacity(0.12)); if let avatar = avatarImage(thread.peer?.avatar) { avatar.resizable().scaledToFill().clipShape(Circle()) } else if thread.group { Image(systemName: "person.3.fill") } else { Text(String(thread.name.prefix(1)).uppercased()).font(.headline) } }.foregroundStyle(.tint).frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 4) {
- HStack { Text(thread.name).font(.headline); Spacer(); if let last = thread.messages.last { Text(last.date, style: .time).font(.caption).foregroundStyle(.secondary) } }
+ HStack { Text(thread.name).font(.headline).lineLimit(1); Spacer(); if let last = thread.messages.last { Text(last.date, style: .time).font(.caption).foregroundStyle(.secondary).fixedSize() } }
Text(thread.pending ? "Waiting for them to accept…" : thread.messages.last?.text ?? "Say hello").font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
}
}.padding(.vertical, 5)
diff --git a/apple/Infinity/OutgoingMessages.swift b/apple/Infinity/OutgoingMessages.swift
new file mode 100644
index 0000000..b957b26
--- /dev/null
+++ b/apple/Infinity/OutgoingMessages.swift
@@ -0,0 +1,91 @@
+// 🤖 Pending text stays in Keychain; the UI never waits for network work to paint it.
+import SwiftUI
+import OchoCore
+struct PendingSend: Codable {
+ var id: String, thread: String, text: String, at: Int64
+ var prepared: String?, status: String
+}
+extension AccountStore {
+ private var outgoingKey: String? { identity.map { "outgoing-" + $0.signing } }
+ private var testingOutgoing: Bool {
+ #if DEBUG
+ return ProcessInfo.processInfo.environment["OCHO_UI_IDENTITY"] != nil
+ #else
+ return false
+ #endif
+ }
+ func persistOutgoing() throws {
+ guard !testingOutgoing, let key = outgoingKey else { return }
+ if outgoing.isEmpty { try KeychainIdentity.remove(account: key) }
+ else { try KeychainIdentity.write(JSONEncoder().encode(outgoing), account: key) }
+ }
+ func restoreOutgoing() throws {
+ guard !testingOutgoing, let key = outgoingKey else { return }
+ outgoing = [:]
+ if let data = try KeychainIdentity.read(account: key) {
+ outgoing = try JSONDecoder().decode([String: PendingSend].self, from: data)
+ for id in outgoing.keys where outgoing[id]?.status != "accepted" { outgoing[id]?.status = "failed" }
+ }
+ }
+ var conversationThreads: [OchoCore.Thread] {
+ guard let identity else { return threads }
+ var result = threads
+ for row in outgoing.values {
+ guard let index = result.firstIndex(where: { $0.id == row.thread }) else { continue }
+ let status = row.status == "failed" ? "Not sent" : "Sending…"
+ if let existing = result[index].messages.firstIndex(where: { $0.id == row.id && $0.mine }) {
+ if result[index].messages[existing].delivery != "Sent" {
+ if row.status == "failed" { result[index].messages[existing].delivery = status; result[index].messages[existing].retry = true }
+ }
+ } else {
+ result[index].messages.append(Message(id: row.id, text: row.text, at: row.at, mine: true, name: identity.name, signer: identity.signing, delivery: status, retry: row.status == "failed"))
+ }
+ result[index].messages.sort { $0.at == $1.at ? $0.id < $1.id : $0.at < $1.at }
+ }
+ result.sort { ($0.messages.last?.at ?? 0) > ($1.messages.last?.at ?? 0) }
+ return result
+ }
+ func reconcileOutgoing() {
+ let delivered = Set(threads.flatMap(\.messages).filter { $0.mine && $0.delivery == "Sent" }.map(\.id))
+ if !delivered.isDisjoint(with: outgoing.keys) {
+ outgoing = outgoing.filter { !delivered.contains($0.key) }
+ try? persistOutgoing()
+ }
+ }
+ func queueMessage(_ text: String, thread: String) throws {
+ let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, text.utf16.count <= 8000, threads.contains(where: { $0.id == thread && !$0.pending && !$0.left }) else { throw OchoError.invalid("Write a message of up to 8,000 characters in an active conversation.") }
+ let id = Crypto.random()
+ outgoing[id] = PendingSend(id: id, thread: thread, text: text, at: Int64(Date().timeIntervalSince1970 * 1000), prepared: nil, status: "sending")
+ do { try persistOutgoing() } catch { outgoing.removeValue(forKey: id); throw error }
+ scheduleSend(id)
+ }
+ func retryMessage(_ id: String) {
+ guard outgoing[id]?.status == "failed" else {
+ Task {
+ do { try await client?.retryDelivery(id); try await refresh() }
+ catch { self.error = error.localizedDescription }
+ }
+ return
+ }
+ outgoing[id]?.status = "sending"; scheduleSend(id)
+ }
+ private func scheduleSend(_ id: String) {
+ let previous = sendingTask
+ sendingTask = Task { await previous?.value; await transmitMessage(id) }
+ }
+ private func transmitMessage(_ id: String) async {
+ guard let client, let row = outgoing[id] else { return }
+ do {
+ let prepared: String
+ if let saved = row.prepared { prepared = saved }
+ else { prepared = try await client.prepareMessage(thread: row.thread, text: row.text, id: row.id, at: row.at) }
+ outgoing[id]?.prepared = prepared; try persistOutgoing()
+ try await client.sendPreparedMessage(prepared)
+ } catch {
+ outgoing[id]?.status = "failed"; try? persistOutgoing(); return
+ }
+ outgoing[id]?.status = "accepted"; try? persistOutgoing()
+ try? await refresh()
+ }
+}
diff --git a/apple/InfinityMacUITests/NativeChatTests.swift b/apple/InfinityMacUITests/NativeChatTests.swift
new file mode 100644
index 0000000..091d6c4
--- /dev/null
+++ b/apple/InfinityMacUITests/NativeChatTests.swift
@@ -0,0 +1,57 @@
+// 🤖 Real SwiftUI feed/composer against two disposable local personal processes.
+import XCTest
+import OchoCore
+@MainActor final class NativeChatTests: XCTestCase {
+ func testNativeConversationAndSend() async throws {
+ let claim = String(repeating: "a", count: 43)
+ let me = try Identity.create(origin: "http://localhost:18873", name: "Native UI Alice")
+ let peer = try Identity.create(origin: "http://localhost:18874", name: "Native UI Bob")
+ let a = try OchoClient(identity: me), b = try OchoClient(identity: peer)
+ try await a.setup(claim: claim); try await b.setup(claim: claim)
+ let thread = try await b.connect(Invite.parse(try await a.invite().absoluteString))
+ let view = try await a.refresh(); try await a.accept(XCTUnwrap(view.requests.first))
+ try await b.message(thread: thread, text: "Hello from the other native client")
+ let app = XCUIApplication()
+ app.launchEnvironment["OCHO_UI_IDENTITY"] = try JSONEncoder().encode(me).base64URL
+ app.launch()
+ continueAfterFailure = false
+ let row = app.descendants(matching: .any)["conversation-" + thread].firstMatch
+ XCTAssertTrue(row.waitForExistence(timeout: 20), app.debugDescription)
+ row.click()
+ XCTAssertTrue(app.staticTexts["Hello from the other native client"].waitForExistence(timeout: 10))
+ let field = app.textFields["Message"].exists ? app.textFields["Message"] : app.textViews.firstMatch
+ XCTAssertFalse(app.buttons["Send message"].exists)
+ var fault = URLRequest(url: URL(string: "http://localhost:18873/__test/fail-next-send")!)
+ fault.httpMethod = "POST"
+ _ = try await URLSession.shared.data(for: fault)
+ XCTAssertTrue(field.waitForExistence(timeout: 5)); field.tap(); field.typeText("Sent from the native SwiftUI composer")
+ field.typeKey(.return, modifierFlags: [])
+ XCTAssertTrue(app.staticTexts["Sent from the native SwiftUI composer"].waitForExistence(timeout: 1))
+ XCTAssertTrue(app.staticTexts["Sending…"].exists)
+ field.typeText("A newer draft")
+ XCTAssertTrue(app.buttons["Retry sending message"].waitForExistence(timeout: 15))
+ XCTAssertEqual(field.value as? String, "A newer draft")
+ app.buttons["Retry sending message"].click()
+ XCTAssertTrue(app.staticTexts["Sent"].firstMatch.waitForExistence(timeout: 15))
+ field.click(); field.typeKey("a", modifierFlags: .command); field.typeKey(.delete, modifierFlags: [])
+ let received = try await b.refresh()
+ XCTAssertEqual(received.threads.first?.messages.last?.text, "Sent from the native SwiftUI composer")
+ // Return keeps the composer usable; Shift-Return must insert a newline.
+ field.typeText("First line")
+ field.typeKey(.return, modifierFlags: .shift)
+ field.typeText("Second line")
+ XCTAssertEqual(field.value as? String, "First line\nSecond line")
+ field.typeKey(.return, modifierFlags: [])
+ XCTAssertTrue(app.staticTexts["First line\nSecond line"].waitForExistence(timeout: 15))
+ let sent = NSPredicate { _, _ in app.staticTexts.matching(identifier: "Sent").count == 2 }
+ let delivered = expectation(for: sent, evaluatedWith: nil)
+ await fulfillment(of: [delivered], timeout: 15)
+ let multiline = try await b.refresh()
+ XCTAssertEqual(multiline.threads.first?.messages.last?.text, "First line\nSecond line")
+ let sidebar = app.outlines.firstMatch
+ XCTAssertTrue(sidebar.exists)
+ XCTAssertGreaterThanOrEqual(sidebar.frame.width, 260)
+ XCTAssertEqual(app.webViews.count, 0)
+ let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.lifetime = .keepAlways; add(attachment)
+ }
+}
diff --git a/apple/InfinityUITests/NativeChatTests.swift b/apple/InfinityUITests/NativeChatTests.swift
index bd4745f..9f32a12 100644
--- a/apple/InfinityUITests/NativeChatTests.swift
+++ b/apple/InfinityUITests/NativeChatTests.swift
@@ -21,6 +21,7 @@ import OchoCore
XCTAssertTrue(field.waitForExistence(timeout: 5)); field.tap(); field.typeText("Sent from the native SwiftUI composer")
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Sent from the native SwiftUI composer"].waitForExistence(timeout: 15))
+ XCTAssertTrue(app.staticTexts["Sent"].firstMatch.waitForExistence(timeout: 15))
let received = try await b.refresh()
XCTAssertEqual(received.threads.first?.messages.last?.text, "Sent from the native SwiftUI composer")
XCTAssertEqual(app.webViews.count, 0)
diff --git a/apple/OchoCore/Sources/OchoCore/Client.swift b/apple/OchoCore/Sources/OchoCore/Client.swift
index ff7947a..55b05fa 100644
--- a/apple/OchoCore/Sources/OchoCore/Client.swift
+++ b/apple/OchoCore/Sources/OchoCore/Client.swift
@@ -22,6 +22,9 @@ public actor OchoClient {
public func accept(_ request: Event) async throws { try core.accept(request.thread) }
public func reject(_ request: Event) async throws { try core.reject(request.thread) }
public func message(thread: String, text: String) async throws { try core.message(thread, text: text) }
+ public func prepareMessage(thread: String, text: String, id: String, at: Int64) async throws -> String { try engineString { core.prepareMessage(thread, text: text, id_: id, at: at, error: $0) } }
+ public func retryDelivery(_ id: String) async throws { try core.retryDelivery(id) }
+ public func sendPreparedMessage(_ value: String) async throws { try core.sendPreparedMessage(value) }
public func group(name: String, contacts: Set<String>) async throws -> String { let ids = try json(Array(contacts)); return try engineString { core.group(name, contactIDsJSON: ids, error: $0) } }
public func removeMember(thread: String, signing: String) async throws { try core.removeMember(thread, signing: signing) }
}
diff --git a/apple/OchoCore/Sources/OchoCore/Models.swift b/apple/OchoCore/Sources/OchoCore/Models.swift
index c09ef96..289639b 100644
--- a/apple/OchoCore/Sources/OchoCore/Models.swift
+++ b/apple/OchoCore/Sources/OchoCore/Models.swift
@@ -19,6 +19,10 @@ public struct Event: Codable, Sendable {
public struct Message: Identifiable, Codable, Sendable {
public var id: String, text: String, at: Int64, mine: Bool, name: String, signer: String
public var delivery: String?
+ public var retry: Bool?
+ public init(id: String, text: String, at: Int64, mine: Bool, name: String, signer: String, delivery: String? = nil, retry: Bool? = nil) {
+ self.id=id; self.text=text; self.at=at; self.mine=mine; self.name=name; self.signer=signer; self.delivery=delivery; self.retry=retry
+ }
public var date: Date { Date(timeIntervalSince1970: Double(at) / 1000) }
}
public struct Thread: Identifiable, Codable, Sendable {
diff --git a/apple/README.md b/apple/README.md
index 427c187..ca7469e 100644
--- a/apple/README.md
+++ b/apple/README.md
@@ -11,9 +11,12 @@ tools/infinity/build-mobile apple
xcodegen generate --spec apple/project.yml
tools/infinity/check-native
tools/infinity/check-native-ui
+tools/infinity/check-native-mac-ui
```
-The last command uses two disposable local personal processes and a dedicated iPhone simulator (override `OCHO_SIMULATOR_ID`). It verifies native receipt/composition/send, peer decryption and zero WebViews. The package tests cover Web Crypto interoperability, group integrity, restored history and read-only legacy migration.
+The iPhone UI command uses two disposable local personal processes and a dedicated iPhone simulator (override `OCHO_SIMULATOR_ID`). It verifies native receipt/composition/send, peer decryption and zero WebViews. The package tests cover Web Crypto interoperability, group integrity, restored history and read-only legacy migration.
+
+The Mac UI command verifies Return-to-send, Shift-Return for a newline, peer decryption and a readable sidebar against disposable accounts.
Build the generated `Infinity` or `InfinityMac` Xcode scheme. Device signing needs a team/profile for `chat.ocho.app` or `chat.ocho.app.mac`, with `webcredentials:ocho.chat` and the matching server association. Both apps display **Ocho**. Physical passkey ceremonies need user presence and a PRF-capable provider; unsupported providers show a retry message before account allocation. Signup and sign-in are passkey-only.
diff --git a/apple/project.yml b/apple/project.yml
index 2a61a6e..2f64bfe 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -8,7 +8,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
MARKETING_VERSION: '0.2.0'
- CURRENT_PROJECT_VERSION: '7'
+ CURRENT_PROJECT_VERSION: '8'
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic
@@ -84,6 +84,17 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.app.uitests
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_NSAppTransportSecurity: { NSAllowsLocalNetworking: true }
+ InfinityMacUITests:
+ type: bundle.ui-testing
+ platform: macOS
+ sources: [InfinityMacUITests]
+ dependencies:
+ - target: InfinityMac
+ - package: OchoCore
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: chat.ocho.app.mac.uitests
+ GENERATE_INFOPLIST_FILE: YES
schemes:
Infinity:
test:
@@ -92,6 +103,8 @@ schemes:
targets:
Infinity: all
InfinityMac:
+ test:
+ targets: [InfinityMacUITests]
build:
targets:
InfinityMac: all
diff --git a/docs/infinity/local-sending.md b/docs/infinity/local-sending.md
new file mode 100644
index 0000000..20cedf5
--- /dev/null
+++ b/docs/infinity/local-sending.md
@@ -0,0 +1,13 @@
+# 🤖 Immediate sending — 2026-09-08
+
+Paul requests the Eleven-style web composer: no divider above the account footer or composer, a transparent composer area, and Send visible only with non-whitespace text. The scrolling feed ends above the composer so content never obscures input.
+
+Outgoing text appears locally before network work, with a small Sending… status. Failed uploads retain their text and expose Retry; delivered messages show Sent. Keep a stable message ID and the exact encrypted envelopes across retries, including a lost successful response. Refresh current membership before first encryption and before resubmission; do not send to an obsolete group membership snapshot. A failed refresh after an accepted upload is not an upload failure.
+
+The browser persists pending messages encrypted under the existing vault key in account-scoped local storage. Plaintext exists in client memory only. Reload restores unfinished sends for explicit retry. Server receipt/echo reconciliation replaces the local row without duplication. Sending must not erase a newer draft or move the caret during background refresh.
+
+Media is a separate integration: reuse Eleven's pickers, thumbnails, drag/drop and viewing components where appropriate, but adapt its encrypted attachment format, blob capabilities and routing to Ocho's per-person instances. Current Ocho messages contain text only; avatars are the existing image exception. No new media feature is claimed by this change.
+
+Native Apple clients use a Keychain-backed pending queue and the same prepared-envelope contract through Go/gomobile. Browser and native membership validation remains in place. A per-message owner-authenticated retry endpoint advances that message's server backoff without changing its ID or ciphertext; normal background polling retains backoff. Delivery confirmation means the recipient instance accepted the envelope, not that the recipient read it.
+
+Validation covers immediate rendering before a delayed upload completes, failure with a newer draft, retry after browser reload, encrypted local pending storage, exact-envelope retry after a lost response, recipient decryption, obsolete membership refusal, Mac Return/Shift-Return, and the native iOS UI.
diff --git a/internal/infinity/app.go b/internal/infinity/app.go
index b4b28b4..09eb02b 100644
--- a/internal/infinity/app.go
+++ b/internal/infinity/app.go
@@ -73,6 +73,7 @@ func (a *App) Handler() *http.ServeMux {
m.Handle("POST /api/send", a.owner(a.send))
m.Handle("POST /api/push", a.owner(a.registerPush))
m.Handle("POST /api/retry", a.owner(a.retry))
+ m.Handle("POST /api/retry/{id}", a.owner(a.retryMessage))
m.Handle("GET /api/delivery", a.owner(a.delivery))
m.HandleFunc("POST /tasks/delivery", a.tick)
static, _ := fs.Sub(assets, "static")
diff --git a/internal/infinity/app_test.go b/internal/infinity/app_test.go
index 050590d..1efa341 100644
--- a/internal/infinity/app_test.go
+++ b/internal/infinity/app_test.go
@@ -191,8 +191,10 @@ func TestOutboxPersistsAndRetryDeduplicates(t *testing.T) {
if a.a.NextDue().IsZero() {
t.Fatal("pending delivery has no wake deadline")
}
- a.a.db.Exec("UPDATE outbox SET destination=?,next_attempt=0 WHERE delivered=0", b.s.URL)
- a.call(t, "POST", "/api/retry", a.auth, nil, 200)
+ a.a.db.Exec("UPDATE outbox SET destination=?,next_attempt=9999999999999 WHERE delivered=0", b.s.URL)
+ a.call(t, "POST", "/api/retry/"+item.Envelope.ID, "", nil, 401)
+ a.call(t, "POST", "/api/retry/invalid", a.auth, nil, 400)
+ a.call(t, "POST", "/api/retry/"+item.Envelope.ID, a.auth, nil, 200)
b.a.db.QueryRow("SELECT count(*) FROM inbox").Scan(&n)
if n != 2 {
t.Fatal(n)
diff --git a/internal/infinity/outbox.go b/internal/infinity/outbox.go
index d75c3d5..bf73778 100644
--- a/internal/infinity/outbox.go
+++ b/internal/infinity/outbox.go
@@ -121,6 +121,20 @@ func (a *App) flush(ctx context.Context) {
}
}
}
+
+// An explicit user retry advances only this owner's selected pending message.
+func (a *App) retryMessage(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if !validSecret(id) {
+ fail(w, 400, "Invalid message.")
+ return
+ }
+ if _, e := a.db.ExecContext(r.Context(), "UPDATE outbox SET next_attempt=0 WHERE id=? AND delivered=0", id); e != nil {
+ dbError(w, e)
+ return
+ }
+ a.retry(w, r)
+}
func (a *App) retry(w http.ResponseWriter, r *http.Request) {
a.flush(r.Context())
a.flushPush(r.Context())
diff --git a/internal/infinity/static/chat.js b/internal/infinity/static/chat.js
index 7395666..d26b74c 100644
--- a/internal/infinity/static/chat.js
+++ b/internal/infinity/static/chat.js
@@ -5,7 +5,7 @@ import { editProfile, passkeys } from './profile.js';
export class Chat {
constructor(root, client) { this.root = root; this.client = client; this.selected = null; this.drafts = new Map(); this.query = ''; this.error = ''; this.refreshing = false; }
async start(invite) {
- await this.client.refresh(); this.render();
+ await this.client.refresh(); this.render();this.client.changed=()=>this.render();
if (invite) connectDialog(this.client, id => this.select(id), invite);
this.timer = setInterval(() => this.poll(), 3000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.poll(); });
@@ -22,6 +22,9 @@ export class Chat {
setStatus() { const node = this.root.querySelector('.connection'); if (node) node.textContent = this.error; }
render() {
const focused = document.activeElement?.id;
+ const caret=focused==='composer'?[document.activeElement.selectionStart,document.activeElement.selectionEnd]:null;
+ const oldLog=this.root.querySelector('.messages'),oldThread=oldLog?.dataset.thread;
+ const oldScroll=oldLog&&oldLog.scrollHeight-oldLog.clientHeight-oldLog.scrollTop>48?oldLog.scrollTop:null;
const menuWasOpen = this.root.querySelector('.account-menu')?.open;
const selected = this.client.view.threads.find(t => t.id === this.selected);
const shell = el('div', { class: 'shell' + (selected ? ' conversation-open' : '') });
@@ -34,7 +37,8 @@ export class Chat {
menu.open = Boolean(menuWasOpen);
const aside = el('aside', { class: 'sidebar' }, header, el('div', { class: 'search-wrap' }, search), this.list, el('p', { class: 'connection', role: 'status' }, this.error), menu);
shell.append(aside, selected ? this.conversation(selected) : this.empty()); this.root.replaceChildren(shell); this.renderList();
- if (focused === 'composer') this.root.querySelector('#composer')?.focus();
+ if (focused === 'composer') {const input=this.root.querySelector('#composer');input?.focus();if(caret)input?.setSelectionRange(...caret);}
+ if(oldScroll!==null&&oldThread===this.selected)requestAnimationFrame(()=>{const log=this.root.querySelector('.messages');if(log)log.scrollTop=oldScroll;});
}
renderList() {
this.list.replaceChildren();
@@ -55,25 +59,28 @@ export class Chat {
empty() { return el('section', { class: 'empty-pane' }, mark(), el('h1', {}, 'A little closer.'), el('p', {}, 'A private place for your everyday conversations.'), button('Invite someone', () => share(this.client), { class: 'primary' }), el('span', { class: 'encryption-note' }, 'End-to-end encrypted')); }
conversation(t) {
const header = el('header', { class: 'chat-header' }, button('‹', () => this.select(null), { class: 'back quiet', 'aria-label': 'Back to conversations' }), avatar(t.name,t.peer?.avatar), el('div', { class: 'chat-title' }, el('h1', {}, t.name), el('p', {}, t.group ? t.members.length + ' people · End-to-end encrypted' : 'End-to-end encrypted')), button('Details', () => details(this.client, t, id => this.select(id)), { class: 'quiet' }));
- const log = el('div', { class: 'messages', role: 'log', 'aria-label': 'Messages', 'aria-live': 'polite' });
+ const log = el('div', { class: 'messages', role: 'log', 'aria-label': 'Messages', 'aria-live': 'polite', 'data-thread':t.id });
if (!t.messages.length) log.append(el('p', { class: 'start-note' }, t.pending ? 'Your invitation was sent. You can chat when it’s accepted.' : 'The start of your conversation.'));
let date = '';
for (const m of t.messages) {
const day = new Date(m.at).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
if (day !== date) { log.append(el('div', { class: 'date-label' }, day)); date = day; }
- log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), m.delivery ? ' · ' + m.delivery : '')));
+ log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), m.delivery ? ' · ' + m.delivery : '', m.retry && button('Retry',()=>this.client.retryMessage(m.id),{class:'retry-send','aria-label':'Retry sending message'}))));
}
const note = el('div', { class: 'composer-note', role: 'status' });
const input = el('textarea', { id: 'composer', rows: 1, maxlength: 8000, placeholder: 'Message', 'aria-label': 'Message', disabled: t.pending || t.left }); input.value = this.drafts.get(t.id) || '';
- input.addEventListener('input', () => this.drafts.set(t.id, input.value));
const submit = el('button', { class: 'send primary', type: 'submit', 'aria-label': 'Send message', disabled: t.pending || t.left }, '↑');
- const form = el('form', { class: 'composer' }, input, submit);
- form.addEventListener('submit', async event => { event.preventDefault(); await busy(submit, async () => {
- try { note.replaceChildren(); await this.client.message(t.id, input.value); this.drafts.delete(t.id); this.render(); this.root.querySelector('#composer')?.focus(); }
- catch (e) { note.replaceChildren(message(e.message, true)); }
- }); });
+ const update=()=>{this.drafts.set(t.id,input.value);submit.hidden=!input.value.trim();input.style.height='auto';input.style.height=Math.min(140,input.scrollHeight)+'px';};
+ input.addEventListener('input',update);submit.hidden=!input.value.trim();
+ const form = el('form', { class: 'composer' }, el('div',{class:'composer-input'},input,submit));
+ form.addEventListener('submit', event => {
+ event.preventDefault();if(!input.value.trim()||t.pending||t.left)return;
+ const text=input.value;this.drafts.delete(t.id);
+ try { note.replaceChildren();this.client.message(t.id,text);this.render();this.root.querySelector('#composer')?.focus(); }
+ catch(e){this.drafts.set(t.id,text);note.replaceChildren(message(e.message,true));}
+ });
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
- requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; });
+ requestAnimationFrame(() => { log.scrollTop = log.scrollHeight;input.style.height='auto';input.style.height=Math.min(140,input.scrollHeight)+'px'; });
return el('section', { class: 'conversation' }, header, log, t.left && el('p', { class: 'notice' }, 'You are no longer in this group. Your previous messages stay here.'), note, form);
}
privacy() {
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js
index 260c936..9b1b54a 100644
--- a/internal/infinity/static/client.js
+++ b/internal/infinity/static/client.js
@@ -3,10 +3,12 @@ import { request, peerOrigin, usingRelay } from './api.js';
import { random, publicIdentity, seal, open, lock, unlock, b64 } from './crypto.js';
import { createShortInvite, contactProof, verifiedConsent } from './invitations.js';
import { validAvatar } from './avatars.js';
+import { Outgoing } from './outgoing.js';
import { project } from './protocol.js';
export class Client {
constructor(identity) {
this.id = identity; this.state = { name: identity.name, contacts: {}, invites: {}, accepted: {}, blocked: {} };
+ this.outgoing = new Outgoing(this);
this.revision = 0; this.events = []; this.cursor = 0; this.statuses = []; this.view = { threads: [], requests: [] };
}
api(path, options = {}) { return request(this.id.origin, path, { auth: this.id.auth, ...options }); }
@@ -32,7 +34,12 @@ export class Client {
const res = await this.api('/api/vault', { method: 'PUT', body: { format:2, revision: this.revision, box: await lock(this.id.vaultKey, this.state) } });
this.revision = res.revision;
}
- async refresh() {
+ refresh() {
+ if(this.syncing)return this.syncing;
+ this.syncing=this.synchronize().finally(()=>{this.syncing=null;});return this.syncing;
+ }
+ async synchronize() {
+ await this.outgoing.restore();
await this.load();
for(const [thread,items] of Object.entries(this.state.handshakes||{})) {
await this.api('/api/send',{method:'POST',body:{items}});delete this.state.handshakes[thread];await this.save();
@@ -41,6 +48,7 @@ export class Client {
for (;;) {
const rows = await this.api('/api/events?after=' + this.cursor);
for (const row of rows) {
+ if(row.seq<=this.cursor)continue;
try {
const event = {...await open(this.id,row.envelope),signer:row.envelope.sender,seq:row.seq};
event.preapproved = false;
@@ -66,13 +74,19 @@ export class Client {
for (const thread of this.view.threads) for (const message of thread.messages) {
if (!message.mine) continue;
const receipts = this.statuses.filter(s => s.id === message.id && s.destination !== this.id.origin);
+ if(receipts.some(s=>!s.delivered&&s.attempts>0))message.retry=true;
if (receipts.length) message.delivery = receipts.every(s => s.delivered > 0) ? 'Sent' : receipts.some(s => s.attempts > 0) ? 'Not delivered · retrying' : 'Sending…';
}
+ this.outgoing.overlay(this.view);
}
- async emit(people, payload) {
+ async envelopes(people, payload) {
if(payload.kind==='group')payload={...payload,members:payload.members.map(({avatar,...person})=>person)};
const unique = [...new Map(people.map(p => [p.signing, p])).values()];
const items = await Promise.all(unique.map(async person => ({ destination: peerOrigin(person.origin), capability: person.cap, envelope: await seal(this.id, person, payload) })));
+ return items;
+ }
+ async emit(people, payload) {
+ const items=await this.envelopes(people,payload);
await this.api('/api/send', { method: 'POST', body: { items } });
return items.map(i => i.envelope.id);
}
@@ -118,13 +132,14 @@ export class Client {
await this.refresh();
}
async reject(req) { await this.load(); this.state.blocked[req.thread] = true; await this.save(); this.rebuild(); }
- async message(thread, text) {
- text = text.trim(); if (!text || text.length > 8000) throw new Error('Write a message of up to 8,000 characters.');
- await this.refresh();
- const t = this.view.threads.find(t => t.id === thread);
- if (!t || t.pending || t.left) throw new Error('This conversation is not ready to send.');
- const payload = { kind: 'message', thread, id: random(), text, at: Date.now(), epoch: t.epoch || 0 };
- await this.emit(t.members, payload); await this.refresh();
+ message(thread, text) { return this.outgoing.message(thread,text); }
+ async retryMessage(id) {
+ if(this.outgoing.rows.get(id)?.status==='failed')return this.outgoing.retry(id);
+ for(const t of this.view.threads)for(const m of t.messages)if(m.id===id){m.delivery='Sending…';m.retry=false;}
+ this.changed?.();
+ try {await this.api('/api/retry/'+encodeURIComponent(id),{method:'POST'});await this.refresh();}
+ catch {for(const t of this.view.threads)for(const m of t.messages)if(m.id===id){m.delivery='Not delivered';m.retry=true;}}
+ this.changed?.();
}
async updateProfile(name,avatar) {
name=name.trim();if(!name || name.length>80 || !validAvatar(avatar))throw Error('Choose a name and a supported avatar.');
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js
index f1476e9..7c1e003 100644
--- a/internal/infinity/static/onboarding.js
+++ b/internal/infinity/static/onboarding.js
@@ -69,7 +69,7 @@ export function welcome(root,params,ready,saved=null) {
};
const showChoices=()=>{
history.replaceState(null,'','/'+hash);
- shell.replaceChildren(mark(),el('h1',{},title),!params.code&&el('p',{class:'lead'},'Just you. And your people.'),note);
+ shell.replaceChildren(mark(),el('h1',{},title),...(!params.code?[el('p',{class:'lead'},'Just you. And your people.')]:[]),note);
if(!saved) shell.append(button(params.code?'Sign up to accept':'Sign up',showName,{class:'primary'}));
shell.append(signIn);
};
diff --git a/internal/infinity/static/outgoing.js b/internal/infinity/static/outgoing.js
new file mode 100644
index 0000000..abcbb38
--- /dev/null
+++ b/internal/infinity/static/outgoing.js
@@ -0,0 +1,68 @@
+// 🤖 Local pending rows are encrypted at rest; retries keep their original envelope IDs.
+import { random, lock, unlock } from './crypto.js';
+export class Outgoing {
+ constructor(client) {
+ this.client=client;this.rows=new Map();this.writes=Promise.resolve();this.sends=Promise.resolve();
+ this.key='ocho.outgoing.v1.'+client.id.signing;
+ }
+ async restore() {
+ if(this.restored)return this.restored;
+ return this.restored=(async()=>{
+ const saved=globalThis.localStorage?.getItem(this.key);if(!saved)return;
+ const rows=await unlock(this.client.id.vaultKey,saved);
+ for(const row of rows)if(!this.rows.has(row.payload.id))this.rows.set(row.payload.id,{...row,status:row.status==='accepted'?'accepted':'failed'});
+ })();
+ }
+ persist() {
+ return this.writes=this.writes.catch(()=>{}).then(async()=>{
+ if(!globalThis.localStorage)return;
+ if(this.rows.size)localStorage.setItem(this.key,await lock(this.client.id.vaultKey,[...this.rows.values()]));
+ else localStorage.removeItem(this.key);
+ });
+ }
+ changed() { this.client.rebuild();this.client.changed?.(); }
+ message(thread,text) {
+ text=text.trim();if(!text||text.length>8000)throw Error('Write a message of up to 8,000 characters.');
+ const t=this.client.view.threads.find(t=>t.id===thread);
+ if(!t||t.pending||t.left)throw Error('This conversation is not ready to send.');
+ const row={payload:{kind:'message',thread,id:random(),text,at:Date.now(),epoch:t.epoch||0},status:'sending'};
+ this.rows.set(row.payload.id,row);this.changed();return this.enqueue(row);
+ }
+ retry(id) {
+ const row=this.rows.get(id);if(!row||row.status==='sending')return Promise.resolve();
+ row.status='sending';this.changed();return this.enqueue(row);
+ }
+ enqueue(row) {
+ const work=this.sends.catch(()=>{}).then(()=>this.transmit(row));this.sends=work;return work;
+ }
+ async transmit(row) {
+ try {
+ await this.restore();await this.persist();await this.client.refresh();
+ if(!this.rows.has(row.payload.id))return;
+ const t=this.client.view.threads.find(t=>t.id===row.payload.thread);
+ if(!t||t.pending||t.left||(t.epoch||0)!==row.payload.epoch)throw Error('The conversation membership changed.');
+ row.items ||= await this.client.envelopes(t.members,row.payload);
+ await this.persist();
+ await this.client.api('/api/send',{method:'POST',body:{items:row.items}});
+ row.status='accepted';await this.persist().catch(()=>{});this.changed();
+ } catch(error) {
+ row.status='failed';await this.persist().catch(()=>{});this.changed();return;
+ }
+ // A slow refresh must not turn an acknowledged upload into a failed send.
+ await this.client.refresh().catch(()=>{});this.changed();
+ }
+ overlay(view) {
+ let removed=false;
+ for(const [id,row] of this.rows) {
+ const t=view.threads.find(t=>t.id===row.payload.thread);if(!t)continue;
+ let message=t.messages.find(m=>m.id===id&&m.mine);
+ if(message?.delivery==='Sent') {this.rows.delete(id);removed=true;continue;}
+ if(!message) {message={...row.payload,mine:true,name:this.client.state.name,signer:this.client.id.signing};t.messages.push(message);}
+ message.delivery=row.status==='failed'?'Not sent':message.delivery||'Sending…';
+ message.retry=row.status==='failed'||Boolean(message.retry);
+ t.messages.sort((a,b)=>a.at-b.at||a.id.localeCompare(b.id));
+ }
+ view.threads.sort((a,b)=>(b.messages.at(-1)?.at||0)-(a.messages.at(-1)?.at||0));
+ if(removed)this.persist().catch(()=>{});
+ }
+}
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css
index e59b109..b846ae9 100644
--- a/internal/infinity/static/style.css
+++ b/internal/infinity/static/style.css
@@ -1,8 +1,8 @@
/* 🤖 ∞: calm purple, readable text, one conversation list. */
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#252332;background:#faf9fd;font-synthesis:none;--accent:#6550ce;--ink:#252332;--muted:#706c7f;--line:#eae7f0;--surface:#fff;--soft:#f1eef9;color-scheme:light}
-*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea,summary{-webkit-tap-highlight-color:transparent}button{cursor:pointer;border:0;border-radius:12px;padding:11px 17px;background:var(--soft);color:var(--ink);font-weight:600}button:hover{filter:brightness(.96)}button:disabled{cursor:default;opacity:.55}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #a997f0;outline-offset:3px}.primary{background:var(--accent);color:#fff}.quiet{background:transparent;color:var(--accent)}.small{padding:8px 13px;font-size:13px}.danger{color:#a83248}p{line-height:1.6}h1,h2,h3,p{margin-top:0}.mark{display:block;flex:none}.muted,.footnote{color:var(--muted)}.footnote{font-size:13px}.loading{display:grid;place-items:center;min-height:100dvh;color:var(--muted)}.shell{height:100dvh;display:grid;grid-template-columns:340px 1fr;max-width:1600px;margin:auto;background:var(--surface)}.sidebar{min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--surface)}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:28px 22px 18px;gap:12px}.brand{display:flex;gap:12px;align-items:center;font-size:20px;font-weight:700;letter-spacing:-.5px}.brand .mark{width:35px;height:35px}.search-wrap{padding:0 20px 20px}input[type=search]{width:100%;border:0;background:#f4f2f7;border-radius:12px;padding:11px 14px;font-size:14px;color:var(--ink)}.thread-list{flex:1;overflow:auto;padding:0 10px}.thread-row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:transparent;padding:14px 12px;border-radius:13px;margin:3px 0;font-weight:400}.thread-row.active{background:#eee9fc}.avatar{width:44px;height:44px;border-radius:50%;display:inline-grid;place-items:center;flex:none;font-size:18px;font-weight:600;background:#e8e1fa;color:#6550a7}.thread-summary{min-width:0;flex:1}.thread-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}.thread-top strong{font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.thread-top time{font-size:11px;color:var(--muted);flex:none}.preview{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--muted)}.list-empty{padding:25px 10px;text-align:center;color:var(--muted);font-size:14px}.account-menu{border-top:1px solid var(--line);padding:15px 22px;position:relative}.account-menu summary{display:flex;gap:12px;align-items:center;cursor:pointer;font-size:14px}.account-menu summary .muted{margin-left:auto}.account-menu .avatar{width:32px;height:32px;font-size:14px}.menu-items{position:absolute;bottom:75px;left:16px;right:16px;background:var(--surface);border:1px solid var(--line);border-radius:15px;padding:8px;box-shadow:0 12px 36px #231d4315;z-index:2}.menu-items button{display:block;text-align:left;width:100%;background:transparent;font-size:14px}.connection{font-size:12px;color:var(--muted);margin:0;padding:0 15px}.connection:not(:empty){padding:10px 15px}.empty-pane{display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at center,#f2eefc 0%,#faf9fd 70%);padding:30px;text-align:center}.empty-pane .mark{width:88px;height:88px;margin-bottom:30px}.empty-pane h1{font-size:34px;letter-spacing:-1.1px;margin-bottom:12px}.empty-pane p{color:var(--muted);margin-bottom:27px}.encryption-note{font-size:12px;color:var(--muted);margin-top:25px}.conversation{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fbfafd}.chat-header{height:90px;display:flex;align-items:center;gap:14px;padding:18px 30px;background:var(--surface);border-bottom:1px solid var(--line)}.chat-title{flex:1;min-width:0}.chat-title h1{font-size:17px;margin:0 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-title p{font-size:12px;color:var(--muted);margin:0}.back{display:none}.messages{flex:1;overflow:auto;padding:25px 40px;display:flex;flex-direction:column;gap:8px;overscroll-behavior:contain}.date-label{text-align:center;color:var(--muted);font-size:11px;margin:14px 0}.message{max-width:min(76%,560px);padding:11px 15px 8px;border-radius:19px;align-self:flex-start;background:var(--surface);box-shadow:0 1px 3px #21113b0b;min-width:74px;overflow-wrap:anywhere}.message.mine{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:5px}.message.theirs{border-bottom-left-radius:5px;border:1px solid #efebf4}.message p{margin:0;white-space:pre-wrap;font-size:15px;line-height:1.5}.message time{display:block;text-align:right;margin-top:4px;font-size:10px;opacity:.8}.sender-name{font-size:11px;display:block;margin-bottom:4px;color:var(--accent)}.composer{display:flex;align-items:flex-end;gap:12px;padding:16px 28px 24px;background:var(--surface);border-top:1px solid var(--line)}.composer textarea{resize:none;flex:1;border:1px solid var(--line);background:#faf9fc;border-radius:24px;padding:13px 19px;line-height:22px;min-height:48px;max-height:140px;color:var(--ink)}.send{border-radius:50%;height:46px;width:46px;padding:0;font-size:26px}.composer-note{background:var(--surface);padding:0 28px}.start-note{margin:auto;text-align:center;color:var(--muted);font-size:13px}.request{background:var(--soft);border-radius:14px;margin:8px 2px 14px;padding:15px}.request strong{font-size:14px}.request p{font-size:12px;margin:5px 0 10px}.actions{display:flex;gap:8px}.welcome-wrap{min-height:100dvh;display:grid;place-items:center;padding:32px 20px;background:radial-gradient(ellipse at 50% 25%,#eee8ff,#faf9fd 65%)}.welcome{width:min(100%,440px)}.welcome>.mark{width:72px;height:72px;margin-bottom:34px}.welcome h1{font-size:42px;line-height:1.12;letter-spacing:-1.7px;max-width:400px;margin-bottom:20px}.lead{font-size:20px;color:#58516f}.welcome form{margin:25px 0 16px}.welcome form>.primary{width:100%}.welcome>.quiet{padding-left:0}.field{display:flex;flex-direction:column;gap:9px;font-size:13px;font-weight:600;margin:18px 0}.field input{width:100%;padding:13px 14px;border:1px solid #d9d3e6;border-radius:11px;background:var(--surface);color:var(--ink);font-size:16px}.notice{font-size:13px;color:var(--muted);padding:9px 0;margin:0}.error{color:#a83248}.onboard-form .field{margin-top:0}dialog{width:min(92vw,480px);max-height:85dvh;overflow:auto;border:1px solid var(--line);border-radius:22px;padding:25px;color:var(--ink);background:var(--surface);box-shadow:0 25px 100px #20103d33}dialog::backdrop{background:#21182d55;backdrop-filter:blur(3px)}dialog header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}dialog h2{font-size:20px;margin:0}dialog header button{font-size:12px;padding:7px}dialog p{font-size:14px;color:var(--muted)}dialog textarea{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;margin:10px 0;resize:vertical;font-size:12px;color:var(--ink);background:var(--soft)}dialog form>.primary{width:100%;margin-top:12px}.choices label{display:flex;align-items:center;gap:12px;padding:12px 0}.choices input{width:18px;height:18px;accent-color:var(--accent)}.member{display:flex;align-items:center;justify-content:space-between;padding:10px 0}.safety{font-size:15px;line-height:1.8;white-space:pre-wrap;text-align:center;background:var(--soft);padding:18px;border-radius:14px}
+*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea,summary{-webkit-tap-highlight-color:transparent}button{cursor:pointer;border:0;border-radius:12px;padding:11px 17px;background:var(--soft);color:var(--ink);font-weight:600}button:hover{filter:brightness(.96)}button:disabled{cursor:default;opacity:.55}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #a997f0;outline-offset:3px}.primary{background:var(--accent);color:#fff}.quiet{background:transparent;color:var(--accent)}.small{padding:8px 13px;font-size:13px}.danger{color:#a83248}p{line-height:1.6}h1,h2,h3,p{margin-top:0}.mark{display:block;flex:none}.muted,.footnote{color:var(--muted)}.footnote{font-size:13px}.loading{display:grid;place-items:center;min-height:100dvh;color:var(--muted)}.shell{height:100dvh;display:grid;grid-template-columns:340px 1fr;max-width:1600px;margin:auto;background:var(--surface)}.sidebar{min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--surface)}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:28px 22px 18px;gap:12px}.brand{display:flex;gap:12px;align-items:center;font-size:20px;font-weight:700;letter-spacing:-.5px}.brand .mark{width:35px;height:35px}.search-wrap{padding:0 20px 20px}input[type=search]{width:100%;border:0;background:#f4f2f7;border-radius:12px;padding:11px 14px;font-size:14px;color:var(--ink)}.thread-list{flex:1;overflow:auto;padding:0 10px}.thread-row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:transparent;padding:14px 12px;border-radius:13px;margin:3px 0;font-weight:400}.thread-row.active{background:#eee9fc}.avatar{width:44px;height:44px;border-radius:50%;display:inline-grid;place-items:center;flex:none;font-size:18px;font-weight:600;background:#e8e1fa;color:#6550a7}.thread-summary{min-width:0;flex:1}.thread-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}.thread-top strong{font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.thread-top time{font-size:11px;color:var(--muted);flex:none}.preview{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--muted)}.list-empty{padding:25px 10px;text-align:center;color:var(--muted);font-size:14px}.account-menu{padding:15px 22px;position:relative}.account-menu summary{display:flex;gap:12px;align-items:center;cursor:pointer;font-size:14px}.account-menu summary .muted{margin-left:auto}.account-menu .avatar{width:32px;height:32px;font-size:14px}.menu-items{position:absolute;bottom:75px;left:16px;right:16px;background:var(--surface);border:1px solid var(--line);border-radius:15px;padding:8px;box-shadow:0 12px 36px #231d4315;z-index:2}.menu-items button{display:block;text-align:left;width:100%;background:transparent;font-size:14px}.connection{font-size:12px;color:var(--muted);margin:0;padding:0 15px}.connection:not(:empty){padding:10px 15px}.empty-pane{display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at center,#f2eefc 0%,#faf9fd 70%);padding:30px;text-align:center}.empty-pane .mark{width:88px;height:88px;margin-bottom:30px}.empty-pane h1{font-size:34px;letter-spacing:-1.1px;margin-bottom:12px}.empty-pane p{color:var(--muted);margin-bottom:27px}.encryption-note{font-size:12px;color:var(--muted);margin-top:25px}.conversation{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fbfafd}.chat-header{height:90px;display:flex;align-items:center;gap:14px;padding:18px 30px;background:var(--surface);border-bottom:1px solid var(--line)}.chat-title{flex:1;min-width:0}.chat-title h1{font-size:17px;margin:0 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-title p{font-size:12px;color:var(--muted);margin:0}.back{display:none}.messages{flex:1;overflow:auto;padding:25px 40px;display:flex;flex-direction:column;gap:8px;overscroll-behavior:contain}.date-label{text-align:center;color:var(--muted);font-size:11px;margin:14px 0}.message{max-width:min(76%,560px);padding:11px 15px 8px;border-radius:19px;align-self:flex-start;background:var(--surface);box-shadow:0 1px 3px #21113b0b;min-width:74px;overflow-wrap:anywhere}.message.mine{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:5px}.message.theirs{border-bottom-left-radius:5px;border:1px solid #efebf4}.message p{margin:0;white-space:pre-wrap;font-size:15px;line-height:1.5}.message time{display:block;text-align:right;margin-top:4px;font-size:10px;opacity:.8}.sender-name{font-size:11px;display:block;margin-bottom:4px;color:var(--accent)}.composer{display:flex;align-items:flex-end;gap:12px;padding:10px 18px 14px;background:transparent}.composer textarea{resize:none;flex:1;min-width:0;border:0;background:transparent;border-radius:24px;padding:13px 19px;line-height:22px;min-height:48px;max-height:140px;color:var(--ink)}.send{border-radius:50%;height:46px;width:46px;padding:0;font-size:26px}.composer-note{background:transparent;padding:0 28px}.start-note{margin:auto;text-align:center;color:var(--muted);font-size:13px}.request{background:var(--soft);border-radius:14px;margin:8px 2px 14px;padding:15px}.request strong{font-size:14px}.request p{font-size:12px;margin:5px 0 10px}.actions{display:flex;gap:8px}.welcome-wrap{min-height:100dvh;display:grid;place-items:center;padding:32px 20px;background:radial-gradient(ellipse at 50% 25%,#eee8ff,#faf9fd 65%)}.welcome{width:min(100%,440px)}.welcome>.mark{width:72px;height:72px;margin-bottom:34px}.welcome h1{font-size:42px;line-height:1.12;letter-spacing:-1.7px;max-width:400px;margin-bottom:20px}.lead{font-size:20px;color:#58516f}.welcome form{margin:25px 0 16px}.welcome form>.primary{width:100%}.welcome>.quiet{padding-left:0}.field{display:flex;flex-direction:column;gap:9px;font-size:13px;font-weight:600;margin:18px 0}.field input{width:100%;padding:13px 14px;border:1px solid #d9d3e6;border-radius:11px;background:var(--surface);color:var(--ink);font-size:16px}.notice{font-size:13px;color:var(--muted);padding:9px 0;margin:0}.error{color:#a83248}.onboard-form .field{margin-top:0}dialog{width:min(92vw,480px);max-height:85dvh;overflow:auto;border:1px solid var(--line);border-radius:22px;padding:25px;color:var(--ink);background:var(--surface);box-shadow:0 25px 100px #20103d33}dialog::backdrop{background:#21182d55;backdrop-filter:blur(3px)}dialog header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}dialog h2{font-size:20px;margin:0}dialog header button{font-size:12px;padding:7px}dialog p{font-size:14px;color:var(--muted)}dialog textarea{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;margin:10px 0;resize:vertical;font-size:12px;color:var(--ink);background:var(--soft)}dialog form>.primary{width:100%;margin-top:12px}.choices label{display:flex;align-items:center;gap:12px;padding:12px 0}.choices input{width:18px;height:18px;accent-color:var(--accent)}.member{display:flex;align-items:center;justify-content:space-between;padding:10px 0}.safety{font-size:15px;line-height:1.8;white-space:pre-wrap;text-align:center;background:var(--soft);padding:18px;border-radius:14px}
@media(max-width:700px){.shell{display:block}.sidebar,.conversation{height:100dvh}.sidebar-header{padding-top:max(22px,env(safe-area-inset-top))}.empty-pane{display:none}.conversation-open .sidebar{display:none}.shell:not(.conversation-open) .conversation{display:none}.chat-header{height:auto;min-height:76px;padding:14px 16px;padding-top:max(14px,env(safe-area-inset-top));gap:10px}.back{display:block;font-size:32px;padding:0 5px}.chat-header .avatar{width:37px;height:37px;font-size:16px}.chat-title p{font-size:10px}.chat-header>.quiet:last-child{font-size:12px;padding:8px}.messages{padding:10px 15px}.message{max-width:85%}.composer{padding:12px 13px max(14px,env(safe-area-inset-bottom));gap:8px}.welcome h1{font-size:37px}.account-menu{padding-bottom:max(16px,env(safe-area-inset-bottom))}}
-@media(prefers-color-scheme:dark){:root{--accent:#7965de;--ink:#f0edf8;--muted:#aaa3bb;--line:#302b3c;--surface:#211d2a;--soft:#30283f;color:var(--ink);background:#19161f;color-scheme:dark}.empty-pane,.welcome-wrap{background:radial-gradient(ellipse at center,#302440,#19161f 75%)}.conversation{background:#19161f}input[type=search],.composer textarea{background:#2a2433}.thread-row.active{background:#372c4d}.avatar{background:#423258;color:#d4c2ef}.message.theirs{border-color:#383044}.lead{color:#c2b6d3}.field input{border-color:#493d59}.error,.danger{color:#f7a5b2}}
+@media(prefers-color-scheme:dark){:root{--accent:#7965de;--ink:#f0edf8;--muted:#aaa3bb;--line:#302b3c;--surface:#211d2a;--soft:#30283f;color:var(--ink);background:#19161f;color-scheme:dark}.empty-pane,.welcome-wrap{background:radial-gradient(ellipse at center,#302440,#19161f 75%)}.conversation{background:#19161f}input[type=search]{background:#2a2433}.thread-row.active{background:#372c4d}.avatar{background:#423258;color:#d4c2ef}.message.theirs{border-color:#383044}.lead{color:#c2b6d3}.field input{border-color:#493d59}.error,.danger{color:#f7a5b2}}
/* 🤖 Setup gets its own stage. The travelling light is indeterminate, not a fabricated percentage. */
.setup-progress{width:min(100%,520px);padding:24px 0}.setup-progress>.mark{width:54px;height:54px;margin-bottom:44px}.setup-eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:11px;font-weight:700;color:var(--accent);margin-bottom:18px}.setup-progress h1{font-size:clamp(38px,7vw,60px);line-height:1.06;letter-spacing:-2.4px;margin:0 0 48px;outline:none}.infinity-meter{position:relative;padding-right:60px;margin-bottom:44px}.infinity-track{position:relative;height:12px;border-radius:20px;background:var(--soft);box-shadow:inset 0 0 0 1px var(--line);overflow:hidden}.infinity-trail{position:absolute;inset:0 auto 0 0;width:44%;border-radius:inherit;background:linear-gradient(90deg,transparent,var(--accent));animation:infinity-travel 2.8s cubic-bezier(.4,0,.2,1) infinite}.infinity-ticks{display:flex;justify-content:space-between;padding:12px 3px 0;font-size:11px;font-variant-numeric:tabular-nums;color:var(--muted)}.infinity-ticks span:last-child{font-weight:700;color:var(--accent)}.infinity-beyond{position:absolute;right:0;top:-20px;color:var(--accent);font-size:54px;line-height:1;animation:infinity-glow 2.8s ease-in-out infinite}.infinity-meter::after{content:'';position:absolute;right:41px;top:5px;width:17px;border-top:2px dotted var(--accent);opacity:.35}.setup-status{min-height:86px}.setup-status-title{font-size:19px;font-weight:600;letter-spacing:-.35px;margin:0 0 6px}.setup-status-detail{font-size:14px;color:var(--muted);margin:0}.setup-footnote{margin:30px 0 0;font-size:12px;color:var(--muted)}[data-phase=opening] .infinity-trail{animation-duration:1.5s}@keyframes infinity-travel{0%{transform:translateX(-110%)}100%{transform:translateX(340%)}}@keyframes infinity-glow{0%,100%{opacity:.45}55%{opacity:1}}@media(prefers-reduced-motion:reduce){.infinity-trail{animation:none;left:28%;background:var(--accent);opacity:.65}.infinity-beyond{animation:none}}@media(max-width:420px){.setup-progress h1{letter-spacing:-1.6px}.setup-progress>.mark{margin-bottom:34px}.setup-footnote{max-width:240px}.setup-progress{padding:12px 0}}
@@ -14,3 +14,11 @@
.simple-welcome h1 { font-size:clamp(2rem,6vw,3.3rem); }
.simple-welcome .onboard-form button { width:100%; }
.profile-avatar img,.avatar { object-fit:cover; border-radius:50%; }
+
+/* 🤖 The feed stops above a borderless composer; only the input carries a rim. */
+.composer-input{display:flex;align-items:flex-end;gap:6px;width:100%;border:1px solid var(--line);border-radius:26px;padding:3px;background:transparent}
+.composer-input:focus-within{border-color:#a997f0}
+.composer textarea:focus-visible{outline:none}
+.composer .send{flex:none;width:38px;height:38px;margin:2px;font-size:25px}
+.send[hidden]{display:none}
+.retry-send{display:inline;padding:0 0 0 7px;background:none;color:inherit;font:inherit;text-decoration:underline}
diff --git a/mobile/client.go b/mobile/client.go
index d8f2b1b..abe9995 100644
--- a/mobile/client.go
+++ b/mobile/client.go
@@ -198,6 +198,7 @@ func (c *Client) refresh() (projection, error) {
status = "Sent"
} else if attempted {
status = "Not delivered · retrying"
+ t.Messages[i].Retry = true
}
t.Messages[i].Delivery = status
}
diff --git a/mobile/conversations.go b/mobile/conversations.go
index bcd3792..f214fb6 100644
--- a/mobile/conversations.go
+++ b/mobile/conversations.go
@@ -8,7 +8,7 @@ import (
"time"
)
-func (c *Client) emit(people []person, p event) error {
+func (c *Client) envelopes(people []person, p event) ([]deliveryItem, error) {
if p.Kind == "group" {
members := append([]person(nil), p.Members...)
for i := range members {
@@ -20,7 +20,7 @@ func (c *Client) emit(people []person, p event) error {
seen := map[string]bool{}
body, e := json.Marshal(p)
if e != nil {
- return e
+ return nil, e
}
for _, to := range people {
if seen[to.Signing] {
@@ -28,10 +28,10 @@ func (c *Client) emit(people []person, p event) error {
}
seen[to.Signing] = true
if _, e := secureOrigin(to.Origin); e != nil {
- return e
+ return nil, e
}
if to.Cap == "" {
- return errors.New("This contact is not ready.")
+ return nil, errors.New("This contact is not ready.")
}
id := ""
if p.Kind == "message" {
@@ -39,10 +39,17 @@ func (c *Client) emit(people []person, p event) error {
}
box, e := seal(c.id, to, body, id)
if e != nil {
- return e
+ return nil, e
}
items = append(items, deliveryItem{Destination: to.Origin, Capability: to.Cap, Envelope: box})
}
+ return items, nil
+}
+func (c *Client) emit(people []person, p event) error {
+ items, e := c.envelopes(people, p)
+ if e != nil {
+ return e
+ }
return c.api("/api/send", "POST", map[string]any{"items": items}, nil)
}
func now() *int64 { n := time.Now().UnixMilli(); return &n }
diff --git a/mobile/outgoing.go b/mobile/outgoing.go
new file mode 100644
index 0000000..25c1f73
--- /dev/null
+++ b/mobile/outgoing.go
@@ -0,0 +1,72 @@
+// 🤖 Native UI keeps these prepared ciphertexts in secure storage for exact retries.
+package ocho
+
+import (
+ "errors"
+ "strings"
+)
+
+type preparedMessage struct {
+ Thread string `json:"thread"`
+ Epoch int `json:"epoch"`
+ Items []deliveryItem `json:"items"`
+}
+
+// PrepareMessage validates current membership and seals a stable, client-chosen message ID.
+func (c *Client) PrepareMessage(threadID, text, id string, at int64) (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ key, keyError := decode(id)
+ text = strings.TrimSpace(text)
+ if text == "" || textLength(text) > 8000 || (keyError != nil || len(key) != 32) || at < 0 {
+ return "", errors.New("Write a message of up to 8,000 characters.")
+ }
+ view, e := c.refresh()
+ if e != nil {
+ return "", e
+ }
+ for _, t := range view.Threads {
+ if t.ID == threadID && !t.Pending && !t.Left {
+ items, e := c.envelopes(t.Members, event{Kind: "message", Thread: threadID, ID: id, Text: text, At: &at, Epoch: epoch(t.Epoch)})
+ if e != nil {
+ return "", e
+ }
+ return marshal(preparedMessage{Thread: threadID, Epoch: t.Epoch, Items: items})
+ }
+ }
+ return "", errors.New("This conversation is not ready to send.")
+}
+
+// SendPreparedMessage reuses ciphertexts and refuses obsolete membership snapshots.
+func (c *Client) SendPreparedMessage(raw string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ var p preparedMessage
+ if e := parse(raw, &p); e != nil {
+ return e
+ }
+ if len(p.Items) == 0 || len(p.Items) > 32 {
+ return errors.New("Invalid pending message.")
+ }
+ view, e := c.refresh()
+ if e != nil {
+ return e
+ }
+ for _, t := range view.Threads {
+ if t.ID == p.Thread && !t.Pending && !t.Left && t.Epoch == p.Epoch {
+ return c.api("/api/send", "POST", map[string]any{"items": p.Items}, nil)
+ }
+ }
+ return errors.New("The conversation membership changed.")
+}
+
+// RetryDelivery immediately retries an already queued message with the same ciphertexts.
+func (c *Client) RetryDelivery(id string) error {
+ key, e := decode(id)
+ if e != nil || len(key) != 32 {
+ return errors.New("Invalid message.")
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.api("/api/retry/"+id, "POST", nil, nil)
+}
diff --git a/mobile/outgoing_test.go b/mobile/outgoing_test.go
new file mode 100644
index 0000000..a974a96
--- /dev/null
+++ b/mobile/outgoing_test.go
@@ -0,0 +1,92 @@
+// 🤖 Prepared retries preserve ciphertext after a lost response and reject stale groups.
+package ocho
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestPreparedMessageRetry(t *testing.T) {
+ var disk vaultReply
+ var calls []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/vault":
+ json.NewEncoder(w).Encode(disk)
+ case "/api/send":
+ b, _ := io.ReadAll(r.Body)
+ calls = append(calls, string(b))
+ if len(calls) == 1 {
+ w.WriteHeader(503)
+ w.Write([]byte(`{"error":"lost response"}`))
+ } else {
+ w.Write([]byte(`{}`))
+ }
+ case "/api/retry":
+ w.Write([]byte(`{}`))
+ default:
+ w.Write([]byte(`[]`))
+ }
+ }))
+ defer server.Close()
+ raw, _ := NewIdentity(server.URL, "Self")
+ me, _ := readIdentity(raw)
+ peerRaw, _ := NewIdentity("https://peer.example", "Peer")
+ peer, _ := readIdentity(peerRaw)
+ state := newVault("Self")
+ state.Contacts["thread"] = contact{Person: peer.person()}
+ plain, _ := marshal(state)
+ disk.Box, _ = Lock(me.VaultKey, plain)
+ disk.Revision = 1
+ c, _ := NewClient(raw)
+ id := Random()
+ prepared, e := c.PrepareMessage("thread", "immediate native message", id, 12345)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if len(calls) != 0 {
+ t.Fatal("preparation uploaded before durable client storage")
+ }
+ if e = c.SendPreparedMessage(prepared); e == nil {
+ t.Fatal("lost response ignored")
+ }
+ c, _ = NewClient(raw)
+ if e = c.SendPreparedMessage(prepared); e != nil {
+ t.Fatal(e)
+ }
+ if len(calls) != 2 || calls[0] != calls[1] {
+ t.Fatal("retry changed ciphertext")
+ }
+ var p preparedMessage
+ json.Unmarshal([]byte(prepared), &p)
+ if len(p.Items) != 2 {
+ t.Fatal("missing participant")
+ }
+ for _, item := range p.Items {
+ if item.Envelope.ID != id {
+ t.Fatal("message ID changed")
+ }
+ }
+ opened, e := open(peer, p.Items[1].Envelope)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var message event
+ json.Unmarshal(opened, &message)
+ if message.ID != id || message.Text != "immediate native message" {
+ t.Fatal("peer could not read original message")
+ }
+ state.Contacts["thread"] = contact{Person: peer.person(), Pending: true}
+ plain, _ = marshal(state)
+ disk.Box, _ = Lock(me.VaultKey, plain)
+ if e = c.SendPreparedMessage(prepared); e == nil {
+ t.Fatal("retry ignored changed consent")
+ }
+ if len(calls) != 2 {
+ t.Fatal("invalid retry contacted old recipients")
+ }
+}
diff --git a/mobile/projection.go b/mobile/projection.go
index 2b9eb0b..8708eb8 100644
--- a/mobile/projection.go
+++ b/mobile/projection.go
@@ -74,6 +74,7 @@ type message struct {
Name string `json:"name"`
Signer string `json:"signer"`
Delivery string `json:"delivery,omitempty"`
+ Retry bool `json:"retry,omitempty"`
}
type thread struct {
ID string `json:"id"`
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs
index 1157b8e..c4b5164 100644
--- a/test/infinity/home-browser.mjs
+++ b/test/infinity/home-browser.mjs
@@ -2,6 +2,7 @@
import {createRequire} from 'node:module';
import {spawn} from 'node:child_process';
import assert from 'node:assert/strict';
+import {outgoingChecks} from './outgoing-browser.mjs';
const require=createRequire(new URL('./package.json',import.meta.url));
const {chromium,webkit}=require('playwright');
const home=process.env.OCHO_HOME_URL||'http://localhost:18860',live=process.env.OCHO_LIVE==='1';
@@ -23,9 +24,14 @@ try{
await alice.page.getByRole('button',{name:'Invite',exact:true}).click();const link=alice.page.getByLabel('Invitation link');await link.waitFor();const invitation=await link.inputValue();assert.match(invitation,new RegExp('^'+home.replaceAll('.','\\.')+'/#[-_A-Za-z0-9]{22}$'));
await alice.page.getByRole('button',{name:'Close',exact:true}).click();
await bob.page.goto(invitation);await bob.page.getByRole('heading',{name:'Paul is inviting you to chat on Ocho',exact:true}).waitFor();
- await bob.page.reload();await bob.page.getByRole('button',{name:'Sign up to accept',exact:true}).click();await bob.page.getByLabel('Name',{exact:true}).fill('Bob');
+ assert.equal(await bob.page.locator('.simple-welcome').innerText(),'Paul is inviting you to chat on Ocho\nSign up to accept\nAccept');
+ await bob.page.getByRole('button',{name:'Sign up to accept',exact:true}).click();await bob.page.getByRole('button',{name:'Back',exact:true}).click();
+ assert.equal(await bob.page.locator('.simple-welcome').innerText(),'Paul is inviting you to chat on Ocho\nSign up to accept\nAccept');
+ await bob.page.reload();await bob.page.getByRole('button',{name:'Sign up to accept',exact:true}).waitFor();
+ assert.equal(await bob.page.locator('.simple-welcome').innerText(),'Paul is inviting you to chat on Ocho\nSign up to accept\nAccept');
+ await bob.page.getByRole('button',{name:'Sign up to accept',exact:true}).click();await bob.page.getByLabel('Name',{exact:true}).fill('Bob');
await alice.page.goto('about:blank'); // The inviter's client cannot approve or exchange another capability.
- await bob.page.getByRole('button',{name:'Connect with Paul',exact:true}).click();await chat(bob.page);console.log('Invite signup passed');await bob.page.getByRole('textbox',{name:'Message',exact:true}).fill('Straight into our conversation');await bob.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();
+ await bob.page.getByRole('button',{name:'Connect with Paul',exact:true}).click();await chat(bob.page);console.log('Invite signup passed');await outgoingChecks(bob.page,'Straight into our conversation');await bob.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();
await alice.page.goto(home);await chat(alice.page);await alice.page.locator('.thread-row').filter({hasText:'Bob'}).click();await alice.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();assert.equal(await alice.page.getByRole('button',{name:'Accept',exact:true}).count(),0);
await alice.page.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob');await alice.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
await alice.page.locator('.account-menu summary').click();await alice.page.getByRole('button',{name:'Your profile',exact:true}).click();await alice.page.getByLabel('Display name',{exact:true}).fill('Paul updated');const png=await alice.page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=16;canvas.getContext('2d').fillRect(0,0,16,16);return canvas.toDataURL('image/png').split(',')[1];});await alice.page.getByLabel('Avatar',{exact:true}).setInputFiles({name:'avatar.png',mimeType:'image/png',buffer:Buffer.from(png,'base64')});await alice.page.getByAltText('Your avatar',{exact:true}).waitFor();await alice.page.getByRole('button',{name:'Save',exact:true}).click();await bob.page.getByRole('heading',{name:'Paul updated',exact:true}).waitFor();await bob.page.locator('.chat-header img.avatar').waitFor();assert.match(await bob.page.locator('.chat-header img.avatar').getAttribute('src'),/^data:image\/jpeg;base64,/);
diff --git a/test/infinity/native-send-proxy.py b/test/infinity/native-send-proxy.py
new file mode 100644
index 0000000..5982d0c
--- /dev/null
+++ b/test/infinity/native-send-proxy.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+# 🤖 Disposable UI-test fault injection. Never used by the app or deployment.
+import http.client
+import http.server
+import threading
+import time
+
+armed = False
+lock = threading.Lock()
+class Proxy(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_): pass
+ def do_GET(self): self.forward()
+ def do_POST(self): self.forward()
+ def do_PUT(self): self.forward()
+ def forward(self):
+ global armed
+ body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
+ if self.path == '/__test/fail-next-send':
+ with lock: armed = True
+ self.send_response(200); self.end_headers(); return
+ with lock:
+ fail = armed and self.path == '/api/send'
+ if fail: armed = False
+ if fail:
+ time.sleep(3)
+ self.send_response(503); self.send_header('Content-Type', 'application/json'); self.end_headers()
+ self.wfile.write(b'{"error":"Test interrupted upload"}'); return
+ conn = http.client.HTTPConnection('127.0.0.1', 18875, timeout=30)
+ try:
+ headers = dict(self.headers)
+ conn.request(self.command, self.path, body=body, headers=headers)
+ response = conn.getresponse(); data = response.read()
+ self.send_response(response.status)
+ self.send_header('Content-Type', response.getheader('Content-Type', 'application/json'))
+ self.send_header('Content-Length', str(len(data))); self.end_headers(); self.wfile.write(data)
+ finally: conn.close()
+http.server.ThreadingHTTPServer(('127.0.0.1', 18873), Proxy).serve_forever()
diff --git a/test/infinity/outgoing-browser.mjs b/test/infinity/outgoing-browser.mjs
new file mode 100644
index 0000000..1801107
--- /dev/null
+++ b/test/infinity/outgoing-browser.mjs
@@ -0,0 +1,32 @@
+// 🤖 Real UI with a delayed failed upload, encrypted reload recovery and exact retry.
+import assert from 'node:assert/strict';
+export async function outgoingChecks(page,text) {
+ const field=page.getByRole('textbox',{name:'Message',exact:true}),send=page.getByRole('button',{name:'Send message',exact:true});
+ assert.equal(await send.isVisible(),false);
+ const styles=await page.evaluate(()=>{const form=getComputedStyle(document.querySelector('.composer')),account=getComputedStyle(document.querySelector('.account-menu'));return[form.borderTopWidth,form.backgroundColor,account.borderTopWidth];});
+ assert.deepEqual(styles,['0px','rgba(0, 0, 0, 0)','0px']);
+ let release,seen;const gate=new Promise(r=>release=r),arrived=new Promise(r=>seen=r),bodies=[];
+ const match=url=>url.pathname.endsWith('/send');
+ await page.route(match,async route=>{bodies.push(route.request().postDataJSON());if(bodies.length===1){seen();await gate;await route.abort();}else await route.continue();});
+ await field.fill(text);assert.equal(await send.isVisible(),true);await send.click();
+ await page.getByRole('log').getByText(text,{exact:true}).waitFor({timeout:1500});
+ assert.match(await page.getByRole('log').innerText(),/Sending…/);
+ await arrived;await field.fill('A newer draft');release();
+ await page.getByRole('button',{name:'Retry sending message',exact:true}).waitFor();assert.equal(await field.inputValue(),'A newer draft');
+ const pending=await page.evaluate(()=>Object.entries(localStorage).filter(([key])=>key.startsWith('ocho.outgoing.')).map(([,value])=>value));
+ assert.equal(pending.length,1);assert.ok(!pending[0].includes(text));
+ await page.reload();await page.locator('.thread-row').first().click();
+ await page.getByRole('button',{name:'Retry sending message',exact:true}).click();
+ await page.getByRole('log').locator('.message').filter({hasText:text}).getByText(/Sent/).waitFor();
+ assert.equal(await page.getByRole('log').getByText(text,{exact:true}).count(),1);assert.deepEqual(bodies[0],bodies[1]);
+ assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isVisible(),false);
+ await page.unroute(match);
+ // The server may accept the ciphertext even when its success response is lost.
+ await page.route(match,async route=>{const response=await route.fetch();assert.equal(response.status(),202);await route.abort();});
+ const marker='One message after a lost success response';
+ await page.getByRole('textbox',{name:'Message',exact:true}).fill(marker);
+ await page.getByRole('button',{name:'Send message',exact:true}).click();
+ await page.getByRole('log').locator('.message').filter({hasText:marker}).getByText(/Sent/).waitFor();
+ assert.equal(await page.getByRole('log').getByText(marker,{exact:true}).count(),1);
+ await page.unroute(match);
+}
diff --git a/test/infinity/outgoing.test.mjs b/test/infinity/outgoing.test.mjs
new file mode 100644
index 0000000..689dd82
--- /dev/null
+++ b/test/infinity/outgoing.test.mjs
@@ -0,0 +1,22 @@
+// 🤖 Failure/retry contracts use real envelope crypto and encrypted local persistence.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {Outgoing} from '../../internal/infinity/static/outgoing.js';
+import {createIdentity,publicIdentity,seal} from '../../internal/infinity/static/crypto.js';
+test('pending text is immediate, encrypted on disk, and exact-envelope retry survives reopen',async()=>{
+ const disk=new Map();globalThis.localStorage={getItem:k=>disk.get(k),setItem:(k,v)=>disk.set(k,v),removeItem:k=>disk.delete(k)};
+ const id=await createIdentity('https://self.example','Self'),peer=await createIdentity('https://peer.example','Peer');
+ const sent=[];let fail=true;
+ function client(){const c={id,state:{name:'Self'},view:{threads:[{id:'thread',epoch:0,members:[publicIdentity(id),publicIdentity(peer)],messages:[]}]},rebuild(){},async refresh(){},async envelopes(people,payload){return Promise.all(people.map(async p=>({destination:p.origin,capability:p.cap,envelope:await seal(id,p,payload)})));},async api(path,options){sent.push(options.body);if(fail)throw Error('response lost');}};return c;}
+ const c=client(),out=new Outgoing(c);const done=out.message('thread','pending secret marker');out.overlay(c.view);
+ assert.equal(c.view.threads[0].messages[0].text,'pending secret marker');assert.equal(c.view.threads[0].messages[0].delivery,'Sending…');
+ await done;assert.equal(out.rows.values().next().value.status,'failed');assert.ok(![...disk.values()].join('').includes('pending secret marker'));
+ const reopened=client(),retry=new Outgoing(reopened);await retry.restore();retry.overlay(reopened.view);assert.equal(reopened.view.threads[0].messages[0].retry,true);
+ fail=false;await retry.retry([...retry.rows.keys()][0]);assert.deepEqual(sent[0],sent[1]);assert.equal(retry.rows.values().next().value.status,'accepted');
+ delete globalThis.localStorage;
+});
+test('retry refuses an obsolete membership snapshot',async()=>{
+ const id=await createIdentity('https://self.example','Self');let calls=0;
+ const c={id,state:{name:'Self'},view:{threads:[{id:'group',epoch:1,messages:[]}]},rebuild(){},async refresh(){this.view.threads[0].epoch=2;},async envelopes(){calls++;},async api(){calls++;}};
+ const out=new Outgoing(c);await out.message('group','old membership');assert.equal(calls,0);assert.equal(out.rows.values().next().value.status,'failed');
+});
diff --git a/tools/infinity/check b/tools/infinity/check
index 65e3723..1d02b78 100755
--- a/tools/infinity/check
+++ b/tools/infinity/check
@@ -12,6 +12,11 @@ case "${1:-all}" in
node test/infinity/browser.mjs
INFINITY_BROWSER=webkit node test/infinity/browser.mjs
;;
+ home-browser)
+ node --test test/infinity/outgoing.test.mjs
+ CGO_ENABLED=0 go test -c -o .build/native-check/home-test ./internal/infinityhome
+ node test/infinity/home-browser.mjs
+ ;;
mac)
tools/infinity/build-mobile apple
xcodegen generate --spec apple/project.yml
@@ -25,6 +30,7 @@ case "${1:-all}" in
all)
"$0" server
"$0" browser
+ "$0" home-browser
;;
- *) echo 'usage: tools/infinity/check [server|browser|mac|ios|all]' >&2; exit 2;;
+ *) echo 'usage: tools/infinity/check [server|browser|home-browser|mac|ios|all]' >&2; exit 2;;
esac
diff --git a/tools/infinity/check-native-mac-ui b/tools/infinity/check-native-mac-ui
new file mode 100755
index 0000000..0b03fc6
--- /dev/null
+++ b/tools/infinity/check-native-mac-ui
@@ -0,0 +1,17 @@
+#!/bin/sh
+# 🤖 Exercise the Mac sidebar and keyboard composer against disposable accounts.
+set -eu
+cd "$(dirname "$0")/../.."
+mkdir -p .build/native-check
+CGO_ENABLED=0 go build -o .build/native-check/infinity ./cmd/infinity
+native_ui_dir=$(mktemp -d)
+first_pid= second_pid= proxy_pid=
+trap 'kill ${first_pid:-} ${second_pid:-} ${proxy_pid:-} 2>/dev/null || true; rm -rf "$native_ui_dir"' EXIT
+INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18875 --db "$native_ui_dir/a.db" > "$native_ui_dir/a.log" 2>&1 &
+first_pid=$!
+INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18874 --db "$native_ui_dir/b.db" > "$native_ui_dir/b.log" 2>&1 &
+second_pid=$!
+python3 test/infinity/native-send-proxy.py > "$native_ui_dir/proxy.log" 2>&1 &
+proxy_pid=$!
+xcodegen generate --spec apple/project.yml
+xcodebuild -project apple/Infinity.xcodeproj -scheme InfinityMac -destination 'platform=macOS' -derivedDataPath apple/build-team-tito-mac test