mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
c36448ef3e
commit
bb28f59908
@@ -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/
|
||||
|
||||
Generated
+1
@@ -925,6 +925,7 @@ dependencies = [
|
||||
"earshot",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"image",
|
||||
"infer",
|
||||
"keyring",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user