Browse Source

Update IndexedDB Handling and Enhance App Functionality

- Modified the NostrClient's publish method to allow for specific relay usage and added event storage in IndexedDB.
- Introduced a debug function for IndexedDB to inspect stored events and their counts by kind.
- Updated the App.svelte component to expose the debug function globally for easier access during development.
- Enhanced the CSS styles for better user feedback on export results and status messages.
- Incremented the IndexedDB version to accommodate new indexes and improve event storage management.
- Updated the version number to v0.19.1.
main
mleku 3 months ago
parent
commit
5452da6ecc
No known key found for this signature in database
  1. 7
      .gitignore
  2. 6
      app/web/dist/bundle.css
  3. 28
      app/web/dist/bundle.js
  4. 2
      app/web/dist/bundle.js.map
  5. 533
      app/web/src/App.svelte
  6. 320
      app/web/src/nostr.js
  7. 2
      pkg/version/version

7
.gitignore vendored

@ -99,6 +99,13 @@ cmd/benchmark/data
!.github/** !.github/**
!.github/workflows/** !.github/workflows/**
!app/web/dist/** !app/web/dist/**
!app/web/dist/*.js
!app/web/dist/*.js.map
!app/web/dist/*.css
!app/web/dist/*.html
!app/web/dist/*.ico
!app/web/dist/*.png
!app/web/dist/*.svg
# ...even if they are in subdirectories # ...even if they are in subdirectories
!*/ !*/
/blocklist.json /blocklist.json

6
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

28
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

533
app/web/src/App.svelte

@ -9,6 +9,9 @@
searchEvents, searchEvents,
fetchEventById, fetchEventById,
fetchDeleteEventsByTarget, fetchDeleteEventsByTarget,
queryEvents,
queryEventsFromDB,
debugIndexedDB,
nostrClient, nostrClient,
NostrClient, NostrClient,
Nip07Signer, Nip07Signer,
@ -16,6 +19,11 @@
} from "./nostr.js"; } from "./nostr.js";
import { publishEventWithAuth } from "./websocket-auth.js"; import { publishEventWithAuth } from "./websocket-auth.js";
// Expose debug function globally for console access
if (typeof window !== "undefined") {
window.debugIndexedDB = debugIndexedDB;
}
let isDarkTheme = false; let isDarkTheme = false;
let showLoginModal = false; let showLoginModal = false;
let isLoggedIn = false; let isLoggedIn = false;
@ -25,7 +33,7 @@
let userRole = ""; let userRole = "";
let userSigner = null; let userSigner = null;
let showSettingsDrawer = false; let showSettingsDrawer = false;
let selectedTab = "export"; let selectedTab = localStorage.getItem("selectedTab") || "export";
let isSearchMode = false; let isSearchMode = false;
let searchQuery = ""; let searchQuery = "";
let searchTabs = []; let searchTabs = [];
@ -86,47 +94,93 @@
let recoveryNewestTimestamp = null; let recoveryNewestTimestamp = null;
// Replaceable kinds for the recovery dropdown // Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
const replaceableKinds = [ const replaceableKinds = [
{ value: 0, label: "Profile Metadata (0)" }, // Basic replaceable kinds (0, 3)
{ value: 3, label: "Follow List (3)" }, { value: 0, label: "User Metadata (0)" },
{ value: 10000, label: "Relay List Metadata (10000)" }, { value: 3, label: "Follows (3)" },
{ value: 10001, label: "Mute List (10001)" },
{ value: 10002, label: "Pin List (10002)" }, // Replaceable range 10000-19999
{ value: 10003, label: "Bookmark List (10003)" }, { value: 10000, label: "Mute list (10000)" },
{ value: 10004, label: "Communities List (10004)" }, { value: 10001, label: "Pin list (10001)" },
{ value: 10005, label: "Public Chats List (10005)" }, { value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10006, label: "Blocked Relays List (10006)" }, { value: 10003, label: "Bookmark list (10003)" },
{ value: 10007, label: "Search Relays List (10007)" }, { value: 10004, label: "Communities list (10004)" },
{ value: 10015, label: "Interests List (10015)" }, { value: 10005, label: "Public chats list (10005)" },
{ value: 10030, label: "Emoji Sets (10030)" }, { value: 10006, label: "Blocked relays list (10006)" },
{ value: 30000, label: "Categorized People List (30000)" }, { value: 10007, label: "Search relays list (10007)" },
{ value: 30001, label: "Categorized Bookmark List (30001)" }, { value: 10009, label: "User groups (10009)" },
{ value: 30002, label: "Relay Sets (30002)" }, { value: 10012, label: "Favorite relays list (10012)" },
{ value: 30003, label: "Bookmark Sets (30003)" }, { value: 10013, label: "Private event relay list (10013)" },
{ value: 30004, label: "Curation Sets (30004)" }, { value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" }, { value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" }, { value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest Sets (30015)" }, { value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Stall Definition (30017)" }, { value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Product Definition (30018)" }, { value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" }, { value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product Sold as Auction (30020)" }, { value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Article (30023)" }, { value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" }, { value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji Sets (30030)" }, { value: 30030, label: "Emoji sets (30030)" },
{ value: 30078, label: "Application Specific Data (30078)" }, { value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" }, { value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" }, { value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" }, { value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" }, { value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 31922, label: "Date-based Calendar Event (31922)" }, { value: 30617, label: "Repository announcements (30617)" },
{ value: 31923, label: "Time-based Calendar Event (31923)" }, { value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" }, { value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" }, { value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler Recommendation (31989)" }, { value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler Information (31990)" }, { value: 31990, label: "Handler information (31990)" },
{ value: 32123, label: "WaveLake Track (32123)" }, { value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" }, { value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
]; ];
// Kind name mapping based on NIP specification // Kind name mapping based on NIP specification
@ -667,7 +721,16 @@
const kindToUse = recoveryCustomKind const kindToUse = recoveryCustomKind
? parseInt(recoveryCustomKind) ? parseInt(recoveryCustomKind)
: recoverySelectedKind; : recoverySelectedKind;
if (!kindToUse || !isLoggedIn) return;
if (kindToUse === null || kindToUse === undefined || isNaN(kindToUse)) {
console.log("No valid kind to load, kindToUse:", kindToUse);
return;
}
if (!isLoggedIn) {
console.log("Not logged in, cannot load recovery events");
return;
}
console.log( console.log(
"Loading recovery events for kind:", "Loading recovery events for kind:",
@ -679,18 +742,26 @@
try { try {
// Fetch multiple versions using limit parameter // Fetch multiple versions using limit parameter
// For replaceable events, limit > 1 returns multiple versions // For replaceable events, limit > 1 returns multiple versions
const filters = { const filters = [
{
kinds: [kindToUse], kinds: [kindToUse],
authors: [userPubkey], authors: [userPubkey],
limit: 100, // Get up to 100 versions limit: 100, // Get up to 100 versions
}; },
];
if (recoveryOldestTimestamp) { if (recoveryOldestTimestamp) {
filters.until = recoveryOldestTimestamp; filters[0].until = recoveryOldestTimestamp;
} }
console.log("Recovery filters:", filters); console.log("Recovery filters:", filters);
const events = await fetchAllEvents(filters);
// Use queryEvents which checks IndexedDB cache first, then relay
const events = await queryEvents(filters, {
timeout: 30000,
cacheFirst: true, // Check cache first
});
console.log("Recovery events received:", events.length); console.log("Recovery events received:", events.length);
console.log( console.log(
"Recovery events kinds:", "Recovery events kinds:",
@ -721,46 +792,73 @@
} }
async function repostEvent(event) { async function repostEvent(event) {
if (!isLoggedIn || !userSigner) { if (!confirm("Are you sure you want to repost this event?")) {
alert("Please log in to repost events");
return; return;
} }
try { try {
// Create a new event with the same content but current timestamp const localRelayUrl = `wss://${window.location.host}/`;
const newEvent = { console.log(
kind: event.kind, "Reposting event to local relay:",
content: event.content, localRelayUrl,
tags: event.tags, event,
created_at: Math.floor(Date.now() / 1000), );
pubkey: userPubkey,
};
// Sign and publish the event // Create a new event with updated timestamp
const result = await publishEventWithAuth( const newEvent = { ...event };
DEFAULT_RELAYS[0], newEvent.created_at = Math.floor(Date.now() / 1000);
newEvent, newEvent.id = ""; // Clear the old ID so it gets recalculated
userSigner, newEvent.sig = ""; // Clear the old signature
userPubkey,
// For addressable events, ensure the d tag matches
if (event.kind >= 30000 && event.kind <= 39999) {
const dTag = event.tags.find((tag) => tag[0] === "d");
if (dTag) {
newEvent.tags = newEvent.tags.filter(
(tag) => tag[0] !== "d",
); );
newEvent.tags.push(dTag);
}
}
if (result.success) { // Sign the event before publishing
if (userSigner) {
const signedEvent = await userSigner.sign(newEvent);
console.log("Signed event for repost:", signedEvent);
const result = await nostrClient.publish(signedEvent, [
localRelayUrl,
]);
console.log("Repost publish result:", result);
if (result.success && result.okCount > 0) {
alert("Event reposted successfully!"); alert("Event reposted successfully!");
// Reload the recovery events to show the new current version recoveryHasMore = false; // Reset to allow reloading
recoveryEvents = []; await loadRecoveryEvents(); // Reload the events to show the new version
recoveryOldestTimestamp = null;
await loadRecoveryEvents();
} else { } else {
alert("Failed to repost event"); alert("Failed to repost event. Check console for details.");
}
} else {
alert("No signer available. Please log in.");
} }
} catch (error) { } catch (error) {
console.error("Failed to repost event:", error); console.error("Error reposting event:", error);
alert("Failed to repost event: " + error.message); alert("Error reposting event: " + error.message);
} }
} }
function selectRecoveryKind(kind) { function selectRecoveryKind() {
recoverySelectedKind = kind; console.log(
"selectRecoveryKind called, recoverySelectedKind:",
recoverySelectedKind,
);
if (
recoverySelectedKind === null ||
recoverySelectedKind === undefined
) {
console.log("No kind selected, skipping load");
return;
}
recoveryCustomKind = ""; // Clear custom kind when selecting from dropdown recoveryCustomKind = ""; // Clear custom kind when selecting from dropdown
recoveryEvents = []; recoveryEvents = [];
recoveryOldestTimestamp = null; recoveryOldestTimestamp = null;
@ -769,7 +867,13 @@
} }
function handleCustomKindInput() { function handleCustomKindInput() {
if (recoveryCustomKind) { console.log(
"handleCustomKindInput called, recoveryCustomKind:",
recoveryCustomKind,
);
// Check if a valid number was entered (including 0)
const kindNum = parseInt(recoveryCustomKind);
if (recoveryCustomKind !== "" && !isNaN(kindNum) && kindNum >= 0) {
recoverySelectedKind = null; // Clear dropdown selection when using custom recoverySelectedKind = null; // Clear dropdown selection when using custom
recoveryEvents = []; recoveryEvents = [];
recoveryOldestTimestamp = null; recoveryOldestTimestamp = null;
@ -2181,6 +2285,11 @@
alert("Error publishing event: " + error.message); alert("Error publishing event: " + error.message);
} }
} }
// Persist selected tab to local storage
$: {
localStorage.setItem("selectedTab", selectedTab);
}
</script> </script>
<!-- Header --> <!-- Header -->
@ -2270,7 +2379,7 @@
{#if selectedTab === "export"} {#if selectedTab === "export"}
{#if isLoggedIn} {#if isLoggedIn}
<div class="export-section"> <div class="export-section">
<h2>Export My Events</h2> <h3>Export My Events</h3>
<p>Download your personal events as a JSONL file.</p> <p>Download your personal events as a JSONL file.</p>
<button class="export-btn" on:click={exportMyEvents}> <button class="export-btn" on:click={exportMyEvents}>
📤 Export My Events 📤 Export My Events
@ -2297,12 +2406,13 @@
</div> </div>
{/if} {/if}
{:else if selectedTab === "import"} {:else if selectedTab === "import"}
<div class="import-view"> <div class="import-section">
{#if isLoggedIn && (userRole === "admin" || userRole === "owner")} {#if isLoggedIn && (userRole === "admin" || userRole === "owner")}
<h2>Import Events</h2> <h3>Import Events</h3>
<p> <p>
Upload a JSONL file to import events into the database. Upload a JSONL file to import events into the database.
</p> </p>
<div class="recovery-controls-card">
<input <input
type="file" type="file"
id="import-file" id="import-file"
@ -2314,18 +2424,23 @@
on:click={importEvents} on:click={importEvents}
disabled={!selectedFile} disabled={!selectedFile}
> >
📥 Import Events Import Events
</button> </button>
</div>
{:else if isLoggedIn} {:else if isLoggedIn}
<div class="permission-denied"> <div class="permission-denied">
<p> <h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
❌ Admin or owner permission required for import ❌ Admin or owner permission required for import
functionality. functionality.
</p> </p>
</div> </div>
{:else} {:else}
<div class="login-prompt"> <div class="login-prompt">
<p>Please log in to access import functionality.</p> <h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
Please log in to access import functionality.
</p>
<button class="login-btn" on:click={openLoginModal} <button class="login-btn" on:click={openLoginModal}
>Log In</button >Log In</button
> >
@ -2785,25 +2900,28 @@
</div> </div>
{:else if selectedTab === "recovery"} {:else if selectedTab === "recovery"}
<div class="recovery-tab"> <div class="recovery-tab">
<div class="recovery-header"> <div>
<h2>🔄 Event Recovery</h2> <h3>Event Recovery</h3>
<p>Search and recover old versions of replaceable events</p> <p>Search and recover old versions of replaceable events</p>
</div> </div>
<div class="recovery-controls-card">
<div class="recovery-controls"> <div class="recovery-controls">
<div class="kind-selector"> <div class="kind-selector">
<label for="recovery-kind">Select Event Kind:</label> <label for="recovery-kind">Select Event Kind:</label
>
<select <select
id="recovery-kind" id="recovery-kind"
bind:value={recoverySelectedKind} bind:value={recoverySelectedKind}
on:change={() => on:change={selectRecoveryKind}
selectRecoveryKind(recoverySelectedKind)}
> >
<option value={null} <option value={null}
>Choose a replaceable kind...</option >Choose a replaceable kind...</option
> >
{#each replaceableKinds as kind} {#each replaceableKinds as kind}
<option value={kind.value}>{kind.label}</option> <option value={kind.value}
>{kind.label}</option
>
{/each} {/each}
</select> </select>
</div> </div>
@ -2822,8 +2940,9 @@
/> />
</div> </div>
</div> </div>
</div>
{#if recoverySelectedKind || recoveryCustomKind} {#if (recoverySelectedKind !== null && recoverySelectedKind !== undefined && recoverySelectedKind >= 0) || (recoveryCustomKind !== "" && parseInt(recoveryCustomKind) >= 0)}
<div class="recovery-results"> <div class="recovery-results">
{#if isLoadingRecovery} {#if isLoadingRecovery}
<div class="loading">Loading events...</div> <div class="loading">Loading events...</div>
@ -2840,14 +2959,18 @@
class:old-version={!isCurrent} class:old-version={!isCurrent}
> >
<div class="event-header"> <div class="event-header">
<span class="event-kind" <div class="event-header-left">
>Kind {event.kind}</span <span class="event-kind">
{#if isCurrent}
Current Version{/if}</span
> >
<span class="event-timestamp"> <span class="event-timestamp">
{new Date( {new Date(
event.created_at * 1000, event.created_at * 1000,
).toLocaleString()} ).toLocaleString()}
</span> </span>
</div>
<div class="event-header-actions">
{#if !isCurrent} {#if !isCurrent}
<button <button
class="repost-button" class="repost-button"
@ -2857,59 +2980,29 @@
🔄 Repost 🔄 Repost
</button> </button>
{/if} {/if}
<button
class="copy-json-btn"
on:click|stopPropagation={(
e,
) =>
copyEventToClipboard(
event,
e,
)}
>
📋 Copy JSON
</button>
</div>
</div> </div>
<div class="event-content"> <div class="event-content">
{#if event.kind === 0} <pre
<!-- Profile metadata --> class="event-json">{JSON.stringify(
{#if event.content} event,
<div
class="profile-content"
>
<pre>{JSON.stringify(
JSON.parse(
event.content,
),
null, null,
2, 2,
)}</pre> )}</pre>
</div> </div>
{/if}
{:else if event.kind === 3}
<!-- Follow list -->
<div class="follow-list">
<p>
Follows {event.tags.filter(
(t) => t[0] === "p",
).length} people
</p>
<div class="follow-tags">
{#each event.tags.filter((t) => t[0] === "p") as tag}
<span
class="pubkey-tag"
>{tag[1].substring(
0,
8,
)}...</span
>
{/each}
</div>
</div>
{:else}
<!-- Generic content -->
<div class="generic-content">
<pre>{event.content}</pre>
</div>
{/if}
</div>
<div class="event-tags">
{#each event.tags as tag}
<span class="tag"
>{tag[0]}: {tag[1]}</span
>
{/each}
</div>
</div> </div>
{/each} {/each}
</div> </div>
@ -3897,6 +3990,7 @@
width: 2.5em; width: 2.5em;
height: 2.5em; height: 2.5em;
object-fit: cover; object-fit: cover;
border-radius: 50%;
} }
.user-avatar-placeholder { .user-avatar-placeholder {
@ -4108,9 +4202,9 @@
padding: 1em; padding: 1em;
max-width: 32em; max-width: 32em;
margin: 0; margin: 0;
background: var(--header-bg); background: transparent;
color: var(--text-color); color: var(--text-color);
border-radius: 8px; border-radius: 0;
} }
/* Managed ACL View */ /* Managed ACL View */
@ -4195,7 +4289,7 @@
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
} }
.import-view h2 { .import-view h3 {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
color: var(--text-color); color: var(--text-color);
font-size: 1.5rem; font-size: 1.5rem;
@ -4204,7 +4298,7 @@
.export-section, .export-section,
.import-section { .import-section {
background: var(--header-bg); background: transparent;
padding: 1em; padding: 1em;
border-radius: 8px; border-radius: 8px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -4213,18 +4307,16 @@
.export-section h3, .export-section h3,
.import-section h3 { .import-section h3 {
margin: 0 0 1rem 0; margin: 0 0 10px 0;
color: var(--text-color); color: var(--text-color);
font-size: 1.2rem;
font-weight: 500;
} }
.export-section p, .export-section p,
.import-section p { .import-section p {
margin: 0 0 1rem 0; margin: 0;
color: var(--text-color); color: var(--text-color);
opacity: 0.8; opacity: 0.7;
line-height: 1.5; padding: 0.5em;
} }
.events-view-buttons { .events-view-buttons {
@ -4250,6 +4342,7 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
height: 2em; height: 2em;
margin: 1em;
} }
.export-btn:hover, .export-btn:hover,
@ -4579,21 +4672,16 @@
} }
.copy-json-btn { .copy-json-btn {
position: absolute; background: var(--active-tab-bg);
top: 0.5rem;
right: 0.5rem;
background: var(--button-bg);
color: var(--button-text); color: var(--button-text);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 1.6rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: transition:
background-color 0.2s, background-color 0.2s,
border-color 0.2s; border-color 0.2s;
z-index: 10;
opacity: 0.8;
width: auto; width: auto;
height: auto; height: auto;
display: flex; display: flex;
@ -4604,13 +4692,10 @@
.copy-json-btn:hover { .copy-json-btn:hover {
background: var(--button-hover-bg); background: var(--button-hover-bg);
border-color: var(--button-hover-border); border-color: var(--button-hover-border);
opacity: 1;
} }
.event-json { .event-json {
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 1rem; padding: 1rem;
margin: 0; margin: 0;
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
@ -4958,33 +5043,37 @@
.recovery-tab { .recovery-tab {
padding: 20px; padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0;
} }
.recovery-header { .recovery-header {
margin-bottom: 30px; margin-bottom: 30px;
text-align: center; text-align: left;
} }
.recovery-header h2 { .recovery-tab h3 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: var(--text-color); color: var(--text-color);
} }
.recovery-header p { .recovery-tab p {
margin: 0; margin: 0;
color: var(--text-color); color: var(--text-color);
opacity: 0.7; opacity: 0.7;
padding: 0.5em;
}
.recovery-controls-card {
background-color: transparent;
border: none;
border-radius: 0;
padding: 0;
} }
.recovery-controls { .recovery-controls {
display: flex; display: flex;
gap: 20px; gap: 20px;
align-items: center; align-items: center;
margin-bottom: 30px;
padding: 20px;
background: var(--bg-color);
border-radius: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -5039,8 +5128,8 @@
.loading, .loading,
.no-events { .no-events {
text-align: center; text-align: left;
padding: 40px; padding: 40px 20px;
color: var(--text-color); color: var(--text-color);
opacity: 0.7; opacity: 0.7;
} }
@ -5052,17 +5141,18 @@
} }
.event-item { .event-item {
background: var(--bg-color); background: var(--surface-bg);
border: 1px solid var(--border-color); border: 2px solid var(--primary);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
transition: all 0.2s ease; transition: all 0.2s ease;
background: var(--header-bg);
} }
.event-item.old-version { .event-item.old-version {
opacity: 0.7; opacity: 0.85;
border-color: #ffc107; border: none;
background: rgba(255, 193, 7, 0.1); background: var(--header-bg);
} }
.event-header { .event-header {
@ -5074,6 +5164,19 @@
gap: 10px; gap: 10px;
} }
.event-header-left {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.event-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.event-kind { .event-kind {
font-weight: 600; font-weight: 600;
color: var(--primary); color: var(--primary);
@ -5113,6 +5216,8 @@
font-size: 0.9em; font-size: 0.9em;
margin: 0; margin: 0;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
white-space: pre-wrap;
word-wrap: break-word;
} }
.follow-list p { .follow-list p {
@ -5177,7 +5282,115 @@
/* Dark theme adjustments for recovery tab */ /* Dark theme adjustments for recovery tab */
:global(body.dark-theme) .event-item.old-version { :global(body.dark-theme) .event-item.old-version {
background: rgba(255, 193, 7, 0.1); background: var(--header-bg);
border-color: #ffc107; border: none;
}
.tab-panel {
display: none;
padding: 1em;
background-color: var(--panel-bg);
border-radius: 0 0 8px 8px;
color: var(--text-color);
text-align: left;
}
.tab-panel.active {
display: block;
}
.recovery-header {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 0.5em;
color: var(--header-text-color);
}
.recovery-description {
margin-bottom: 1.5em;
color: var(--description-text-color);
}
.recovery-controls-card {
background-color: transparent;
border-radius: 8px;
padding: 1em;
margin-bottom: 1em;
width: 100%;
}
.form-group {
margin-bottom: 1em;
}
.form-group label {
display: block;
margin-bottom: 0.5em;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-bg);
color: var(--input-text-color);
}
.form-group button {
background-color: var(--primary);
color: white;
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.form-group button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.status-message {
padding: 1em;
border-radius: 4px;
margin-top: 1em;
}
.status-message.success {
background-color: var(--success-bg);
color: var(--success-text);
}
.status-message.error {
background-color: var(--error-bg);
color: var(--error-text);
}
.export-results {
margin-top: 2em;
}
.export-results h3 {
margin-bottom: 1em;
}
.export-results button {
background-color: var(--primary);
color: white;
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
margin-bottom: 1em;
}
.events-preview {
max-height: 400px;
overflow-y: auto;
} }
</style> </style>

320
app/web/src/nostr.js

@ -83,13 +83,20 @@ class NostrClient {
} }
// Publish an event // Publish an event
async publish(event) { async publish(event, specificRelays = null) {
console.log("Publishing event:", event); if (!this.isConnected) {
console.warn("Not connected to any relays, attempting to connect first");
await this.connect();
}
try { try {
const promises = this.pool.publish(this.relays, event); const relaysToUse = specificRelays || this.relays;
const promises = this.pool.publish(relaysToUse, event);
await Promise.allSettled(promises); await Promise.allSettled(promises);
console.log("✓ Event published successfully"); console.log("✓ Event published successfully");
// Store the published event in IndexedDB
await putEvents([event]);
console.log("Event stored in IndexedDB");
return { success: true, okCount: 1, errorCount: 0 }; return { success: true, okCount: 1, errorCount: 0 };
} catch (error) { } catch (error) {
console.error("✗ Failed to publish event:", error); console.error("✗ Failed to publish event:", error);
@ -172,32 +179,56 @@ export class Nip07Signer {
} }
} }
// IndexedDB helpers for caching events (kind 0 profiles) // IndexedDB helpers for unified event storage
// This provides a local cache that all components can access
const DB_NAME = "nostrCache"; const DB_NAME = "nostrCache";
const DB_VERSION = 1; const DB_VERSION = 2; // Incremented for new indexes
const STORE_EVENTS = "events"; const STORE_EVENTS = "events";
function openDB() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const req = indexedDB.open(DB_NAME, DB_VERSION); const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => { req.onupgradeneeded = (event) => {
const db = req.result; const db = req.result;
const oldVersion = event.oldVersion;
// Create or update the events store
let store;
if (!db.objectStoreNames.contains(STORE_EVENTS)) { if (!db.objectStoreNames.contains(STORE_EVENTS)) {
const store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" }); store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" });
} else {
// Get existing store during upgrade
store = req.transaction.objectStore(STORE_EVENTS);
}
// Create indexes if they don't exist
if (!store.indexNames.contains("byKindAuthor")) {
store.createIndex("byKindAuthor", ["kind", "pubkey"], { store.createIndex("byKindAuthor", ["kind", "pubkey"], {
unique: false, unique: false,
}); });
}
if (!store.indexNames.contains("byKindAuthorCreated")) {
store.createIndex( store.createIndex(
"byKindAuthorCreated", "byKindAuthorCreated",
["kind", "pubkey", "created_at"], ["kind", "pubkey", "created_at"],
{ unique: false }, { unique: false },
); );
} }
if (!store.indexNames.contains("byKind")) {
store.createIndex("byKind", "kind", { unique: false });
}
if (!store.indexNames.contains("byAuthor")) {
store.createIndex("byAuthor", "pubkey", { unique: false });
}
if (!store.indexNames.contains("byCreatedAt")) {
store.createIndex("byCreatedAt", "created_at", { unique: false });
}
}; };
req.onsuccess = () => resolve(req.result); req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
} catch (e) { } catch (e) {
console.error("Failed to open IndexedDB", e);
reject(e); reject(e);
} }
}); });
@ -240,6 +271,146 @@ async function putEvent(event) {
} }
} }
// Store multiple events in IndexedDB
async function putEvents(events) {
if (!events || events.length === 0) return;
try {
const db = await openDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_EVENTS, "readwrite");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
const store = tx.objectStore(STORE_EVENTS);
for (const event of events) {
store.put(event);
}
});
console.log(`Stored ${events.length} events in IndexedDB`);
} catch (e) {
console.warn("IDB putEvents failed", e);
}
}
// Query events from IndexedDB by filters
async function queryEventsFromDB(filters) {
try {
const db = await openDB();
const results = [];
console.log("QueryEventsFromDB: Starting query with filters:", filters);
for (const filter of filters) {
console.log("QueryEventsFromDB: Processing filter:", filter);
const events = await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_EVENTS, "readonly");
const store = tx.objectStore(STORE_EVENTS);
const allEvents = [];
// Determine which index to use based on filter
let req;
if (filter.kinds && filter.kinds.length > 0 && filter.authors && filter.authors.length > 0) {
// Use byKindAuthor index for the most specific query
const kind = filter.kinds[0];
const author = filter.authors[0];
console.log(`QueryEventsFromDB: Using byKindAuthorCreated index for kind=${kind}, author=${author.substring(0, 8)}...`);
const idx = store.index("byKindAuthorCreated");
const range = IDBKeyRange.bound(
[kind, author, -Infinity],
[kind, author, Infinity]
);
req = idx.openCursor(range, "prev"); // newest first
} else if (filter.kinds && filter.kinds.length > 0) {
// Use byKind index
console.log(`QueryEventsFromDB: Using byKind index for kind=${filter.kinds[0]}`);
const idx = store.index("byKind");
req = idx.openCursor(IDBKeyRange.only(filter.kinds[0]));
} else if (filter.authors && filter.authors.length > 0) {
// Use byAuthor index
console.log(`QueryEventsFromDB: Using byAuthor index for author=${filter.authors[0].substring(0, 8)}...`);
const idx = store.index("byAuthor");
req = idx.openCursor(IDBKeyRange.only(filter.authors[0]));
} else {
// Scan all events
console.log("QueryEventsFromDB: Scanning all events (no specific index)");
req = store.openCursor();
}
req.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const evt = cursor.value;
// Apply additional filters
let matches = true;
// Filter by kinds
if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(evt.kind)) {
matches = false;
}
// Filter by authors
if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(evt.pubkey)) {
matches = false;
}
// Filter by since
if (filter.since && evt.created_at < filter.since) {
matches = false;
}
// Filter by until
if (filter.until && evt.created_at > filter.until) {
matches = false;
}
// Filter by IDs
if (filter.ids && filter.ids.length > 0 && !filter.ids.includes(evt.id)) {
matches = false;
}
if (matches) {
allEvents.push(evt);
}
// Apply limit
if (filter.limit && allEvents.length >= filter.limit) {
console.log(`QueryEventsFromDB: Reached limit of ${filter.limit}, found ${allEvents.length} matching events`);
resolve(allEvents);
return;
}
cursor.continue();
} else {
console.log(`QueryEventsFromDB: Cursor exhausted, found ${allEvents.length} matching events`);
resolve(allEvents);
}
};
req.onerror = () => {
console.error("QueryEventsFromDB: Cursor error:", req.error);
reject(req.error);
};
});
console.log(`QueryEventsFromDB: Found ${events.length} events for this filter`);
results.push(...events);
}
// Sort by created_at (newest first) and apply global limit
results.sort((a, b) => b.created_at - a.created_at);
console.log(`QueryEventsFromDB: Returning ${results.length} total events`);
return results;
} catch (e) {
console.error("QueryEventsFromDB failed:", e);
return [];
}
}
function parseProfileFromEvent(event) { function parseProfileFromEvent(event) {
try { try {
const profile = JSON.parse(event.content || "{}"); const profile = JSON.parse(event.content || "{}");
@ -296,6 +467,16 @@ export async function fetchUserProfile(pubkey) {
// Cache the event // Cache the event
await putEvent(profileEvent); await putEvent(profileEvent);
// Publish the profile event to the local relay
try {
console.log("Publishing profile event to local relay:", profileEvent.id);
await nostrClient.publish(profileEvent);
console.log("Profile event successfully saved to local relay");
} catch (publishError) {
console.warn("Failed to publish profile to local relay:", publishError);
// Don't fail the whole operation if publishing fails
}
// Parse profile data // Parse profile data
const profile = parseProfileFromEvent(profileEvent); const profile = parseProfileFromEvent(profileEvent);
@ -324,33 +505,78 @@ export async function fetchUserProfile(pubkey) {
// Fetch events // Fetch events
export async function fetchEvents(filters, options = {}) { export async function fetchEvents(filters, options = {}) {
console.log(`Starting event fetch with filters:`, filters); console.log(`Starting event fetch with filters:`, JSON.stringify(filters, null, 2));
console.log(`Current relays:`, nostrClient.relays);
// Ensure client is connected
if (!nostrClient.isConnected || nostrClient.relays.length === 0) {
console.warn("Client not connected, initializing...");
await initializeNostrClient();
}
const { const {
timeout = 30000, timeout = 30000,
useCache = true, // Option to query from cache first
} = options; } = options;
// Try to get cached events first if requested
if (useCache) {
try {
const cachedEvents = await queryEventsFromDB(filters);
if (cachedEvents.length > 0) {
console.log(`Found ${cachedEvents.length} cached events in IndexedDB`);
}
} catch (e) {
console.warn("Failed to query cached events", e);
}
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const events = []; const events = [];
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log(`Timeout reached after ${timeout}ms, returning ${events.length} events`); console.log(`Timeout reached after ${timeout}ms, returning ${events.length} events`);
sub.close(); sub.close();
// Store all received events in IndexedDB before resolving
if (events.length > 0) {
putEvents(events).catch(e => console.warn("Failed to cache events", e));
}
resolve(events); resolve(events);
}, timeout); }, timeout);
try { try {
// Generate a subscription ID for logging
const subId = Math.random().toString(36).substring(7);
console.log(`📤 REQ [${subId}]:`, JSON.stringify(["REQ", subId, ...filters], null, 2));
const sub = nostrClient.pool.subscribeMany( const sub = nostrClient.pool.subscribeMany(
nostrClient.relays, nostrClient.relays,
filters, filters,
{ {
onevent(event) { onevent(event) {
console.log("Event received:", event); console.log(`📥 EVENT received for REQ [${subId}]:`, {
id: event.id?.substring(0, 8) + '...',
kind: event.kind,
pubkey: event.pubkey?.substring(0, 8) + '...',
created_at: event.created_at,
content_preview: event.content?.substring(0, 50)
});
events.push(event); events.push(event);
// Store event immediately in IndexedDB
putEvent(event).catch(e => console.warn("Failed to cache event", e));
}, },
oneose() { oneose() {
console.log(`EOSE received, got ${events.length} events`); console.log(`EOSE received for REQ [${subId}], got ${events.length} events`);
clearTimeout(timeoutId); clearTimeout(timeoutId);
sub.close(); sub.close();
// Store all events in IndexedDB before resolving
if (events.length > 0) {
putEvents(events).catch(e => console.warn("Failed to cache events", e));
}
resolve(events); resolve(events);
} }
} }
@ -495,3 +721,77 @@ export async function fetchDeleteEventsByTarget(eventId, options = {}) {
export async function initializeNostrClient() { export async function initializeNostrClient() {
await nostrClient.connect(); await nostrClient.connect();
} }
// Query events from cache and relay combined
// This is the main function components should use
export async function queryEvents(filters, options = {}) {
const {
timeout = 30000,
cacheFirst = true, // Try cache first before hitting relay
cacheOnly = false, // Only use cache, don't query relay
} = options;
let cachedEvents = [];
// Try cache first
if (cacheFirst || cacheOnly) {
try {
cachedEvents = await queryEventsFromDB(filters);
console.log(`Found ${cachedEvents.length} events in cache`);
if (cacheOnly || cachedEvents.length > 0) {
return cachedEvents;
}
} catch (e) {
console.warn("Failed to query cache", e);
}
}
// If cache didn't have results and we're not cache-only, query relay
if (!cacheOnly) {
const relayEvents = await fetchEvents(filters, { timeout, useCache: false });
console.log(`Fetched ${relayEvents.length} events from relay`);
return relayEvents;
}
return cachedEvents;
}
// Export cache query function for direct access
export { queryEventsFromDB };
// Debug function to check database contents
export async function debugIndexedDB() {
try {
const db = await openDB();
const tx = db.transaction(STORE_EVENTS, "readonly");
const store = tx.objectStore(STORE_EVENTS);
const allEvents = await new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const byKind = allEvents.reduce((acc, e) => {
acc[e.kind] = (acc[e.kind] || 0) + 1;
return acc;
}, {});
console.log("===== IndexedDB Contents =====");
console.log(`Total events: ${allEvents.length}`);
console.log("Events by kind:", byKind);
console.log("Kind 0 events:", allEvents.filter(e => e.kind === 0));
console.log("All event IDs:", allEvents.map(e => ({ id: e.id.substring(0, 8), kind: e.kind, pubkey: e.pubkey.substring(0, 8) })));
console.log("==============================");
return {
total: allEvents.length,
byKind,
events: allEvents
};
} catch (e) {
console.error("Failed to debug IndexedDB:", e);
return null;
}
}

2
pkg/version/version

@ -1 +1 @@
v0.19.0 v0.19.1
Loading…
Cancel
Save