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.
 
 
 
 
 

800 lines
22 KiB

<script lang="ts">
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../types/nostr.js';
import { nostrClient } from '../services/nostr/nostr-client.js';
import { relayManager } from '../services/nostr/relay-manager.js';
import EventJsonModal from './modals/EventJsonModal.svelte';
import PublicationStatusModal from './modals/PublicationStatusModal.svelte';
import {
isPinned,
isBookmarked,
isHighlighted,
togglePin,
toggleBookmark,
toggleHighlight
} from '../services/user-actions.js';
import { eventMenuStore } from '../services/event-menu-store.js';
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 { goto } from '$app/navigation';
import Icon from './ui/Icon.svelte';
interface Props {
event: NostrEvent;
showContentActions?: boolean; // Show pin/bookmark/highlight for notes with content
onReply?: () => void; // Callback for reply action
}
let { event, showContentActions = false, onReply }: Props = $props();
let menuOpen = $state(false);
let jsonModalOpen = $state(false);
let relatedEventsModalOpen = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let broadcasting = $state(false);
let deleting = $state(false);
let deleteConfirmOpen = $state(false);
let copied = $state<string | null>(null);
let menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null);
let menuPosition = $state({ top: 0, right: 0 });
// Unique ID for this menu instance
let menuId = $derived(event.id);
// Note: Removed isContentNote check - all events should have the same menu (except profile pages/cards)
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey());
let isOwnEvent = $derived(isLoggedIn && currentUserPubkey === event.pubkey);
// Track pin/bookmark/highlight state
let pinnedState = $state(false);
let bookmarkedState = $state(false);
let highlightedState = $state(false);
let stateUpdateTrigger = $state(0); // Trigger to force state updates
// Update state when event changes or when trigger changes
$effect(() => {
highlightedState = isHighlighted(event.id);
// Access trigger to make effect reactive to it
void stateUpdateTrigger;
// Update pin and bookmark state asynchronously
isPinned(event.id).then(pinned => {
pinnedState = pinned;
});
isBookmarked(event.id).then(bookmarked => {
bookmarkedState = bookmarked;
});
});
function toggleMenu() {
if (menuOpen) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
menuOpen = true;
// Register this menu as open - this will close any other open menu
eventMenuStore.openMenu(menuId, closeMenu);
// Position menu after opening
requestAnimationFrame(() => {
positionMenu();
});
}
function positionMenu() {
if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isMobile = viewportWidth < 640;
const padding = isMobile ? 4 : 8; // Smaller padding on mobile
// Get dropdown dimensions (estimate or use actual if available)
let dropdownWidth = isMobile ? 180 : 200; // min-width from CSS
let dropdownHeight = 300; // Estimate, will be updated
// Position below button by default, aligned to right edge
let top = buttonRect.bottom + 4;
let right = viewportWidth - buttonRect.right;
// Get actual dimensions after rendering
requestAnimationFrame(() => {
if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect();
dropdownWidth = dropdownRect.width;
dropdownHeight = dropdownRect.height;
// Calculate left position from right
const left = viewportWidth - right - dropdownWidth;
// Check and adjust for viewport boundaries
let adjustedTop = top;
let adjustedRight = right;
// Check bottom overflow
if (top + dropdownHeight + padding > viewportHeight) {
// Try positioning above button
const spaceAbove = buttonRect.top;
const spaceBelow = viewportHeight - buttonRect.bottom;
if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) {
adjustedTop = buttonRect.top - dropdownHeight - 4;
} else {
// Not enough space above, position at bottom of viewport
adjustedTop = viewportHeight - dropdownHeight - padding;
}
}
// Check top overflow
if (adjustedTop < padding) {
adjustedTop = padding;
}
// Check right overflow (menu goes off right edge)
if (left < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
}
// Check left overflow (menu goes off left edge)
const adjustedLeft = viewportWidth - adjustedRight - dropdownWidth;
if (adjustedLeft < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
}
// Ensure menu doesn't go off right edge
if (adjustedRight < padding) {
adjustedRight = padding;
}
// On mobile, ensure menu doesn't go off left edge
if (isMobile && adjustedLeft < padding) {
adjustedRight = Math.max(padding, viewportWidth - dropdownWidth - padding);
}
menuPosition = { top: adjustedTop, right: adjustedRight };
});
// Set initial position
menuPosition = { top, right };
}
function closeMenu() {
menuOpen = false;
eventMenuStore.closeMenu(menuId);
}
// Close menu when clicking outside
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.event-menu-container') && !target.closest('.menu-dropdown')) {
eventMenuStore.closeCurrentMenu();
}
}
$effect(() => {
if (menuOpen) {
document.addEventListener('click', handleClickOutside);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', positionMenu);
// Reposition on scroll to keep menu aligned
const handleScroll = () => {
if (menuOpen) {
positionMenu();
}
};
window.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', positionMenu);
window.removeEventListener('scroll', handleScroll, true);
};
}
});
async function copyUserId() {
try {
const npub = nip19.npubEncode(event.pubkey);
await navigator.clipboard.writeText(npub);
copied = 'npub';
setTimeout(() => {
copied = null;
}, 2000);
closeMenu();
} catch (error) {
console.error('Failed to copy user ID:', error);
}
}
async function copyEventId() {
try {
const nevent = nip19.neventEncode({
id: event.id,
relays: []
});
await navigator.clipboard.writeText(nevent);
copied = 'nevent';
setTimeout(() => {
copied = null;
}, 2000);
closeMenu();
} catch (error) {
console.error('Failed to copy event ID:', error);
}
}
function viewJson() {
jsonModalOpen = true;
closeMenu();
}
function viewRelatedEvents() {
relatedEventsModalOpen = true;
closeMenu();
}
async function broadcastEvent() {
broadcasting = true;
closeMenu();
try {
// Get all available relays for broadcasting
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
const results = await nostrClient.publish(event, { relays });
publicationResults = results;
publicationModalOpen = true;
} catch (error) {
console.error('Error broadcasting event:', error);
publicationResults = {
success: [],
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }]
};
publicationModalOpen = true;
} finally {
broadcasting = false;
}
}
async function shareWithaitherboard() {
try {
const url = `${window.location.origin}/event/${event.id}`;
await navigator.clipboard.writeText(url);
copied = 'share';
setTimeout(() => {
copied = null;
}, 2000);
closeMenu();
} catch (error) {
console.error('Failed to copy share URL:', error);
}
}
async function pinNote() {
await togglePin(event.id);
// Force state update
stateUpdateTrigger++;
closeMenu();
}
async function bookmarkNote() {
await toggleBookmark(event.id);
// Force state update by re-checking bookmark status
const newBookmarked = await isBookmarked(event.id);
bookmarkedState = newBookmarked;
closeMenu();
}
function highlightNote() {
// Extract content and e/a tags for highlight
const content = event.content || '';
// Collect all relevant tags: e-tag with the event's ID, a-tag (if available), and p-tag with the event's pubkey
const tagsToInclude: string[][] = [];
// Always add e-tag with the event ID of the event being highlighted
tagsToInclude.push(['e', event.id]);
// Also check for a-tag in the original event (for parameterized replaceable events)
const aTag = event.tags.find(tag => tag[0] === 'a');
if (aTag) {
tagsToInclude.push(aTag);
}
// Add p-tag with the pubkey of the event being highlighted
tagsToInclude.push(['p', event.pubkey]);
// Store highlight data in sessionStorage
const highlightData = {
content,
tags: tagsToInclude
};
sessionStorage.setItem('aitherboard_highlightData', JSON.stringify(highlightData));
// Navigate to write form with kind 9802 (highlight)
goto('/write?kind=9802');
closeMenu();
}
function confirmDelete() {
deleteConfirmOpen = 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
);
// 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;
}
}
</script>
<div class="event-menu-container">
<button
bind:this={menuButtonElement}
class="menu-button"
onclick={toggleMenu}
aria-label="Event menu"
aria-expanded={menuOpen}
>
<span class="menu-icon"></span>
</button>
{#if menuOpen}
<div
bind:this={menuDropdownElement}
class="menu-dropdown"
style="top: {menuPosition.top}px; right: {menuPosition.right}px;"
>
<button class="menu-item" onclick={copyUserId}>
<Icon name="copy" size={16} />
<span>Copy user ID</span>
{#if copied === 'npub'}
<span class="copied-indicator"></span>
{/if}
</button>
<button class="menu-item" onclick={copyEventId}>
<Icon name="copy" size={16} />
<span>Copy event ID</span>
{#if copied === 'nevent'}
<span class="copied-indicator"></span>
{/if}
</button>
<button class="menu-item" onclick={viewJson}>
<Icon name="code" size={16} />
<span>View JSON</span>
</button>
{#if isLoggedIn}
<button class="menu-item" onclick={viewRelatedEvents}>
<Icon name="search" size={16} />
<span>View your related events</span>
</button>
{/if}
<button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}>
<Icon name="radio" size={16} />
<span>{broadcasting ? 'Broadcasting...' : 'Broadcast event'}</span>
</button>
<button class="menu-item" onclick={shareWithaitherboard}>
<Icon name="share" size={16} />
<span>Share with aitherboard</span>
{#if copied === 'share'}
<span class="copied-indicator"></span>
{/if}
</button>
{#if isLoggedIn && onReply}
<div class="menu-divider"></div>
<button class="menu-item menu-item-reply" onclick={() => { onReply(); closeMenu(); }}>
<Icon name="message-square" size={16} />
<span>Reply</span>
</button>
{/if}
{#if isLoggedIn && showContentActions}
<div class="menu-divider"></div>
<button class="menu-item" onclick={pinNote} class:active={pinnedState}>
<Icon name="plus" size={16} />
<span>Pin note</span>
{#if pinnedState}
<span class="action-indicator"></span>
{/if}
</button>
<button class="menu-item" onclick={bookmarkNote} class:active={bookmarkedState}>
<Icon name="plus" size={16} />
<span>Bookmark note</span>
{#if bookmarkedState}
<span class="action-indicator"></span>
{/if}
</button>
<button class="menu-item" onclick={highlightNote} class:active={highlightedState}>
<Icon name="edit" size={16} />
<span>Highlight note</span>
{#if highlightedState}
<span class="action-indicator"></span>
{/if}
</button>
{/if}
{#if isLoggedIn && isOwnEvent}
<div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={confirmDelete} disabled={deleting}>
<Icon name="trash" size={16} />
<span>{deleting ? 'Deleting...' : 'Delete event'}</span>
</button>
{/if}
</div>
{/if}
</div>
<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}
<style>
.event-menu-container {
position: relative;
display: inline-block;
/* Ensure menu can overflow container */
overflow: visible;
}
.menu-button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: var(--fog-text-light, #52667a);
font-size: 1.25rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
filter: grayscale(100%);
}
.menu-button:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .menu-button {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .menu-button:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f1f5f9);
}
.menu-icon {
user-select: none;
transform: rotate(90deg);
display: inline-block;
filter: grayscale(100%);
}
.menu-dropdown {
position: fixed;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
z-index: 1000;
overflow-y: auto;
overflow-x: hidden;
}
.menu-item-reply {
display: flex !important;
visibility: visible !important;
}
@media (max-width: 640px) {
.menu-dropdown {
min-width: 180px;
max-width: calc(100vw - 8px);
font-size: 0.875rem;
}
.menu-item {
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
min-height: 2.5rem;
}
.menu-button {
padding: 0.375rem 0.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
}
}
:global(.dark) .menu-dropdown {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
transition: background-color 0.2s;
gap: 0.5rem;
}
.menu-item :global(.icon) {
flex-shrink: 0;
}
.menu-item span {
flex: 1;
}
.menu-item:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
}
.menu-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.menu-item.active {
background: var(--fog-highlight, #f3f4f6);
font-weight: 500;
}
:global(.dark) .menu-item {
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .menu-item:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
}
:global(.dark) .menu-item.active {
background: var(--fog-dark-highlight, #374151);
}
.action-indicator {
color: var(--fog-accent, #64748b);
font-weight: 600;
margin-left: 0.5rem;
}
:global(.dark) .action-indicator {
color: var(--fog-dark-accent, #94a3b8);
}
.menu-divider {
height: 1px;
background: var(--fog-border, #e5e7eb);
margin: 0.25rem 0;
}
:global(.dark) .menu-divider {
background: var(--fog-dark-border, #374151);
}
.copied-indicator {
color: var(--fog-accent, #64748b);
font-weight: 600;
margin-left: 0.5rem;
}
:global(.dark) .copied-indicator {
color: var(--fog-dark-accent, #94a3b8);
}
.menu-item-danger {
color: var(--fog-danger, #dc2626);
}
:global(.dark) .menu-item-danger {
color: var(--fog-dark-danger, #ef4444);
}
.menu-item-danger:hover:not(:disabled) {
background: var(--fog-danger-light, #fee2e2);
}
: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>