Enhance event management in App.svelte by implementing pagination and caching for user and all events. Introduce new functions for loading events with timestamp-based pagination, and update UI components to support event expansion and deletion. Refactor event fetching logic in nostr.js to utilize WebSocket REQ envelopes for improved performance. Update default relay settings in constants.js to include local WebSocket endpoint.

This commit is contained in:
2025-10-09 16:14:18 +01:00
parent ade987c9ac
commit ec50afdec0
3 changed files with 887 additions and 92 deletions

View File

@@ -1,6 +1,6 @@
<script>
import LoginModal from './LoginModal.svelte';
import { initializeNostrClient, fetchUserProfile } from './nostr.js';
import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents } from './nostr.js';
let isDarkTheme = false;
let showLoginModal = false;
@@ -18,6 +18,161 @@
let myEvents = [];
let allEvents = [];
let selectedFile = null;
let expandedEvents = new Set();
let isLoadingEvents = false;
let hasMoreEvents = true;
let eventsPerPage = 100;
let oldestEventTimestamp = null; // For timestamp-based pagination
// My Events pagination state
let isLoadingMyEvents = false;
let hasMoreMyEvents = true;
let oldestMyEventTimestamp = null; // For timestamp-based pagination
// Shared event cache system
let eventCache = new Map(); // pubkey -> events[]
let cacheTimestamps = new Map(); // pubkey -> timestamp
let globalEventsCache = []; // All events cache
let globalCacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Kind name mapping based on repository kind definitions
const kindNames = {
0: "ProfileMetadata",
1: "TextNote",
2: "RecommendRelay",
3: "FollowList",
4: "EncryptedDirectMessage",
5: "EventDeletion",
6: "Repost",
7: "Reaction",
8: "BadgeAward",
13: "Seal",
14: "PrivateDirectMessage",
15: "ReadReceipt",
16: "GenericRepost",
40: "ChannelCreation",
41: "ChannelMetadata",
42: "ChannelMessage",
43: "ChannelHideMessage",
44: "ChannelMuteUser",
1021: "Bid",
1022: "BidConfirmation",
1040: "OpenTimestamps",
1059: "GiftWrap",
1060: "GiftWrapWithKind4",
1063: "FileMetadata",
1311: "LiveChatMessage",
1517: "BitcoinBlock",
1808: "LiveStream",
1971: "ProblemTracker",
1984: "Reporting",
1985: "Label",
4550: "CommunityPostApproval",
5000: "JobRequestStart",
5999: "JobRequestEnd",
6000: "JobResultStart",
6999: "JobResultEnd",
7000: "JobFeedback",
9041: "ZapGoal",
9734: "ZapRequest",
9735: "Zap",
9882: "Highlights",
10000: "BlockList",
10001: "PinList",
10002: "RelayListMetadata",
10003: "BookmarkList",
10004: "CommunitiesList",
10005: "PublicChatsList",
10006: "BlockedRelaysList",
10007: "SearchRelaysList",
10015: "InterestsList",
10030: "UserEmojiList",
10050: "DMRelaysList",
10096: "FileStorageServerList",
13004: "JWTBinding",
13194: "NWCWalletServiceInfo",
19999: "ReplaceableEnd",
20000: "EphemeralStart",
21000: "LightningPubRPC",
22242: "ClientAuthentication",
23194: "WalletRequest",
23195: "WalletResponse",
23196: "WalletNotificationNip4",
23197: "WalletNotification",
24133: "NostrConnect",
27235: "HTTPAuth",
29999: "EphemeralEnd",
30000: "FollowSets",
30001: "GenericLists",
30002: "RelaySets",
30003: "BookmarkSets",
30004: "CurationSets",
30008: "ProfileBadges",
30009: "BadgeDefinition",
30015: "InterestSets",
30017: "StallDefinition",
30018: "ProductDefinition",
30019: "MarketplaceUIUX",
30020: "ProductSoldAsAuction",
30023: "LongFormContent",
30024: "DraftLongFormContent",
30030: "EmojiSets"
};
function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
function truncatePubkey(pubkey) {
return pubkey.slice(0, 8) + '...' + pubkey.slice(-8);
}
function truncateContent(content, maxLength = 100) {
if (!content) return '';
return content.length > maxLength ? content.slice(0, maxLength) + '...' : content;
}
function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
} else {
expandedEvents.add(eventId);
}
expandedEvents = expandedEvents; // Trigger reactivity
}
async function deleteEvent(eventId) {
if (!isLoggedIn || (userRole !== 'admin' && userRole !== 'owner')) {
alert('Admin or owner permission required');
return;
}
if (!confirm('Are you sure you want to delete this event?')) {
return;
}
try {
const authHeader = await createNIP98AuthHeader(`/api/events/${eventId}`, 'DELETE');
const response = await fetch(`/api/events/${eventId}`, {
method: 'DELETE',
headers: {
'Authorization': authHeader
}
});
if (!response.ok) {
throw new Error(`Delete failed: ${response.status} ${response.statusText}`);
}
// Remove from local list
allEvents = allEvents.filter(event => event.id !== eventId);
alert('Event deleted successfully');
} catch (error) {
console.error('Delete failed:', error);
alert('Delete failed: ' + error.message);
}
}
// Safely render "about" text: convert double newlines to a single HTML line break
function escapeHtml(str) {
@@ -57,6 +212,124 @@
// Fetch user role for already logged in users
fetchUserRole();
}
// Load persistent app state
loadPersistentState();
}
function savePersistentState() {
if (typeof localStorage === 'undefined') return;
const state = {
selectedTab,
expandedEvents: Array.from(expandedEvents),
eventCache: Object.fromEntries(eventCache),
cacheTimestamps: Object.fromEntries(cacheTimestamps),
globalEventsCache,
globalCacheTimestamp,
hasMoreEvents,
oldestEventTimestamp,
hasMoreMyEvents,
oldestMyEventTimestamp
};
localStorage.setItem('app_state', JSON.stringify(state));
}
function loadPersistentState() {
if (typeof localStorage === 'undefined') return;
try {
const savedState = localStorage.getItem('app_state');
if (savedState) {
const state = JSON.parse(savedState);
// Restore tab state
if (state.selectedTab && baseTabs.some(tab => tab.id === state.selectedTab)) {
selectedTab = state.selectedTab;
}
// Restore expanded events
if (state.expandedEvents) {
expandedEvents = new Set(state.expandedEvents);
}
// Restore cache data
if (state.eventCache) {
eventCache = new Map(Object.entries(state.eventCache));
}
if (state.cacheTimestamps) {
cacheTimestamps = new Map(Object.entries(state.cacheTimestamps));
}
if (state.globalEventsCache) {
globalEventsCache = state.globalEventsCache;
}
if (state.globalCacheTimestamp) {
globalCacheTimestamp = state.globalCacheTimestamp;
}
if (state.hasMoreEvents !== undefined) {
hasMoreEvents = state.hasMoreEvents;
}
if (state.oldestEventTimestamp) {
oldestEventTimestamp = state.oldestEventTimestamp;
}
if (state.hasMoreMyEvents !== undefined) {
hasMoreMyEvents = state.hasMoreMyEvents;
}
if (state.oldestMyEventTimestamp) {
oldestMyEventTimestamp = state.oldestMyEventTimestamp;
}
// Restore events from cache
restoreEventsFromCache();
}
} catch (error) {
console.error('Failed to load persistent state:', error);
}
}
function restoreEventsFromCache() {
// Restore global events cache
if (globalEventsCache.length > 0 && isCacheValid(globalCacheTimestamp)) {
allEvents = globalEventsCache;
}
// Restore user's events from cache
if (userPubkey && eventCache.has(userPubkey) && isCacheValid(cacheTimestamps.get(userPubkey))) {
myEvents = eventCache.get(userPubkey);
}
}
function isCacheValid(timestamp) {
if (!timestamp) return false;
return Date.now() - timestamp < CACHE_DURATION;
}
function updateCache(pubkey, events) {
eventCache.set(pubkey, events);
cacheTimestamps.set(pubkey, Date.now());
savePersistentState();
}
function updateGlobalCache(events) {
globalEventsCache = events;
globalCacheTimestamp = Date.now();
savePersistentState();
}
function clearCache() {
eventCache.clear();
cacheTimestamps.clear();
globalEventsCache = [];
globalCacheTimestamp = 0;
savePersistentState();
}
const baseTabs = [
@@ -82,6 +355,7 @@
function selectTab(tabId) {
selectedTab = tabId;
savePersistentState();
}
function toggleTheme() {
@@ -129,6 +403,13 @@
userSigner = null;
showSettingsDrawer = false;
// Clear events
myEvents = [];
allEvents = [];
// Clear cache
clearCache();
// Clear stored authentication
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('nostr_auth_method');
@@ -344,67 +625,158 @@
}
// Events loading functionality
async function loadMyEvents() {
async function loadMyEvents(reset = false) {
if (!isLoggedIn) {
alert('Please log in first');
return;
}
if (isLoadingMyEvents) return;
// Check cache first for initial load
if (reset && eventCache.has(userPubkey) && isCacheValid(cacheTimestamps.get(userPubkey))) {
myEvents = eventCache.get(userPubkey);
// Set oldest timestamp from cached events
if (myEvents.length > 0) {
oldestMyEventTimestamp = Math.min(...myEvents.map(e => e.created_at));
}
return;
}
isLoadingMyEvents = true;
try {
const authHeader = await createNIP98AuthHeader('/api/events/mine', 'GET');
const response = await fetch('/api/events/mine', {
method: 'GET',
headers: {
'Authorization': authHeader
}
// Use WebSocket REQ to fetch user events with timestamp-based pagination
const events = await fetchUserEvents(userPubkey, {
limit: eventsPerPage,
until: reset ? null : oldestMyEventTimestamp
});
if (!response.ok) {
throw new Error(`Failed to load events: ${response.status} ${response.statusText}`);
if (reset) {
myEvents = events;
// Update cache
updateCache(userPubkey, events);
} else {
myEvents = [...myEvents, ...events];
// Update cache with all events
updateCache(userPubkey, myEvents);
}
const data = await response.json();
myEvents = data.events || [];
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(...events.map(e => e.created_at));
if (!oldestMyEventTimestamp || oldestInBatch < oldestMyEventTimestamp) {
oldestMyEventTimestamp = oldestInBatch;
}
}
hasMoreMyEvents = events.length === eventsPerPage;
} catch (error) {
console.error('Failed to load events:', error);
alert('Failed to load events: ' + error.message);
} finally {
isLoadingMyEvents = false;
}
}
async function loadAllEvents() {
async function loadMoreMyEvents() {
if (!isLoadingMyEvents && hasMoreMyEvents) {
await loadMyEvents(false);
}
}
function handleMyEventsScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Load more when 50% of content is out of view below
if (scrollPercentage > 0.5 && !isLoadingMyEvents && hasMoreMyEvents) {
loadMoreMyEvents();
}
}
async function loadAllEvents(reset = false) {
if (!isLoggedIn || (userRole !== 'write' && userRole !== 'admin' && userRole !== 'owner')) {
alert('Write, admin, or owner permission required');
return;
}
if (isLoadingEvents) return;
// Check cache first for initial load
if (reset && globalEventsCache.length > 0 && isCacheValid(globalCacheTimestamp)) {
allEvents = globalEventsCache;
// Set oldest timestamp from cached events
if (allEvents.length > 0) {
oldestEventTimestamp = Math.min(...allEvents.map(e => e.created_at));
}
return;
}
isLoadingEvents = true;
try {
const authHeader = await createNIP98AuthHeader('/api/export', 'GET');
const response = await fetch('/api/export', {
method: 'GET',
headers: {
'Authorization': authHeader
}
// Use WebSocket REQ to fetch events with timestamp-based pagination
const events = await fetchAllEvents({
limit: eventsPerPage,
until: reset ? null : oldestEventTimestamp
});
if (!response.ok) {
throw new Error(`Failed to load events: ${response.status} ${response.statusText}`);
if (reset) {
allEvents = events;
// Update global cache
updateGlobalCache(events);
} else {
allEvents = [...allEvents, ...events];
// Update global cache with all events
updateGlobalCache(allEvents);
}
const text = await response.text();
const lines = text.trim().split('\n');
allEvents = lines.map(line => {
try {
return JSON.parse(line);
} catch (e) {
return null;
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(...events.map(e => e.created_at));
if (!oldestEventTimestamp || oldestInBatch < oldestEventTimestamp) {
oldestEventTimestamp = oldestInBatch;
}
}).filter(event => event !== null);
}
hasMoreEvents = events.length === eventsPerPage;
} catch (error) {
console.error('Failed to load events:', error);
alert('Failed to load events: ' + error.message);
} finally {
isLoadingEvents = false;
}
}
async function loadMoreEvents() {
if (!isLoadingEvents && hasMoreEvents) {
await loadAllEvents(false);
}
}
function handleScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Load more when 50% of content is out of view below
if (scrollPercentage > 0.5 && !isLoadingEvents && hasMoreEvents) {
loadMoreEvents();
}
}
// Load initial events when allevents tab is selected
$: if (selectedTab === 'allevents' && isLoggedIn && (userRole === 'write' || userRole === 'admin' || userRole === 'owner') && allEvents.length === 0) {
loadAllEvents(true);
}
// Load user events when myevents tab is selected
$: if (selectedTab === 'myevents' && isLoggedIn && userPubkey && myEvents.length === 0) {
loadMyEvents(true);
}
// NIP-98 authentication helper
async function createNIP98AuthHeader(url, method) {
if (!isLoggedIn || !userPubkey) {
@@ -573,29 +945,64 @@
{/if}
</div>
{:else if selectedTab === 'myevents'}
<div class="events-view">
<h2>My Events</h2>
<div class="allevents-container">
{#if isLoggedIn}
<div class="events-section">
<p>View and manage your personal events.</p>
<button class="refresh-btn" on:click={loadMyEvents}>
<div class="allevents-header">
<button class="refresh-btn" on:click={() => loadMyEvents(true)} disabled={isLoadingMyEvents}>
🔄 Refresh Events
</button>
<div class="events-list">
{#if myEvents.length > 0}
{#each myEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">Kind {event.kind}</span>
<span class="event-time">{new Date(event.created_at * 1000).toLocaleString()}</span>
</div>
<div class="allevents-list" on:scroll={handleMyEventsScroll}>
{#if myEvents.length > 0}
{#each myEvents as event}
<div class="allevents-event-item" class:expanded={expandedEvents.has(event.id)}>
<div class="allevents-event-row" on:click={() => toggleEventExpansion(event.id)} on:keydown={(e) => e.key === 'Enter' && toggleEventExpansion(event.id)} role="button" tabindex="0">
<div class="allevents-event-avatar">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content">{event.content}</div>
<div class="allevents-event-info">
<div class="allevents-event-author">
{truncatePubkey(event.pubkey)}
</div>
<div class="allevents-event-kind">
<span class="kind-number">{event.kind}</span>
<span class="kind-name">{getKindName(event.kind)}</span>
</div>
</div>
<div class="allevents-event-content">
{truncateContent(event.content)}
</div>
{#if userRole === 'admin' || userRole === 'owner'}
<button class="delete-btn" on:click|stopPropagation={() => deleteEvent(event.id)}>
🗑️
</button>
{/if}
</div>
{/each}
{:else}
{#if expandedEvents.has(event.id)}
<div class="allevents-event-details">
<pre class="event-json">{JSON.stringify(event, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{:else if !isLoadingMyEvents}
<div class="no-events">
<p>No events found.</p>
{/if}
</div>
</div>
{/if}
{#if isLoadingMyEvents}
<div class="loading-events">
<div class="loading-spinner"></div>
<p>Loading events...</p>
</div>
{/if}
{#if !hasMoreMyEvents && myEvents.length > 0}
<div class="end-of-events">
<p>No more events to load.</p>
</div>
{/if}
</div>
{:else}
<div class="login-prompt">
@@ -605,29 +1012,64 @@
{/if}
</div>
{:else if selectedTab === 'allevents'}
<div class="events-view">
<h2>All Events</h2>
<div class="allevents-container">
{#if isLoggedIn && (userRole === 'write' || userRole === 'admin' || userRole === 'owner')}
<div class="events-section">
<p>View all events in the database.</p>
<button class="refresh-btn" on:click={loadAllEvents}>
<div class="allevents-header">
<button class="refresh-btn" on:click={() => loadAllEvents(true)} disabled={isLoadingEvents}>
🔄 Refresh Events
</button>
<div class="events-list">
{#if allEvents.length > 0}
{#each allEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">Kind {event.kind}</span>
<span class="event-time">{new Date(event.created_at * 1000).toLocaleString()}</span>
</div>
<div class="allevents-list" on:scroll={handleScroll}>
{#if allEvents.length > 0}
{#each allEvents as event}
<div class="allevents-event-item" class:expanded={expandedEvents.has(event.id)}>
<div class="allevents-event-row" on:click={() => toggleEventExpansion(event.id)} on:keydown={(e) => e.key === 'Enter' && toggleEventExpansion(event.id)} role="button" tabindex="0">
<div class="allevents-event-avatar">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content">{event.content}</div>
<div class="allevents-event-info">
<div class="allevents-event-author">
{truncatePubkey(event.pubkey)}
</div>
<div class="allevents-event-kind">
<span class="kind-number">{event.kind}</span>
<span class="kind-name">{getKindName(event.kind)}</span>
</div>
</div>
<div class="allevents-event-content">
{truncateContent(event.content)}
</div>
{#if userRole === 'admin' || userRole === 'owner'}
<button class="delete-btn" on:click|stopPropagation={() => deleteEvent(event.id)}>
🗑️
</button>
{/if}
</div>
{/each}
{:else}
{#if expandedEvents.has(event.id)}
<div class="allevents-event-details">
<pre class="event-json">{JSON.stringify(event, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{:else if !isLoadingEvents}
<div class="no-events">
<p>No events found.</p>
{/if}
</div>
</div>
{/if}
{#if isLoadingEvents}
<div class="loading-events">
<div class="loading-spinner"></div>
<p>Loading events...</p>
</div>
{/if}
{#if !hasMoreEvents && allEvents.length > 0}
<div class="end-of-events">
<p>No more events to load.</p>
</div>
{/if}
</div>
{:else if isLoggedIn}
<div class="permission-denied">
@@ -1281,21 +1723,21 @@
word-break: break-all;
}
/* Export/Import/Events Views */
.export-view, .import-view, .events-view {
/* Export/Import Views */
.export-view, .import-view {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.export-view h2, .import-view h2, .events-view h2 {
.export-view h2, .import-view h2 {
margin: 0 0 2rem 0;
color: var(--text-color);
font-size: 1.5rem;
font-weight: 600;
}
.export-section, .import-section, .events-section {
.export-section, .import-section {
background: var(--header-bg);
padding: 1.5rem;
border-radius: 8px;
@@ -1309,7 +1751,7 @@
font-weight: 500;
}
.export-section p, .import-section p, .events-section p {
.export-section p, .import-section p {
margin: 0 0 1rem 0;
color: var(--text-color);
opacity: 0.8;
@@ -1378,44 +1820,210 @@
font-weight: 500;
}
.events-list {
margin-top: 1rem;
}
.event-item {
/* All Events Container */
.allevents-container {
position: fixed;
top: 3em;
left: 200px;
right: 0;
bottom: 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.event-header {
color: var(--text-color);
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-direction: column;
overflow: hidden;
}
.event-kind {
.allevents-header {
padding: 1rem;
background: var(--header-bg);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.allevents-list {
flex: 1;
overflow-y: auto;
padding: 0;
}
.allevents-event-item {
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.allevents-event-item:hover {
background: var(--button-hover-bg);
}
.allevents-event-item.expanded {
background: var(--button-hover-bg);
}
.allevents-event-row {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
gap: 0.75rem;
min-height: 3rem;
}
.allevents-event-avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
.allevents-event-info {
flex-shrink: 0;
width: 12rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.allevents-event-author {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.8;
}
.allevents-event-kind {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-number {
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 500;
font-family: monospace;
}
.kind-name {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.7;
font-weight: 500;
}
.event-time {
.allevents-event-content {
flex: 1;
color: var(--text-color);
opacity: 0.7;
font-size: 0.9rem;
line-height: 1.3;
word-break: break-word;
padding: 0 0.5rem;
}
.delete-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
font-size: 0.9rem;
}
.event-content {
color: var(--text-color);
.delete-btn:hover {
background: var(--warning);
color: white;
}
.allevents-event-details {
border-top: 1px solid var(--border-color);
background: var(--header-bg);
padding: 1rem;
}
.event-json {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 1rem;
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text-color);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.no-events {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.7;
}
.no-events p {
margin: 0;
font-size: 1rem;
}
.loading-events {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.7;
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--border-color);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-events p {
margin: 0;
font-size: 0.9rem;
}
.end-of-events {
padding: 1rem;
text-align: center;
color: var(--text-color);
opacity: 0.5;
font-size: 0.8rem;
border-top: 1px solid var(--border-color);
}
.end-of-events p {
margin: 0;
}
@media (max-width: 640px) {
@@ -1433,12 +2041,32 @@
.profile-username { font-size: 1rem; }
.profile-nip05-inline { font-size: 0.8rem; }
.export-view, .import-view, .events-view {
.export-view, .import-view {
padding: 1rem;
}
.export-section, .import-section, .events-section {
.export-section, .import-section {
padding: 1rem;
}
.allevents-container {
left: 160px;
}
.allevents-event-info {
width: 8rem;
}
.allevents-event-author {
font-size: 0.7rem;
}
.kind-name {
font-size: 0.7rem;
}
.allevents-event-content {
font-size: 0.8rem;
}
}
</style>

View File

@@ -1,5 +1,8 @@
// Default Nostr relays for searching
export const DEFAULT_RELAYS = [
// Use the local relay WebSocket endpoint
`wss://${window.location.host}/ws`,
// Fallback to external relays if local fails
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",

View File

@@ -94,6 +94,12 @@ class NostrClient {
console.log(
`End of stored events for subscription ${subscriptionId} from ${relayUrl}`,
);
// Dispatch EOSE event for fetchEvents function
if (this.subscriptions.has(subscriptionId)) {
window.dispatchEvent(new CustomEvent('nostr-eose', {
detail: { subscriptionId, relayUrl }
}));
}
} else if (type === "NOTICE") {
console.warn(`Notice from ${relayUrl}:`, subscriptionId);
} else {
@@ -353,6 +359,164 @@ export async function fetchUserProfile(pubkey) {
});
}
// Fetch events using WebSocket REQ envelopes
export async function fetchEvents(filters, options = {}) {
return new Promise(async (resolve, reject) => {
console.log(`Starting event fetch with filters:`, filters);
let resolved = false;
let events = [];
let debounceTimer = null;
let overallTimer = null;
let subscriptionId = null;
let eoseReceived = false;
const {
timeout = 30000,
debounceDelay = 1000,
limit = null
} = options;
function cleanup() {
if (subscriptionId) {
try {
nostrClient.unsubscribe(subscriptionId);
} catch {}
}
if (debounceTimer) clearTimeout(debounceTimer);
if (overallTimer) clearTimeout(overallTimer);
}
// Set overall timeout
overallTimer = setTimeout(() => {
if (!resolved) {
console.log("Event fetch timeout reached");
if (events.length > 0) {
resolve(events);
} else {
reject(new Error("Event fetch timeout"));
}
resolved = true;
}
cleanup();
}, timeout);
// Subscribe to events
setTimeout(() => {
console.log("Starting event subscription...");
// Add limit to filters if specified
const requestFilters = { ...filters };
if (limit) {
requestFilters.limit = limit;
}
subscriptionId = nostrClient.subscribe(
requestFilters,
(event) => {
if (!event) return;
console.log("Event received:", event);
// Check if we already have this event (deduplication)
const existingEvent = events.find(e => e.id === event.id);
if (!existingEvent) {
events.push(event);
}
// If we have a limit and reached it, resolve immediately
if (limit && events.length >= limit) {
if (!resolved) {
resolve(events.slice(0, limit));
resolved = true;
}
cleanup();
return;
}
// Debounce to wait for more events
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (eoseReceived && !resolved) {
resolve(events);
resolved = true;
cleanup();
}
}, debounceDelay);
},
);
// Listen for EOSE events
const handleEOSE = (event) => {
if (event.detail.subscriptionId === subscriptionId) {
console.log("EOSE received for subscription", subscriptionId);
eoseReceived = true;
// If we haven't resolved yet and have events, resolve now
if (!resolved && events.length > 0) {
resolve(events);
resolved = true;
cleanup();
}
}
};
// Add EOSE listener
window.addEventListener('nostr-eose', handleEOSE);
// Cleanup EOSE listener
const originalCleanup = cleanup;
cleanup = () => {
window.removeEventListener('nostr-eose', handleEOSE);
originalCleanup();
};
}, 1000);
});
}
// Fetch all events with timestamp-based pagination
export async function fetchAllEvents(options = {}) {
const {
limit = 10,
since = null,
until = null
} = options;
const filters = {};
if (since) filters.since = since;
if (until) filters.until = until;
const events = await fetchEvents(filters, {
limit: limit,
timeout: 30000
});
return events;
}
// Fetch user's events with timestamp-based pagination
export async function fetchUserEvents(pubkey, options = {}) {
const {
limit = 10,
since = null,
until = null
} = options;
const filters = {
authors: [pubkey]
};
if (since) filters.since = since;
if (until) filters.until = until;
const events = await fetchEvents(filters, {
limit: limit,
timeout: 30000
});
return events;
}
// Initialize client connection
export async function initializeNostrClient() {
await nostrClient.connect();