Browse Source

bug-fix

aggregate file uploads
master
Silberengel 1 month ago
parent
commit
e9545a056c
  1. 4
      public/healthz.json
  2. 21
      src/lib/components/content/EmbeddedEvent.svelte
  3. 531
      src/lib/components/content/EmojiPicker.svelte
  4. 131
      src/lib/components/content/GifPicker.svelte
  5. 101
      src/lib/components/content/MarkdownRenderer.svelte
  6. 336
      src/lib/components/write/CreateEventForm.svelte
  7. 194
      src/lib/modules/comments/CommentForm.svelte
  8. 62
      src/lib/modules/feed/FeedPage.svelte
  9. 6
      src/lib/modules/profiles/ProfilePage.svelte
  10. 157
      src/lib/services/nostr/file-upload.ts
  11. 12
      src/lib/services/nostr/nip21-parser.ts

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-04T16:28:03.763Z",
"buildTime": "2026-02-04T16:38:18.760Z",
"gitCommit": "unknown",
"timestamp": 1770222483764
"timestamp": 1770223098760
}

21
src/lib/components/content/EmbeddedEvent.svelte

@ -33,6 +33,18 @@ @@ -33,6 +33,18 @@
}
});
// Validate if a string is a valid bech32 or hex string
function isValidNostrId(str: string): boolean {
if (!str || typeof str !== 'string') return false;
// Check for HTML tags or other invalid characters
if (/<[^>]+>/.test(str)) return false;
// Check if it's hex (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(str)) return true;
// Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true;
return false;
}
async function loadEvent() {
// Prevent concurrent loads for the same event
if (loadingEvent) {
@ -42,6 +54,15 @@ @@ -42,6 +54,15 @@
loading = true;
error = false;
try {
// Validate eventId before processing
if (!eventId || !isValidNostrId(eventId)) {
console.warn('Invalid event ID format:', eventId);
error = true;
loading = false;
loadingEvent = false;
return;
}
// Decode event ID
let hexId: string | null = null;

531
src/lib/components/content/EmojiPicker.svelte

@ -5,10 +5,12 @@ @@ -5,10 +5,12 @@
import { loadAllEmojiPacks, getAllCustomEmojis } from '../../services/nostr/nip30-emoji.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { uploadFileToServer } from '../../services/nostr/file-upload.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
interface Props {
open: boolean;
@ -27,6 +29,20 @@ @@ -27,6 +29,20 @@
let fileInput: HTMLInputElement | null = $state(null);
let shortcodeInput: HTMLInputElement | null = $state(null);
let showUploadForm = $state(false);
// Metadata form state (like GIF picker)
let showMetadataForm = $state(false);
let pendingUpload: { file: File; fileUrl: string; shortcode: string } | null = $state(null);
let metadataForm = $state({
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: '',
content: ''
});
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -132,7 +148,7 @@ @@ -132,7 +148,7 @@
onClose();
}
// Convert file to data URL
// Convert file to data URL (fallback method)
function fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -142,7 +158,19 @@ @@ -142,7 +158,19 @@
});
}
// Handle emoji file upload
// Upload file to media server using shared service
async function uploadEmojiFile(file: File): Promise<string> {
const maxRecommendedSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxRecommendedSize) {
console.warn(`[EmojiPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may fail`);
}
// Use shared upload service
const result = await uploadFileToServer(file, 'EmojiPicker');
return result.url;
}
// Handle emoji file upload (like GIF picker)
async function handleEmojiUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
@ -158,12 +186,103 @@ @@ -158,12 +186,103 @@
uploadError = null;
try {
// Process first file (like GIF picker, one at a time)
const file = Array.from(files)[0];
const fileName = file.name;
// Verify it's an image
if (!file.type.startsWith('image/')) {
uploadError = `${fileName} is not an image file`;
uploading = false;
return;
}
// Get shortcode: use input if provided, otherwise use filename
let shortcode = '';
if (shortcodeInput && shortcodeInput.value.trim()) {
shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
} else {
shortcode = fileName.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
}
if (!shortcode) {
uploadError = `Please provide a shortcode for ${fileName}`;
uploading = false;
return;
}
// Upload file to media server to get URL
let fileUrl: string;
try {
fileUrl = await uploadEmojiFile(file);
console.log(`[EmojiPicker] Uploaded ${fileName} to ${fileUrl}`);
} catch (uploadError) {
console.error(`[EmojiPicker] Failed to upload ${fileName} to media server:`, uploadError);
const errorMessage = uploadError instanceof Error ? uploadError.message : String(uploadError);
// Only use data URL fallback for very small files (< 500KB)
const maxDataUrlSize = 500 * 1024; // 500KB
if (file.size <= maxDataUrlSize) {
console.warn(`[EmojiPicker] Using data URL fallback for ${fileName} (${(file.size / 1024).toFixed(1)}KB)`);
fileUrl = await fileToDataURL(file);
} else {
const serverUrl = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
throw new Error(
`Failed to upload ${fileName} to media server: ${errorMessage}. ` +
`File is too large (${(file.size / 1024 / 1024).toFixed(2)}MB) to use data URL fallback. ` +
`If you're seeing CORS errors, try configuring a different media upload server in Preferences (current: ${serverUrl}).`
);
}
}
// Store pending upload for metadata form
pendingUpload = { file, fileUrl, shortcode };
showMetadataForm = true;
// Reset metadata form and prefill with shortcode and image URL
metadataForm = {
title: shortcode, // Prefill with shortcode
summary: '',
alt: `:${shortcode}: emoji`,
dim: '',
blurhash: '',
thumb: '',
image: fileUrl, // Prefill with the uploaded emoji URL
content: ''
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const fileName = files && files.length > 0 ? files[0].name : 'file';
console.error(`[EmojiPicker] Error processing ${fileName}:`, error);
uploadError = errorMessage;
} finally {
uploading = false;
}
}
async function publishWithMetadata() {
if (!pendingUpload) return;
const { file, fileUrl, shortcode } = pendingUpload;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to publish emojis';
return;
}
uploading = true;
uploadError = null;
try {
// Use publish relays for emoji sets
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// Fetch existing emoji set to merge with new emojis
// Fetch existing emoji set to merge with new emoji
const existingRelays = relayManager.getProfileReadRelays();
const existingEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EMOJI_SET], authors: [session.pubkey], limit: 1 }],
@ -181,61 +300,24 @@ @@ -181,61 +300,24 @@
}
}
// Process each selected file
const newEmojiTags: string[][] = [];
const fileArray = Array.from(files);
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
// Verify it's an image
if (!file.type.startsWith('image/')) {
uploadError = `${file.name} is not an image file`;
continue;
}
// Get shortcode: use input if single file, otherwise use filename
let shortcode = '';
if (fileArray.length === 1 && shortcodeInput && shortcodeInput.value.trim()) {
shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
} else {
// Use filename without extension for each file
shortcode = file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_');
}
if (!shortcode) {
uploadError = `Please provide a shortcode for ${file.name}`;
continue;
}
// Check if shortcode already exists
if (existingEmojiTags.some(tag => tag[1] === shortcode) ||
newEmojiTags.some(tag => tag[1] === shortcode)) {
// Append number if duplicate
let counter = 1;
let uniqueShortcode = `${shortcode}_${counter}`;
while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode) ||
newEmojiTags.some(tag => tag[1] === uniqueShortcode)) {
counter++;
uniqueShortcode = `${shortcode}_${counter}`;
}
shortcode = uniqueShortcode;
// Check if shortcode already exists
let finalShortcode = shortcode;
if (existingEmojiTags.some(tag => tag[1] === shortcode)) {
// Append number if duplicate
let counter = 1;
let uniqueShortcode = `${shortcode}_${counter}`;
while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode)) {
counter++;
uniqueShortcode = `${shortcode}_${counter}`;
}
// Convert to data URL
const dataUrl = await fileToDataURL(file);
// Add emoji tag
newEmojiTags.push(['emoji', shortcode, dataUrl]);
finalShortcode = uniqueShortcode;
}
if (newEmojiTags.length === 0) {
uploadError = 'No valid emojis to upload';
return;
}
// Add new emoji tag with uploaded URL
const newEmojiTag: string[] = ['emoji', finalShortcode, fileUrl];
// Merge existing and new emoji tags
const allEmojiTags = [...existingEmojiTags, ...newEmojiTags];
const allEmojiTags = [...existingEmojiTags, newEmojiTag];
// Create or update kind 10030 emoji set event
const event: Omit<NostrEvent, 'sig' | 'id'> = {
@ -249,23 +331,41 @@ @@ -249,23 +331,41 @@
};
// Publish the event
await signAndPublish(event, relays);
// Reload custom emojis
await loadCustomEmojis();
const result = await signAndPublish(event, relays);
// Reset form
if (fileInput) fileInput.value = '';
if (shortcodeInput) shortcodeInput.value = '';
showUploadForm = false;
if (result.success.length > 0) {
console.log(`[EmojiPicker] Published emoji set event for ${file.name} (shortcode: :${finalShortcode}:) to ${result.success.length} relay(s)`);
showMetadataForm = false;
pendingUpload = null;
showUploadForm = false;
// Reset form
if (fileInput) fileInput.value = '';
if (shortcodeInput) shortcodeInput.value = '';
// Reload custom emojis
await loadCustomEmojis();
} else {
const errorMsg = result.failed.length > 0
? result.failed.map(f => `${f.relay}: ${f.error}`).join(', ')
: 'No relays accepted the event';
throw new Error(`Failed to publish ${file.name}: ${errorMsg}`);
}
} catch (error) {
console.error('Error uploading emoji:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload emoji';
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[EmojiPicker] Error publishing ${file.name}:`, error);
uploadError = errorMessage;
} finally {
uploading = false;
}
}
function cancelMetadataForm() {
showMetadataForm = false;
pendingUpload = null;
uploading = false;
}
function triggerEmojiUpload() {
if (showUploadForm) {
fileInput?.click();
@ -385,6 +485,116 @@ @@ -385,6 +485,116 @@
{/snippet}
</EmojiDrawer>
<!-- Metadata Form Modal (like GIF picker) -->
{#if showMetadataForm && pendingUpload}
<div
class="metadata-modal-backdrop"
onclick={cancelMetadataForm}
onkeydown={(e) => e.key === 'Escape' && cancelMetadataForm()}
role="button"
tabindex="0"
aria-label="Close metadata form"
>
<div
class="metadata-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="emoji-metadata-modal-title"
tabindex="-1"
>
<div class="metadata-modal-header">
<h3 id="emoji-metadata-modal-title">Add Metadata for {pendingUpload.file.name}</h3>
<button class="metadata-modal-close" onclick={cancelMetadataForm}>×</button>
</div>
<div class="metadata-modal-content">
<div class="metadata-form-group">
<label for="emoji-metadata-title">Title/Shortcode (t tag)</label>
<input
id="emoji-metadata-title"
type="text"
bind:value={metadataForm.title}
placeholder="e.g., myemoji"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-summary">Summary</label>
<textarea
id="emoji-metadata-summary"
bind:value={metadataForm.summary}
placeholder="Brief description of the emoji"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-alt">Alt Text</label>
<textarea
id="emoji-metadata-alt"
bind:value={metadataForm.alt}
placeholder="Accessibility description"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-dim">Dimensions (dim tag, e.g., 128x128)</label>
<input
id="emoji-metadata-dim"
type="text"
bind:value={metadataForm.dim}
placeholder="widthxheight"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-blurhash">Blurhash</label>
<input
id="emoji-metadata-blurhash"
type="text"
bind:value={metadataForm.blurhash}
placeholder="Blurhash string"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-thumb">Thumbnail URL</label>
<input
id="emoji-metadata-thumb"
type="url"
bind:value={metadataForm.thumb}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="emoji-metadata-image">Image URL</label>
<input
id="emoji-metadata-image"
type="url"
bind:value={metadataForm.image}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-actions">
<button class="metadata-button cancel" onclick={cancelMetadataForm} disabled={uploading}>
Cancel
</button>
<button class="metadata-button publish" onclick={publishWithMetadata} disabled={uploading}>
{uploading ? 'Publishing...' : 'Publish'}
</button>
</div>
{#if uploadError}
<div class="metadata-error">{uploadError}</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.emoji-picker-wrapper {
display: flex;
@ -580,4 +790,199 @@ @@ -580,4 +790,199 @@
:global(.dark) .emoji-upload-error {
color: var(--fog-dark-error, #ef4444);
}
/* Metadata Form Modal (like GIF picker) */
.metadata-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.metadata-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .metadata-modal {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.metadata-modal-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .metadata-modal-close:hover {
background: var(--fog-dark-highlight, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-content {
padding: 1.5rem;
}
.metadata-form-group {
margin-bottom: 1rem;
}
.metadata-form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-form-group label {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-input,
.metadata-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
}
:global(.dark) .metadata-input,
:global(.dark) .metadata-textarea {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-textarea {
resize: vertical;
min-height: 60px;
}
.metadata-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-form-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.metadata-button {
padding: 0.5rem 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.metadata-button.cancel {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-button.cancel {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-button.cancel:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish:hover:not(:disabled) {
opacity: 0.9;
}
.metadata-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.metadata-error {
margin-top: 1rem;
padding: 0.75rem;
background: var(--fog-error-bg, #fee2e2);
border: 1px solid var(--fog-error, #dc2626);
border-radius: 0.375rem;
color: var(--fog-error, #dc2626);
font-size: 0.875rem;
}
:global(.dark) .metadata-error {
background: var(--fog-dark-error-bg, #7f1d1d);
border-color: var(--fog-dark-error, #ef4444);
color: var(--fog-dark-error, #ef4444);
}
</style>

131
src/lib/components/content/GifPicker.svelte

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.js';
import { signAndPublish, signHttpAuth } from '../../services/nostr/auth-handler.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { uploadFileToServer } from '../../services/nostr/file-upload.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
@ -127,133 +128,17 @@ @@ -127,133 +128,17 @@
});
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise<string> {
// Get media upload server from preferences
// Note: nostr.build has CORS issues, so users may need to configure an alternative
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// Upload file to media server using shared service
async function uploadGifFile(file: File): Promise<string> {
// For very large files, warn but still try
const maxRecommendedSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxRecommendedSize) {
console.warn(`[GifPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may fail`);
}
// Step 1: Try NIP-96 discovery (like jumble does)
let uploadUrl: string | null = null;
try {
const nip96Url = `${mediaServer}/.well-known/nostr/nip96.json`;
console.log(`[GifPicker] Trying NIP-96 discovery: ${nip96Url}`);
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
if (uploadUrl) {
console.log(`[GifPicker] Found NIP-96 upload URL: ${uploadUrl}`);
}
}
} catch (error) {
console.log(`[GifPicker] NIP-96 discovery failed, will try direct endpoints:`, error);
}
// Step 2: If no NIP-96 URL, try common endpoints
const endpoints = uploadUrl
? [uploadUrl]
: [
`${mediaServer}/api/upload`,
`${mediaServer}/upload`,
`${mediaServer}/api/v1/upload`,
`${mediaServer}/api/v2/upload`,
mediaServer // Direct to root
];
// Step 3: Try uploading using XMLHttpRequest (like jumble) for better error handling
const formData = new FormData();
formData.append('file', file);
let lastError: Error | null = null;
for (const endpoint of endpoints) {
try {
console.log(`[GifPicker] Trying upload endpoint: ${endpoint}`);
// Sign HTTP auth BEFORE creating the Promise (like jumble does)
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[GifPicker] Failed to sign HTTP auth, trying without:', authError);
// Continue without auth - some servers might not require it
}
// Use XMLHttpRequest for better error handling (like jumble)
// Match jumble's exact implementation
const result = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
// Add HTTP auth header if we have it (exactly like jumble)
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
// Don't set any other headers - let browser handle Content-Type for FormData
xhr.onerror = () => {
// Network errors - match jumble's exact error message
reject(new Error('Network error'));
};
xhr.onload = () => {
// Match jumble's exact error handling
if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response;
try {
// Match jumble's exact parsing logic
const tags = data?.nip94_event?.tags ?? [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
if (urlTag?.[1]) {
console.log(`[GifPicker] Upload successful (NIP-96), got URL: ${urlTag[1]}`);
resolve(urlTag[1]);
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
// Match jumble's exact error format
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
// NetworkError, CORS error, etc.
const errorMsg = error instanceof Error ? error.message : String(error);
lastError = error instanceof Error ? error : new Error(errorMsg);
console.warn(`[GifPicker] Endpoint ${endpoint} failed:`, errorMsg);
// If it's a CORS or network error on the first endpoint, don't try others
if (endpoint === endpoints[0] && (errorMsg.includes('NetworkError') || errorMsg.includes('CORS') || errorMsg.includes('Failed to fetch'))) {
console.warn(`[GifPicker] Network/CORS error on primary endpoint, skipping remaining endpoints`);
break;
}
continue;
}
}
// All endpoints failed - throw error so caller can handle fallback
throw lastError || new Error('Failed to upload file: all endpoints failed');
// Use shared upload service
const result = await uploadFileToServer(file, 'GifPicker');
return result.url;
}
// Handle file upload
@ -298,7 +183,7 @@ @@ -298,7 +183,7 @@
let fileUrl: string;
try {
fileUrl = await uploadFileToServer(file);
fileUrl = await uploadGifFile(file);
console.log(`[GifPicker] Uploaded ${file.name} to ${fileUrl}`);
} catch (uploadError) {
console.error(`[GifPicker] Failed to upload ${file.name} to media server:`, uploadError);

101
src/lib/components/content/MarkdownRenderer.svelte

@ -27,10 +27,28 @@ @@ -27,10 +27,28 @@
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]);
let highlightsLoaded = $state(false);
// Validate if a string is a valid bech32 or hex string
function isValidNostrId(str: string): boolean {
if (!str || typeof str !== 'string') return false;
// Check for HTML tags or other invalid characters
if (/<[^>]+>/.test(str)) return false;
// Check if it's hex (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(str)) return true;
// Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true;
return false;
}
// Extract pubkey from npub or nprofile
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
if (!parsed) return null;
// Validate before decoding
if (!isValidNostrId(parsed.data)) {
console.warn('Invalid NIP-21 data format:', parsed.data);
return null;
}
try {
// parsed.data is the bech32 string (e.g., "npub1..." or "nprofile1...")
// We need to decode it to get the actual pubkey
@ -439,6 +457,89 @@ @@ -439,6 +457,89 @@
// Post-process to fix any greentext that markdown converted to blockquotes
html = postProcessGreentext(html);
// Fix malformed image tags - ensure all img src attributes are absolute URLs
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs
html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => {
// Extract src attribute
const srcMatch = attributes.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
// If src doesn't start with http:// or https://, it might be malformed
// Check if it looks like it should be a URL but isn't properly formatted
if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) {
// This might be a malformed URL - try to extract a valid URL from it
const urlMatch = src.match(/(https?:\/\/[^\s<>"']+)/i);
if (urlMatch) {
// Replace with the valid URL
return match.replace(srcMatch[0], `src="${urlMatch[1]}"`);
}
// If no valid URL found, remove the img tag to prevent 404s
return '';
}
}
// If no src attribute or src is empty, remove the img tag
if (!srcMatch || !srcMatch[1]) {
return '';
}
return match;
});
// Fix markdown image syntax that wasn't properly converted (e.g., ![image](url) as text)
// This should be rare, but if marked didn't convert it, we need to handle it
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
// Only convert if it's a valid URL
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
const escapedAlt = escapeHtml(alt);
return `<img src="${escapedUrl}" alt="${escapedAlt}" loading="lazy" />`;
}
// If not a valid URL, remove the markdown syntax to prevent 404s
return alt || '';
});
// Remove any escaped HTML that looks like img tags (e.g., &lt;img src="..."&gt;)
// These might be causing the browser to try to fetch them as URLs
html = html.replace(/&lt;img\s+[^&]*src=["']([^"']+)["'][^&]*&gt;/gi, (match, url) => {
// If it's a valid image URL, convert to a proper img tag
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
return `<img src="${escapedUrl}" alt="" loading="lazy" />`;
}
// Otherwise, remove to prevent 404s
return '';
});
// Remove any plain text patterns that look like markdown image syntax or HTML img tags
// These patterns in text nodes can cause the browser to try to fetch them as relative URLs
// We match text between HTML tags (not inside tags) that contains these patterns
html = html.replace(/>([^<]*?)(!\[[^\]]*\]\(https?:\/\/[^)]+\)|img\s+src=["']https?:\/\/[^"']+["'])([^<]*?)</gi, (match, before, pattern, after) => {
// Extract URL from the pattern
const urlMatch = pattern.match(/(https?:\/\/[^\s<>"')]+)/i);
if (urlMatch) {
const url = urlMatch[1];
// If it's an image URL, convert to proper img tag
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i.test(url)) {
const escapedUrl = escapeHtml(url);
return `>${before}<img src="${escapedUrl}" alt="" loading="lazy" />${after}<`;
}
}
// Otherwise, remove the problematic pattern to prevent 404s
return `>${before}${after}<`;
});
// Filter out invalid relative links (like /a, /b, etc.) that cause 404s
// These are likely malformed markdown links
html = html.replace(/<a\s+href="\/([a-z]|\.)"[^>]*>.*?<\/a>/gi, (match, char) => {
// Only filter single-character or dot links that aren't valid routes
const validSingleCharRoutes = ['r', 'f', 'w', 't', 'c']; // /rss, /feed, /write, /topics, /cache
if (validSingleCharRoutes.includes(char.toLowerCase())) {
return match; // Keep valid routes
}
// Remove the link but keep the text content
const textMatch = match.match(/>(.*?)<\/a>/);
return textMatch ? textMatch[1] : '';
});
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);

336
src/lib/components/write/CreateEventForm.svelte

@ -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 30818:
case 1068:
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 30041:
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;
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);
}
// Check file type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
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}`);
const uploadPromises: Promise<void>[] = [];
// 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, `![${file.name}](${url})\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${url}\n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${url}\n`);
// 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>

194
src/lib/modules/comments/CommentForm.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<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 { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { fetchRelayLists } from '../../services/user-data.js';
@ -8,6 +9,7 @@ @@ -8,6 +9,7 @@
import GifPicker from '../../components/content/GifPicker.svelte';
import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import { insertTextAtCursor } from '../../services/text-utils.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
@ -171,22 +173,35 @@ @@ -171,22 +173,35 @@
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
tags.push(file.imetaTag);
// Ensure imetaTag is a plain array (not a Proxy) to avoid cloning issues
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
console.log(`[CommentForm] Adding imeta tag to event:`, imetaTag);
tags.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`;
}
console.log(`[CommentForm] Final tags before publishing:`, tags);
// Convert all tags to plain arrays to avoid Proxy cloning issues
const plainTags: string[][] = tags.map(tag => [...tag]);
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
tags: plainTags,
content: contentWithUrls.trim()
};
console.log(`[CommentForm] Event to publish:`, JSON.stringify(event, null, 2));
// Get target's inbox if replying to someone
let targetInbox: string[] | undefined;
@ -303,111 +318,34 @@ @@ -303,111 +318,34 @@
tags.push(['client', 'aitherboard']);
}
// Add file attachments as imeta tags (same as publish function)
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
// Ensure imetaTag is a plain array (not a Proxy) to avoid cloning issues
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
tags.push(imetaTag);
// 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`;
}
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
content: contentWithUrls.trim()
};
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('[CommentForm] 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('[CommentForm] 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(`[CommentForm] Upload failed for ${endpoint}:`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
@ -431,29 +369,23 @@ @@ -431,29 +369,23 @@
uploading = true;
try {
// Upload file to media server (like jumble)
const { url, tags } = await uploadFileToServer(file);
console.log(`[CommentForm] Uploaded ${file.name} to ${url}`);
// Upload file to media server
const uploadResult = await uploadFileToServer(file, 'CommentForm');
console.log(`[CommentForm] Uploaded ${file.name} to ${uploadResult.url}`, { tags: uploadResult.tags });
// 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}`)];
// Build imeta tag from upload response (NIP-92 format)
const imetaTag = buildImetaTag(file, uploadResult);
console.log(`[CommentForm] Built imeta tag for ${file.name}:`, imetaTag);
// Store file with imeta tag
uploadedFiles.push({
url,
url: uploadResult.url,
imetaTag
});
// Insert file URL into textarea
// Insert file URL into textarea (plain URL for all file types)
if (textareaRef) {
if (isImage) {
insertTextAtCursor(textareaRef, `![${file.name}](${url})\n`);
} else if (isVideo) {
insertTextAtCursor(textareaRef, `${url}\n`);
} else if (isAudio) {
insertTextAtCursor(textareaRef, `${url}\n`);
}
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`);
}
} catch (error) {
console.error('[CommentForm] File upload failed:', error);
@ -652,13 +584,35 @@ @@ -652,13 +584,35 @@
{@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 tags: string[][] = [];
for (const file of uploadedFiles) {
tags.push(file.imetaTag);
}
return {
kind: getReplyKind(),
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags,
content: previewContent,
id: '',
sig: ''
} as NostrEvent;
})()}
<MediaAttachments event={previewEvent} />
<MarkdownRenderer content={previewContent} />
{:else}
<p class="text-muted">No content to preview</p>

62
src/lib/modules/feed/FeedPage.svelte

@ -282,7 +282,12 @@ @@ -282,7 +282,12 @@
relays,
(event: NostrEvent) => {
// Only add events that are newer than what we already have
const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]);
// Check against all feed event types
const existingIds = new Set([
...posts.map(p => p.id),
...highlights.map(h => h.id),
...otherFeedEvents.map(e => e.id)
]);
if (!existingIds.has(event.id)) {
handleUpdate([event]);
}
@ -311,16 +316,18 @@ @@ -311,16 +316,18 @@
try {
const relays = relayManager.getFeedReadRelays();
// Get the newest post's timestamp to only fetch newer events
const newestTimestamp = posts.length > 0
? Math.max(...posts.map(p => p.created_at))
: Math.floor(Date.now() / 1000) - 60; // Last minute if no posts
// Get the newest event's timestamp from all feed event types to only fetch newer events
const allFeedEvents = [...posts, ...highlights, ...otherFeedEvents];
const newestTimestamp = allFeedEvents.length > 0
? Math.max(...allFeedEvents.map(e => e.created_at))
: Math.floor(Date.now() / 1000) - 60; // Last minute if no events
const filters = [{
kinds: [KIND.SHORT_TEXT_NOTE],
const feedKinds = getFeedKinds();
const filters = feedKinds.map(kind => ({
kinds: [kind],
limit: 50,
since: newestTimestamp + 1 // Only get events newer than what we have
}];
}));
// Fetch new events (without cache to ensure we query relays)
const events = await nostrClient.fetchEvents(
@ -377,11 +384,9 @@ @@ -377,11 +384,9 @@
// Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
// Load both kind 1 posts and kind 9802 highlights
const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
// Load all feed kinds
const feedKinds = getFeedKinds();
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 }));
// For single relay mode, load from cache first for immediate display
// Then query the relay in background to get fresh data
@ -583,18 +588,12 @@ @@ -583,18 +588,12 @@
// Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const filters = [
{
kinds: [KIND.SHORT_TEXT_NOTE],
const feedKinds = getFeedKinds();
const filters = feedKinds.map(kind => ({
kinds: [kind],
limit: 20,
until: oldestTimestamp || undefined
},
{
kinds: [KIND.HIGHLIGHTED_ARTICLE],
limit: 20,
until: oldestTimestamp || undefined
}
];
}));
// For single relay mode, try cache first, then query relay
let events: NostrEvent[] = [];
@ -731,7 +730,12 @@ @@ -731,7 +730,12 @@
if (!updated || updated.length === 0) return;
// Deduplicate incoming updates before adding to pending
const existingIds = new Set(allPosts.map(p => p.id));
// Check against all feed event types
const existingIds = new Set([
...allPosts.map(p => p.id),
...allHighlights.map(h => h.id),
...allOtherFeedEvents.map(e => e.id)
]);
const newUpdates = updated.filter(e => e && e.id && !existingIds.has(e.id));
if (newUpdates.length === 0) {
@ -758,8 +762,12 @@ @@ -758,8 +762,12 @@
return;
}
// Final deduplication check against current allPosts (allPosts may have changed)
const currentIds = new Set(allPosts.map(p => p.id));
// Final deduplication check against all feed event types (may have changed)
const currentIds = new Set([
...allPosts.map(p => p.id),
...allHighlights.map(h => h.id),
...allOtherFeedEvents.map(e => e.id)
]);
const newEvents = pendingUpdates.filter(e => e && e.id && !currentIds.has(e.id));
if (newEvents.length === 0) {
@ -944,7 +952,7 @@ @@ -944,7 +952,7 @@
</div>
{:else}
<div class="feed-posts">
{#each [...posts, ...highlights].sort((a, b) => b.created_at - a.created_at) as event (event.id)}
{#each [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at) as event (event.id)}
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<HighlightCard highlight={event} onOpenEvent={openDrawer} />
{:else}

6
src/lib/modules/profiles/ProfilePage.svelte

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { KIND, getFeedKinds } from '../../types/kind-lookup.js';
let profile = $state<ProfileData | null>(null);
let userStatus = $state<string | null>(null);
@ -408,8 +408,10 @@ @@ -408,8 +408,10 @@
const responseRelays = relayManager.getFeedResponseReadRelays();
// Load posts first (needed for response filtering)
// Fetch all feed kinds (not just kind 1)
const feedKinds = getFeedKinds();
const feedEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 20 }],
[{ kinds: feedKinds, authors: [pubkey], limit: 50 }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);

157
src/lib/services/nostr/file-upload.ts

@ -0,0 +1,157 @@ @@ -0,0 +1,157 @@
/**
* Shared file upload service for media files (images, videos, audio)
* Supports NIP-96 discovery and NIP-94 response format
*/
import { sessionManager } from '../auth/session-manager.js';
import { signHttpAuth } from '../nostr/auth-handler.js';
export interface UploadResult {
url: string;
tags: string[][];
}
/**
* Upload a file to a media server using NIP-96 discovery
* @param file - The file to upload
* @param context - Optional context string for logging (e.g., 'CommentForm', 'CreateEventForm')
* @returns Promise with the upload URL and tags from the response
*/
export async function uploadFileToServer(
file: File,
context: string = 'FileUpload'
): Promise<UploadResult> {
const mediaServer = typeof window !== 'undefined'
? localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build'
: '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(`[${context}] 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(`[${context}] Failed to sign HTTP auth, trying without:`, authError);
}
const result = await new Promise<UploadResult>((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(`[${context}] Upload failed for ${endpoint}:`, error);
if (endpoint === endpoints[endpoints.length - 1]) {
throw error;
}
}
}
throw new Error('All upload endpoints failed');
}
/**
* Build an imeta tag (NIP-92) from upload response
* @param file - The original file
* @param uploadResult - The upload result with URL and tags
* @returns imeta tag array in NIP-92 format: ['imeta', 'url <url>', 'm <mime>', ...]
*/
export function buildImetaTag(
file: File,
uploadResult: UploadResult
): string[] {
const { url, tags } = uploadResult;
const imetaFields: string[] = [];
// Always include url first
const urlTag = tags.find(([n]) => n === 'url');
if (urlTag && urlTag[1]) {
imetaFields.push(`url ${urlTag[1]}`);
} else {
imetaFields.push(`url ${url}`);
}
// Add mime type if available
const mimeTag = tags.find(([n]) => n === 'm');
if (mimeTag && mimeTag[1]) {
imetaFields.push(`m ${mimeTag[1]}`);
} else if (file.type) {
imetaFields.push(`m ${file.type}`);
}
// Add all other tags from upload response
for (const [name, value] of tags) {
if (name !== 'url' && name !== 'm' && value) {
imetaFields.push(`${name} ${value}`);
}
}
// Build imeta tag array: ['imeta', 'url <url>', 'm <mime>', ...]
return ['imeta', ...imetaFields];
}

12
src/lib/services/nostr/nip21-parser.ts

@ -42,11 +42,19 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -42,11 +42,19 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
const type = typeMatch[1] as ParsedNIP21['type'];
// Validate: check for HTML tags or other invalid characters
if (/<[^>]+>/.test(identifier)) {
return null;
}
// Try to decode (optional, for validation)
let entity: any = null;
try {
const decoded = nip19.decode(identifier);
entity = decoded;
// Only decode if it's a valid bech32 string
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(identifier)) {
const decoded = nip19.decode(identifier);
entity = decoded;
}
} catch {
// If decoding fails, we can still use the bech32 string
}

Loading…
Cancel
Save