Browse Source

efficiency improvements

master
Silberengel 1 month ago
parent
commit
9054484a30
  1. 4
      src/lib/components/content/MediaAttachments.svelte
  2. 31
      src/lib/components/layout/ProfileBadge.svelte
  3. 93
      src/lib/components/write/CreateEventForm.svelte
  4. 30
      src/lib/modules/comments/CommentForm.svelte
  5. 236
      src/lib/services/auto-tagging.ts
  6. 18
      src/lib/services/text-utils.ts
  7. 32
      src/routes/bookmarks/+page.svelte
  8. 18
      src/routes/cache/+page.svelte
  9. 114
      src/routes/replaceable/[d_tag]/+page.svelte
  10. 25
      src/routes/repos/[naddr]/+page.svelte
  11. 48
      src/routes/topics/[name]/+page.svelte

4
src/lib/components/content/MediaAttachments.svelte

@ -193,6 +193,8 @@ @@ -193,6 +193,8 @@
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
decoding="async"
/>
</div>
{/if}
@ -206,6 +208,8 @@ @@ -206,6 +208,8 @@
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
decoding="async"
/>
</div>
{:else if item.type === 'video'}

31
src/lib/components/layout/ProfileBadge.svelte

@ -140,15 +140,44 @@ @@ -140,15 +140,44 @@
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full" class:picture-only={pictureOnly}>
{#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;
}
})()}
<img
src={profile.picture}
src={compressedPictureUrl}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded-full flex-shrink-0"
class:nav-picture={pictureOnly}
loading="lazy"
decoding="async"
onerror={() => {
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}
<div

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

@ -13,7 +13,8 @@ @@ -13,7 +13,8 @@
import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { extractMentions, getMentionPubkeys } from '../../services/mentions.js';
import { autoExtractTags, ensureDTagForParameterizedReplaceable } from '../../services/auto-tagging.js';
import { isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
const SUPPORTED_KINDS = [
{ value: 1, label: '1 - Short Text Note' },
@ -508,11 +509,20 @@ @@ -508,11 +509,20 @@
}
}
// Extract mentions and add p tags
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
allTags.push(['p', pubkey]);
// Auto-extract tags from content (hashtags, mentions, nostr: links)
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: allTags,
kind: effectiveKind
});
allTags.push(...autoTagsResult.tags);
// For parameterized replaceable events, ensure d-tag exists (for preview)
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind);
if (dTagResult) {
allTags.push(['d', dTagResult.dTag]);
}
}
if (shouldIncludeClientTag()) {
@ -574,11 +584,29 @@ @@ -574,11 +584,29 @@
}
}
// Extract mentions and add p tags
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
allTags.push(['p', pubkey]);
// Auto-extract tags from content (hashtags, mentions, nostr: links)
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: allTags,
kind: effectiveKind
});
allTags.push(...autoTagsResult.tags);
// For parameterized replaceable events (30000-39999), ensure d-tag exists
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind);
if (dTagResult) {
allTags.push(['d', dTagResult.dTag]);
} else {
// Check if d-tag exists (it should after ensureDTagForParameterizedReplaceable if title exists)
const existingDTag = allTags.find(t => 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 @@ @@ -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 @@ @@ -957,6 +993,14 @@
sig: ''
} as NostrEvent;
})()}
{#if isParameterizedReplaceableKind(effectiveKind)}
{@const dTag = previewEvent.tags.find(t => t[0] === 'd' && t[1])}
{#if dTag}
<div class="d-tag-preview">
<strong>d-tag:</strong> <code>{dTag[1]}</code>
</div>
{/if}
{/if}
<MediaAttachments event={previewEvent} />
<MarkdownRenderer content={previewContent} event={previewEvent} />
{:else}
@ -1607,6 +1651,33 @@ @@ -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;

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

@ -14,7 +14,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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) {

236
src/lib/services/auto-tagging.ts

@ -0,0 +1,236 @@ @@ -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<string>();
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<string[][]> {
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<string[][]> {
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<AutoTaggingResult> {
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 };
}

18
src/lib/services/text-utils.ts

@ -79,3 +79,21 @@ export function stripMarkdown(markdown: string): string { @@ -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
}

32
src/routes/bookmarks/+page.svelte

@ -522,38 +522,6 @@ @@ -522,38 +522,6 @@
{/each}
</div>
{#if totalPages > 1}
<div class="pagination">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{#if totalPages > 1}
<div class="pagination pagination-bottom">
<button

18
src/routes/cache/+page.svelte vendored

@ -305,19 +305,26 @@ @@ -305,19 +305,26 @@
}
}
// Debounce timers for input handlers
let pubkeyFilterTimeout: ReturnType<typeof setTimeout> | null = null;
let eventSearchTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Handle pubkey filter input - decode bech32 to hex
*/
function handlePubkeyFilterInput(value: string) {
selectedPubkey = value;
const timeout = setTimeout(() => {
if (pubkeyFilterTimeout) {
clearTimeout(pubkeyFilterTimeout);
}
pubkeyFilterTimeout = setTimeout(() => {
const hexPubkey = decodePubkeyToHex(value);
if (hexPubkey !== value) {
selectedPubkey = hexPubkey;
}
handleFilterChange();
pubkeyFilterTimeout = null;
}, 500);
return () => clearTimeout(timeout);
}
/**
@ -325,14 +332,17 @@ @@ -325,14 +332,17 @@
*/
function handleEventSearchInput(value: string) {
searchTerm = value;
const timeout = setTimeout(() => {
if (eventSearchTimeout) {
clearTimeout(eventSearchTimeout);
}
eventSearchTimeout = setTimeout(() => {
const hexId = decodeEventIdToHex(value);
if (hexId !== value && hexId) {
searchTerm = hexId;
}
handleFilterChange();
eventSearchTimeout = null;
}, 500);
return () => clearTimeout(timeout);
}
/**

114
src/routes/replaceable/[d_tag]/+page.svelte

@ -6,7 +6,6 @@ @@ -6,7 +6,6 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../../lib/types/kind-lookup.js';
import { goto } from '$app/navigation';
let events = $state<NostrEvent[]>([]);
@ -32,44 +31,109 @@ @@ -32,44 +31,109 @@
const relays = relayManager.getProfileReadRelays();
// Fetch all replaceable events with matching d-tag
// Use range queries which are more efficient than listing all kinds
// Most relays support range queries or we can use multiple filters
const allEvents: NostrEvent[] = [];
// Build list of replaceable kinds to check:
// - Replaceable: 0, 3, and 10000-19999
// - Parameterized replaceable: 30000-39999
const kindsToCheck: number[] = [0, 3]; // Basic replaceable kinds
// Query basic replaceable kinds (0, 3)
const basicKinds = [0, 3];
const basicEvents = await nostrClient.fetchEvents(
[{ kinds: basicKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...basicEvents);
// Add replaceable range (10000-19999)
for (let kind = 10000; kind < 20000; kind++) {
kindsToCheck.push(kind);
}
// Add parameterized replaceable range (30000-39999)
for (let kind = 30000; kind < 40000; kind++) {
kindsToCheck.push(kind);
// Query replaceable range (10000-19999) - use a single filter with all kinds
// If relay doesn't support large kind lists, it will return an error and we skip
try {
const replaceableKinds: number[] = [];
for (let kind = 10000; kind < 20000; kind++) {
replaceableKinds.push(kind);
}
const replaceableEvents = await nostrClient.fetchEvents(
[{ kinds: replaceableKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...replaceableEvents);
} catch (rangeError) {
// If single query fails (relay limit), fall back to smaller batches
const BATCH_SIZE = 1000;
for (let start = 10000; start < 20000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 20000); kind++) {
batchKinds.push(kind);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...batchEvents);
} catch {
// Skip failed batches
}
}
}
for (const kind of kindsToCheck) {
const kindEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], '#d': [dTag], limit: 100 }],
// Query parameterized replaceable range (30000-39999)
try {
const paramReplaceableKinds: number[] = [];
for (let kind = 30000; kind < 40000; kind++) {
paramReplaceableKinds.push(kind);
}
const paramEvents = await nostrClient.fetchEvents(
[{ kinds: paramReplaceableKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// For replaceable events, get the newest version of each (by pubkey)
const eventsByPubkey = new Map<string, NostrEvent>();
for (const event of kindEvents) {
const existing = eventsByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) {
eventsByPubkey.set(event.pubkey, event);
allEvents.push(...paramEvents);
} catch (rangeError) {
// If single query fails, fall back to smaller batches
const BATCH_SIZE = 1000;
for (let start = 30000; start < 40000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 40000); kind++) {
batchKinds.push(kind);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...batchEvents);
} catch {
// Skip failed batches
}
}
}
// For replaceable events, get the newest version of each (by pubkey and kind)
// For parameterized replaceable, get newest by (pubkey, kind, d-tag)
const eventsByKey = new Map<string, NostrEvent>();
for (const event of allEvents) {
// Key is pubkey:kind for replaceable, pubkey:kind:d-tag for parameterized
const isParamReplaceable = event.kind >= 30000 && event.kind < 40000;
const eventDTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = isParamReplaceable
? `${event.pubkey}:${event.kind}:${eventDTag}`
: `${event.pubkey}:${event.kind}`;
allEvents.push(...Array.from(eventsByPubkey.values()));
const existing = eventsByKey.get(key);
if (!existing || event.created_at > existing.created_at) {
eventsByKey.set(key, event);
}
}
// Sort by created_at descending
events = allEvents.sort((a, b) => b.created_at - a.created_at);
events = Array.from(eventsByKey.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading replaceable events:', error);
events = [];

25
src/routes/repos/[naddr]/+page.svelte

@ -1204,31 +1204,6 @@ @@ -1204,31 +1204,6 @@
{/each}
<!-- Pagination controls -->
{#if totalPages > 1}
<div class="pagination">
<button
onclick={() => issuesPage = Math.max(1, issuesPage - 1)}
disabled={issuesPage === 1}
class="pagination-button"
aria-label="Previous page"
>
Previous
</button>
<span class="pagination-info">
Page {issuesPage} of {totalPages} ({filteredIssues.length} {filteredIssues.length === 1 ? 'issue' : 'issues'})
</span>
<button
onclick={() => issuesPage = Math.min(totalPages, issuesPage + 1)}
disabled={issuesPage === totalPages}
class="pagination-button"
aria-label="Next page"
>
Next
</button>
</div>
{/if}
<!-- Pagination controls (bottom) -->
{#if totalPages > 1}
<div class="pagination pagination-bottom">
<button

48
src/routes/topics/[name]/+page.svelte

@ -115,22 +115,24 @@ @@ -115,22 +115,24 @@
try {
const relays = relayManager.getFeedReadRelays();
// Fetch events with matching t-tag (most efficient - uses relay filtering)
// Only fetch events with matching t-tag (most efficient - uses relay filtering)
// This avoids fetching all events and filtering client-side, saving bandwidth
const tTagEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: config.feedLimit }],
relays,
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` }
);
// Also search for hashtags in content (less efficient but catches events without t-tags)
// We'll fetch a larger set and filter, but prioritize t-tag events
// Also search for hashtags in content, but only fetch a smaller sample
// We'll only fetch events that might have the hashtag (limited to reduce bandwidth)
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i');
const allEvents: NostrEvent[] = [...tTagEvents];
// For content-based hashtag search, we need to fetch more events
// But we'll limit this to avoid fetching too much
// For content-based hashtag search, fetch a smaller sample and filter
// This is less efficient but catches events without t-tags
// Limit to 50 events to reduce bandwidth usage
const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: config.feedLimit }],
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 50 }],
relays,
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (content)` }
);
@ -240,40 +242,6 @@ @@ -240,40 +242,6 @@
</button>
</div>
{/if}
{#if totalPages > 1}
<div class="pagination-controls mt-6 flex justify-center items-center gap-4">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) {
currentPage--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
← Previous
</button>
<span class="pagination-info text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
<button
class="pagination-button"
disabled={currentPage >= totalPages}
onclick={() => {
if (currentPage < totalPages) {
currentPage++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
Next →
</button>
</div>
{/if}
{/if}
</div>
</main>

Loading…
Cancel
Save