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.
 
 
 
 
 

516 lines
13 KiB

<script lang="ts">
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getProfile } from '../../services/cache/profile-cache.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { toggleMute, toggleFollow, isMuted, isFollowed } from '../../services/user-actions.js';
import Icon from '../ui/Icon.svelte';
interface Props {
pubkey: string;
onOpenBookmarks?: () => void;
}
let { pubkey, onOpenBookmarks }: Props = $props();
let menuOpen = $state(false);
let menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null);
let menuPosition = $state({ top: 0, right: 0 });
let copied = $state<string | null>(null);
let profileEvent = $state<NostrEvent | null>(null);
let muting = $state(false);
let following = $state(false);
let isLoggedIn = $derived(sessionManager.isLoggedIn());
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey());
let isOwnProfile = $derived(isLoggedIn && currentUserPubkey === pubkey);
let muted = $state(false);
let followed = $state(false);
// Update mute and follow state
$effect(() => {
if (pubkey) {
isMuted(pubkey).then(m => {
muted = m;
});
isFollowed(pubkey).then(f => {
followed = f;
});
}
});
// Load kind 0 event for this profile
$effect(() => {
if (pubkey) {
loadProfileEvent();
}
});
async function loadProfileEvent() {
try {
// Try cache first
const cached = await getProfile(pubkey);
if (cached) {
profileEvent = cached.event;
return;
}
// Fetch from relays if not in cache
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
profileEvent = events[0];
}
} catch (error) {
console.error('Error loading profile event:', error);
}
}
function toggleMenu() {
if (menuOpen) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
menuOpen = true;
requestAnimationFrame(() => {
positionMenu();
});
}
// Reposition menu on window resize
$effect(() => {
if (!menuOpen) return;
function handleResize() {
positionMenu();
}
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleResize, true);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleResize, true);
};
});
function closeMenu() {
menuOpen = false;
}
function positionMenu() {
if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 8; // Padding from viewport edges
// Initial position: below button, aligned to right edge
let top = buttonRect.bottom + 4;
let right = viewportWidth - buttonRect.right;
menuPosition = { top, right };
requestAnimationFrame(() => {
if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect();
const dropdownWidth = dropdownRect.width;
const dropdownHeight = dropdownRect.height;
// Calculate left position from right
const left = viewportWidth - right - dropdownWidth;
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;
}
menuPosition = { top: adjustedTop, right: adjustedRight };
});
}
// Close menu when clicking outside
$effect(() => {
if (!menuOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
menuButtonElement &&
menuDropdownElement &&
!menuButtonElement.contains(event.target as Node) &&
!menuDropdownElement.contains(event.target as Node)
) {
closeMenu();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu();
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
});
async function copyUserId() {
try {
const npub = nip19.npubEncode(pubkey);
await navigator.clipboard.writeText(npub);
copied = 'userId';
setTimeout(() => {
copied = null;
}, 2000);
closeMenu();
} catch (error) {
console.error('Failed to copy npub:', error);
}
}
async function copyEventId() {
try {
if (!profileEvent) {
await loadProfileEvent();
}
if (profileEvent) {
const nevent = nip19.neventEncode({
id: profileEvent.id,
author: profileEvent.pubkey,
relays: []
});
await navigator.clipboard.writeText(nevent);
copied = 'eventId';
setTimeout(() => {
copied = null;
}, 2000);
}
closeMenu();
} catch (error) {
console.error('Failed to copy nevent:', error);
}
}
function shareWithAitherboard() {
const url = `${window.location.origin}/profile/${pubkey}`;
navigator.clipboard.writeText(url).then(() => {
copied = 'share';
setTimeout(() => {
copied = null;
}, 2000);
}).catch(error => {
console.error('Failed to copy share URL:', error);
});
closeMenu();
}
function seeBookmarks() {
if (onOpenBookmarks) {
onOpenBookmarks();
}
closeMenu();
}
async function handleMute() {
if (muting || isOwnProfile) return;
muting = true;
try {
await toggleMute(pubkey);
// Update state
const newMuted = await isMuted(pubkey);
muted = newMuted;
} catch (error) {
console.error('Failed to toggle mute:', error);
} finally {
muting = false;
closeMenu();
}
}
async function handleFollow() {
if (following || isOwnProfile) return;
following = true;
try {
await toggleFollow(pubkey);
// Update state
const newFollowed = await isFollowed(pubkey);
followed = newFollowed;
} catch (error) {
console.error('Failed to toggle follow:', error);
} finally {
following = false;
closeMenu();
}
}
</script>
<div class="profile-menu-container">
<button
bind:this={menuButtonElement}
class="menu-button"
onclick={toggleMenu}
aria-label="Profile menu"
aria-expanded={menuOpen}
aria-haspopup="true"
>
<span class="menu-icon"></span>
</button>
{#if menuOpen}
<div
bind:this={menuDropdownElement}
class="menu-dropdown"
style="top: {menuPosition.top}px; right: {menuPosition.right}px;"
role="menu"
>
<button class="menu-item" onclick={copyUserId} role="menuitem">
<span class="menu-item-icon">📋</span>
<span class="menu-item-text">Copy user ID (npub)</span>
{#if copied === 'userId'}
<span class="menu-item-check"></span>
{/if}
</button>
<button class="menu-item" onclick={copyEventId} role="menuitem" disabled={!profileEvent}>
<span class="menu-item-icon">📋</span>
<span class="menu-item-text">Copy event ID (nevent)</span>
{#if copied === 'eventId'}
<span class="menu-item-check"></span>
{/if}
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={shareWithAitherboard} role="menuitem">
<span class="menu-item-icon">🔗</span>
<span class="menu-item-text">Share with aitherboard</span>
{#if copied === 'share'}
<span class="menu-item-check"></span>
{/if}
</button>
{#if isLoggedIn && !isOwnProfile}
<div class="menu-divider"></div>
<button
class="menu-item"
onclick={handleMute}
role="menuitem"
disabled={muting}
>
<span class="menu-item-icon">{muted ? '🔇' : '🔊'}</span>
<span class="menu-item-text">{muted ? 'Unmute this user' : 'Mute this user'}</span>
{#if muted}
<span class="menu-item-check"></span>
{/if}
</button>
<button
class="menu-item"
onclick={handleFollow}
role="menuitem"
disabled={following}
>
<span class="menu-item-icon">
{#if followed}
<Icon name="user" size={16} />
{:else}
<Icon name="plus" size={16} />
{/if}
</span>
<span class="menu-item-text">{followed ? 'Unfollow this user' : 'Follow this user'}</span>
{#if followed}
<span class="menu-item-check"></span>
{/if}
</button>
{/if}
</div>
{/if}
</div>
<style>
.profile-menu-container {
position: relative;
display: inline-block;
}
.menu-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
min-width: 2rem;
min-height: 2rem;
filter: grayscale(100%);
}
:global(.dark) .menu-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.menu-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .menu-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.menu-icon {
font-size: 1.25rem;
line-height: 1;
color: var(--fog-text, #1f2937);
filter: grayscale(100%);
}
:global(.dark) .menu-icon {
color: var(--fog-dark-text, #f9fafb);
}
.menu-dropdown {
position: fixed;
z-index: 1000;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
padding: 0.25rem;
overflow-y: auto;
overflow-x: hidden;
}
:global(.dark) .menu-dropdown {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
text-align: left;
transition: background 0.2s;
}
:global(.dark) .menu-item {
color: var(--fog-dark-text, #f9fafb);
}
.menu-item:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .menu-item:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item-icon {
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
filter: grayscale(100%);
}
.menu-item-text {
flex: 1;
}
.menu-item-check {
color: var(--fog-accent, #64748b);
font-weight: 600;
flex-shrink: 0;
}
:global(.dark) .menu-item-check {
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);
}
</style>