diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f8746b1d8b..3fc3bade15 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -191,7 +191,9 @@ const overrides = new Map([ // added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8). // codex-install-auto-restart: restarted_count + failed_restart_count added to // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). - ["src/shared/api/tauri.ts", 1282], + // NIP-37 synced drafts: Tauri bridges for capability gating and opaque + // secret-derived draft addresses. Both preserve the key boundary. + ["src/shared/api/tauri.ts", 1298], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index beeb22cb73..725883781a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -925,6 +925,7 @@ dependencies = [ "earshot", "futures-util", "hex", + "hmac 0.13.0", "image", "infer", "keyring", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index aadbd07705..e8b60ed50b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -89,6 +89,7 @@ mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "ebc3ab4c4b5f4a45d5bc942c66c18583800c3f82", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } base64 = "0.22" sha2 = "0.11" +hmac = "0.13" tar = "0.4" bzip2 = "0.6" chrono = { version = "0.4", features = ["serde"] } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 3d9cdd42fb..19829e4ab9 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -1,6 +1,8 @@ +use hmac::{Hmac, KeyInit, Mac}; use nostr::{ nips::nip44, Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, ToBech32, }; +use sha2::Sha256; use tauri::Manager; use tauri::State; @@ -417,6 +419,45 @@ pub async fn nip44_encrypt_to_self( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +const DRAFT_ADDRESS_DOMAIN: &[u8] = b"buzz-draft-address/v1"; + +type HmacSha256 = Hmac; + +fn derive_draft_address_from_keys( + keys: &Keys, + logical_compose_key: &str, + relay_scope: &str, +) -> Result { + let conversation_key = + nip44::v2::ConversationKey::derive(keys.secret_key(), &keys.public_key()) + .map_err(|error| format!("nip44 conversation key failed: {error}"))?; + let mut mac = HmacSha256::new_from_slice(conversation_key.as_bytes()) + .map_err(|error| format!("draft address HMAC initialization failed: {error}"))?; + mac.update(DRAFT_ADDRESS_DOMAIN); + mac.update(&[0]); + mac.update(relay_scope.as_bytes()); + mac.update(&[0]); + mac.update(logical_compose_key.as_bytes()); + Ok(hex::encode(mac.finalize().into_bytes())) +} + +/// Derive an opaque, stable NIP-37 `d` tag from the identity's NIP-44-to-self +/// conversation key. The secret-derived operation stays in Rust so raw key +/// material never enters the webview. +#[tauri::command] +pub async fn derive_draft_address( + logical_compose_key: String, + relay_scope: String, + state: State<'_, AppState>, +) -> Result { + let keys = state.signing_keys()?; + tauri::async_runtime::spawn_blocking(move || { + derive_draft_address_from_keys(&keys, &logical_compose_key, &relay_scope) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + #[tauri::command] pub async fn nip44_decrypt_from_self( ciphertext: String, @@ -432,6 +473,33 @@ pub async fn nip44_decrypt_from_self( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +#[cfg(test)] +mod draft_address_tests { + use super::derive_draft_address_from_keys; + use nostr::Keys; + + #[test] + fn derive_draft_address_is_deterministic_and_scoped() { + let keys = Keys::generate(); + let first = + derive_draft_address_from_keys(&keys, "thread:root-a", "wss://relay.example").unwrap(); + let repeated = + derive_draft_address_from_keys(&keys, "thread:root-a", "wss://relay.example").unwrap(); + let different_key = + derive_draft_address_from_keys(&keys, "thread:root-b", "wss://relay.example").unwrap(); + let different_relay = + derive_draft_address_from_keys(&keys, "thread:root-a", "wss://other.example").unwrap(); + + assert_eq!(first, repeated); + assert_ne!(first, different_key); + assert_ne!(first, different_relay); + assert_eq!(first.len(), 64); + assert!(first + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())); + } +} + #[cfg(test)] mod nostr_identity_binding_tests { use super::build_nostr_identity_binding_event; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 56100acf03..15ac78f7d0 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -46,6 +46,38 @@ pub async fn fetch_workspace_icon( Ok(doc.icon.filter(|icon| !icon.is_empty())) } +#[derive(Deserialize)] +struct RelayInfoDocument { + #[serde(default)] + supported_nips: Vec, +} + +/// Return whether the active relay advertises a requested NIP in its NIP-11 +/// document. Network and parse failures deliberately return false so callers +/// can preserve offline/local-only behavior. +#[tauri::command] +pub async fn relay_supports_nip(nip: u32, state: State<'_, AppState>) -> Result { + let relay_url = relay::relay_ws_url_with_override(&state); + let http_url = relay::relay_http_base_url(&relay_url); + let Ok(response) = state + .http_client + .get(http_url) + .header("Accept", "application/nostr+json") + .send() + .await + else { + return Ok(false); + }; + if !response.status().is_success() { + return Ok(false); + } + Ok(response + .json::() + .await + .map(|document| document.supported_nips.contains(&nip)) + .unwrap_or(false)) +} + #[derive(Serialize)] pub struct ActiveWorkspaceInfo { relay_url: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6fadf13265..a837a5e64a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -703,6 +703,8 @@ pub fn run() { create_auth_event, nip44_encrypt_to_self, nip44_decrypt_from_self, + derive_draft_address, + relay_supports_nip, get_channels, create_channel, open_dm, diff --git a/desktop/src/features/messages/lib/draftPayload.test.mjs b/desktop/src/features/messages/lib/draftPayload.test.mjs new file mode 100644 index 0000000000..c1be2acff9 --- /dev/null +++ b/desktop/src/features/messages/lib/draftPayload.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseDraftPayload, serializeDraftPayload } from "./draftPayload.ts"; + +const pubkey = "a".repeat(64); +const draft = { + content: "cross-device draft", + selectionStart: 0, + selectionEnd: 18, + channelId: "550e8400-e29b-41d4-a716-446655440000", + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:01:00.000Z", + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", +}; + +test("test_draft_payload_round_trip_recovers_thread_context", () => { + const plaintext = serializeDraftPayload( + "thread:root-event", + draft, + pubkey, + 100, + ); + const decoded = parseDraftPayload(plaintext, pubkey, 9, draft.updatedAt); + + assert.ok(decoded); + assert.equal(decoded.draftKey, "thread:root-event"); + assert.equal(decoded.draft.content, draft.content); + assert.equal(decoded.draft.channelId, draft.channelId); +}); + +test("test_draft_payload_foreign_author_is_rejected", () => { + const plaintext = serializeDraftPayload( + draft.channelId, + draft, + "b".repeat(64), + 100, + ); + assert.equal(parseDraftPayload(plaintext, pubkey, 9, draft.updatedAt), null); +}); diff --git a/desktop/src/features/messages/lib/draftPayload.ts b/desktop/src/features/messages/lib/draftPayload.ts new file mode 100644 index 0000000000..86f3702524 --- /dev/null +++ b/desktop/src/features/messages/lib/draftPayload.ts @@ -0,0 +1,106 @@ +import { + buildImetaTags, + imetaMediaFromTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import type { DraftState } from "@/features/messages/lib/useDrafts"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; + +export type DraftPayload = { + kind: number; + created_at: number; + tags: string[][]; + content: string; + pubkey: string; +}; + +export type DecodedDraftPayload = { + draftKey: string; + draft: DraftState; +}; + +function isStringTag(tag: unknown): tag is string[] { + return Array.isArray(tag) && tag.every((value) => typeof value === "string"); +} + +function isSupportedKind(kind: unknown): kind is number { + return kind === KIND_STREAM_MESSAGE; +} + +/** Serialize an interoperable unsigned event; editor-only selection stays local. */ +export function serializeDraftPayload( + draftKey: string, + draft: DraftState, + pubkey: string, + createdAt = Math.floor(Date.now() / 1_000), +): string { + const tags = [["h", draft.channelId], ...buildImetaTags(draft.pendingImeta)]; + if (draftKey.startsWith("thread:")) { + const rootId = draftKey.slice("thread:".length); + if (rootId) tags.splice(1, 0, ["e", rootId, "", "reply"]); + } + return JSON.stringify({ + kind: KIND_STREAM_MESSAGE, + created_at: createdAt, + tags, + content: draft.content, + pubkey, + } satisfies DraftPayload); +} + +/** + * Validate a decrypted NIP-37 payload before it reaches the local draft store. + * The compose key is reconstructed solely from encrypted inner tags. + */ +export function parseDraftPayload( + plaintext: string, + expectedPubkey: string, + expectedKind: number, + fallbackUpdatedAt: string, +): DecodedDraftPayload | null { + try { + const value: unknown = JSON.parse(plaintext); + if (typeof value !== "object" || value === null) return null; + const payload = value as Partial; + if ( + !isSupportedKind(payload.kind) || + payload.kind !== expectedKind || + typeof payload.content !== "string" || + typeof payload.pubkey !== "string" || + payload.pubkey.toLowerCase() !== expectedPubkey.toLowerCase() || + !Array.isArray(payload.tags) || + !payload.tags.every(isStringTag) + ) { + return null; + } + const channelId = getChannelIdFromTags(payload.tags); + if (!channelId) return null; + const thread = getThreadReference(payload.tags); + const draftKey = thread.rootId ? `thread:${thread.rootId}` : channelId; + const media = imetaMediaFromTags(payload.tags); + const createdAt = + typeof payload.created_at === "number" && + Number.isFinite(payload.created_at) + ? new Date(payload.created_at * 1_000).toISOString() + : fallbackUpdatedAt; + return { + draftKey, + draft: { + content: payload.content, + selectionStart: payload.content.length, + selectionEnd: payload.content.length, + channelId, + createdAt, + updatedAt: fallbackUpdatedAt, + pendingImeta: media, + spoileredAttachmentUrls: [], + status: "active", + }, + }; + } catch { + return null; + } +} diff --git a/desktop/src/features/messages/lib/draftSync.test.mjs b/desktop/src/features/messages/lib/draftSync.test.mjs new file mode 100644 index 0000000000..ff6ba17ebd --- /dev/null +++ b/desktop/src/features/messages/lib/draftSync.test.mjs @@ -0,0 +1,599 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +function makeLocalStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }; +} + +globalThis.window = { + localStorage: makeLocalStorage(), + setTimeout, + clearTimeout, +}; +Object.defineProperty(globalThis, "localStorage", { + get: () => globalThis.window.localStorage, +}); + +import { DraftSyncManager } from "./draftSync.ts"; +import { + clearAllDrafts, + initDraftStore, + loadDraftEntry, + removeRemoteDraftEntry, + saveDraftEntry, +} from "./useDrafts.ts"; + +const pubkey = "a".repeat(64); +const channelA = "550e8400-e29b-41d4-a716-446655440000"; +const channelB = "550e8400-e29b-41d4-a716-446655440001"; + +function wrapped({ id, createdAt, address, channelId, content }) { + return { + id, + created_at: createdAt, + kind: 31234, + pubkey, + content, + sig: "", + tags: [ + ["d", address], + ["h", channelId], + ["k", "9"], + ], + }; +} + +function payload(channelId, content) { + return JSON.stringify({ + kind: 9, + created_at: 1, + pubkey, + content, + tags: [["h", channelId]], + }); +} + +function setup() { + globalThis.window.localStorage = makeLocalStorage(); + clearAllDrafts(); + initDraftStore(pubkey, "wss://relay.example"); +} + +test("test_two_addresses_out_of_order_each_merge_current_head", async () => { + setup(); + const events = [ + wrapped({ + id: "new-a", + createdAt: 20, + address: "address-a", + channelId: channelA, + content: "cipher-a", + }), + wrapped({ + id: "old-b", + createdAt: 10, + address: "address-b", + channelId: channelB, + content: "cipher-b", + }), + ]; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async (cipher) => + cipher === "cipher-a" ? payload(channelA, "A") : payload(channelB, "B"), + deriveAddress: async (draftKey) => + draftKey === channelA ? "address-a" : "address-b", + fetchEvents: async () => events, + }); + + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA)?.content, "A"); + assert.equal(loadDraftEntry(channelB)?.content, "B"); +}); + +test("test_older_event_for_same_address_does_not_replace_newer_draft", async () => { + setup(); + const newer = wrapped({ + id: "newer", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "new-cipher", + }); + const older = wrapped({ + id: "older", + createdAt: 1, + address: "address-a", + channelId: channelA, + content: "old-cipher", + }); + let events = [newer]; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async (cipher) => + payload(channelA, cipher === "new-cipher" ? "new draft" : "old draft"), + deriveAddress: async () => "address-a", + fetchEvents: async () => events, + }); + + await manager.fetchAllOwnDrafts(); + events = [older]; + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA)?.content, "new draft"); +}); + +test("test_decrypted_context_with_mismatched_address_is_rejected", async () => { + setup(); + const address = "address-from-event"; + const mismatched = wrapped({ + id: "mismatched-address", + createdAt: 2, + address, + channelId: channelA, + content: "mismatched-cipher", + }); + const valid = wrapped({ + id: "valid-address", + createdAt: 1, + address, + channelId: channelA, + content: "valid-cipher", + }); + let events = [mismatched]; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async (cipher) => + cipher === "mismatched-cipher" + ? JSON.stringify({ + kind: 9, + created_at: 1, + pubkey, + content: "must not restore", + tags: [ + ["h", channelA], + ["e", "other-root", "", "reply"], + ], + }) + : payload(channelA, "valid draft"), + deriveAddress: async (draftKey) => + draftKey === channelA ? address : "address-derived-from-context", + fetchEvents: async () => events, + }); + + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA), undefined); + events = [valid]; + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA)?.content, "valid draft"); +}); + +test("test_tombstone_failure_sidecar_suppresses_remote_resurrection", async () => { + setup(); + const remote = wrapped({ + id: "remote", + createdAt: 1, + address: "address-a", + channelId: channelA, + content: "cipher", + }); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + sign: async (input) => ({ + ...remote, + id: "tombstone", + created_at: 2, + content: input.content, + }), + publishEvent: async () => { + throw new Error("offline"); + }, + decrypt: async () => payload(channelA, "must not return"), + fetchEvents: async () => [remote], + }); + + await manager.queueDeletion(channelA, channelA); + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA), undefined); + assert.match( + localStorage.getItem(`buzz-draft-sync.v1:wss://relay.example:${pubkey}`) ?? + "", + /address-a/, + ); +}); + +test("test_remote_tombstone_blocks_stale_cleanup_publish", async () => { + setup(); + const remote = wrapped({ + id: "remote-draft", + createdAt: 1, + address: "address-a", + channelId: channelA, + content: "cipher", + }); + const tombstone = wrapped({ + id: "remote-tombstone", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "", + }); + let events = [remote]; + const published = []; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async () => payload(channelA, "draft"), + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => events, + sign: async (input) => ({ + id: "stale-cleanup-publish", + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => published.push(event), + }); + + await manager.fetchAllOwnDrafts(); + events = [tombstone]; + await manager.fetchAllOwnDrafts(); + + // Models the mounted composer's stale cleanup after the remote delete. The + // unconditional tombstone abort must remove this local write without + // publishing it back to the relay. + const stale = draft(channelA, "stale cleanup content"); + saveDraftEntry(channelA, stale); + manager.queuePublish(channelA, stale); + await manager.flushPublishes(); + await manager.destroy(); + + assert.deepEqual(published, []); + assert.equal(loadDraftEntry(channelA), undefined); +}); + +test("test_remote_tombstone_removes_known_draft", async () => { + setup(); + const remote = wrapped({ + id: "remote", + createdAt: 1, + address: "address-a", + channelId: channelA, + content: "cipher", + }); + const tombstone = wrapped({ + id: "tombstone", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "", + }); + let events = [remote]; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async () => payload(channelA, "draft"), + deriveAddress: async () => "address-a", + fetchEvents: async () => events, + }); + + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA)?.content, "draft"); + events = [tombstone]; + await manager.fetchAllOwnDrafts(); + assert.equal(loadDraftEntry(channelA), undefined); +}); + +function draft(channelId, content, pendingImeta = []) { + return { + channelId, + content, + selectionStart: content.length, + selectionEnd: content.length, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + pendingImeta, + spoileredAttachmentUrls: [], + status: "active", + }; +} + +test("test_pending_local_publish_observing_tombstone_does_not_resurrect_draft", async () => { + setup(); + const tombstone = wrapped({ + id: "tombstone", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "", + }); + const published = []; + const local = draft(channelA, "local edit"); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + fetchEvents: async () => [tombstone], + sign: async (input) => ({ ...tombstone, ...input, id: "signed" }), + publishEvent: async (event) => published.push(event), + }); + + saveDraftEntry(channelA, local); + manager.queuePublish(channelA, local); + await manager.destroy(); + + assert.equal(published.length, 0); + assert.equal(loadDraftEntry(channelA), undefined); +}); + +test("test_remote_update_does_not_overwrite_pending_local_edit", async () => { + setup(); + const remote = wrapped({ + id: "remote", + createdAt: 2, + address: "address-a", + channelId: channelA, + content: "remote-cipher", + }); + const local = draft(channelA, "local edit"); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async () => payload(channelA, "remote edit"), + deriveAddress: async () => "address-a", + fetchEvents: async () => [remote], + }); + + saveDraftEntry(channelA, local); + manager.queuePublish(channelA, local); + await manager.fetchAllOwnDrafts(); + + assert.equal(loadDraftEntry(channelA)?.content, "local edit"); +}); + +test("test_deleting_one_draft_does_not_strand_another_pending_publish", async () => { + setup(); + const published = []; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async (draftKey) => + draftKey === channelA ? "address-a" : "address-b", + fetchEvents: async () => [], + encrypt: async () => "cipher", + sign: async (input) => ({ + id: `signed-${input.content || "tombstone"}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => published.push(event), + }); + + manager.queuePublish(channelA, draft(channelA, "draft A")); + await manager.queueDeletion(channelB, channelB); + await manager.destroy(); + + assert.ok(published.some((event) => event.content === "cipher")); +}); + +test("test_tombstone_rebases_after_future_remote_head", async () => { + setup(); + const future = Math.floor(Date.now() / 1_000) + 10_000; + const remote = wrapped({ + id: "remote", + createdAt: future, + address: "address-a", + channelId: channelA, + content: "cipher", + }); + const signed = []; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + decrypt: async () => payload(channelA, "draft"), + deriveAddress: async () => "address-a", + fetchEvents: async () => [remote], + sign: async (input) => { + signed.push(input); + return { ...remote, ...input, id: "tombstone" }; + }, + publishEvent: async () => {}, + }); + + await manager.fetchAllOwnDrafts(); + await manager.queueDeletion(channelA, channelA); + + assert.equal(signed[0].content, ""); + assert.equal(signed[0].createdAt, future + 1); +}); + +test("test_unuploaded_attachment_cancels_stale_text_publish", async () => { + setup(); + const published = []; + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + fetchEvents: async () => [], + encrypt: async () => "cipher", + sign: async (input) => ({ + id: "signed", + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => published.push(event), + }); + + manager.queuePublish(channelA, draft(channelA, "text")); + manager.queuePublish( + channelA, + draft(channelA, "text", [{ uploaded: false }]), + ); + await manager.destroy(); + + assert.equal(published.length, 0); +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((complete, fail) => { + resolve = complete; + reject = fail; + }); + return { promise, resolve, reject }; +} + +test("test_newer_edit_during_inflight_publish_survives", async () => { + setup(); + const published = []; + const firstPublish = deferred(); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + published.push(event); + if (published.length === 1) await firstPublish.promise; + }, + }); + + manager.queuePublish(channelA, draft(channelA, "older edit")); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + manager.queuePublish(channelA, draft(channelA, "newer edit")); + firstPublish.resolve(); + await flush; + await manager.destroy(); + + assert.equal(published.length, 2); + assert.match(published[1].content, /newer edit/); +}); + +test("test_deletion_during_inflight_publish_wins", async () => { + setup(); + const published = []; + const firstPublish = deferred(); + const tombstonePublished = deferred(); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + published.push(event); + if (published.length === 1) await firstPublish.promise; + if (event.content === "") tombstonePublished.resolve(); + }, + }); + + const local = draft(channelA, "draft to delete"); + saveDraftEntry(channelA, local); + manager.queuePublish(channelA, local); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + const deletion = manager.queueDeletion(channelA, channelA); + await tombstonePublished.promise; + removeRemoteDraftEntry(channelA); + firstPublish.resolve(); + await flush; + await deletion; + await manager.destroy(); + + const draftEvent = published.find((event) => event.content !== ""); + const rebasedTombstone = published.find( + (event) => event.content === "" && event.created_at > draftEvent.created_at, + ); + assert.ok(rebasedTombstone); + assert.equal(loadDraftEntry(channelA), undefined); +}); + +test("test_stale_tombstone_completion_preserves_rebased_delete", async () => { + setup(); + const published = []; + const draftPublish = deferred(); + const staleTombstone = deferred(); + const rebasedTombstone = deferred(); + const rebasedTombstoneStarted = deferred(); + const manager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + encrypt: async (content) => content, + fetchEvents: async () => [], + sign: async (input) => ({ + id: `signed-${published.length}`, + created_at: + input.content === "" && published.length === 1 + ? (input.createdAt ?? 0) - 1 + : (input.createdAt ?? 0), + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + publishEvent: async (event) => { + const publishIndex = published.push(event); + if (publishIndex === 1) await draftPublish.promise; + if (publishIndex === 2) await staleTombstone.promise; + if (publishIndex === 3) { + rebasedTombstoneStarted.resolve(); + await rebasedTombstone.promise; + } + }, + }); + + const local = draft(channelA, "draft to delete"); + saveDraftEntry(channelA, local); + manager.queuePublish(channelA, local); + const flush = manager.flushPublishes(); + while (published.length === 0) await Promise.resolve(); + const deletion = manager.queueDeletion(channelA, channelA); + removeRemoteDraftEntry(channelA); + while (published.length < 2) await Promise.resolve(); + draftPublish.resolve(); + const draftEvent = published[0]; + staleTombstone.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await rebasedTombstoneStarted.promise; + rebasedTombstone.reject(new Error("offline")); + await flush; + await deletion; + + const retried = []; + const retryManager = new DraftSyncManager(pubkey, "wss://relay.example", { + deriveAddress: async () => "address-a", + publishEvent: async (event) => retried.push(event), + sign: async (input) => ({ + id: "retry", + created_at: input.createdAt ?? 0, + kind: input.kind, + pubkey, + content: input.content, + sig: "", + tags: input.tags, + }), + }); + retryManager.start(); + await Promise.resolve(); + await retryManager.destroy(); + await manager.destroy(); + + assert.ok(retried.some((event) => event.content === "")); + assert.ok(retried.every((event) => event.created_at > draftEvent.created_at)); +}); diff --git a/desktop/src/features/messages/lib/draftSync.ts b/desktop/src/features/messages/lib/draftSync.ts new file mode 100644 index 0000000000..2c13df7a94 --- /dev/null +++ b/desktop/src/features/messages/lib/draftSync.ts @@ -0,0 +1,590 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { relayClient } from "@/shared/api/relayClient"; +import { + deriveDraftAddress, + nip44DecryptFromSelf, + nip44EncryptToSelf, + relaySupportsNip, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_DRAFT, KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { DraftState } from "./useDrafts"; +import { + getActiveDraftEntries, + mergeRemoteDraftEntry, + removeRemoteDraftEntry, +} from "./useDrafts"; +import { parseDraftPayload, serializeDraftPayload } from "./draftPayload"; + +const DEBOUNCE_MS = 2_000; +const SIDECAR_PREFIX = "buzz-draft-sync.v1"; + +type RemoteHead = Pick; +type AddressState = { + draftKey?: string; + remoteHead?: RemoteHead; + base?: RemoteHead; + pendingPublish?: PendingPublish; + pendingDeletion?: PendingDeletion; +}; +type PendingPublish = { + draftKey: string; + draft: DraftState; + channelId: string; + address?: string; +}; +type PendingDeletion = { + draftKey: string; + channelId: string; + address: string | null; + base?: RemoteHead; +}; +type Sidecar = Record; + +export type DraftSyncDependencies = { + decrypt?: typeof nip44DecryptFromSelf; + encrypt?: typeof nip44EncryptToSelf; + sign?: typeof signRelayEvent; + deriveAddress?: typeof deriveDraftAddress; + fetchEvents?: typeof relayClient.fetchEvents; + publishEvent?: typeof relayClient.publishEvent; +}; + +function compareHeads(left: RemoteHead, right: RemoteHead): number { + if (left.created_at !== right.created_at) + return left.created_at - right.created_at; + return right.id.localeCompare(left.id); +} + +function tagValue(event: RelayEvent, name: string): string | null { + const tag = event.tags.find((candidate) => candidate[0] === name); + return tag?.[1] ?? null; +} + +function newerHead( + left?: RemoteHead, + right?: RemoteHead, +): RemoteHead | undefined { + if (!left || !right) return left ?? right; + return compareHeads(left, right) >= 0 ? left : right; +} + +export class DraftSyncManager { + private readonly relayScope: string; + private readonly state = new Map(); + private readonly deps: Required; + private timer: number | null = null; + private destroyed = false; + private unsubscribeReconnect: (() => void) | null = null; + private liveSubscriptions = new Map Promise>(); + + private readonly pubkey: string; + + constructor( + pubkey: string, + relayUrl: string, + dependencies: DraftSyncDependencies = {}, + ) { + this.pubkey = pubkey; + this.relayScope = normalizeRelayUrl(relayUrl); + this.deps = { + decrypt: dependencies.decrypt ?? nip44DecryptFromSelf, + encrypt: dependencies.encrypt ?? nip44EncryptToSelf, + sign: dependencies.sign ?? signRelayEvent, + deriveAddress: dependencies.deriveAddress ?? deriveDraftAddress, + fetchEvents: + dependencies.fetchEvents ?? relayClient.fetchEvents.bind(relayClient), + publishEvent: + dependencies.publishEvent ?? relayClient.publishEvent.bind(relayClient), + }; + } + + start(): void { + this.unsubscribeReconnect ??= relayClient.subscribeToReconnects(() => { + void this.fetchAllOwnDrafts(); + for (const channelId of this.liveSubscriptions.keys()) { + void this.fetchOwnDraftsForChannel(channelId); + } + void this.replayPendingDeletions(); + }); + void this.fetchAllOwnDrafts(); + void this.replayPendingDeletions(); + } + + queuePublish(draftKey: string, draft: DraftState): void { + if (this.destroyed) return; + const entry = this.state.get(draftKey) ?? { draftKey }; + if (draft.pendingImeta.some((media) => !media.uploaded)) { + entry.pendingPublish = undefined; + this.reschedulePublishes(); + return; + } + entry.draftKey = draftKey; + entry.pendingPublish = { + draftKey, + draft, + channelId: draft.channelId, + }; + this.state.set(draftKey, entry); + // Resolve the opaque address while the draft is alive so normal delete + // paths can durably record it before removing visible local state. + void this.deps + .deriveAddress(draftKey, this.relayScope) + .then((address) => { + const current = this.state.get(draftKey); + if (!current) return; + const linked = this.linkState(draftKey, address); + if (current.pendingPublish) + linked.pendingPublish = current.pendingPublish; + if (linked.pendingPublish) linked.pendingPublish.address = address; + }) + .catch(() => {}); + this.reschedulePublishes(true); + } + + async queueDeletion(draftKey: string, channelId: string): Promise { + if (this.destroyed) return; + const entry = this.state.get(draftKey) ?? { draftKey }; + entry.draftKey = draftKey; + const cachedAddress = entry.pendingPublish?.address ?? null; + const pendingDeletion = { + draftKey, + channelId, + address: cachedAddress, + base: entry.remoteHead, + }; + entry.pendingDeletion = pendingDeletion; + entry.pendingPublish = undefined; + this.state.set(draftKey, entry); + this.reschedulePublishes(); + // Persist the deletion intent synchronously before visible local state is + // cleared; derive the opaque address afterward if it was not prewarmed. + this.writeSidecar(); + if (!pendingDeletion.address) { + try { + pendingDeletion.address = await this.deps.deriveAddress( + draftKey, + this.relayScope, + ); + } catch { + return; + } + const linked = this.linkState(draftKey, pendingDeletion.address); + linked.pendingDeletion = pendingDeletion; + pendingDeletion.base ??= linked.remoteHead; + this.writeSidecar(); + } + await this.publishTombstone(pendingDeletion); + } + + async fetchAllOwnDrafts(): Promise { + await this.fetchAndMerge({ + kinds: [KIND_DRAFT], + authors: [this.pubkey], + limit: 500, + }); + } + + async fetchOwnDraftsForChannel(channelId: string): Promise { + await this.fetchAndMerge({ + kinds: [KIND_DRAFT], + authors: [this.pubkey], + "#h": [channelId], + limit: 500, + }); + } + + async subscribeToChannel(channelId: string): Promise { + if (this.destroyed || this.liveSubscriptions.has(channelId)) return; + await this.fetchOwnDraftsForChannel(channelId); + const unsubscribe = await relayClient.subscribeLive( + { + kinds: [KIND_DRAFT], + authors: [this.pubkey], + "#h": [channelId], + limit: 0, + }, + (event) => void this.mergeEvent(event), + ); + this.liveSubscriptions.set(channelId, unsubscribe); + } + + async destroy(): Promise { + this.destroyed = true; + if (this.timer !== null) window.clearTimeout(this.timer); + this.timer = null; + await this.flushPublishes(); + this.unsubscribeReconnect?.(); + this.unsubscribeReconnect = null; + await Promise.all( + [...this.liveSubscriptions.values()].map((unsubscribe) => unsubscribe()), + ); + this.liveSubscriptions.clear(); + } + + private async flushPublishes(): Promise { + for (const state of new Set(this.state.values())) { + const pending = state.pendingPublish; + if (!pending || state.pendingDeletion) continue; + await this.publishDraft(pending, state); + } + } + + private async publishDraft( + pending: PendingPublish, + state: AddressState, + ): Promise { + try { + const address = + pending.address ?? + (await this.deps.deriveAddress(pending.draftKey, this.relayScope)); + pending.address = address; + state = this.linkState(pending.draftKey, address); + await this.fetchAndMerge({ + kinds: [KIND_DRAFT], + authors: [this.pubkey], + "#d": [address], + limit: 1, + }); + state = this.state.get(address) ?? state; + if (state.pendingDeletion) { + this.reschedulePublishes(); + return; + } + if (state.remoteHead?.content === "") { + state.pendingPublish = undefined; + removeRemoteDraftEntry(pending.draftKey); + this.reschedulePublishes(); + return; + } + const content = await this.deps.encrypt( + serializeDraftPayload(pending.draftKey, pending.draft, this.pubkey), + ); + const event = await this.deps.sign({ + kind: KIND_DRAFT, + content, + createdAt: Math.max( + Math.floor(Date.now() / 1_000), + (state.remoteHead?.created_at ?? 0) + 1, + ), + tags: [ + ["d", address], + ["h", pending.channelId], + ["k", String(KIND_STREAM_MESSAGE)], + ], + }); + await this.deps.publishEvent( + event, + "Timed out publishing draft.", + "Failed to publish draft.", + ); + const current = this.state.get(address) ?? state; + const deletionWon = + current.pendingDeletion !== undefined || + current.remoteHead?.content === ""; + if (!current.remoteHead || compareHeads(event, current.remoteHead) >= 0) { + current.base = event; + current.remoteHead = event; + } + if (current.pendingPublish === pending) + current.pendingPublish = undefined; + if (deletionWon) { + const deletion = + current.pendingDeletion ?? + ({ + draftKey: pending.draftKey, + channelId: pending.channelId, + address, + } satisfies PendingDeletion); + deletion.base = event; + current.pendingDeletion = deletion; + this.writeSidecar(); + await this.publishTombstone(deletion); + } + this.reschedulePublishes(); + } catch (error) { + console.warn("[draftSync] draft publish failed:", error); + } + } + + private async publishTombstone(pending: PendingDeletion): Promise { + if (!pending.address) return; + try { + const state = this.linkState(pending.draftKey, pending.address); + const base = pending.base ?? state.remoteHead; + const event = await this.deps.sign({ + kind: KIND_DRAFT, + content: "", + createdAt: Math.max( + Math.floor(Date.now() / 1_000), + (base?.created_at ?? 0) + 1, + ), + tags: [ + ["d", pending.address], + ["h", pending.channelId], + ["k", String(KIND_STREAM_MESSAGE)], + ], + }); + await this.deps.publishEvent( + event, + "Timed out deleting draft.", + "Failed to delete draft.", + ); + const current = this.state.get(pending.address) ?? state; + const winning = + !current.remoteHead || compareHeads(event, current.remoteHead) >= 0 + ? event + : current.remoteHead; + if ( + current.pendingDeletion?.address === pending.address && + winning.content === "" + ) { + current.pendingDeletion = undefined; + current.remoteHead = winning; + this.writeSidecar(); + } + } catch (error) { + console.warn("[draftSync] draft tombstone failed:", error); + } + } + + private async replayPendingDeletions(): Promise { + for (const pending of Object.values(this.readSidecar())) { + const state = this.state.get(pending.draftKey) ?? { + draftKey: pending.draftKey, + }; + state.draftKey = pending.draftKey; + state.pendingDeletion = pending; + this.state.set(pending.draftKey, state); + if (!pending.address) { + try { + pending.address = await this.deps.deriveAddress( + pending.draftKey, + this.relayScope, + ); + } catch { + continue; + } + this.linkState(pending.draftKey, pending.address).pendingDeletion = + pending; + this.writeSidecar(); + } + await this.publishTombstone(pending); + } + } + + private async findLocalDraftKeyForAddress( + address: string, + ): Promise { + for (const { key } of getActiveDraftEntries()) { + try { + if ((await this.deps.deriveAddress(key, this.relayScope)) === address) + return key; + } catch { + // A failed derivation cannot identify a local compose context. + } + } + return null; + } + + private async fetchAndMerge(filter: { + kinds: number[]; + authors: string[]; + limit: number; + "#h"?: string[]; + "#d"?: string[]; + }): Promise { + try { + const events = await this.deps.fetchEvents(filter); + await Promise.all(events.map((event) => this.mergeEvent(event))); + } catch { + // Remote failures preserve the local write-through cache. + } + } + + private async mergeEvent(event: RelayEvent): Promise { + if ( + event.pubkey.toLowerCase() !== this.pubkey.toLowerCase() || + event.kind !== KIND_DRAFT + ) + return; + const address = tagValue(event, "d"); + const channelId = tagValue(event, "h"); + const kind = Number(tagValue(event, "k")); + if (!address || !channelId || kind !== KIND_STREAM_MESSAGE) return; + const state = this.state.get(address) ?? {}; + if (state.remoteHead && compareHeads(event, state.remoteHead) <= 0) return; + if ( + state.pendingDeletion || + Object.values(this.readSidecar()).some( + (entry) => entry.address === address, + ) + ) + return; + if (event.content === "") { + state.remoteHead = event; + const draftKey = + state.draftKey ?? (await this.findLocalDraftKeyForAddress(address)); + if (draftKey) { + const linked = this.linkState(draftKey, address, state); + linked.remoteHead = event; + linked.pendingPublish = undefined; + removeRemoteDraftEntry(draftKey); + this.reschedulePublishes(); + } else { + this.state.set(address, state); + } + return; + } + try { + const plaintext = await this.deps.decrypt(event.content); + const decoded = parseDraftPayload( + plaintext, + this.pubkey, + kind, + new Date(event.created_at * 1_000).toISOString(), + ); + if (!decoded || decoded.draft.channelId !== channelId) return; + if ( + (await this.deps.deriveAddress(decoded.draftKey, this.relayScope)) !== + address + ) + return; + const targetState = this.linkState(decoded.draftKey, address, state); + if ( + targetState.remoteHead && + compareHeads(event, targetState.remoteHead) < 0 + ) + return; + targetState.remoteHead = event; + if ( + targetState.pendingDeletion || + targetState.pendingPublish || + Object.values(this.readSidecar()).some( + (entry) => entry.draftKey === decoded.draftKey, + ) + ) + return; + mergeRemoteDraftEntry(decoded.draftKey, decoded.draft); + } catch { + // Untrusted ciphertext must not affect local drafts. + } + } + + private linkState( + draftKey: string, + address: string, + candidate?: AddressState, + ): AddressState { + const byKey = this.state.get(draftKey); + const byAddress = this.state.get(address); + const state = byKey ?? byAddress ?? candidate ?? { draftKey }; + if (byKey && byAddress && byKey !== byAddress) { + state.remoteHead = newerHead(byKey.remoteHead, byAddress.remoteHead); + state.base = newerHead(byKey.base, byAddress.base); + state.pendingPublish ??= byKey.pendingPublish ?? byAddress.pendingPublish; + state.pendingDeletion ??= + byKey.pendingDeletion ?? byAddress.pendingDeletion; + } + state.draftKey = draftKey; + this.state.set(draftKey, state); + this.state.set(address, state); + return state; + } + + private reschedulePublishes(reset = false): void { + if (this.timer !== null && (reset || !this.hasPendingPublishes())) { + window.clearTimeout(this.timer); + this.timer = null; + } + if (this.timer === null && this.hasPendingPublishes()) { + this.timer = window.setTimeout(() => { + this.timer = null; + void this.flushPublishes(); + }, DEBOUNCE_MS); + } + } + + private hasPendingPublishes(): boolean { + return [...new Set(this.state.values())].some( + (state) => state.pendingPublish && !state.pendingDeletion, + ); + } + + private sidecarKey(): string { + return `${SIDECAR_PREFIX}:${this.relayScope}:${this.pubkey}`; + } + private readSidecar(): Sidecar { + try { + return JSON.parse( + localStorage.getItem(this.sidecarKey()) ?? "{}", + ) as Sidecar; + } catch { + return {}; + } + } + private writeSidecar(): void { + const sidecar: Sidecar = {}; + for (const entry of this.state.values()) { + if (entry.pendingDeletion) { + // The compose key is durable until address derivation returns, then the + // opaque address becomes the sidecar key. This keeps a just-cleared + // offline draft from being lost during that asynchronous boundary. + sidecar[ + entry.pendingDeletion.address ?? entry.pendingDeletion.draftKey + ] = entry.pendingDeletion; + } + } + localStorage.setItem(this.sidecarKey(), JSON.stringify(sidecar)); + } +} + +let activeManager: DraftSyncManager | null = null; +let configurationGeneration = 0; +const activeChannels = new Set(); + +/** Configure the workspace-scoped singleton without delaying workspace startup. */ +export function configureDraftSync(pubkey: string, relayUrl: string): void { + const generation = ++configurationGeneration; + void (async () => { + try { + if ( + !(await relaySupportsNip(37)) || + generation !== configurationGeneration + ) + return; + activeManager?.destroy().catch(() => {}); + activeManager = new DraftSyncManager(pubkey, relayUrl); + activeManager.start(); + for (const channelId of activeChannels) + void activeManager.subscribeToChannel(channelId); + } catch { + // Capability probe failures deliberately leave draft behavior local-only. + } + })(); +} + +export function resetDraftSync(): void { + configurationGeneration += 1; + activeManager?.destroy().catch(() => {}); + activeManager = null; + activeChannels.clear(); +} + +export function syncPersistedDraft(draftKey: string, draft: DraftState): void { + activeManager?.queuePublish(draftKey, draft); +} + +export function syncDeletedDraft(draftKey: string, channelId: string): void { + void activeManager?.queueDeletion(draftKey, channelId); +} + +export function syncDraftChannel(channelId: string): void { + activeChannels.add(channelId); + void activeManager?.subscribeToChannel(channelId); +} + +export function backfillSyncedDrafts(): void { + void activeManager?.fetchAllOwnDrafts(); +} diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 5ff6b3b959..f3821985ed 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -1,6 +1,8 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { syncDeletedDraft, syncPersistedDraft } from "./draftSync"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; // ── Store reactivity ───────────────────────────────────────────────────────── @@ -10,7 +12,9 @@ import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota" // so any component consuming `useDraftsSnapshot()` re-renders immediately. type Subscriber = () => void; +type RemoteDraftRemovalSubscriber = (draftKey: string) => void; const _subscribers = new Set(); +const _remoteDraftRemovalSubscribers = new Set(); let _version = 0; /** Notify all active subscribers. Called by every write path. */ @@ -28,6 +32,16 @@ function subscribeToStore(callback: Subscriber): () => void { }; } +/** Subscribe to explicit remote NIP-37 tombstone removals. */ +export function subscribeToRemoteDraftRemovals( + callback: RemoteDraftRemovalSubscriber, +): () => void { + _remoteDraftRemovalSubscribers.add(callback); + return () => { + _remoteDraftRemovalSubscribers.delete(callback); + }; +} + function getStoreSnapshot(): number { return _version; } @@ -71,14 +85,24 @@ export type DraftState = { /** Serialised shape stored in localStorage (same as DraftState for round-trips). */ type StoredDrafts = Record; -const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; +const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v2"; +const LEGACY_DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; const MAX_DRAFTS = 100; -/** Module-level pubkey set by `initDraftStore`. Empty string = no identity. */ +/** Module-level workspace identity set by `initDraftStore`. Empty = no workspace. */ let currentPubkey = ""; +let currentRelayScope = ""; function storageKey(): string { - return `${DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; + // The no-relay form is retained for direct legacy callers/tests. App startup + // always supplies a normalized relay and therefore uses the v2 scoped key. + return currentRelayScope + ? `${DRAFT_STORE_KEY_PREFIX}:${currentRelayScope}:${currentPubkey}` + : legacyStorageKey(); +} + +function legacyStorageKey(): string { + return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } /** @@ -88,11 +112,13 @@ function storageKey(): string { * identity switch (without a prior `clearAllDrafts`) never serves the * wrong identity's drafts. */ -export function initDraftStore(pubkey: string): void { - if (currentPubkey !== pubkey) { +export function initDraftStore(pubkey: string, relayUrl = ""): void { + const relayScope = normalizeRelayUrl(relayUrl); + if (currentPubkey !== pubkey || currentRelayScope !== relayScope) { _memCache = null; } currentPubkey = pubkey; + currentRelayScope = relayScope; // Eagerly load to surface corruption errors in console at startup rather // than on first draft interaction. readStore(); @@ -104,6 +130,7 @@ export function initDraftStore(pubkey: string): void { */ export function clearAllDrafts(): void { currentPubkey = ""; + currentRelayScope = ""; _memCache = null; } @@ -123,13 +150,19 @@ function readStore(): Map { } const raw = localStorage.getItem(storageKey()); - if (!raw) { + // One-time forward migration keeps pre-NIP-37 local-only drafts available + // after keys become relay-scoped. Never read legacy entries once a v2 store + // exists, so another workspace cannot import them later. + const legacyRaw = + raw || !currentRelayScope ? null : localStorage.getItem(legacyStorageKey()); + const source = raw ?? legacyRaw; + if (!source) { _memCache = map; return map; } try { - const parsed: unknown = JSON.parse(raw); + const parsed: unknown = JSON.parse(source); if ( parsed !== null && typeof parsed === "object" && @@ -152,6 +185,10 @@ function readStore(): Map { } _memCache = map; + if (legacyRaw !== null) { + flushStore(map); + localStorage.removeItem(legacyStorageKey()); + } return map; } @@ -222,15 +259,43 @@ export function saveDraftEntry(draftKey: string, draft: DraftState): void { evictOldest(map); flushStore(map); notifySubscribers(); + syncPersistedDraft(draftKey, draft); } export function loadDraftEntry(draftKey: string): DraftState | undefined { return readStore().get(draftKey); } +/** Replace a local entry from a validated remote payload without scheduling a publish. */ +export function mergeRemoteDraftEntry( + draftKey: string, + draft: DraftState, +): void { + const map = readStore(); + map.set(draftKey, draft); + evictOldest(map); + flushStore(map); + notifySubscribers(); +} + +/** Remove a draft after observing its remote NIP-37 tombstone. */ +export function removeRemoteDraftEntry(draftKey: string): void { + const map = readStore(); + if (!map.delete(draftKey)) return; + flushStore(map); + notifySubscribers(); + for (const subscriber of _remoteDraftRemovalSubscribers) { + subscriber(draftKey); + } +} + export function clearDraftEntry(draftKey: string): void { const map = readStore(); - if (map.has(draftKey)) { + const existing = map.get(draftKey); + if (existing) { + // Ask the sync layer to durably record a deletion before the visible cache + // is removed. The local clear remains synchronous/offline-first. + syncDeletedDraft(draftKey, existing.channelId); map.delete(draftKey); flushStore(map); notifySubscribers(); diff --git a/desktop/src/features/messages/ui/DraftsPanel.tsx b/desktop/src/features/messages/ui/DraftsPanel.tsx index f058c1a9a9..90f8f97a76 100644 --- a/desktop/src/features/messages/ui/DraftsPanel.tsx +++ b/desktop/src/features/messages/ui/DraftsPanel.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { backfillSyncedDrafts } from "@/features/messages/lib/draftSync"; import { clearDraftEntry, getActiveDraftEntries, @@ -466,6 +467,10 @@ export function DraftsPanel() { const currentPubkey = identityQuery.data?.pubkey; const channelsQuery = useChannelsQuery(); + React.useEffect(() => { + backfillSyncedDrafts(); + }, []); + // Collapse the old `sections` state + `refreshDrafts` pattern onto a // reactive snapshot: every draft write re-renders via useSyncExternalStore. useDraftsSnapshot(); diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index ba4c1a22e4..a810690613 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -400,3 +400,81 @@ test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => { await handle.unmount(); }); + +/** + * A remote tombstone is a delete-wins signal, not an ordinary missing-store + * entry. The mounted composer must clear its snapshot before cleanup so stale + * editor text cannot recreate the deleted draft or queue a relay publish. + */ +test("remote_tombstone_clears_mounted_snapshot_before_cleanup", async () => { + const DRAFT_KEY = "chan-lifecycle-remote-delete"; + setupStore("pubkey-lifecycle-remote-delete"); + persistDraftEntry( + DRAFT_KEY, + "stale editor text", + DRAFT_KEY, + [IMG_A], + [IMG_A.url], + ); + + let editorContent = "stale editor text"; + let pendingImeta = [IMG_A]; + const spoileredRef = { current: new Set([IMG_A.url]) }; + const persisted = []; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: (key) => loadDraftEntry(key), + persistDraft: (key, content, channelId, imeta, spoileredUrls) => { + persisted.push({ content, imeta, spoileredUrls }); + persistDraftEntry(key, content, channelId, imeta, spoileredUrls); + }, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredRef.current = urls; + }, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + persisted.length = 0; + const { removeRemoteDraftEntry } = await import("../lib/useDrafts.ts"); + await act(async () => { + removeRemoteDraftEntry(DRAFT_KEY); + }); + await handle.unmount(); + + assert.equal(editorContent, "", "remote delete clears mounted editor text"); + assert.deepEqual(pendingImeta, [], "remote delete clears pending images"); + assert.deepEqual( + [...spoileredRef.current], + [], + "remote delete clears spoiler state", + ); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "cleanup does not recreate draft", + ); + assert.ok( + persisted.every( + ({ content, imeta }) => content === "" && imeta.length === 0, + ), + "cleanup never persists the stale draft contents", + ); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index d1b0f70f6b..adb31d16d5 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,7 +1,11 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { DraftState } from "@/features/messages/lib/useDrafts"; +import { syncDraftChannel } from "@/features/messages/lib/draftSync"; +import { + subscribeToRemoteDraftRemovals, + type DraftState, +} from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { effectiveDraftKey: string | null | undefined; @@ -83,6 +87,7 @@ export function useDraftPersistLifecycle({ // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { + if (channelId) syncDraftChannel(channelId); // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -119,4 +124,24 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + // Remote tombstones are an explicit delete-wins signal. Do not infer this + // from store absence: an unsaved composer may legitimately have no entry. + React.useEffect(() => { + if (!effectiveDraftKey) return; + return subscribeToRemoteDraftRemovals((draftKey) => { + if (draftKey !== effectiveDraftKey) return; + clearContent(); + pendingImetaForPersistRef.current = []; + setPendingImeta([]); + spoileredAttachmentUrlsRef.current = new Set(); + setSpoileredAttachmentUrls(new Set()); + }); + }, [ + effectiveDraftKey, + clearContent, + setPendingImeta, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + ]); } diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index de82407bb8..9a93f6ac33 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -5,6 +5,10 @@ import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { + configureDraftSync, + resetDraftSync, +} from "@/features/messages/lib/draftSync"; import { initDraftStore } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { @@ -37,6 +41,7 @@ function resetWorkspaceState(): void { resetMediaCaches(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); + resetDraftSync(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } @@ -178,7 +183,8 @@ export function useWorkspaceInit( // Initialise the draft store for this identity so localStorage drafts // are scoped to the correct pubkey before the app renders. if (activeWorkspace.pubkey) { - initDraftStore(activeWorkspace.pubkey); + initDraftStore(activeWorkspace.pubkey, activeWorkspace.relayUrl); + configureDraftSync(activeWorkspace.pubkey, activeWorkspace.relayUrl); } // Restore any turn state saved for this workspace (a prior A→B round- // trip). This runs after applyWorkspace succeeds and before the app diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 9253497787..edf62ca375 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1236,6 +1236,22 @@ export async function nip44DecryptFromSelf( return invokeTauri("nip44_decrypt_from_self", { ciphertext }); } +/** True only when the active relay advertises the requested NIP in NIP-11. */ +export function relaySupportsNip(nip: number): Promise { + return invokeTauri("relay_supports_nip", { nip }); +} + +/** Derive an opaque, identity-scoped NIP-37 draft address without exposing key material. */ +export function deriveDraftAddress( + logicalComposeKey: string, + relayScope: string, +): Promise { + return invokeTauri("derive_draft_address", { + logicalComposeKey, + relayScope, + }); +} + // ── NIP-AB device pairing ─────────────────────────────────────────────────── export async function startPairing(): Promise { diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 8b16a89886..a7f55659e0 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -40,6 +40,8 @@ export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; // NIP-78 application-specific data. All use kind 30078; the relay // differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort"). +// NIP-37 encrypted draft wrapper. Draft text and compose metadata remain in the NIP-44-to-self payload. +export const KIND_DRAFT = 31234; export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078;