mirror of
https://github.com/hoornet/vega.git
synced 2026-05-08 05:09:12 -07:00
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:
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user