Browse Source

deletion, reporting

master
Silberengel 1 month ago
parent
commit
d82277fc4a
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 6
      public/healthz.json
  4. 224
      src/lib/components/EventMenu.svelte
  5. 549
      src/lib/components/modals/DeleteAllEventsModal.svelte
  6. 371
      src/lib/components/modals/DeleteEventModal.svelte
  7. 355
      src/lib/components/modals/ReportEventModal.svelte
  8. 353
      src/lib/components/modals/ReportUserModal.svelte
  9. 449
      src/lib/components/modals/VersionHistoryModal.svelte
  10. 379
      src/lib/components/profile/EditProfileEventsPanel.svelte
  11. 69
      src/lib/components/profile/ProfileMenu.svelte
  12. 38
      src/lib/services/cache/event-archive.ts
  13. 47
      src/lib/services/cache/event-cache.ts
  14. 10
      src/lib/services/cache/indexeddb-store.ts
  15. 12
      src/lib/services/cache/profile-cache.ts
  16. 151
      src/lib/services/cache/version-history.ts
  17. 2
      src/lib/types/kind-lookup.ts
  18. 10
      src/routes/about/+page.svelte

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "aitherboard",
"version": "0.2.1",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aitherboard",
"version": "0.2.1",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "aitherboard",
"version": "0.2.1",
"version": "0.3.0",
"type": "module",
"author": "silberengel@gitcitadel.com",
"description": "A decentralized messageboard built on the Nostr protocol.",

6
public/healthz.json

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
{
"status": "ok",
"service": "aitherboard",
"version": "0.2.1",
"buildTime": "2026-02-11T09:48:17.166Z",
"version": "0.3.0",
"buildTime": "2026-02-11T10:22:29.390Z",
"gitCommit": "unknown",
"timestamp": 1770803297166
"timestamp": 1770805349390
}

224
src/lib/components/EventMenu.svelte

@ -5,6 +5,9 @@ @@ -5,6 +5,9 @@
import { relayManager } from '../services/nostr/relay-manager.js';
import EventJsonModal from './modals/EventJsonModal.svelte';
import PublicationStatusModal from './modals/PublicationStatusModal.svelte';
import ReportEventModal from './modals/ReportEventModal.svelte';
import DeleteEventModal from './modals/DeleteEventModal.svelte';
import VersionHistoryModal from './modals/VersionHistoryModal.svelte';
import {
isPinned,
isBookmarked,
@ -17,7 +20,7 @@ @@ -17,7 +20,7 @@
import { sessionManager } from '../services/auth/session-manager.js';
import { signAndPublish } from '../services/nostr/auth-handler.js';
import RelatedEventsModal from './modals/RelatedEventsModal.svelte';
import { KIND } from '../types/kind-lookup.js';
import { KIND, isReplaceableKind, isParameterizedReplaceableKind } from '../types/kind-lookup.js';
import { goto } from '$app/navigation';
import Icon from './ui/Icon.svelte';
import { getEventLink } from '../services/event-links.js';
@ -38,7 +41,13 @@ @@ -38,7 +41,13 @@
let broadcasting = $state(false);
let deleting = $state(false);
let deleteConfirmOpen = $state(false);
let reportModalOpen = $state(false);
let deleteEventModalOpen = $state(false);
let versionHistoryModalOpen = $state(false);
let copied = $state<string | null>(null);
// Check if this is a replaceable event
let isReplaceable = $derived(isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind));
let menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null);
let menuPosition = $state({ top: 0, right: 0 });
@ -365,47 +374,19 @@ @@ -365,47 +374,19 @@
closeMenu();
}
function confirmDelete() {
deleteConfirmOpen = true;
function openReportModal() {
reportModalOpen = true;
closeMenu();
}
async function deleteEvent() {
if (!isLoggedIn) return;
deleting = true;
deleteConfirmOpen = false;
try {
// Create kind 5 deletion event
const deletionEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.EVENT_DELETION,
pubkey: currentUserPubkey!, // Use the current user's pubkey (the person deleting)
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id]], // Reference the deleted event
content: ''
};
// Get all available relays for publishing
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
function openDeleteModal() {
deleteEventModalOpen = true;
closeMenu();
}
// Sign and publish
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
} catch (error) {
console.error('Error deleting event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
deleting = false;
}
function openVersionHistory() {
versionHistoryModalOpen = true;
closeMenu();
}
</script>
@ -435,6 +416,12 @@ @@ -435,6 +416,12 @@
<Icon name="code" size={16} />
<span>View JSON</span>
</button>
{#if isReplaceable}
<button class="menu-item" onclick={openVersionHistory}>
<span class="menu-item-icon">🕐</span>
<span>See version history</span>
</button>
{/if}
<div class="menu-divider"></div>
@ -517,12 +504,21 @@ @@ -517,12 +504,21 @@
</button>
{/if}
<!-- Report action (logged in and not own event) -->
{#if isLoggedIn && !isOwnEvent}
<div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={openReportModal}>
<span class="menu-item-icon">🚩</span>
<span>Report this event</span>
</button>
{/if}
<!-- Delete action (logged in and own event only) -->
{#if isLoggedIn && isOwnEvent}
<div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={confirmDelete} disabled={deleting}>
<button class="menu-item menu-item-danger" onclick={openDeleteModal}>
<Icon name="trash" size={16} />
<span>{deleting ? 'Deleting...' : 'Delete event'}</span>
<span>Delete this event</span>
</button>
{/if}
</div>
@ -532,41 +528,9 @@ @@ -532,41 +528,9 @@
<EventJsonModal bind:open={jsonModalOpen} event={event} />
<RelatedEventsModal bind:open={relatedEventsModalOpen} event={event} />
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#if deleteConfirmOpen}
<div
class="delete-confirm-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
onclick={(e) => {
if (e.target === e.currentTarget) {
deleteConfirmOpen = false;
}
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
deleteConfirmOpen = false;
}
}}
tabindex="-1"
>
<div class="delete-confirm-dialog">
<h3 id="delete-dialog-title">Delete Event?</h3>
<p>Are you sure you want to delete this event? This action cannot be undone.</p>
<div class="delete-confirm-buttons">
<button class="btn-cancel flex items-center gap-2" onclick={() => deleteConfirmOpen = false}>
<Icon name="x" size={16} />
<span>Cancel</span>
</button>
<button class="btn-delete flex items-center gap-2" onclick={deleteEvent} disabled={deleting}>
<Icon name="trash" size={16} />
<span>{deleting ? 'Deleting...' : 'Delete'}</span>
</button>
</div>
</div>
</div>
{/if}
<ReportEventModal bind:open={reportModalOpen} event={event} onClose={() => reportModalOpen = false} />
<DeleteEventModal bind:open={deleteEventModalOpen} event={event} onClose={() => deleteEventModalOpen = false} />
<VersionHistoryModal bind:open={versionHistoryModalOpen} event={event} onClose={() => versionHistoryModalOpen = false} />
<style>
.event-menu-container {
@ -678,6 +642,12 @@ @@ -678,6 +642,12 @@
flex-shrink: 0;
}
.menu-item-icon {
flex-shrink: 0;
font-size: 1rem;
line-height: 1;
}
.menu-item span {
flex: 1;
}
@ -753,108 +723,4 @@ @@ -753,108 +723,4 @@
:global(.dark) .menu-item-danger:hover:not(:disabled) {
background: var(--fog-dark-danger-light, #7f1d1d);
}
.delete-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.delete-confirm-dialog {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:global(.dark) .delete-confirm-dialog {
background: var(--fog-dark-post, #1f2937);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.delete-confirm-dialog h3 {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 1.25rem;
}
:global(.dark) .delete-confirm-dialog h3 {
color: var(--fog-dark-text, #f9fafb);
}
.delete-confirm-dialog p {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .delete-confirm-dialog p {
color: var(--fog-dark-text-light, #a8b8d0);
}
.delete-confirm-buttons {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel,
.btn-delete {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-cancel {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .btn-cancel {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.btn-cancel:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .btn-cancel:hover {
background: var(--fog-dark-border, #475569);
}
.btn-delete {
background: var(--fog-danger, #dc2626);
color: white;
}
:global(.dark) .btn-delete {
background: var(--fog-dark-danger, #ef4444);
}
.btn-delete:hover:not(:disabled) {
background: var(--fog-danger-dark, #b91c1c);
}
:global(.dark) .btn-delete:hover:not(:disabled) {
background: var(--fog-dark-danger-dark, #dc2626);
}
.btn-delete:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

549
src/lib/components/modals/DeleteAllEventsModal.svelte

@ -0,0 +1,549 @@ @@ -0,0 +1,549 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent, getEventsByPubkey, deleteEvents } from '../../services/cache/event-cache.js';
import { deleteArchivedEventsByPubkey } from '../../services/cache/event-archive.js';
import { deleteProfile } from '../../services/cache/profile-cache.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import PublicationStatusModal from './PublicationStatusModal.svelte';
import { KIND } from '../../types/kind-lookup.js';
import { goto } from '$app/navigation';
interface Props {
open?: boolean;
pubkey: string;
onClose: () => void;
}
let { open = $bindable(false), pubkey, onClose }: Props = $props();
let reason = $state('');
let selectedRelays = $state<Set<string>>(new Set());
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let availableRelays = $state<string[]>([]);
// Load available relays when modal opens
$effect(() => {
if (open) {
loadAvailableRelays();
}
});
function loadAvailableRelays() {
// Get all available relays (default, profile, user inbox/outbox, etc.)
const allRelays = relayManager.getAllAvailableRelays();
// Remove duplicates and sort
availableRelays = [...new Set(allRelays)].sort();
// Pre-select all relays by default
selectedRelays = new Set(availableRelays);
}
function toggleRelay(relay: string) {
const newSet = new Set(selectedRelays);
if (newSet.has(relay)) {
newSet.delete(relay);
} else {
newSet.add(relay);
}
selectedRelays = newSet;
}
function selectAll() {
selectedRelays = new Set(availableRelays);
}
function deselectAll() {
selectedRelays = new Set();
}
function close() {
open = false;
reason = '';
selectedRelays = new Set();
publicationModalOpen = false;
publicationResults = null;
}
async function deleteAllEvents() {
if (selectedRelays.size === 0) {
alert('Please select at least one relay to send the deletion request to.');
return;
}
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to delete events.');
return;
}
if (session.pubkey !== pubkey) {
alert('You can only delete your own events.');
return;
}
publishing = true;
try {
// Create kind 62 deletion request
// According to NIP-62, this should have:
// - p tag with the pubkey whose events should be deleted
// - r tags for each relay that should receive the request
// - Optional reason in content
const tags: string[][] = [['p', pubkey]];
// Add r tags for selected relays
for (const relay of selectedRelays) {
tags.push(['r', relay]);
}
const deletionRequest = {
kind: KIND.DELETION_REQUEST,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content: reason.trim() || '' // Optional reason
};
const signedEvent = await session.signer(deletionRequest);
await cacheEvent(signedEvent);
// Publish to the selected relays
const relays = Array.from(selectedRelays);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Delete all events from this pubkey from cache and archive
try {
// Get all events by this pubkey from cache
const cachedEvents = await getEventsByPubkey(pubkey);
if (cachedEvents.length > 0) {
const eventIds = cachedEvents.map(e => e.id);
await deleteEvents(eventIds);
}
// Delete all archived events by this pubkey
await deleteArchivedEventsByPubkey(pubkey);
// Delete profile from cache (so profile page shows blank)
await deleteProfile(pubkey);
} catch (error) {
console.error('Error deleting events from cache/archive:', error);
}
// Clear the form after successful publication
reason = '';
selectedRelays = new Set();
// Close the modal after a delay and refresh the profile page
setTimeout(() => {
close();
// Reload the current page to refresh the UI (profile should be blank)
window.location.reload();
}, 2000);
}
} catch (error) {
console.error('Error creating deletion request:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
onkeydown={(e) => {
if (e.key === 'Escape') close();
}}
role="dialog"
aria-modal="true"
aria-labelledby="delete-all-modal-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="delete-all-modal-title">Delete All Events</h2>
<button onclick={close} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p class="modal-description">
This will create a kind 62 deletion request to delete all events from this npub. Select which relays should receive the request.
</p>
<div class="form-group">
<label for="delete-reason" class="form-label">Reason (optional)</label>
<textarea
id="delete-reason"
bind:value={reason}
class="reason-input"
rows="4"
placeholder="Enter an optional reason for deletion..."
disabled={publishing}
></textarea>
</div>
<div class="form-group">
<div class="relay-selection-header">
<div class="form-label">Select Relays ({selectedRelays.size} of {availableRelays.length})</div>
<div class="relay-selection-actions">
<button class="select-all-button" onclick={selectAll} disabled={publishing}>
Select All
</button>
<button class="deselect-all-button" onclick={deselectAll} disabled={publishing}>
Deselect All
</button>
</div>
</div>
<div class="relay-list">
{#each availableRelays as relay}
<label class="relay-item">
<input
type="checkbox"
checked={selectedRelays.has(relay)}
onchange={() => toggleRelay(relay)}
disabled={publishing}
/>
<span class="relay-url">{relay}</span>
</label>
{/each}
</div>
</div>
<div class="form-actions">
<button
class="cancel-button"
onclick={close}
disabled={publishing}
>
Cancel
</button>
<button
class="delete-button"
onclick={deleteAllEvents}
disabled={publishing || selectedRelays.size === 0}
>
{publishing ? 'Sending...' : 'Delete All Events'}
</button>
</div>
</div>
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
:global(.dark) .modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
position: relative;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 600px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .modal-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.reason-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
:global(.dark) .reason-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.reason-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reason-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.reason-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.relay-selection-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.relay-selection-actions {
display: flex;
gap: 0.5rem;
}
.select-all-button,
.deselect-all-button {
padding: 0.25rem 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .select-all-button,
:global(.dark) .deselect-all-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.select-all-button:hover:not(:disabled),
.deselect-all-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .select-all-button:hover:not(:disabled),
:global(.dark) .deselect-all-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
}
.select-all-button:disabled,
.deselect-all-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.relay-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
padding: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .relay-list {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.relay-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background 0.2s;
}
.relay-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .relay-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.relay-item input[type="checkbox"] {
cursor: pointer;
}
.relay-url {
font-family: monospace;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
word-break: break-all;
}
:global(.dark) .relay-url {
color: var(--fog-dark-text, #f9fafb);
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.delete-button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.cancel-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
}
.delete-button {
background: var(--fog-danger, #dc2626);
color: white;
border: none;
}
:global(.dark) .delete-button {
background: var(--fog-dark-danger, #ef4444);
}
.delete-button:hover:not(:disabled) {
opacity: 0.9;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

371
src/lib/components/modals/DeleteEventModal.svelte

@ -0,0 +1,371 @@ @@ -0,0 +1,371 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent, deleteEvent as deleteEventFromCache } from '../../services/cache/event-cache.js';
import { deleteArchivedEvent } from '../../services/cache/event-archive.js';
import { deleteProfile } from '../../services/cache/profile-cache.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import PublicationStatusModal from './PublicationStatusModal.svelte';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open?: boolean;
event: NostrEvent;
onClose: () => void;
}
let { open = $bindable(false), event, onClose }: Props = $props();
let reason = $state('');
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
function close() {
open = false;
reason = '';
publicationModalOpen = false;
publicationResults = null;
}
async function deleteEvent() {
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to delete an event.');
return;
}
if (session.pubkey !== event.pubkey) {
alert('You can only delete your own events.');
return;
}
publishing = true;
try {
// Create kind 5 deletion event
const deletionEvent = {
kind: KIND.EVENT_DELETION,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id]], // Reference the deleted event
content: reason.trim() || '' // Optional reason
};
const signedEvent = await session.signer(deletionEvent);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Delete from cache and archive
try {
await deleteEventFromCache(event.id);
await deleteArchivedEvent(event.id);
// If this is a profile event (kind 0), also delete the profile cache
if (event.kind === KIND.METADATA) {
await deleteProfile(event.pubkey);
}
} catch (error) {
console.error('Error deleting event from cache/archive:', error);
}
// Clear the form after successful publication
reason = '';
// Close the modal after a delay and refresh the page
setTimeout(() => {
close();
// Reload the current page to refresh the UI
window.location.reload();
}, 2000);
}
} catch (error) {
console.error('Error deleting event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
onkeydown={(e) => {
if (e.key === 'Escape') close();
}}
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="delete-modal-title">Delete Event</h2>
<button onclick={close} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p class="modal-description">
This will create a deletion request (kind 5) for this event. You can optionally provide a reason.
</p>
<div class="form-group">
<label for="delete-reason" class="form-label">Reason (optional)</label>
<textarea
id="delete-reason"
bind:value={reason}
class="reason-input"
rows="4"
placeholder="Enter an optional reason for deletion..."
disabled={publishing}
></textarea>
</div>
<div class="form-actions">
<button
class="cancel-button"
onclick={close}
disabled={publishing}
>
Cancel
</button>
<button
class="delete-button"
onclick={deleteEvent}
disabled={publishing}
>
{publishing ? 'Deleting...' : 'Delete Event'}
</button>
</div>
</div>
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
:global(.dark) .modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
position: relative;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .modal-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.reason-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
:global(.dark) .reason-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.reason-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reason-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.reason-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.delete-button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.cancel-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
}
.delete-button {
background: var(--fog-danger, #dc2626);
color: white;
border: none;
}
:global(.dark) .delete-button {
background: var(--fog-dark-danger, #ef4444);
}
.delete-button:hover:not(:disabled) {
opacity: 0.9;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

355
src/lib/components/modals/ReportEventModal.svelte

@ -0,0 +1,355 @@ @@ -0,0 +1,355 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import PublicationStatusModal from './PublicationStatusModal.svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open?: boolean;
event: NostrEvent;
onClose: () => void;
}
let { open = $bindable(false), event, onClose }: Props = $props();
let reason = $state('');
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
function close() {
open = false;
reason = '';
publicationModalOpen = false;
publicationResults = null;
}
async function sendReport() {
if (!reason.trim()) {
alert('Please enter a reason for reporting this event.');
return;
}
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to report an event.');
return;
}
publishing = true;
try {
const eventTemplate = {
kind: 1984,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', event.id], // Tag the reported event
['p', event.pubkey] // Tag the event author
],
content: reason.trim()
};
const signedEvent = await session.signer(eventTemplate);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Clear the form after successful publication
reason = '';
// Close the modal after a delay
setTimeout(() => {
close();
}, 2000);
}
} catch (error) {
console.error('Error publishing report:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
onkeydown={(e) => {
if (e.key === 'Escape') close();
}}
role="dialog"
aria-modal="true"
aria-labelledby="report-modal-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="report-modal-title">Report Event</h2>
<button onclick={close} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p class="modal-description">
Please provide a reason for reporting this event. This will create a kind 1984 event.
</p>
<div class="form-group">
<label for="report-reason" class="form-label">Reason</label>
<textarea
id="report-reason"
bind:value={reason}
class="reason-input"
rows="6"
placeholder="Enter the reason for reporting this event..."
disabled={publishing}
></textarea>
</div>
<div class="form-actions">
<button
class="cancel-button"
onclick={close}
disabled={publishing}
>
Cancel
</button>
<button
class="send-button"
onclick={sendReport}
disabled={publishing || !reason.trim()}
>
{publishing ? 'Sending...' : 'Send Report'}
</button>
</div>
</div>
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
:global(.dark) .modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
position: relative;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .modal-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.reason-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
:global(.dark) .reason-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.reason-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reason-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.reason-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.send-button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.cancel-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
}
.send-button {
background: var(--fog-accent, #64748b);
color: white;
border: none;
}
:global(.dark) .send-button {
background: var(--fog-dark-accent, #94a3b8);
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
}
.send-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

353
src/lib/components/modals/ReportUserModal.svelte

@ -0,0 +1,353 @@ @@ -0,0 +1,353 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import PublicationStatusModal from './PublicationStatusModal.svelte';
interface Props {
open?: boolean;
reportedPubkey: string;
onClose: () => void;
}
let { open = $bindable(false), reportedPubkey, onClose }: Props = $props();
let reason = $state('');
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
function close() {
open = false;
reason = '';
publicationModalOpen = false;
publicationResults = null;
}
async function sendReport() {
if (!reason.trim()) {
alert('Please enter a reason for reporting this user.');
return;
}
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to report a user.');
return;
}
publishing = true;
try {
const eventTemplate = {
kind: 1984,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', reportedPubkey] // Tag the reported user
],
content: reason.trim()
};
const signedEvent = await session.signer(eventTemplate);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Clear the form after successful publication
reason = '';
// Close the modal after a delay
setTimeout(() => {
close();
}, 2000);
}
} catch (error) {
console.error('Error publishing report:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
onkeydown={(e) => {
if (e.key === 'Escape') close();
}}
role="dialog"
aria-modal="true"
aria-labelledby="report-modal-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="report-modal-title">Report User</h2>
<button onclick={close} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p class="modal-description">
Please provide a reason for reporting this user. This will create a kind 1984 event.
</p>
<div class="form-group">
<label for="report-reason" class="form-label">Reason</label>
<textarea
id="report-reason"
bind:value={reason}
class="reason-input"
rows="6"
placeholder="Enter the reason for reporting this user..."
disabled={publishing}
></textarea>
</div>
<div class="form-actions">
<button
class="cancel-button"
onclick={close}
disabled={publishing}
>
Cancel
</button>
<button
class="send-button"
onclick={sendReport}
disabled={publishing || !reason.trim()}
>
{publishing ? 'Sending...' : 'Send Report'}
</button>
</div>
</div>
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
:global(.dark) .modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
position: relative;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-description {
margin: 0 0 1rem 0;
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .modal-description {
color: var(--fog-dark-text-light, #a8b8d0);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.reason-input {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
:global(.dark) .reason-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.reason-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reason-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.reason-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.send-button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.cancel-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .cancel-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
}
.send-button {
background: var(--fog-accent, #64748b);
color: white;
border: none;
}
:global(.dark) .send-button {
background: var(--fog-dark-accent, #94a3b8);
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
}
.send-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

449
src/lib/components/modals/VersionHistoryModal.svelte

@ -0,0 +1,449 @@ @@ -0,0 +1,449 @@
<script lang="ts">
import { getVersionsForEvent } from '../../services/cache/version-history.js';
import type { EventVersion } from '../../services/cache/version-history.js';
import type { NostrEvent } from '../../types/nostr.js';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
interface Props {
open?: boolean;
event: NostrEvent;
onClose: () => void;
}
let { open = $bindable(false), event, onClose }: Props = $props();
let versions = $state<EventVersion[]>([]);
let loading = $state(false);
let selectedVersion = $state<EventVersion | null>(null);
let jsonPreviewRef: HTMLElement | null = $state(null);
// Load versions when modal opens
$effect(() => {
if (open) {
loadVersions();
}
});
async function loadVersions() {
loading = true;
try {
versions = await getVersionsForEvent(event);
// If there are versions, select the first one (newest)
if (versions.length > 0) {
selectedVersion = versions[0];
}
} catch (error) {
console.error('Error loading versions:', error);
versions = [];
} finally {
loading = false;
}
}
// Highlight JSON when selectedVersion changes
$effect(() => {
if (jsonPreviewRef && selectedVersion && jsonPreviewRef instanceof HTMLElement) {
try {
const jsonText = JSON.stringify(selectedVersion.event, null, 2);
const highlighted = hljs.highlight(jsonText, { language: 'json' }).value;
jsonPreviewRef.innerHTML = highlighted;
jsonPreviewRef.className = 'hljs language-json';
} catch (err) {
// Fallback to plain text if highlighting fails
if (selectedVersion) {
jsonPreviewRef.textContent = JSON.stringify(selectedVersion.event, null, 2);
jsonPreviewRef.className = 'language-json';
}
}
}
});
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === 'Escape') onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="version-history-modal-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="version-history-modal-title">Version History</h2>
<button onclick={onClose} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
{#if loading}
<p class="loading-text">Loading versions...</p>
{:else if versions.length === 0}
<p class="no-versions-text">No version history available for this event.</p>
{:else}
<div class="version-history-container">
<div class="version-list">
<h3 class="version-list-title">Versions ({versions.length})</h3>
<div class="version-items">
{#each versions as version (version.id)}
<button
class="version-item"
class:active={selectedVersion?.id === version.id}
onclick={() => selectedVersion = version}
>
<div class="version-item-header">
<span class="version-number">Version {version.versionNumber}</span>
{#if version.versionNumber === versions[0].versionNumber}
<span class="version-badge">Latest</span>
{/if}
</div>
<div class="version-item-meta">
<span class="version-date">{formatDate(version.savedAt)}</span>
</div>
<div class="version-item-id">
<code class="event-id">{version.event.id.substring(0, 16)}...</code>
</div>
</button>
{/each}
</div>
</div>
<div class="version-viewer">
{#if selectedVersion}
<div class="version-viewer-header">
<h3>Version {selectedVersion.versionNumber}</h3>
<div class="version-viewer-meta">
<span>Saved: {formatDate(selectedVersion.savedAt)}</span>
<span>Event ID: <code>{selectedVersion.event.id}</code></span>
</div>
</div>
<div class="version-viewer-content">
<pre class="json-content"><code bind:this={jsonPreviewRef} class="language-json"></code></pre>
</div>
{:else}
<p class="no-selection-text">Select a version to view</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
:global(.dark) .modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
position: relative;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 90vw;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f9fafb);
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.loading-text,
.no-versions-text,
.no-selection-text {
text-align: center;
color: var(--fog-text-light, #52667a);
padding: 2rem;
}
:global(.dark) .loading-text,
:global(.dark) .no-versions-text,
:global(.dark) .no-selection-text {
color: var(--fog-dark-text-light, #a8b8d0);
}
.version-history-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 1rem;
height: calc(90vh - 200px);
min-height: 400px;
}
@media (max-width: 768px) {
.version-history-container {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
.version-list {
display: flex;
flex-direction: column;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
overflow: hidden;
}
:global(.dark) .version-list {
border-color: var(--fog-dark-border, #374151);
}
.version-list-title {
margin: 0;
padding: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
background: var(--fog-highlight, #f3f4f6);
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .version-list-title {
color: var(--fog-dark-text, #f9fafb);
background: var(--fog-dark-highlight, #374151);
border-bottom-color: var(--fog-dark-border, #374151);
}
.version-items {
overflow-y: auto;
flex: 1;
}
.version-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
padding: 0.75rem;
background: var(--fog-post, #ffffff);
border: none;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
text-align: left;
cursor: pointer;
transition: background 0.2s;
}
:global(.dark) .version-item {
background: var(--fog-dark-post, #1f2937);
border-bottom-color: var(--fog-dark-border, #374151);
}
.version-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .version-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.version-item.active {
background: var(--fog-accent, #64748b);
color: white;
}
:global(.dark) .version-item.active {
background: var(--fog-dark-accent, #94a3b8);
}
.version-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.version-number {
font-weight: 600;
font-size: 0.875rem;
}
.version-badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: var(--fog-accent, #64748b);
color: white;
border-radius: 0.25rem;
}
.version-item.active .version-badge {
background: rgba(255, 255, 255, 0.3);
}
.version-item-meta {
font-size: 0.75rem;
opacity: 0.8;
}
.version-item-id {
font-size: 0.75rem;
opacity: 0.7;
}
.event-id {
font-family: monospace;
}
.version-viewer {
display: flex;
flex-direction: column;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
overflow: hidden;
}
:global(.dark) .version-viewer {
border-color: var(--fog-dark-border, #374151);
}
.version-viewer-header {
padding: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .version-viewer-header {
background: var(--fog-dark-highlight, #374151);
border-bottom-color: var(--fog-dark-border, #374151);
}
.version-viewer-header h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .version-viewer-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.version-viewer-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .version-viewer-meta {
color: var(--fog-dark-text-light, #a8b8d0);
}
.version-viewer-meta code {
font-family: monospace;
font-size: 0.75rem;
}
.version-viewer-content {
flex: 1;
overflow: auto;
padding: 1rem;
}
.json-content {
background: #1e1e1e !important;
border: 1px solid #3e3e3e;
border-radius: 0.25rem;
padding: 1rem;
margin: 0;
overflow-x: auto;
}
.json-content code {
display: block;
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
</style>

379
src/lib/components/profile/EditProfileEventsPanel.svelte

@ -0,0 +1,379 @@ @@ -0,0 +1,379 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
isOpen: boolean;
pubkey: string;
onClose: () => void;
}
let { isOpen, pubkey, onClose }: Props = $props();
const PROFILE_EVENT_KINDS = [
{ kind: 0, name: 'Metadata (Profile)' },
{ kind: 3, name: 'Contacts' },
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
{ kind: 10002, name: 'Relay List' },
{ kind: 10432, name: 'Local Relays' },
{ kind: 10001, name: 'Pin List' },
{ kind: 10003, name: 'Bookmarks' },
{ kind: 10895, name: 'RSS Feed' },
{ kind: 10015, name: 'Interest List' },
{ kind: 10030, name: 'Emoji Set' },
{ kind: 30030, name: 'Emoji Pack' },
{ kind: 10000, name: 'Mute List' },
{ kind: 30008, name: 'Badges' },
{ kind: 30000, name: 'Follow Set' }
];
let eventMap = $state<Map<number, NostrEvent>>(new Map());
let loadingKinds = $state<Set<number>>(new Set());
let loadedKinds = $state<Set<number>>(new Set());
// Load events for all kinds when panel opens
$effect(() => {
if (isOpen && pubkey) {
loadAllEvents();
}
});
async function loadAllEvents() {
// Reset state
eventMap = new Map();
loadingKinds = new Set();
loadedKinds = new Set();
// Load events for all kinds in parallel
const loadPromises = PROFILE_EVENT_KINDS.map(({ kind }) => loadEventForKind(kind));
await Promise.all(loadPromises);
}
async function loadEventForKind(kind: number) {
if (loadingKinds.has(kind)) return;
loadingKinds = new Set([...loadingKinds, kind]);
try {
const relays = relayManager.getProfileReadRelays();
const limit = isParameterizedReplaceableKind(kind) ? 50 : 1;
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], limit }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
// Get newest version (or first if multiple allowed)
const event = events.sort((a, b) => b.created_at - a.created_at)[0];
const newMap = new Map(eventMap);
newMap.set(kind, event);
eventMap = newMap;
}
} catch (error) {
console.error(`Error loading event for kind ${kind}:`, error);
} finally {
loadingKinds = new Set([...loadingKinds].filter(k => k !== kind));
loadedKinds = new Set([...loadedKinds, kind]);
}
}
function handleEdit(kind: number, event: NostrEvent) {
// Store event data in sessionStorage for the write page to pick up
const editData = {
kind: event.kind,
content: event.content,
tags: event.tags,
isClone: false
};
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(editData));
onClose();
goto('/write');
}
function handleCreate(kind: number) {
// Navigate to write page with kind parameter
onClose();
goto(`/write?kind=${kind}`);
}
</script>
{#if isOpen}
<div
class="panel-backdrop"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClose();
}
}}
role="button"
tabindex="0"
aria-label="Close panel"
></div>
<div class="edit-profile-events-panel">
<div class="panel-header">
<h2 class="panel-title">Edit Profile Events</h2>
<button class="panel-close" onclick={onClose}>×</button>
</div>
<div class="panel-content">
<div class="kind-list">
{#each PROFILE_EVENT_KINDS as { kind, name }}
{@const event = eventMap.get(kind)}
{@const isLoading = loadingKinds.has(kind)}
{@const isLoaded = loadedKinds.has(kind)}
<div class="kind-item">
<div class="kind-info">
<span class="kind-name">{name}</span>
<span class="kind-number">Kind {kind}</span>
</div>
<div class="kind-actions">
{#if isLoading}
<span class="loading-text">Loading...</span>
{:else if event}
<button
class="action-button edit-button"
onclick={() => handleEdit(kind, event)}
>
Edit
</button>
{:else if isLoaded}
<button
class="action-button create-button"
onclick={() => handleCreate(kind)}
>
Create
</button>
{:else}
<button
class="action-button create-button"
onclick={() => handleCreate(kind)}
>
Create
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<style>
.panel-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.edit-profile-events-panel {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(500px, 90vw);
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .edit-profile-events-panel {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .panel-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.panel-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .panel-title {
color: var(--fog-dark-text, #f9fafb);
}
.panel-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #52667a);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s;
}
.panel-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .panel-close {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .panel-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.panel-content {
overflow-y: auto;
flex: 1;
padding: 1rem;
}
.kind-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.kind-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
transition: all 0.2s;
}
:global(.dark) .kind-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.kind-item:hover {
border-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .kind-item:hover {
border-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #374151);
}
.kind-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.kind-name {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .kind-name {
color: var(--fog-dark-text, #f9fafb);
}
.kind-number {
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
font-family: monospace;
}
:global(.dark) .kind-number {
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.edit-button {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .edit-button {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.edit-button:hover {
opacity: 0.9;
}
.create-button {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border-color: var(--fog-border, #e5e7eb);
}
:global(.dark) .create-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.create-button:hover {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .create-button:hover {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.loading-text {
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .loading-text {
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

69
src/lib/components/profile/ProfileMenu.svelte

@ -9,6 +9,9 @@ @@ -9,6 +9,9 @@
import type { NostrEvent } from '../../types/nostr.js';
import { toggleMute, toggleFollow, isMuted, isFollowed } from '../../services/user-actions.js';
import Icon from '../ui/Icon.svelte';
import EditProfileEventsPanel from './EditProfileEventsPanel.svelte';
import ReportUserModal from '../modals/ReportUserModal.svelte';
import DeleteAllEventsModal from '../modals/DeleteAllEventsModal.svelte';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
@ -30,6 +33,9 @@ @@ -30,6 +33,9 @@
let following = $state(false);
let showJsonModal = $state(false);
let jsonPreviewRef: HTMLElement | null = $state(null);
let showEditProfileEventsPanel = $state(false);
let showReportModal = $state(false);
let showDeleteAllEventsModal = $state(false);
// Highlight JSON when profileEvent or showJsonModal changes
$effect(() => {
@ -343,6 +349,33 @@ @@ -343,6 +349,33 @@
closeMenu();
}
}
function openEditProfileEventsPanel() {
showEditProfileEventsPanel = true;
closeMenu();
}
function closeEditProfileEventsPanel() {
showEditProfileEventsPanel = false;
}
function openReportModal() {
showReportModal = true;
closeMenu();
}
function closeReportModal() {
showReportModal = false;
}
function openDeleteAllEventsModal() {
showDeleteAllEventsModal = true;
closeMenu();
}
function closeDeleteAllEventsModal() {
showDeleteAllEventsModal = false;
}
</script>
<div class="profile-menu-container">
@ -392,6 +425,24 @@ @@ -392,6 +425,24 @@
</button>
{/if}
{#if isOwnProfile}
<button class="menu-item" onclick={openEditProfileEventsPanel} role="menuitem">
<span class="menu-item-icon"></span>
<span class="menu-item-text">Edit profile events</span>
</button>
<button class="menu-item menu-item-danger" onclick={openDeleteAllEventsModal} role="menuitem">
<span class="menu-item-icon">🗑</span>
<span class="menu-item-text">Delete all events from this npub</span>
</button>
{/if}
{#if isLoggedIn && !isOwnProfile}
<button class="menu-item" onclick={openReportModal} role="menuitem">
<span class="menu-item-icon">🚩</span>
<span class="menu-item-text">Report this user</span>
</button>
{/if}
<div class="menu-divider"></div>
<button class="menu-item" onclick={shareWithAitherboard} role="menuitem">
@ -472,6 +523,24 @@ @@ -472,6 +523,24 @@
</div>
</div>
{/if}
<EditProfileEventsPanel
isOpen={showEditProfileEventsPanel}
pubkey={pubkey}
onClose={closeEditProfileEventsPanel}
/>
<ReportUserModal
bind:open={showReportModal}
reportedPubkey={pubkey}
onClose={closeReportModal}
/>
<DeleteAllEventsModal
bind:open={showDeleteAllEventsModal}
pubkey={pubkey}
onClose={closeDeleteAllEventsModal}
/>
</div>
<style>

38
src/lib/services/cache/event-archive.ts vendored

@ -490,3 +490,41 @@ export async function isEventArchived(id: string): Promise<boolean> { @@ -490,3 +490,41 @@ export async function isEventArchived(id: string): Promise<boolean> {
return false;
}
}
/**
* Delete an archived event by ID
*/
export async function deleteArchivedEvent(id: string): Promise<void> {
try {
const db = await getDB();
await db.delete('eventArchive', id);
} catch (error) {
// Archive deletion failed (non-critical)
}
}
/**
* Delete all archived events by pubkey
*/
export async function deleteArchivedEventsByPubkey(pubkey: string): Promise<number> {
try {
const db = await getDB();
const tx = db.transaction('eventArchive', 'readwrite');
const index = tx.store.index('pubkey');
const archived = await index.getAll(pubkey);
await tx.done;
if (archived.length === 0) return 0;
// Delete in batch
const deleteTx = db.transaction('eventArchive', 'readwrite');
await Promise.all(archived.map(a => deleteTx.store.delete(a.id)));
await deleteTx.done;
return archived.length;
} catch (error) {
// Archive deletion failed (non-critical)
return 0;
}
}

47
src/lib/services/cache/event-cache.ts vendored

@ -4,6 +4,8 @@ @@ -4,6 +4,8 @@
import { getDB } from './indexeddb-store.js';
import { isEventDeleted, getDeletedEventIds } from './deletion-tracker.js';
import { saveEventVersion } from './version-history.js';
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
@ -21,6 +23,51 @@ export async function cacheEvent(event: NostrEvent): Promise<void> { @@ -21,6 +23,51 @@ export async function cacheEvent(event: NostrEvent): Promise<void> {
return;
}
// For replaceable events, check if there's an existing version and save it to history
if (isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)) {
try {
const db = await getDB();
const dTag = isParameterizedReplaceableKind(event.kind)
? event.tags.find(t => t[0] === 'd')?.[1] || null
: null;
// Find existing event with same pubkey, kind, and d tag (if parameterized)
let existingEvent: CachedEvent | undefined;
if (isParameterizedReplaceableKind(event.kind) && dTag) {
// For parameterized replaceable, need to search by pubkey, kind, and d tag
const tx = db.transaction('events', 'readonly');
const pubkeyIndex = tx.store.index('pubkey');
const eventsByPubkey = await pubkeyIndex.getAll(event.pubkey);
await tx.done;
// Filter to same kind and d tag
existingEvent = eventsByPubkey.find((e: CachedEvent) => {
if (e.kind !== event.kind) return false;
const existingDTag = e.tags.find(t => t[0] === 'd')?.[1] || null;
return existingDTag === dTag;
}) as CachedEvent | undefined;
} else {
// For regular replaceable, search by pubkey and kind
const tx = db.transaction('events', 'readonly');
const pubkeyIndex = tx.store.index('pubkey');
const eventsByPubkey = await pubkeyIndex.getAll(event.pubkey);
await tx.done;
// Filter to same kind
existingEvent = eventsByPubkey.find((e: CachedEvent) => e.kind === event.kind) as CachedEvent | undefined;
}
// If we found an existing event and it's different from the new one, save it to version history
if (existingEvent && existingEvent.id !== event.id) {
await saveEventVersion(existingEvent);
}
} catch (error) {
// Version history save failed (non-critical) - continue with caching
console.error('Error saving event version to history:', error);
}
}
const db = await getDB();
const cached: CachedEvent = {
...event,

10
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 10; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store
const DB_VERSION = 11; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store. Version 11: Added event version history store
export interface DatabaseSchema {
events: {
@ -53,6 +53,11 @@ export interface DatabaseSchema { @@ -53,6 +53,11 @@ export interface DatabaseSchema {
value: unknown;
indexes: { cached_at: number; createdAt: number };
};
eventVersions: {
key: string; // composite key: `${pubkey}:${kind}:${dTag || ''}:${versionNumber}`
value: unknown;
indexes: { eventKey: string; pubkey: string; kind: number; savedAt: number };
};
}
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -153,7 +158,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -153,7 +158,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
!dbInstance.objectStoreNames.contains('rss') ||
!dbInstance.objectStoreNames.contains('markdown') ||
!dbInstance.objectStoreNames.contains('eventArchive') ||
!dbInstance.objectStoreNames.contains('gifs')) {
!dbInstance.objectStoreNames.contains('gifs') ||
!dbInstance.objectStoreNames.contains('eventVersions')) {
// Database is corrupted - close and delete it, then recreate
// Database schema outdated, recreating
dbInstance.close();

12
src/lib/services/cache/profile-cache.ts vendored

@ -67,3 +67,15 @@ export async function getProfiles(pubkeys: string[]): Promise<Map<string, Cached @@ -67,3 +67,15 @@ export async function getProfiles(pubkeys: string[]): Promise<Map<string, Cached
return new Map();
}
}
/**
* Delete profile by pubkey from cache
*/
export async function deleteProfile(pubkey: string): Promise<void> {
try {
const db = await getDB();
await db.delete('profiles', pubkey);
} catch (error) {
// Cache deletion failed (non-critical)
}
}

151
src/lib/services/cache/version-history.ts vendored

@ -0,0 +1,151 @@ @@ -0,0 +1,151 @@
/**
* Version history for replaceable events
* Stores previous versions of replaceable events before they are replaced
*/
import { getDB } from './indexeddb-store.js';
import type { NostrEvent } from '../../types/nostr.js';
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
export interface EventVersion {
id: string; // Composite key: `${pubkey}:${kind}:${dTag || ''}:${versionNumber}`
pubkey: string;
kind: number;
dTag: string | null; // For parameterized replaceable events
versionNumber: number; // Incremental version number
event: NostrEvent;
savedAt: number; // When this version was saved
eventKey: string; // Composite key for indexing: `${pubkey}:${kind}:${dTag || ''}`
}
/**
* Get the composite key for a replaceable event
*/
export function getEventKey(pubkey: string, kind: number, dTag: string | null): string {
return `${pubkey}:${kind}:${dTag || ''}`;
}
/**
* Get the next version number for an event
*/
async function getNextVersionNumber(pubkey: string, kind: number, dTag: string | null): Promise<number> {
try {
const db = await getDB();
const eventKey = getEventKey(pubkey, kind, dTag);
// Get all versions for this event key
const tx = db.transaction('eventVersions', 'readonly');
const index = tx.store.index('eventKey');
const versions = await index.getAll(eventKey);
await tx.done;
if (versions.length === 0) return 1;
// Find the highest version number
const maxVersion = Math.max(...versions.map(v => v.versionNumber));
return maxVersion + 1;
} catch (error) {
// If error, start at version 1
return 1;
}
}
/**
* Save a version of a replaceable event to history
*/
export async function saveEventVersion(event: NostrEvent): Promise<void> {
try {
// Only save versions for replaceable events
if (!isReplaceableKind(event.kind) && !isParameterizedReplaceableKind(event.kind)) {
return;
}
const db = await getDB();
// Get d tag for parameterized replaceable events
const dTag = isParameterizedReplaceableKind(event.kind)
? event.tags.find(t => t[0] === 'd')?.[1] || null
: null;
const eventKey = getEventKey(event.pubkey, event.kind, dTag);
// Get the next version number
const versionNumber = await getNextVersionNumber(event.pubkey, event.kind, dTag);
// Create version record
const version: EventVersion = {
id: `${eventKey}:${versionNumber}`,
pubkey: event.pubkey,
kind: event.kind,
dTag,
versionNumber,
event,
savedAt: Date.now(),
eventKey
};
// Save to version history
await db.put('eventVersions', version);
} catch (error) {
// Version history save failed (non-critical)
// Don't throw - version history failures shouldn't break the app
}
}
/**
* Get all versions for a replaceable event
*/
export async function getEventVersions(
pubkey: string,
kind: number,
dTag: string | null = null
): Promise<EventVersion[]> {
try {
const db = await getDB();
const eventKey = getEventKey(pubkey, kind, dTag);
const tx = db.transaction('eventVersions', 'readonly');
const index = tx.store.index('eventKey');
const versions = await index.getAll(eventKey);
await tx.done;
// Sort by version number descending (newest first)
return versions.sort((a, b) => b.versionNumber - a.versionNumber);
} catch (error) {
return [];
}
}
/**
* Get a specific version by event ID (if we know which version it was)
*/
export async function getVersionByEventId(eventId: string): Promise<EventVersion | null> {
try {
const db = await getDB();
const tx = db.transaction('eventVersions', 'readonly');
// Search through all versions to find one with matching event ID
for await (const cursor of tx.store.iterate()) {
if (cursor.value.event.id === eventId) {
return cursor.value as EventVersion;
}
}
await tx.done;
return null;
} catch (error) {
return null;
}
}
/**
* Get versions for a specific event (by current event ID)
* This finds the event key from the event and returns all versions
*/
export async function getVersionsForEvent(event: NostrEvent): Promise<EventVersion[]> {
const dTag = isParameterizedReplaceableKind(event.kind)
? event.tags.find(t => t[0] === 'd')?.[1] || null
: null;
return getEventVersions(event.pubkey, event.kind, dTag);
}

2
src/lib/types/kind-lookup.ts

@ -60,6 +60,7 @@ export const KIND = { @@ -60,6 +60,7 @@ export const KIND = {
CONTACTS: 3,
FOLLOW_SET: 30000,
EVENT_DELETION: 5,
DELETION_REQUEST: 62,
REACTION: 7,
DISCUSSION_THREAD: 11,
COMMENT: 1111,
@ -109,6 +110,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -109,6 +110,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Text Note', showInFeed: true, isSecondaryKind: false },
[KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Contact List', showInFeed: false, isSecondaryKind: false },
[KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isSecondaryKind: false },
[KIND.DELETION_REQUEST]: { number: KIND.DELETION_REQUEST, description: 'Deletion Request', showInFeed: false, isSecondaryKind: false },
[KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isSecondaryKind: true },
[KIND.PUBLIC_MESSAGE]: { number: KIND.PUBLIC_MESSAGE, description: 'Public Message', showInFeed: false, isSecondaryKind: false },

10
src/routes/about/+page.svelte

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import Icon from '../../lib/components/ui/Icon.svelte';
import { getAppVersion } from '../../lib/services/version-manager.js';
let appVersion = $state('0.2.0');
let appVersion = $state('0.3.0');
onMount(async () => {
appVersion = await getAppVersion();
@ -21,6 +21,14 @@ @@ -21,6 +21,14 @@
// Changelog for current version
const changelog: Record<string, string[]> = {
'0.3.0': [
'Version history modal added to event menu',
'Event and npub deletion/reporting added',
'User and event reporting added',
'Profile settings added',
'Finished implementing themes',
'Add Edit/Clone of all events'
],
'0.2.1': [
'Themes added: Fog, Forum, Socialmedia, and Terminal',
'Find page added: search for content by hashtags, users, and content, full-text search'

Loading…
Cancel
Save