Bump to v0.7.0 — writer tools, NIP-98 uploads, multi-draft, article bookmarks

- NIP-98 HTTP Auth for image uploads with fallback services (void.cat, nostrimg.com)
- Markdown toolbar (bold, italic, heading, link, image, quote, code, list) + Ctrl+B/I/K
- Multi-draft management with draft list, resume, delete, auto-migrate
- Cover image file picker in article meta panel
- Article bookmarks via NIP-51 'a' tags; Notes/Articles tabs in BookmarkView
- Removed Rust upload_file command; dropped reqwest/mime_guess deps
- Upload spinner, draft count badge, empty states
This commit is contained in:
Jure
2026-03-18 18:36:08 +01:00
parent c66885440a
commit 092553ab9b
19 changed files with 846 additions and 152 deletions

View File

@@ -616,6 +616,52 @@ export async function publishBookmarkList(eventIds: string[]): Promise<void> {
await event.publish();
}
export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return { eventIds: [], articleAddrs: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const eventIds = event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
const articleAddrs = event.tags.filter((t) => t[0] === "a" && t[1]).map((t) => t[1]);
return { eventIds, articleAddrs };
}
export async function publishBookmarkListFull(eventIds: string[], articleAddrs: string[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) return;
const event = new NDKEvent(instance);
event.kind = 10003 as NDKKind;
event.content = "";
event.tags = [
...eventIds.map((id) => ["e", id]),
...articleAddrs.map((addr) => ["a", addr]),
];
await event.publish();
}
export async function fetchByAddr(addr: string): Promise<NDKEvent | null> {
const instance = getNDK();
// addr format: "30023:<pubkey>:<d-tag>"
const parts = addr.split(":");
if (parts.length < 3) return null;
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts.slice(2).join(":");
const filter: NDKFilter = {
kinds: [kind as NDKKind],
authors: [pubkey],
"#d": [dTag],
limit: 1,
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events)[0] ?? null;
}
export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };

View File

@@ -1,2 +1,2 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export type { UserRelayList } from "./client";

View File

@@ -1,4 +1,33 @@
import { fetch } from "@tauri-apps/plugin-http";
import { getNDK } from "./nostr";
import { NDKEvent } from "@nostr-dev-kit/ndk";
const UPLOAD_SERVICES = [
"https://nostr.build/api/v2/upload/files",
"https://void.cat/upload",
"https://nostrimg.com/api/upload",
];
/**
* Create a NIP-98 HTTP Auth event (kind 27235) for a given URL and method.
* Returns a base64-encoded signed event for the Authorization header.
*/
async function createNip98AuthHeader(url: string, method: string): Promise<string> {
const ndk = getNDK();
if (!ndk.signer) throw new Error("Not logged in — cannot sign NIP-98 auth");
const event = new NDKEvent(ndk);
event.kind = 27235;
event.created_at = Math.floor(Date.now() / 1000);
event.content = "";
event.tags = [
["u", url],
["method", method.toUpperCase()],
];
await event.sign();
const encoded = btoa(JSON.stringify(event.rawEvent()));
return `Nostr ${encoded}`;
}
/**
* Upload an image file to nostr.build and return the hosted URL.
@@ -9,33 +38,60 @@ import { fetch } from "@tauri-apps/plugin-http";
* and build a proper Blob with the correct MIME type.
*/
export async function uploadImage(file: File): Promise<string> {
// Read file bytes — ensures clipboard-pasted images are properly serialized
const bytes = new Uint8Array(await file.arrayBuffer());
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
}
/**
* Upload raw bytes to nostr.build. Used by the native file picker path
* where we already have a Uint8Array from tauri-plugin-fs.
* Upload raw bytes with NIP-98 auth. Tries nostr.build first, then fallbacks.
*/
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
const blob = new Blob([bytes], { type: mimeType });
const errors: string[] = [];
const form = new FormData();
form.append("file", blob, fileName);
for (const serviceUrl of UPLOAD_SERVICES) {
try {
const form = new FormData();
form.append("file", blob, fileName);
const resp = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: form,
});
const headers: Record<string, string> = {};
try {
headers["Authorization"] = await createNip98AuthHeader(serviceUrl, "POST");
} catch {
// If not logged in, try without auth (some services allow anonymous)
}
if (!resp.ok) {
throw new Error(`Upload failed (HTTP ${resp.status})`);
const resp = await fetch(serviceUrl, {
method: "POST",
body: form,
headers,
});
if (!resp.ok) {
errors.push(`${serviceUrl}: HTTP ${resp.status}`);
continue;
}
const data = await resp.json();
// nostr.build response format
if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string;
}
// void.cat response format
if (data.file?.url) {
return data.file.url as string;
}
// nostrimg.com response format
if (data.url) {
return data.url as string;
}
errors.push(`${serviceUrl}: no URL in response`);
} catch (err) {
errors.push(`${serviceUrl}: ${err}`);
}
}
const data = await resp.json();
if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string;
}
throw new Error(data.message || "Upload failed — no URL returned");
throw new Error(`All upload services failed:\n${errors.join("\n")}`);
}