feat(desktop): sync encrypted drafts across devices

Persist drafts locally while synchronizing NIP-37 encrypted events when the relay advertises support.

Use opaque, secret-derived addresses and durable tombstones to protect compose context and offline deletes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-13 11:21:17 -04:00
co-authored by Will Pfleger
parent c36448ef3e
commit bb28f59908
16 changed files with 1055 additions and 10 deletions
+3 -1
View File
@@ -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/
+1
View File
@@ -925,6 +925,7 @@ dependencies = [
"earshot",
"futures-util",
"hex",
"hmac 0.13.0",
"image",
"infer",
"keyring",
+1
View File
@@ -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"] }
@@ -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<Sha256>;
fn derive_draft_address_from_keys(
keys: &Keys,
logical_compose_key: &str,
relay_scope: &str,
) -> Result<String, String> {
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<String, String> {
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;
@@ -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<u32>,
}
/// 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<bool, String> {
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::<RelayInfoDocument>()
.await
.map(|document| document.supported_nips.contains(&nip))
.unwrap_or(false))
}
#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
+2
View File
@@ -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,
@@ -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);
});
@@ -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<DraftPayload>;
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;
}
}
@@ -0,0 +1,228 @@
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 } 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_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);
});
@@ -0,0 +1,482 @@
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<RelayEvent, "id" | "created_at" | "content">;
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<string, PendingDeletion>;
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;
}
export class DraftSyncManager {
private readonly relayScope: string;
private readonly state = new Map<string, AddressState>();
private readonly deps: Required<DraftSyncDependencies>;
private timer: number | null = null;
private destroyed = false;
private unsubscribeReconnect: (() => void) | null = null;
private liveSubscriptions = new Map<string, () => Promise<void>>();
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 || draft.pendingImeta.some((media) => !media.uploaded))
return;
const entry = this.state.get(draftKey) ?? {};
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)?.pendingPublish;
if (current) current.address = address;
})
.catch(() => {});
if (this.timer !== null) window.clearTimeout(this.timer);
this.timer = window.setTimeout(() => {
this.timer = null;
void this.flushPublishes();
}, DEBOUNCE_MS);
}
async queueDeletion(draftKey: string, channelId: string): Promise<void> {
if (this.destroyed) return;
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = null;
}
const entry = this.state.get(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);
// 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;
}
this.writeSidecar();
}
await this.publishTombstone(pendingDeletion);
}
async fetchAllOwnDrafts(): Promise<void> {
await this.fetchAndMerge({
kinds: [KIND_DRAFT],
authors: [this.pubkey],
limit: 500,
});
}
async fetchOwnDraftsForChannel(channelId: string): Promise<void> {
await this.fetchAndMerge({
kinds: [KIND_DRAFT],
authors: [this.pubkey],
"#h": [channelId],
limit: 500,
});
}
async subscribeToChannel(channelId: string): Promise<void> {
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<void> {
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<void> {
for (const state of 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<void> {
try {
const address =
pending.address ??
(await this.deps.deriveAddress(pending.draftKey, this.relayScope));
await this.fetchAndMerge({
kinds: [KIND_DRAFT],
authors: [this.pubkey],
"#d": [address],
limit: 1,
});
if (state.remoteHead?.content === "") 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.",
);
state.base = event;
state.remoteHead = event;
state.pendingPublish = undefined;
} catch (error) {
console.warn("[draftSync] draft publish failed:", error);
}
}
private async publishTombstone(pending: PendingDeletion): Promise<void> {
if (!pending.address) return;
try {
const event = await this.deps.sign({
kind: KIND_DRAFT,
content: "",
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 state = this.state.get(pending.draftKey);
if (state?.pendingDeletion?.address === pending.address) {
state.pendingDeletion = undefined;
state.remoteHead = event;
this.writeSidecar();
}
} catch (error) {
console.warn("[draftSync] draft tombstone failed:", error);
}
}
private async replayPendingDeletions(): Promise<void> {
for (const pending of Object.values(this.readSidecar())) {
const state = this.state.get(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.writeSidecar();
}
await this.publishTombstone(pending);
}
}
private async findLocalDraftKeyForAddress(
address: string,
): Promise<string | null> {
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<void> {
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<void> {
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;
this.state.set(address, state);
const draftKey =
state.draftKey ?? (await this.findLocalDraftKeyForAddress(address));
if (draftKey) removeRemoteDraftEntry(draftKey);
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 keyedState = this.state.get(decoded.draftKey);
const targetState = keyedState ?? state;
if (
targetState.remoteHead &&
compareHeads(event, targetState.remoteHead) < 0
)
return;
targetState.draftKey = decoded.draftKey;
targetState.remoteHead = event;
this.state.set(address, targetState);
this.state.set(decoded.draftKey, targetState);
if (
targetState.pendingDeletion ||
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 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<string>();
/** 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();
}
+58 -8
View File
@@ -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 ─────────────────────────────────────────────────────────
@@ -71,14 +73,24 @@ export type DraftState = {
/** Serialised shape stored in localStorage (same as DraftState for round-trips). */
type StoredDrafts = Record<string, DraftState>;
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 +100,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 +118,7 @@ export function initDraftStore(pubkey: string): void {
*/
export function clearAllDrafts(): void {
currentPubkey = "";
currentRelayScope = "";
_memCache = null;
}
@@ -123,13 +138,19 @@ function readStore(): Map<string, DraftState> {
}
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 +173,10 @@ function readStore(): Map<string, DraftState> {
}
_memCache = map;
if (legacyRaw !== null) {
flushStore(map);
localStorage.removeItem(legacyStorageKey());
}
return map;
}
@@ -222,15 +247,40 @@ 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();
}
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();
@@ -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();
@@ -1,6 +1,7 @@
import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { syncDraftChannel } from "@/features/messages/lib/draftSync";
import type { DraftState } from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
@@ -83,6 +84,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
@@ -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
+16
View File
@@ -1236,6 +1236,22 @@ export async function nip44DecryptFromSelf(
return invokeTauri<string>("nip44_decrypt_from_self", { ciphertext });
}
/** True only when the active relay advertises the requested NIP in NIP-11. */
export function relaySupportsNip(nip: number): Promise<boolean> {
return invokeTauri<boolean>("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<string> {
return invokeTauri<string>("derive_draft_address", {
logicalComposeKey,
relayScope,
});
}
// ── NIP-AB device pairing ───────────────────────────────────────────────────
export async function startPairing(): Promise<string> {
+2
View File
@@ -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:<slotId>", "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;