@ -1,10 +1,12 @@
@@ -1,10 +1,12 @@
< script lang = "ts" >
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish , signHttpAuth } from '../../services/nostr/auth-handler.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { uploadFileToServer , buildImetaTag } from '../../services/nostr/file-upload.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import MarkdownRenderer from '../content/MarkdownRenderer.svelte';
import MediaAttachments from '../content/MediaAttachments.svelte';
import GifPicker from '../content/GifPicker.svelte';
import EmojiPicker from '../content/EmojiPicker.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
@ -16,18 +18,19 @@
@@ -16,18 +18,19 @@
const SUPPORTED_KINDS = [
{ value : 1 , label : '1 - Short Text Note' } ,
{ value : 11 , label : '11 - Discussion Thread' } ,
{ value : 9802 , label : '9802 - Highlighted Article' } ,
{ value : 1222 , label : '1222 - Voice Note' } ,
{ value : 20 , label : '20 - Picture Note' } ,
{ value : 21 , label : '21 - Video Note' } ,
{ value : 22 , label : '22 - Short Video Note' } ,
{ value : 24 , label : '24 - Public Message' } ,
{ value : 1068 , label : '1068 - Poll' } ,
{ value : 1222 , label : '1222 - Voice Note' } ,
{ value : 9802 , label : '9802 - Highlighted Article' } ,
{ value : 10895 , label : '10895 - RSS Feed' } ,
{ value : 30023 , label : '30023 - Long-form Note' } ,
{ value : 30818 , label : '30818 - AsciiDoc' } ,
{ value : 30817 , label : '30817 - AsciiDoc' } ,
{ value : 30041 , label : '30041 - AsciiDoc' } ,
{ value : 30040 , label : '30040 - Event Index (metadata-only)' } ,
{ value : 1068 , label : '1068 - Poll' } ,
{ value : 10895 , label : '10015 - RSS Feed' } ,
{ value : 30041 , label : '30041 - AsciiDoc' } ,
{ value : 30817 , label : '30817 - AsciiDoc' } ,
{ value : 30818 , label : '30818 - AsciiDoc' } ,
{ value : - 1 , label : 'Unknown Kind' }
];
@ -153,69 +156,78 @@
@@ -153,69 +156,78 @@
switch (kind) {
case 1:
return {
description: 'A simple plaintext note for social media. Use for short messages, replies, and general posts .',
description: 'A simple plaintext note (NIP-10). The content property contains some human-readable text .',
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)']
};
case 11:
return {
description: 'A discussion thread. SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
description: 'A thread (NIP-7D). A thread is a kind 11 event. Threads SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
suggestedTags: ['title (required)', 't (topics/hashtags)']
};
case 9802:
return {
description: 'A highlight event to signal content you find valuable. Content is the highlighted text portion.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
};
case 1222:
return {
description: 'A voice message (root). Content MUST be a URL to an audio file (audio/mp4 recommended). Duration SHOULD be ≤60 seconds.',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)']
};
case 20:
return {
description: 'A picture-first post. Content is a description. Images are referenced via imeta tags.',
description: 'Picture-first feeds (NIP-68). Event kind 20 for picture-first clients. Images must be self-contained. They are hosted externally and referenced using imeta tags.',
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning']
};
case 21:
return {
description: 'Video Events (NIP-71). Normal videos representing a dedicated post of externally hosted content. The content is a summary or description on the video content.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 22:
return {
description: kind === 21 ? 'A normal video post. Content is a summary/description.' : 'A short video post (stories/reels style). Content is a summary/description.',
description: 'Video Events (NIP-71). Short videos (stories/reels style) representing a dedicated post of externally hosted content. The content is a summary or description on the video content .',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 30023 :
case 24 :
return {
description: 'A long-form article or blog post. Content is Markdown. Include a d tag for editability .',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at ', 't (hashtags)']
description: 'Public Messages (NIP-A4). A simple plaintext message to one or more Nostr users. The content contains the message. p tags identify one or more receivers. Designed to be shown and replied to from notification screens .',
suggestedTags: ['p (receiver pubkeys, required)', 'expiration (recommended)', 'q (quoted events)', 'imeta (for image/video links) ', 't (hashtags)']
};
case 3081 8:
case 106 8:
return {
description: 'A wiki article (AsciiDoc). Content is AsciiDoc with wikilinks. Identified by lowercase, normalized d tag .',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference )']
description: 'Polls (NIP-88). The poll event is defined as a kind 1068 event. The content key holds the label for the poll .',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp )']
};
case 30817 :
case 1222 :
return {
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions .',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference )']
description: 'Voice Messages (NIP-A0). Root messages for short voice messages, typically up to 60 seconds in length. Content MUST be a URL pointing directly to an audio file (audio/mp4 recommended) .',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash )']
};
case 30041 :
case 9802 :
return {
description: 'Publication content section (AsciiDoc). Content is text/AsciiDoc with wikilinks. Part of a publication structure.',
suggestedTags: ['d (required)', 'title (required)', 'wikilink']
description: 'Highlights (NIP-84). A highlight event to signal content a user finds valuable. The content of these events is the highlighted portion of the text.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
};
case 10895:
return {
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.',
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)']
};
case 30023:
return {
description: 'Long-form Content (NIP-23). Long-form text content, generally referred to as "articles" or "blog posts". The content should be a string text in Markdown syntax. Include a d tag for editability.',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)']
};
case 30040:
return {
description: 'Publication index (metadata-only). Content MUST be empty. Defines structure and metadata of a publication.',
description: 'Publication Index (NKBIP-01). A publication index defines the structure and metadata of a publication. The content field MUST be empty .',
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary']
};
case 1068:
case 3004 1:
return {
description: 'A poll event. Content is the poll label/question. Options and settings are in tags.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
description: 'Publication Content (NKBIP-01). Also known as sections, zettels, episodes, or chapters contain the actual content that makes up a publication. The content field MUST contain text meant for display to the end user and MAY contain AsciiDoc markup .',
suggestedTags: ['d (required)', 'title (required)', 'wikilink ']
};
case 10895 :
case 30817 :
return {
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.',
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)']
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30818:
return {
description: 'Wiki (NIP-54). Descriptions (or encyclopedia entries) of particular subjects. Articles are identified by lowercase, normalized d tags. The content should be Asciidoc with wikilinks and nostr:... links.',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
default:
return {
@ -489,11 +501,14 @@
@@ -489,11 +501,14 @@
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
// Add URL to content field only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
contentWithUrls += `${ file . url } \n`;
}
if (shouldIncludeClientTag()) {
@ -511,100 +526,6 @@
@@ -511,100 +526,6 @@
return JSON.stringify(event, null, 2);
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise< { url : string ; tags : string [][] } > {
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// Try NIP-96 discovery
let uploadUrl: string | null = null;
try {
const nip96Url = `${ mediaServer } /.well-known/nostr/nip96.json`;
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
}
} catch (error) {
console.log('[CreateEventForm] NIP-96 discovery failed, will try direct endpoints:', error);
}
const endpoints = uploadUrl
? [uploadUrl]
: [
`${ mediaServer } /api/upload`,
`${ mediaServer } /upload`,
`${ mediaServer } /api/v1/upload`,
`${ mediaServer } /api/v2/upload`,
mediaServer
];
const formData = new FormData();
formData.append('file', file);
for (const endpoint of endpoints) {
try {
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[CreateEventForm] Failed to sign HTTP auth, trying without:', authError);
}
const result = await new Promise< { url : string ; tags : string [][] } >((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300 ) {
const data = xhr.response;
try {
// Extract tags from NIP-94 response (like jumble)
const tags: string[][] = Array.isArray(data?.nip94_event?.tags)
? data.nip94_event.tags
: [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
const url = urlTag?.[1] || data?.url || (typeof data === 'string' ? data : null);
if (url) {
resolve({ url , tags } );
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
reject(new Error(`HTTP ${ xhr . status } : ${ xhr . statusText } `));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
console.log(`[CreateEventForm] Upload failed for ${ endpoint } :`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
function handleGifSelect(gifUrl: string) {
if (!textareaRef) return;
@ -621,16 +542,24 @@
@@ -621,16 +542,24 @@
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
const files = input.files;
if (!files || files.length === 0) return;
// Filter valid file types
const validFiles: File[] = [];
for (const file of Array.from(files)) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
if (!isImage && !isVideo && !isAudio) {
alert(`${ file . name } is not an image, video, or audio file`);
continue;
}
validFiles.push(file);
}
if (!isImage && !isVideo && !isAudio) {
alert('Please select an image, video, or audio file');
if (validFiles.length === 0) {
return;
}
@ -640,35 +569,42 @@
@@ -640,35 +569,42 @@
}
uploading = true;
try {
// Upload file to media server (like jumble)
const { url , tags } = await uploadFileToServer(file);
console.log(`[CreateEventForm] Uploaded ${ file . name } to ${ url } `);
// Build imeta tag from upload response tags (like jumble)
// Format: ['imeta', 'url < url > ', 'm < mime > ', ...]
const imetaTag: string[] = ['imeta', ...tags.map(([n, v]) => `${ n } ${ v } `)];
// Store file with imeta tag
uploadedFiles.push({
url,
imetaTag
});
// Insert file URL into textarea
if (textareaRef) {
if (isImage) {
insertTextAtCursor(textareaRef, `\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${ url } \n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${ url } \n`);
const uploadPromises: Promise< void > [] = [];
// Process all files
for (const file of validFiles) {
const uploadPromise = (async () => {
try {
// Upload file to media server
const uploadResult = await uploadFileToServer(file, 'CreateEventForm');
console.log(`[CreateEventForm] Uploaded ${ file . name } to ${ uploadResult . url } `);
// Build imeta tag from upload response (NIP-92 format)
const imetaTag = buildImetaTag(file, uploadResult);
// Store file with imeta tag
uploadedFiles.push({
url: uploadResult.url,
imetaTag
});
// Insert file URL into textarea (plain URL for all file types)
if (textareaRef) {
insertTextAtCursor(textareaRef, `${ uploadResult . url } \n`);
}
} catch (error) {
console.error(`[CreateEventForm] File upload failed for ${ file . name } :`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload ${ file . name } : ${ errorMessage } `);
}
}
} catch (error) {
console.error('[CreateEventForm] File upload failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
alert(`Failed to upload file: ${ errorMessage } `);
})();
uploadPromises.push(uploadPromise);
}
// Wait for all uploads to complete
try {
await Promise.all(uploadPromises);
} finally {
uploading = false;
// Reset file input
@ -706,11 +642,14 @@
@@ -706,11 +642,14 @@
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
allTags.push(imetaTag);
// Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
// Add URL to content field only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
contentWithUrls += `${ file . url } \n`;
}
if (shouldIncludeClientTag()) {
@ -956,6 +895,7 @@
@@ -956,6 +895,7 @@
type="file"
bind:this={ fileInputRef }
accept="image/*,video/*,audio/*"
multiple
onchange={ handleFileUpload }
class="hidden"
id="write-file-upload"
@ -1095,13 +1035,45 @@
@@ -1095,13 +1035,45 @@
{ @const previewContent = (() => {
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
// Add URL to content field only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${ file . url } \n`;
}
contentWithUrls += `${ file . url } \n`;
}
return contentWithUrls.trim();
})()}
{ @const previewEvent = (() => {
// Create a mock event for MediaAttachments to process
// MediaAttachments will skip imeta tags if URL is already in content
const previewTags: string[][] = [];
// Include existing tags (like image tags, etc.)
for (const tag of tags) {
if (tag[0] && tag[1]) {
previewTags.push([...tag]);
}
}
// Add imeta tags from uploaded files
for (const file of uploadedFiles) {
previewTags.push(file.imetaTag);
}
return {
kind: effectiveKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags: previewTags,
content: previewContent,
id: '',
sig: ''
} as NostrEvent;
})()}
< MediaAttachments event = { previewEvent } / >
< MarkdownRenderer content = { previewContent } / >
{ : else }
< p class = "text-muted" > No content to preview< / p >