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 @@ -99,6 +99,13 @@ cmd/benchmark/data
!.github/**
!.github/workflows/**
!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
!*/
/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 @@ @@ -9,6 +9,9 @@
searchEvents,
fetchEventById,
fetchDeleteEventsByTarget,
queryEvents,
queryEventsFromDB,
debugIndexedDB,
nostrClient,
NostrClient,
Nip07Signer,
@ -16,6 +19,11 @@ @@ -16,6 +19,11 @@
} from "./nostr.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 showLoginModal = false;
let isLoggedIn = false;
@ -25,7 +33,7 @@ @@ -25,7 +33,7 @@
let userRole = "";
let userSigner = null;
let showSettingsDrawer = false;
let selectedTab = "export";
let selectedTab = localStorage.getItem("selectedTab") || "export";
let isSearchMode = false;
let searchQuery = "";
let searchTabs = [];
@ -86,47 +94,93 @@ @@ -86,47 +94,93 @@
let recoveryNewestTimestamp = null;
// 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 = [
{ value: 0, label: "Profile Metadata (0)" },
{ value: 3, label: "Follow List (3)" },
{ value: 10000, label: "Relay List Metadata (10000)" },
{ value: 10001, label: "Mute List (10001)" },
{ value: 10002, label: "Pin List (10002)" },
{ value: 10003, label: "Bookmark List (10003)" },
{ value: 10004, label: "Communities List (10004)" },
{ value: 10005, label: "Public Chats List (10005)" },
{ value: 10006, label: "Blocked Relays List (10006)" },
{ value: 10007, label: "Search Relays List (10007)" },
{ value: 10015, label: "Interests List (10015)" },
{ value: 10030, label: "Emoji Sets (10030)" },
{ value: 30000, label: "Categorized People List (30000)" },
{ value: 30001, label: "Categorized Bookmark List (30001)" },
{ value: 30002, label: "Relay Sets (30002)" },
{ value: 30003, label: "Bookmark Sets (30003)" },
{ value: 30004, label: "Curation Sets (30004)" },
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ 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: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest Sets (30015)" },
{ value: 30017, label: "Stall Definition (30017)" },
{ value: 30018, label: "Product Definition (30018)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product Sold as Auction (30020)" },
{ value: 30023, label: "Article (30023)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji Sets (30030)" },
{ value: 30078, label: "Application Specific Data (30078)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ 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: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 31922, label: "Date-based Calendar Event (31922)" },
{ value: 31923, label: "Time-based Calendar Event (31923)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ 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: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler Recommendation (31989)" },
{ value: 31990, label: "Handler Information (31990)" },
{ value: 32123, label: "WaveLake Track (32123)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ 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
@ -667,7 +721,16 @@ @@ -667,7 +721,16 @@
const kindToUse = recoveryCustomKind
? parseInt(recoveryCustomKind)
: 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(
"Loading recovery events for kind:",
@ -679,18 +742,26 @@ @@ -679,18 +742,26 @@
try {
// Fetch multiple versions using limit parameter
// For replaceable events, limit > 1 returns multiple versions
const filters = {
const filters = [
{
kinds: [kindToUse],
authors: [userPubkey],
limit: 100, // Get up to 100 versions
};
},
];
if (recoveryOldestTimestamp) {
filters.until = recoveryOldestTimestamp;
filters[0].until = recoveryOldestTimestamp;
}
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 kinds:",
@ -721,46 +792,73 @@ @@ -721,46 +792,73 @@
}
async function repostEvent(event) {
if (!isLoggedIn || !userSigner) {
alert("Please log in to repost events");
if (!confirm("Are you sure you want to repost this event?")) {
return;
}
try {
// Create a new event with the same content but current timestamp
const newEvent = {
kind: event.kind,
content: event.content,
tags: event.tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: userPubkey,
};
const localRelayUrl = `wss://${window.location.host}/`;
console.log(
"Reposting event to local relay:",
localRelayUrl,
event,
);
// Sign and publish the event
const result = await publishEventWithAuth(
DEFAULT_RELAYS[0],
newEvent,
userSigner,
userPubkey,
// Create a new event with updated timestamp
const newEvent = { ...event };
newEvent.created_at = Math.floor(Date.now() / 1000);
newEvent.id = ""; // Clear the old ID so it gets recalculated
newEvent.sig = ""; // Clear the old signature
// 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!");
// Reload the recovery events to show the new current version
recoveryEvents = [];
recoveryOldestTimestamp = null;
await loadRecoveryEvents();
recoveryHasMore = false; // Reset to allow reloading
await loadRecoveryEvents(); // Reload the events to show the new version
} 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) {
console.error("Failed to repost event:", error);
alert("Failed to repost event: " + error.message);
console.error("Error reposting event:", error);
alert("Error reposting event: " + error.message);
}
}
function selectRecoveryKind(kind) {
recoverySelectedKind = kind;
function selectRecoveryKind() {
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
recoveryEvents = [];
recoveryOldestTimestamp = null;
@ -769,7 +867,13 @@ @@ -769,7 +867,13 @@
}
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
recoveryEvents = [];
recoveryOldestTimestamp = null;
@ -2181,6 +2285,11 @@ @@ -2181,6 +2285,11 @@
alert("Error publishing event: " + error.message);
}
}
// Persist selected tab to local storage
$: {
localStorage.setItem("selectedTab", selectedTab);
}
</script>
<!-- Header -->
@ -2270,7 +2379,7 @@ @@ -2270,7 +2379,7 @@
{#if selectedTab === "export"}
{#if isLoggedIn}
<div class="export-section">
<h2>Export My Events</h2>
<h3>Export My Events</h3>
<p>Download your personal events as a JSONL file.</p>
<button class="export-btn" on:click={exportMyEvents}>
📤 Export My Events
@ -2297,12 +2406,13 @@ @@ -2297,12 +2406,13 @@
</div>
{/if}
{:else if selectedTab === "import"}
<div class="import-view">
<div class="import-section">
{#if isLoggedIn && (userRole === "admin" || userRole === "owner")}
<h2>Import Events</h2>
<h3>Import Events</h3>
<p>
Upload a JSONL file to import events into the database.
</p>
<div class="recovery-controls-card">
<input
type="file"
id="import-file"
@ -2314,18 +2424,23 @@ @@ -2314,18 +2424,23 @@
on:click={importEvents}
disabled={!selectedFile}
>
📥 Import Events
Import Events
</button>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<p>
<h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
❌ Admin or owner permission required for import
functionality.
</p>
</div>
{:else}
<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}
>Log In</button
>
@ -2785,25 +2900,28 @@ @@ -2785,25 +2900,28 @@
</div>
{:else if selectedTab === "recovery"}
<div class="recovery-tab">
<div class="recovery-header">
<h2>🔄 Event Recovery</h2>
<div>
<h3>Event Recovery</h3>
<p>Search and recover old versions of replaceable events</p>
</div>
<div class="recovery-controls-card">
<div class="recovery-controls">
<div class="kind-selector">
<label for="recovery-kind">Select Event Kind:</label>
<label for="recovery-kind">Select Event Kind:</label
>
<select
id="recovery-kind"
bind:value={recoverySelectedKind}
on:change={() =>
selectRecoveryKind(recoverySelectedKind)}
on:change={selectRecoveryKind}
>
<option value={null}
>Choose a replaceable kind...</option
>
{#each replaceableKinds as kind}
<option value={kind.value}>{kind.label}</option>
<option value={kind.value}
>{kind.label}</option
>
{/each}
</select>
</div>
@ -2822,8 +2940,9 @@ @@ -2822,8 +2940,9 @@
/>
</div>
</div>
</div>
{#if recoverySelectedKind || recoveryCustomKind}
{#if (recoverySelectedKind !== null && recoverySelectedKind !== undefined && recoverySelectedKind >= 0) || (recoveryCustomKind !== "" && parseInt(recoveryCustomKind) >= 0)}
<div class="recovery-results">
{#if isLoadingRecovery}
<div class="loading">Loading events...</div>
@ -2840,14 +2959,18 @@ @@ -2840,14 +2959,18 @@
class:old-version={!isCurrent}
>
<div class="event-header">
<span class="event-kind"
>Kind {event.kind}</span
<div class="event-header-left">
<span class="event-kind">
{#if isCurrent}
Current Version{/if}</span
>
<span class="event-timestamp">
{new Date(
event.created_at * 1000,
).toLocaleString()}
</span>
</div>
<div class="event-header-actions">
{#if !isCurrent}
<button
class="repost-button"
@ -2857,59 +2980,29 @@ @@ -2857,59 +2980,29 @@
🔄 Repost
</button>
{/if}
<button
class="copy-json-btn"
on:click|stopPropagation={(
e,
) =>
copyEventToClipboard(
event,
e,
)}
>
📋 Copy JSON
</button>
</div>
</div>
<div class="event-content">
{#if event.kind === 0}
<!-- Profile metadata -->
{#if event.content}
<div
class="profile-content"
>
<pre>{JSON.stringify(
JSON.parse(
event.content,
),
<pre
class="event-json">{JSON.stringify(
event,
null,
2,
)}</pre>
</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>
{/each}
</div>
@ -3897,6 +3990,7 @@ @@ -3897,6 +3990,7 @@
width: 2.5em;
height: 2.5em;
object-fit: cover;
border-radius: 50%;
}
.user-avatar-placeholder {
@ -4108,9 +4202,9 @@ @@ -4108,9 +4202,9 @@
padding: 1em;
max-width: 32em;
margin: 0;
background: var(--header-bg);
background: transparent;
color: var(--text-color);
border-radius: 8px;
border-radius: 0;
}
/* Managed ACL View */
@ -4195,7 +4289,7 @@ @@ -4195,7 +4289,7 @@
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.import-view h2 {
.import-view h3 {
margin: 0 0 2rem 0;
color: var(--text-color);
font-size: 1.5rem;
@ -4204,7 +4298,7 @@ @@ -4204,7 +4298,7 @@
.export-section,
.import-section {
background: var(--header-bg);
background: transparent;
padding: 1em;
border-radius: 8px;
margin-bottom: 1.5rem;
@ -4213,18 +4307,16 @@ @@ -4213,18 +4307,16 @@
.export-section h3,
.import-section h3 {
margin: 0 0 1rem 0;
margin: 0 0 10px 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 500;
}
.export-section p,
.import-section p {
margin: 0 0 1rem 0;
margin: 0;
color: var(--text-color);
opacity: 0.8;
line-height: 1.5;
opacity: 0.7;
padding: 0.5em;
}
.events-view-buttons {
@ -4250,6 +4342,7 @@ @@ -4250,6 +4342,7 @@
align-items: center;
gap: 0.25rem;
height: 2em;
margin: 1em;
}
.export-btn:hover,
@ -4579,21 +4672,16 @@ @@ -4579,21 +4672,16 @@
}
.copy-json-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: var(--button-bg);
background: var(--active-tab-bg);
color: var(--button-text);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.5rem 1rem;
font-size: 1.6rem;
font-size: 1rem;
cursor: pointer;
transition:
background-color 0.2s,
border-color 0.2s;
z-index: 10;
opacity: 0.8;
width: auto;
height: auto;
display: flex;
@ -4604,13 +4692,10 @@ @@ -4604,13 +4692,10 @@
.copy-json-btn:hover {
background: var(--button-hover-bg);
border-color: var(--button-hover-border);
opacity: 1;
}
.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;
@ -4958,33 +5043,37 @@ @@ -4958,33 +5043,37 @@
.recovery-tab {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
margin: 0;
}
.recovery-header {
margin-bottom: 30px;
text-align: center;
text-align: left;
}
.recovery-header h2 {
.recovery-tab h3 {
margin: 0 0 10px 0;
color: var(--text-color);
}
.recovery-header p {
.recovery-tab p {
margin: 0;
color: var(--text-color);
opacity: 0.7;
padding: 0.5em;
}
.recovery-controls-card {
background-color: transparent;
border: none;
border-radius: 0;
padding: 0;
}
.recovery-controls {
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: var(--bg-color);
border-radius: 8px;
flex-wrap: wrap;
}
@ -5039,8 +5128,8 @@ @@ -5039,8 +5128,8 @@
.loading,
.no-events {
text-align: center;
padding: 40px;
text-align: left;
padding: 40px 20px;
color: var(--text-color);
opacity: 0.7;
}
@ -5052,17 +5141,18 @@ @@ -5052,17 +5141,18 @@
}
.event-item {
background: var(--bg-color);
border: 1px solid var(--border-color);
background: var(--surface-bg);
border: 2px solid var(--primary);
border-radius: 8px;
padding: 20px;
transition: all 0.2s ease;
background: var(--header-bg);
}
.event-item.old-version {
opacity: 0.7;
border-color: #ffc107;
background: rgba(255, 193, 7, 0.1);
opacity: 0.85;
border: none;
background: var(--header-bg);
}
.event-header {
@ -5074,6 +5164,19 @@ @@ -5074,6 +5164,19 @@
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 {
font-weight: 600;
color: var(--primary);
@ -5113,6 +5216,8 @@ @@ -5113,6 +5216,8 @@
font-size: 0.9em;
margin: 0;
border: 1px solid var(--border-color);
white-space: pre-wrap;
word-wrap: break-word;
}
.follow-list p {
@ -5177,7 +5282,115 @@ @@ -5177,7 +5282,115 @@
/* Dark theme adjustments for recovery tab */
:global(body.dark-theme) .event-item.old-version {
background: rgba(255, 193, 7, 0.1);
border-color: #ffc107;
background: var(--header-bg);
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>

320
app/web/src/nostr.js

@ -83,13 +83,20 @@ class NostrClient { @@ -83,13 +83,20 @@ class NostrClient {
}
// Publish an event
async publish(event) {
console.log("Publishing event:", event);
async publish(event, specificRelays = null) {
if (!this.isConnected) {
console.warn("Not connected to any relays, attempting to connect first");
await this.connect();
}
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);
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 };
} catch (error) {
console.error("✗ Failed to publish event:", error);
@ -172,32 +179,56 @@ export class Nip07Signer { @@ -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_VERSION = 1;
const DB_VERSION = 2; // Incremented for new indexes
const STORE_EVENTS = "events";
function openDB() {
return new Promise((resolve, reject) => {
try {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
req.onupgradeneeded = (event) => {
const db = req.result;
const oldVersion = event.oldVersion;
// Create or update the events store
let store;
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"], {
unique: false,
});
}
if (!store.indexNames.contains("byKindAuthorCreated")) {
store.createIndex(
"byKindAuthorCreated",
["kind", "pubkey", "created_at"],
{ 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.onerror = () => reject(req.error);
} catch (e) {
console.error("Failed to open IndexedDB", e);
reject(e);
}
});
@ -240,6 +271,146 @@ async function putEvent(event) { @@ -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) {
try {
const profile = JSON.parse(event.content || "{}");
@ -296,6 +467,16 @@ export async function fetchUserProfile(pubkey) { @@ -296,6 +467,16 @@ export async function fetchUserProfile(pubkey) {
// Cache the event
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
const profile = parseProfileFromEvent(profileEvent);
@ -324,33 +505,78 @@ export async function fetchUserProfile(pubkey) { @@ -324,33 +505,78 @@ export async function fetchUserProfile(pubkey) {
// Fetch events
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 {
timeout = 30000,
useCache = true, // Option to query from cache first
} = 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) => {
const events = [];
const timeoutId = setTimeout(() => {
console.log(`Timeout reached after ${timeout}ms, returning ${events.length} events`);
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);
}, timeout);
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(
nostrClient.relays,
filters,
{
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);
// Store event immediately in IndexedDB
putEvent(event).catch(e => console.warn("Failed to cache event", e));
},
oneose() {
console.log(`EOSE received, got ${events.length} events`);
console.log(`EOSE received for REQ [${subId}], got ${events.length} events`);
clearTimeout(timeoutId);
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);
}
}
@ -495,3 +721,77 @@ export async function fetchDeleteEventsByTarget(eventId, options = {}) { @@ -495,3 +721,77 @@ export async function fetchDeleteEventsByTarget(eventId, options = {}) {
export async function initializeNostrClient() {
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 @@ @@ -1 +1 @@
v0.19.0
v0.19.1
Loading…
Cancel
Save