You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

649 lines
17 KiB

<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../modals/PublicationStatusModal.svelte';
import { goto } from '$app/navigation';
import { KIND, 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 selectedKind = $state<number | null>(null);
let editingEvent = $state<NostrEvent | null>(null);
let content = $state('');
let tags = $state<string[][]>([]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
async function selectKind(kind: number) {
selectedKind = kind;
// Load existing event(s)
// For parameterized replaceable events (30000-39999), user can have multiple, so we load all
// For other kinds, typically one per user
try {
const relays = relayManager.getProfileReadRelays();
const limit = isParameterizedReplaceableKind(kind) ? 50 : 1; // Load multiple for parameterized replaceable events
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)
editingEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
content = editingEvent.content || '';
tags = [...editingEvent.tags];
} else {
editingEvent = null;
content = '';
tags = [];
}
} catch (error) {
console.error('Error loading event:', error);
editingEvent = null;
content = '';
tags = [];
}
}
function addTag() {
tags = [...tags, ['', '']];
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function updateTag(index: number, field: number, value: string) {
const newTags = [...tags];
if (!newTags[index]) {
newTags[index] = ['', ''];
}
newTags[index] = [...newTags[index]];
newTags[index][field] = value;
while (newTags[index].length <= field) {
newTags[index].push('');
}
tags = newTags;
}
async function publish() {
if (selectedKind === null) return;
const session = sessionManager.getSession();
if (!session || session.pubkey !== pubkey) {
alert('You can only edit your own profile events');
return;
}
publishing = true;
try {
// Always use a new timestamp for newly-published events
// This ensures the event is considered "newer" and replaces older versions for replaceable events
const created_at = Math.floor(Date.now() / 1000);
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at,
tags: tags.filter(t => t[0] && t[1]),
content
};
const signedEvent = await session.signer(eventTemplate);
await cacheEvent(signedEvent);
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
}, 5000);
}
} catch (error) {
console.error('Error publishing event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
publishing = false;
}
}
async function republishFromCache() {
if (!publicationResults || selectedKind === null) return;
publishing = true;
try {
const session = sessionManager.getSession();
if (!session) return;
const relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
// Always use a new timestamp for newly-published events
const eventTemplate = {
kind: selectedKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
content
};
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
} catch (error) {
console.error('Error republishing:', error);
} finally {
publishing = false;
}
}
function closeForm() {
selectedKind = null;
editingEvent = null;
content = '';
tags = [];
}
</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="profile-events-panel">
<div class="panel-header">
<h2 class="panel-title">Adjust Profile Events</h2>
<button class="panel-close" onclick={onClose}>×</button>
</div>
<div class="panel-content">
{#if selectedKind === null}
<div class="kind-list">
{#each PROFILE_EVENT_KINDS as { kind, name }}
<button class="kind-button" onclick={() => selectKind(kind)}>
{name} (Kind {kind})
</button>
{/each}
</div>
{:else}
<div class="edit-form">
<button class="back-button" onclick={closeForm}> Back</button>
<h3 class="form-title">Edit Kind {selectedKind}</h3>
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<textarea
id="content-textarea"
bind:value={content}
class="content-input"
rows="10"
placeholder="Event content..."
></textarea>
</div>
<div class="form-group">
<fieldset>
<legend class="form-label">Tags</legend>
<div class="tags-list">
{#each tags as tag, index (index)}
<div class="tag-row">
<input
type="text"
value={tag[0] || ''}
oninput={(e) => updateTag(index, 0, e.currentTarget.value)}
placeholder="Tag name"
class="tag-input"
/>
{#each tag.slice(1) as value, valueIndex}
<input
type="text"
value={value || ''}
oninput={(e) => updateTag(index, valueIndex + 1, e.currentTarget.value)}
placeholder="Tag value"
class="tag-input"
/>
{/each}
<button class="tag-add-value" onclick={() => {
const newTags = [...tags];
newTags[index] = [...newTags[index], ''];
tags = newTags;
}}>+</button>
<button class="tag-remove" onclick={() => removeTag(index)}>×</button>
</div>
{/each}
<button class="add-tag-button" onclick={addTag}>Add Tag</button>
</div>
</fieldset>
</div>
<div class="form-actions">
<button
class="publish-button"
onclick={publish}
disabled={publishing}
>
{publishing ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}
<div class="republish-section">
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p>
<button class="republish-button" onclick={republishFromCache} disabled={publishing}>
Republish from Cache
</button>
</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;
}
.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) .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, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.panel-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .panel-close {
color: var(--fog-dark-text-light, #6b7280);
}
: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.5rem;
}
.kind-button {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
:global(.dark) .kind-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.kind-button:hover {
border-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .kind-button:hover {
border-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #374151);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
align-self: flex-start;
}
:global(.dark) .back-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.back-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .back-button:hover {
background: var(--fog-dark-border, #475569);
}
.form-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .form-label {
color: var(--fog-dark-text, #f9fafb);
}
.content-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: monospace;
resize: vertical;
}
:global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tag-input {
flex: 1;
padding: 0.5rem;
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;
}
:global(.dark) .tag-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value,
.tag-remove {
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
min-width: 2rem;
}
:global(.dark) .tag-add-value,
:global(.dark) .tag-remove {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.tag-add-value:hover,
.tag-remove:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .tag-add-value:hover,
:global(.dark) .tag-remove:hover {
background: var(--fog-dark-border, #475569);
}
.add-tag-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.add-tag-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .add-tag-button:hover {
background: var(--fog-dark-border, #475569);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.publish-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
:global(.dark) .publish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.publish-button:hover:not(:disabled) {
opacity: 0.9;
}
.publish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.republish-section {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .republish-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.republish-text {
margin: 0 0 0.5rem 0;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .republish-text {
color: var(--fog-dark-text, #f9fafb);
}
.republish-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .republish-button {
background: var(--fog-dark-accent, #94a3b8);
}
.republish-button:hover:not(:disabled) {
opacity: 0.9;
}
.republish-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>