From 9054484a3065a9c5c7230631e8fdc232bfc89ebc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 6 Feb 2026 17:04:33 +0100 Subject: [PATCH] efficiency improvements --- .../content/MediaAttachments.svelte | 4 + src/lib/components/layout/ProfileBadge.svelte | 31 ++- .../components/write/CreateEventForm.svelte | 93 ++++++- src/lib/modules/comments/CommentForm.svelte | 30 ++- src/lib/services/auto-tagging.ts | 236 ++++++++++++++++++ src/lib/services/text-utils.ts | 18 ++ src/routes/bookmarks/+page.svelte | 32 --- src/routes/cache/+page.svelte | 18 +- src/routes/replaceable/[d_tag]/+page.svelte | 114 +++++++-- src/routes/repos/[naddr]/+page.svelte | 25 -- src/routes/topics/[name]/+page.svelte | 48 +--- 11 files changed, 498 insertions(+), 151 deletions(-) create mode 100644 src/lib/services/auto-tagging.ts diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index cbc7a88..b95ce12 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -193,6 +193,8 @@ src={coverImage.url} alt="" class="w-full max-h-96 object-cover rounded" + loading="lazy" + decoding="async" /> {/if} @@ -206,6 +208,8 @@ src={item.url} alt="" class="max-w-full rounded" + loading="lazy" + decoding="async" /> {:else if item.type === 'video'} diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index 87b52a9..f608598 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -140,15 +140,44 @@ {#if !inline || pictureOnly} {#if profile?.picture && !imageError} + {@const compressedPictureUrl = (() => { + // Add compression query params for common image hosts + const url = profile.picture; + try { + const urlObj = new URL(url); + // For nostr.build and similar services, add size/quality params + if (urlObj.hostname.includes('nostr.build') || urlObj.hostname.includes('void.cat')) { + // These services may support size parameters + urlObj.searchParams.set('w', '48'); // 48px width for profile pics + urlObj.searchParams.set('q', '80'); // 80% quality + return urlObj.toString(); + } + // For other hosts, try to use image CDN compression if available + // Or return original URL + return url; + } catch { + // If URL parsing fails, return original + return url; + } + })()} {profile.name { imageError = true; }} + onload={(e) => { + // If compressed URL fails, try original as fallback + if (imageError && compressedPictureUrl !== profile.picture) { + const img = e.currentTarget; + img.src = profile.picture; + imageError = false; + } + }} /> {:else}
t[0] === 'd' && t[1]); + if (!existingDTag) { + // No d-tag and no title tag - alert user + alert(`Parameterized replaceable events (kind ${effectiveKind}) require a d-tag. Please add a d-tag or a title tag that can be normalized to a d-tag.`); + publishing = false; + return; + } + } } if (shouldIncludeClientTag()) { @@ -947,6 +975,14 @@ previewTags.push(file.imetaTag); } + // For parameterized replaceable events, ensure d-tag exists + if (isParameterizedReplaceableKind(effectiveKind)) { + const dTagResult = ensureDTagForParameterizedReplaceable(previewTags, effectiveKind); + if (dTagResult) { + previewTags.push(['d', dTagResult.dTag]); + } + } + return { kind: effectiveKind, pubkey: sessionManager.getCurrentPubkey() || '', @@ -957,6 +993,14 @@ sig: '' } as NostrEvent; })()} + {#if isParameterizedReplaceableKind(effectiveKind)} + {@const dTag = previewEvent.tags.find(t => t[0] === 'd' && t[1])} + {#if dTag} +
+ d-tag: {dTag[1]} +
+ {/if} + {/if} {:else} @@ -1607,6 +1651,33 @@ padding: 1.5rem; } + .d-tag-preview { + padding: 0.75rem; + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + margin-bottom: 1rem; + font-size: 0.875rem; + } + + :global(.dark) .d-tag-preview { + background: #1e293b; + border-color: #475569; + } + + .d-tag-preview code { + background: #e2e8f0; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 0.8125rem; + } + + :global(.dark) .d-tag-preview code { + background: #334155; + color: #f1f5f9; + } + .modal-footer { display: flex; justify-content: flex-end; diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index 8423e36..00dd752 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -14,7 +14,7 @@ import { KIND } from '../../types/kind-lookup.js'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { cacheEvent } from '../../services/cache/event-cache.js'; - import { extractMentions, getMentionPubkeys } from '../../services/mentions.js'; + import { autoExtractTags } from '../../services/auto-tagging.js'; interface Props { threadId: string; // The root event ID @@ -185,12 +185,13 @@ } } - // Extract mentions and add p tags (after file URLs are added) - const mentions = await extractMentions(contentWithUrls); - const mentionPubkeys = getMentionPubkeys(mentions); - for (const pubkey of mentionPubkeys) { - tags.push(['p', pubkey]); - } + // Auto-extract tags from content (hashtags, mentions, nostr: links) + const autoTagsResult = await autoExtractTags({ + content: contentWithUrls, + existingTags: tags, + kind: replyKind + }); + tags.push(...autoTagsResult.tags); console.log(`[CommentForm] Final tags before publishing:`, tags); @@ -308,7 +309,7 @@ } } - // Extract mentions and add p tags + // Add file URLs to content for auto-tagging let contentWithUrls = content.trim(); for (const file of uploadedFiles) { if (!contentWithUrls.includes(file.url)) { @@ -318,11 +319,14 @@ contentWithUrls += `${file.url}\n`; } } - const mentions = await extractMentions(contentWithUrls); - const mentionPubkeys = getMentionPubkeys(mentions); - for (const pubkey of mentionPubkeys) { - tags.push(['p', pubkey]); - } + + // Auto-extract tags from content (hashtags, mentions, nostr: links) + const autoTagsResult = await autoExtractTags({ + content: contentWithUrls, + existingTags: tags, + kind: replyKind + }); + tags.push(...autoTagsResult.tags); // Add file attachments as imeta tags (same as publish function) for (const file of uploadedFiles) { diff --git a/src/lib/services/auto-tagging.ts b/src/lib/services/auto-tagging.ts new file mode 100644 index 0000000..d4c7bfa --- /dev/null +++ b/src/lib/services/auto-tagging.ts @@ -0,0 +1,236 @@ +/** + * Auto-tagging utilities for extracting tags from content + * Automatically extracts t-tags (hashtags), p-tags (mentions), e-tags (event references), and a-tags (addressable events) + */ + +import { extractMentions, getMentionPubkeys } from './mentions.js'; +import { findNIP21Links } from './nostr/nip21-parser.js'; +import { nip19 } from 'nostr-tools'; +import { isParameterizedReplaceableKind } from '../types/kind-lookup.js'; +import { normalizeTitleToDTag } from './text-utils.js'; + +export interface AutoTaggingOptions { + content: string; + existingTags?: string[][]; + kind?: number; + includeHashtags?: boolean; + includeMentions?: boolean; + includeNostrLinks?: boolean; + maxHashtags?: number; +} + +export interface AutoTaggingResult { + tags: string[][]; + dTag?: string; // Normalized d-tag if created from title +} + +/** + * Extract hashtags from content and return as t-tags + * @param content - The content to search for hashtags + * @param maxHashtags - Maximum number of hashtags to extract (default: 3) + * @param existingTags - Existing tags to check for duplicates + * @returns Array of t-tags + */ +export function extractHashtags( + content: string, + maxHashtags: number = 3, + existingTags: string[][] = [] +): string[][] { + const hashtagPattern = /#([a-zA-Z0-9_]+)/g; + const hashtags = new Set(); + let hashtagMatch; + + while ((hashtagMatch = hashtagPattern.exec(content)) !== null) { + const hashtag = hashtagMatch[1].toLowerCase(); + if (hashtag.length > 0) { + hashtags.add(hashtag); + } + } + + // Add t-tags for hashtags (max to avoid spam) + const hashtagArray = Array.from(hashtags).slice(0, maxHashtags); + const tTags: string[][] = []; + + for (const hashtag of hashtagArray) { + // Check if t-tag already exists to avoid duplicates + if (!existingTags.some(t => t[0] === 't' && t[1] === hashtag)) { + tTags.push(['t', hashtag]); + } + } + + return tTags; +} + +/** + * Extract nostr: links and convert to appropriate tags + * @param content - The content to search for nostr: links + * @param existingTags - Existing tags to check for duplicates + * @returns Array of tags (p, e, or a tags) + */ +export async function extractNostrLinkTags( + content: string, + existingTags: string[][] = [] +): Promise { + const tags: string[][] = []; + const nostrLinks = findNIP21Links(content); + + for (const link of nostrLinks) { + const parsed = link.parsed; + try { + if (parsed.type === 'npub' || parsed.type === 'nprofile') { + // Add p-tag for profile mentions + let pubkey: string | undefined; + if (parsed.type === 'npub') { + const decoded = nip19.decode(parsed.data); + if (decoded.type === 'npub') { + pubkey = decoded.data as string; + } + } else if (parsed.type === 'nprofile') { + const decoded = nip19.decode(parsed.data); + if (decoded.type === 'nprofile') { + pubkey = decoded.data.pubkey; + } + } + if (pubkey && !existingTags.some(t => t[0] === 'p' && t[1] === pubkey)) { + tags.push(['p', pubkey]); + } + } else if (parsed.type === 'note' || parsed.type === 'nevent') { + // Add e-tag for event references + let eventId: string | undefined; + if (parsed.type === 'note') { + const decoded = nip19.decode(parsed.data); + if (decoded.type === 'note') { + eventId = decoded.data as string; + } + } else if (parsed.type === 'nevent') { + const decoded = nip19.decode(parsed.data); + if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + eventId = decoded.data.id as string; + } + } + if (eventId && !existingTags.some(t => t[0] === 'e' && t[1] === eventId)) { + tags.push(['e', eventId]); + } + } else if (parsed.type === 'naddr') { + // Add a-tag for parameterized replaceable events + const decoded = nip19.decode(parsed.data); + if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { + const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string }; + const aTag = `${naddrData.kind}:${naddrData.pubkey}:${naddrData.identifier || ''}`; + if (!existingTags.some(t => t[0] === 'a' && t[1] === aTag)) { + tags.push(['a', aTag]); + } + } + } + } catch (e) { + // Skip invalid nostr links + console.debug('Error parsing nostr link:', e); + } + } + + return tags; +} + +/** + * Extract mentions (@handles) and convert to p-tags + * @param content - The content to search for mentions + * @param existingTags - Existing tags to check for duplicates + * @returns Array of p-tags + */ +export async function extractMentionTags( + content: string, + existingTags: string[][] = [] +): Promise { + const mentions = await extractMentions(content); + const mentionPubkeys = getMentionPubkeys(mentions); + const pTags: string[][] = []; + + for (const pubkey of mentionPubkeys) { + if (!existingTags.some(t => t[0] === 'p' && t[1] === pubkey)) { + pTags.push(['p', pubkey]); + } + } + + return pTags; +} + +/** + * Ensure d-tag exists for parameterized replaceable events + * @param tags - Existing tags + * @param kind - Event kind + * @returns d-tag value if created, undefined if already exists or not needed + */ +export function ensureDTagForParameterizedReplaceable( + tags: string[][], + kind: number +): { dTag: string } | null { + if (!isParameterizedReplaceableKind(kind)) { + return null; + } + + // Check if d-tag already exists + const existingDTag = tags.find(t => t[0] === 'd' && t[1]); + if (existingDTag) { + return null; // Already exists + } + + // Try to get d-tag from title tag + const titleTag = tags.find(t => t[0] === 'title' && t[1]); + if (titleTag && titleTag[1]) { + const normalizedDTag = normalizeTitleToDTag(titleTag[1]); + if (normalizedDTag) { + return { dTag: normalizedDTag }; + } + } + + return null; // No title to normalize +} + +/** + * Auto-extract tags from content + * @param options - Auto-tagging options + * @returns Tags and optional d-tag + */ +export async function autoExtractTags(options: AutoTaggingOptions): Promise { + const { + content, + existingTags = [], + kind, + includeHashtags = true, + includeMentions = true, + includeNostrLinks = true, + maxHashtags = 3 + } = options; + + const tags: string[][] = []; + + // Extract hashtags (t-tags) + if (includeHashtags) { + const hashtagTags = extractHashtags(content, maxHashtags, existingTags); + tags.push(...hashtagTags); + } + + // Extract mentions (@handles) as p-tags + if (includeMentions) { + const mentionTags = await extractMentionTags(content, [...existingTags, ...tags]); + tags.push(...mentionTags); + } + + // Extract nostr: links (p, e, a tags) + if (includeNostrLinks) { + const nostrLinkTags = await extractNostrLinkTags(content, [...existingTags, ...tags]); + tags.push(...nostrLinkTags); + } + + // Ensure d-tag for parameterized replaceable events + let dTag: string | undefined; + if (kind !== undefined) { + const dTagResult = ensureDTagForParameterizedReplaceable([...existingTags, ...tags], kind); + if (dTagResult) { + tags.push(['d', dTagResult.dTag]); + dTag = dTagResult.dTag; + } + } + + return { tags, dTag }; +} diff --git a/src/lib/services/text-utils.ts b/src/lib/services/text-utils.ts index dfb5e59..de02d80 100644 --- a/src/lib/services/text-utils.ts +++ b/src/lib/services/text-utils.ts @@ -79,3 +79,21 @@ export function stripMarkdown(markdown: string): string { return text; } + +/** + * Normalize a title to create a valid d-tag for parameterized replaceable events + * Similar to how wiki pages normalize titles (lowercase, replace spaces with hyphens, etc.) + * @param title The title to normalize + * @returns Normalized d-tag value + */ +export function normalizeTitleToDTag(title: string): string { + if (!title) return ''; + + return title + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special characters except word chars, spaces, hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens +} diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte index e1a9c67..1ba01bd 100644 --- a/src/routes/bookmarks/+page.svelte +++ b/src/routes/bookmarks/+page.svelte @@ -522,38 +522,6 @@ {/each}
- {#if totalPages > 1} - - {/if} - {#if totalPages > 1} - {/if} - - {#if totalPages > 1} {/if} - - {#if totalPages > 1} -
- - - - Page {currentPage} of {totalPages} - - - -
- {/if} {/if}