mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
* feat: add NIP-57 zap command foundation Implements the foundational structure for sending Lightning zaps (NIP-57) to Nostr users and events. This commit adds the command interface, UI components, and routing logic. The actual LNURL resolution and zap request creation will be implemented in follow-up commits. Components Added: - ZapWindow: Full-featured UI for zapping with amount presets, custom amounts, wallet integration, and QR code fallback - parseZapCommand: Parser supporting multiple formats (npub, nprofile, nevent, naddr, NIP-05, $me alias) - Command registration in man pages with examples - Window routing and title generation Features: - Preset amount buttons (21, 100, 500, 1000, 5000, 10000 sats) - Custom amount input - Amount usage tracking (remembers most-used amounts) - Comment field for zap messages - Event context rendering (shows zapped event in UI) - Dual payment methods: NWC wallet or QR code/invoice copy - Dynamic recipient resolution (from event author if zapping event) Usage: zap fiatjaf.com # Zap a user by NIP-05 zap npub1... # Zap a user by npub zap nevent1... # Zap an event (recipient = author) zap npub1... nevent1... # Zap specific user for specific event Next Steps: - Implement LNURL-pay resolution (fetch callback URL and nostrPubkey) - Create kind 9734 zap request event with applesauce factory - Implement invoice generation via LNURL callback - Integrate NWC wallet payment - Add zap action to event context menus - Implement zap receipt listening (kind 9735) * feat: implement full NIP-57 zap flow with LNURL and NWC Completes the production-ready implementation of Lightning zaps (NIP-57) with full LNURL-pay resolution, zap request creation, NWC wallet payment, and QR code fallback. Core Implementation: 1. **LNURL Resolution** (src/lib/lnurl.ts) - Resolve Lightning addresses (lud16) to LNURL-pay endpoints - Validate zap support (allowsNostr, nostrPubkey) - Fetch invoices from LNURL callbacks with zap requests - Amount validation (min/max sendable) - Comment length validation 2. **Zap Request Creation** (src/lib/create-zap-request.ts) - Build kind 9734 zap request events using applesauce EventFactory - Sign with user's active account - Include recipient (p tag), amount, relays, optional event context (e/a tags) - Serialize to URL-encoded JSON for LNURL callbacks - Smart relay selection (user's inbox relays for zap receipts) 3. **ZapWindow Complete Flow** (src/components/ZapWindow.tsx) - Resolve recipient's Lightning address from profile (lud16) - Create and sign zap request with user credentials - Fetch invoice from LNURL callback - Pay with NWC wallet OR show QR code - QR code generation with qrcode library - Success feedback with LNURL success actions - Comprehensive error handling and user notifications - Toast notifications for each step 4. **Event Menu Integration** (src/components/nostr/kinds/BaseEventRenderer.tsx) - Add "Zap" action to event dropdown menu - Automatically includes event context (e or a tag) - Yellow zap icon (⚡) for visual consistency - Opens ZapWindow with pre-filled recipient and event Flow Diagram: 1. User clicks "Zap" on event or runs `zap` command 2. Resolve recipient's lud16 → LNURL-pay endpoint 3. Validate zap support (allowsNostr, nostrPubkey) 4. Create kind 9734 zap request (signed by sender) 5. Send to LNURL callback → get BOLT11 invoice 6. Pay via NWC wallet OR show QR code 7. Zap receipt (kind 9735) published by LNURL service Features: - ✅ Full NIP-57 compliance - ✅ LNURL-pay support with validation - ✅ Applesauce EventFactory for signing - ✅ NWC wallet integration - ✅ QR code fallback for manual payment - ✅ Event context (zapping specific notes/articles) - ✅ Amount presets with usage tracking - ✅ Custom amounts and comments - ✅ Comprehensive error handling - ✅ Step-by-step user feedback - ✅ Event menu integration Security: - Uses user's active account signer - Validates LNURL responses - Validates amount ranges - No private key exposure - HTTPS-only LNURL endpoints Dependencies: - qrcode: QR code generation - applesauce-core: EventFactory for signing - Existing NWC wallet implementation Related: #135 (NWC wallet viewer) Implements: NIP-57 (Lightning Zaps) * fix: resolve TypeScript errors in ZapWindow - Remove unused imports (useEffect, isAddressableKind, NostrEvent) - Fix walletInfo access: fetch from getInfo() hook instead of direct property - Store wallet info in component state with useEffect - All TypeScript syntax errors resolved * fix: resolve all TypeScript build errors in zap implementation - ZapWindow.tsx: Import useProfile from @/hooks/useProfile instead of applesauce-react/hooks - ZapWindow.tsx: Remove Tabs import (unused) - ZapWindow.tsx: Remove htmlFor prop from Label components (not supported) - create-zap-request.ts: Remove non-existent EventTemplate import - create-zap-request.ts: Use inferred type for template object - lnurl.ts: Prefix unused lnurl parameter with underscore All TypeScript compilation errors resolved. Code ready for production build. * fix: correct useProfile usage and ProfileContent handling in ZapWindow - Remove eventStore argument from useProfile (takes pubkey and optional relay hints) - Fix recipientProfile usage: already ProfileContent, don't call getProfileContent again - Fix authorProfile: call getProfileContent on NostrEvent, not on ProfileContent - Fix lud16/lud06 access: use recipientProfile directly - Fix success toast: use recipientProfile?.name instead of content?.name All type errors resolved. ProfileContent is returned by useProfile, not NostrEvent. * feat: refine ZapWindow UI and add dynamic window title UI Refinements per user request: - Remove QrCode unused import - Simplify payment flow to single adaptive button - Button shows "Log in to Zap" if user can't sign - Button shows "Pay with Wallet" if NWC available, else "Pay" - Fix activeAccount usage to use accountManager.active - Remove unused getProfileContent import - Remove unused eventAuthorName variable Dynamic Title: - Add "Zap [username]" dynamic title in DynamicWindowTitle - Fetches recipient profile and displays name or fallback - Shows recipient's display name, name, or truncated pubkey Build fixes: - Fix TypeScript errors with unused imports - Fix activeAccount.signer property access - All tests passing (939 passed) * feat: add emoji autocompletion to zap comments and refine UI Zap Comment Enhancements: - Replace plain Input with MentionEditor for emoji autocompletion - Add NIP-30 emoji tag support to zap requests (kind 9734) - Emoji tags are properly serialized and included in zap events - Support :emoji: syntax with custom emoji from emoji search Event Preview Refinements: - Remove Card wrapper from zapped event preview - Remove padding and borders for cleaner display - Event renders directly without container styling Implementation Details: - Add EmojiTag interface to create-zap-request.ts - Update ZapRequestParams to include emojiTags array - Extract emoji tags from MentionEditor in handleZap - Pass emoji tags through zap request creation pipeline - Add useProfileSearch and useEmojiSearch hooks to ZapWindow - Use MentionEditor ref to get serialized content with emojis All tests passing (939 passed) Build successful * feat: compact ZapWindow UI and improve debugging UI Improvements: - Reduce padding from p-6 to p-4 and space-y-6 to space-y-3 - Convert amount grid to single-row flex layout with gap-1.5 - Add formatAmount() helper for shortened numbers (21, 1k, 5k, 10k) - Move custom amount input inline with preset amounts - Reduce button size to "sm" for more compact display - Remove separate label for custom amount - Make comment field more compact (removed min-height) Debugging Enhancements: - Add console.log for recipient profile and lud16/lud06 - Add logging for LNURL resolution steps - Add logging for zap request creation - Add logging for invoice fetch from callback - Add debug logging for emoji search service initialization - Test emoji search on mount to verify it's working Number Format: - 21 → "21" - 1000 → "1k" - 5000 → "5k" - 10000 → "10k" - Handles decimals: 1500 → "1.5k" The compact layout makes better use of vertical space and provides comprehensive debug logging to help troubleshoot LNURL and emoji issues. All tests passing (939 passed) Build successful * fix: open LoginDialog instead of connection window for zap login Changes: - Import LoginDialog component into ZapWindow - Add showLogin state to control LoginDialog visibility - Update handleLogin to open LoginDialog instead of connection window - Remove unused useGrimoire import and addWindow destructuring - Add LoginDialog component to render alongside QR dialog User Experience: - "Log in to Zap" button now opens proper login dialog - Users can log in with extension, readonly, nsec, or NIP-46 - After login, user can proceed with zap flow - More intuitive than opening relay connection window All tests passing (939 passed) Build successful * feat: auto-close ZapWindow after successful wallet payment Changes: - Add onClose callback prop to ZapWindowProps interface - Pass onClose from WindowRenderer to ZapWindow component - Call onClose() with 1.5s delay after successful wallet payment - Allow user to see success toast before window closes User Experience: - After zapping with wallet, window automatically closes - 1.5 second delay allows user to see success message - Prevents accidental double-zapping - Cleaner flow - no manual window closing needed Implementation: - WindowRenderer passes onClose callback to ZapWindow - ZapWindow calls onClose after payment success and toasts - setTimeout(onClose, 1500) provides brief delay for UX - QR code path unchanged (window stays open for payment) All tests passing (939 passed) Build successful * feat: full-width custom amount and wallet timeout with QR fallback Custom Amount Input: - Change custom amount input from w-24 to flex-1 - Now takes full remaining width in flex row - Better UX on smaller screens and more obvious input field Wallet Payment Timeout Handling: - Add 30 second timeout to wallet payments using Promise.race - On timeout, automatically show QR code as fallback - Add paymentTimedOut state to track timeout condition - Toast warning when payment times out Retry with Wallet Feature: - Add handleRetryWallet function to retry timed out payment - Show "Retry with Wallet" button in QR dialog when timed out - Button only appears if wallet is connected and payment capable - Retry uses same 30s timeout, shows error if fails again - Provides loading state with spinner during retry User Flow: 1. User attempts wallet payment 2. If timeout after 30s, shows QR code automatically 3. User can scan QR to pay manually OR 4. User can click "Retry with Wallet" to try again 5. If retry times out, stays on QR for manual payment Implementation Details: - Promise.race between payInvoice and 30s timeout - Timeout throws Error("TIMEOUT") for easy detection - QR dialog conditionally shows retry button - Retry resets state and attempts payment again - Console logging for debugging timeout issues All tests passing (939 passed) Build successful * feat: production-ready zaps with manual close and profile integration Payment Flow Improvements: - Remove auto-close after successful payment - Change "Zap Sent!" button to "Done" button that requires user click - User must manually close window by clicking Done after payment - Retry payment also requires manual close after success Profile Viewer Integration: - Add Zap icon next to lightning address in ProfileViewer - Click zap icon to open ZapWindow for that profile - Yellow icon with hover effect for visual feedback - Integrates seamlessly with existing profile UI Production Cleanup: - Remove all debug console.log statements - Keep console.error for production error logging - Remove unused emojiService variable from useEmojiSearch - Fix Loader2 className typo (animate-spin) - Clean code ready for production deployment User Experience: 1. View profile with lightning address 2. Click yellow zap icon to open zap window 3. Enter amount and optional comment 4. Pay with wallet (or QR code if timeout) 5. See success message 6. Click "Done" to close window (manual control) Testing: - All lint checks pass (no errors, only warnings) - TypeScript build successful - All 939 tests passing - Production-ready code Code Quality: - No debug logging in production - Proper error handling maintained - Clean, maintainable code - Follows project conventions * fix: remove duplicate zapTitle condition in DynamicWindowTitle Removed duplicate zapTitle if-else branch at line 870 that was causing lint error. The first zapTitle condition at line 803 handles all cases, making the second occurrence unreachable. * feat: improve zap UX with inline QR and faster imports - Move imports to top level instead of dynamic imports for faster resolution - Show QR code inline in ZapWindow instead of separate dialog - Show recipient name and address when not zapping an event - Make Lightning address clickable in ProfileViewer with icon on left - Use recipientName consistently throughout zap flow This significantly reduces the "Resolving Lightning address..." delay and provides a cleaner, more integrated UX for viewing and paying invoices. * feat: optimize zap UX with better error handling and UI improvements LNURL improvements: - Add 10s timeouts to Lightning address resolution and invoice fetching - Better error messages with more context (response status, error text) - Handle AbortError for timeout scenarios UI improvements: - Bigger amount buttons (default size instead of sm) - Custom amount on separate line for better layout - Disable all zap UI when recipient has no Lightning address - Show clear warning when Lightning address is missing - Only show comment editor when Lightning address is available Toast cleanup: - Remove chatty info toasts ("Resolving...", "Creating...", "Fetching...") - Only show errors and success messages - Cleaner, less noisy UX This addresses common issues with LNURL requests timing out and makes the UI more responsive and informative when zaps cannot be sent. * feat: full-width custom amount and wallet timeout with QR fallback QR code improvements: - Add profile picture overlay in center of QR code (25% size, circular) - Remove redundant "Copy Invoice" button (keep icon button only) - Show "Open in Wallet" as full-width button UI improvements: - Use UserName component everywhere (clickable, styled, shows Grimoire members) - Custom amount now full-width on separate line - Better visual hierarchy Default amounts updated: - Changed from [21, 100, 500, 1000, 5000, 10000] - To [21, 420, 2100, 42000] - More aligned with common zap amounts The profile picture overlay helps users identify who they're zapping while maintaining QR code scannability. UserName component provides consistent styling and clickable profile links. --------- Co-authored-by: Claude <noreply@anthropic.com>
476 lines
17 KiB
TypeScript
476 lines
17 KiB
TypeScript
import { useProfile } from "@/hooks/useProfile";
|
|
import { UserName } from "./nostr/UserName";
|
|
import Nip05 from "./nostr/nip05";
|
|
import { ProfileCardSkeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Copy,
|
|
CopyCheck,
|
|
User as UserIcon,
|
|
Inbox,
|
|
Send,
|
|
Wifi,
|
|
HardDrive,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { kinds, nip19 } from "nostr-tools";
|
|
import { useEventStore, use$ } from "applesauce-react/hooks";
|
|
import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
|
|
import { useCopy } from "../hooks/useCopy";
|
|
import { RichText } from "./nostr/RichText";
|
|
import { RelayLink } from "./nostr/RelayLink";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "./ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { useRelayState } from "@/hooks/useRelayState";
|
|
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
|
import { addressLoader } from "@/services/loaders";
|
|
import { relayListCache } from "@/services/relay-list-cache";
|
|
import { useEffect, useState } from "react";
|
|
import type { Subscription } from "rxjs";
|
|
import { useGrimoire } from "@/core/state";
|
|
import { USER_SERVER_LIST_KIND, getServersFromEvent } from "@/services/blossom";
|
|
import blossomServerCache from "@/services/blossom-server-cache";
|
|
|
|
export interface ProfileViewerProps {
|
|
pubkey: string;
|
|
}
|
|
|
|
/**
|
|
* ProfileViewer - Detailed view for a user profile
|
|
* Shows profile metadata, inbox/outbox relays, and raw JSON
|
|
*/
|
|
export function ProfileViewer({ pubkey }: ProfileViewerProps) {
|
|
const { state, addWindow } = useGrimoire();
|
|
const accountPubkey = state.activeAccount?.pubkey;
|
|
|
|
// Resolve $me alias
|
|
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
|
|
|
|
const profile = useProfile(resolvedPubkey);
|
|
const eventStore = useEventStore();
|
|
const { copy, copied } = useCopy();
|
|
const { relays: relayStates } = useRelayState();
|
|
|
|
// Fetch fresh relay list from network only if not cached or stale
|
|
useEffect(() => {
|
|
let subscription: Subscription | null = null;
|
|
if (!resolvedPubkey) return;
|
|
|
|
// Check if we have a valid cached relay list
|
|
relayListCache.has(resolvedPubkey).then(async (hasCached) => {
|
|
if (hasCached) {
|
|
console.debug(
|
|
`[ProfileViewer] Using cached relay list for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
|
|
// Load cached event into EventStore so UI can display it
|
|
const cached = await relayListCache.get(resolvedPubkey);
|
|
if (cached?.event) {
|
|
eventStore.add(cached.event);
|
|
console.debug(
|
|
`[ProfileViewer] Loaded cached relay list into EventStore for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No cached or stale - fetch fresh from network
|
|
console.debug(
|
|
`[ProfileViewer] Fetching fresh relay list for ${resolvedPubkey.slice(0, 8)}`,
|
|
);
|
|
subscription = addressLoader({
|
|
kind: kinds.RelayList,
|
|
pubkey: resolvedPubkey,
|
|
identifier: "",
|
|
}).subscribe({
|
|
error: (err) => {
|
|
console.debug(
|
|
`[ProfileViewer] Failed to fetch relay list for ${resolvedPubkey.slice(0, 8)}:`,
|
|
err,
|
|
);
|
|
},
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
if (subscription) {
|
|
subscription.unsubscribe();
|
|
}
|
|
};
|
|
}, [resolvedPubkey, eventStore]);
|
|
|
|
// Get mailbox relays (kind 10002) - will update when fresh data arrives
|
|
const mailboxEvent = use$(
|
|
() =>
|
|
resolvedPubkey
|
|
? eventStore.replaceable(kinds.RelayList, resolvedPubkey, "")
|
|
: undefined,
|
|
[eventStore, resolvedPubkey],
|
|
);
|
|
const inboxRelays =
|
|
mailboxEvent && mailboxEvent.tags ? getInboxes(mailboxEvent) : [];
|
|
const outboxRelays =
|
|
mailboxEvent && mailboxEvent.tags ? getOutboxes(mailboxEvent) : [];
|
|
|
|
// Get profile metadata event (kind 0)
|
|
const profileEvent = use$(
|
|
() =>
|
|
resolvedPubkey
|
|
? eventStore.replaceable(0, resolvedPubkey, "")
|
|
: undefined,
|
|
[eventStore, resolvedPubkey],
|
|
);
|
|
|
|
// Blossom servers state (kind 10063)
|
|
const [blossomServers, setBlossomServers] = useState<string[]>([]);
|
|
|
|
// Fetch Blossom server list (kind 10063)
|
|
useEffect(() => {
|
|
if (!resolvedPubkey) {
|
|
setBlossomServers([]);
|
|
return;
|
|
}
|
|
|
|
// First, check cache for instant display
|
|
blossomServerCache.getServers(resolvedPubkey).then((cachedServers) => {
|
|
if (cachedServers && cachedServers.length > 0) {
|
|
setBlossomServers(cachedServers);
|
|
}
|
|
});
|
|
|
|
// Check if we already have the event in EventStore
|
|
const existingEvent = eventStore.getReplaceable(
|
|
USER_SERVER_LIST_KIND,
|
|
resolvedPubkey,
|
|
"",
|
|
);
|
|
if (existingEvent) {
|
|
const servers = getServersFromEvent(existingEvent);
|
|
setBlossomServers(servers);
|
|
// Also update cache
|
|
blossomServerCache.set(existingEvent);
|
|
}
|
|
|
|
// Subscribe to EventStore for reactive updates
|
|
const storeSubscription = eventStore
|
|
.replaceable(USER_SERVER_LIST_KIND, resolvedPubkey, "")
|
|
.subscribe((event) => {
|
|
if (event) {
|
|
const servers = getServersFromEvent(event);
|
|
setBlossomServers(servers);
|
|
// Also update cache
|
|
blossomServerCache.set(event);
|
|
} else {
|
|
setBlossomServers([]);
|
|
}
|
|
});
|
|
|
|
// Also fetch from network to get latest data
|
|
const networkSubscription = addressLoader({
|
|
kind: USER_SERVER_LIST_KIND,
|
|
pubkey: resolvedPubkey,
|
|
identifier: "",
|
|
}).subscribe();
|
|
|
|
return () => {
|
|
storeSubscription.unsubscribe();
|
|
networkSubscription.unsubscribe();
|
|
};
|
|
}, [resolvedPubkey, eventStore]);
|
|
|
|
// Combine all relays (inbox + outbox) for nprofile
|
|
const allRelays = [...new Set([...inboxRelays, ...outboxRelays])];
|
|
|
|
// Calculate connection count for relay dropdown
|
|
const connectedCount = allRelays.filter(
|
|
(url) => relayStates[url]?.connectionState === "connected",
|
|
).length;
|
|
|
|
// Generate npub or nprofile depending on relay availability
|
|
const identifier =
|
|
resolvedPubkey && allRelays.length > 0
|
|
? nip19.nprofileEncode({
|
|
pubkey: resolvedPubkey,
|
|
relays: allRelays,
|
|
})
|
|
: resolvedPubkey
|
|
? nip19.npubEncode(resolvedPubkey)
|
|
: "";
|
|
|
|
if (pubkey === "$me" && !accountPubkey) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
|
<div className="text-muted-foreground">
|
|
<UserIcon className="size-12 mx-auto mb-3" />
|
|
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
|
<p className="text-sm max-w-md">
|
|
The <code className="bg-muted px-1.5 py-0.5">$me</code> alias
|
|
requires an active account. Please log in to view your profile.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!resolvedPubkey) {
|
|
return (
|
|
<div className="p-4 text-muted-foreground">Invalid profile pubkey.</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Compact Header - Single Line */}
|
|
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
|
{/* Left: npub/nprofile */}
|
|
<button
|
|
onClick={() => copy(identifier)}
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
|
title={identifier}
|
|
aria-label="Copy profile ID"
|
|
>
|
|
{copied ? (
|
|
<CopyCheck className="size-3 flex-shrink-0" />
|
|
) : (
|
|
<Copy className="size-3 flex-shrink-0" />
|
|
)}
|
|
<code className="truncate">
|
|
{identifier.slice(0, 16)}...{identifier.slice(-8)}
|
|
</code>
|
|
</button>
|
|
|
|
{/* Right: Profile icon and Relay dropdown */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
<div className="flex items-center gap-1 text-muted-foreground">
|
|
<UserIcon className="size-3" />
|
|
<span>Profile</span>
|
|
</div>
|
|
|
|
{allRelays.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={`${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""}`}
|
|
>
|
|
<Wifi className="size-3" />
|
|
<span>
|
|
{connectedCount}/{allRelays.length}
|
|
</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
{allRelays.map((url) => {
|
|
const state = relayStates[url];
|
|
const connIcon = getConnectionIcon(state);
|
|
const authIcon = getAuthIcon(state);
|
|
const isInbox = inboxRelays.includes(url);
|
|
const isOutbox = outboxRelays.includes(url);
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={url}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
{isInbox && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Inbox className="size-3 text-muted-foreground flex-shrink-0" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Inbox</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{isOutbox && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Send className="size-3 text-muted-foreground flex-shrink-0" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Outbox</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<RelayLink
|
|
url={url}
|
|
showInboxOutbox={false}
|
|
className="flex-1 min-w-0 hover:bg-transparent"
|
|
iconClassname="size-3"
|
|
urlClassname="text-xs"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="flex items-center gap-1.5 flex-shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{authIcon && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{authIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{authIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="cursor-help">{connIcon.icon}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{connIcon.label}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{/* Blossom servers dropdown */}
|
|
{blossomServers.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label={`${blossomServers.length} Blossom server${blossomServers.length !== 1 ? "s" : ""}`}
|
|
>
|
|
<HardDrive className="size-3" />
|
|
<span>{blossomServers.length}</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80">
|
|
{blossomServers.map((url) => (
|
|
<DropdownMenuItem
|
|
key={url}
|
|
className="flex items-center justify-between gap-2 cursor-crosshair"
|
|
onClick={() => {
|
|
if (resolvedPubkey) {
|
|
addWindow(
|
|
"blossom",
|
|
{
|
|
subcommand: "list",
|
|
pubkey: resolvedPubkey,
|
|
serverUrl: url,
|
|
},
|
|
`Files on ${url}`,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
<HardDrive className="size-3 text-muted-foreground flex-shrink-0" />
|
|
<span className="font-mono text-xs truncate">{url}</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile Content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{!profile && !profileEvent && <ProfileCardSkeleton variant="full" />}
|
|
|
|
{!profile && profileEvent && (
|
|
<div className="text-center text-muted-foreground text-sm">
|
|
No profile metadata found
|
|
</div>
|
|
)}
|
|
|
|
{profile && (
|
|
<div className="flex flex-col gap-4 max-w-2xl">
|
|
<div className="flex flex-col gap-0">
|
|
{/* Display Name */}
|
|
<UserName
|
|
pubkey={pubkey}
|
|
className="text-2xl font-bold pointer-events-none"
|
|
/>
|
|
{/* NIP-05 */}
|
|
{profile.nip05 && (
|
|
<div className="text-xs">
|
|
<Nip05 pubkey={pubkey} profile={profile} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* About/Bio */}
|
|
{profile.about && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
About
|
|
</div>
|
|
<RichText
|
|
className="text-sm whitespace-pre-wrap break-words"
|
|
content={profile.about}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Website */}
|
|
{profile.website && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
Website
|
|
</div>
|
|
<a
|
|
href={profile.website}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-accent underline decoration-dotted"
|
|
>
|
|
{profile.website}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lightning Address */}
|
|
{profile.lud16 && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
Lightning Address
|
|
</div>
|
|
<button
|
|
onClick={() =>
|
|
addWindow("zap", { recipientPubkey: resolvedPubkey })
|
|
}
|
|
className="flex items-center gap-2 w-full text-left hover:bg-muted/50 rounded px-2 py-1 -mx-2 transition-colors group"
|
|
title="Send zap"
|
|
>
|
|
<Zap className="size-4 text-yellow-500 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
|
<code className="text-sm font-mono flex-1 min-w-0 truncate">
|
|
{profile.lud16}
|
|
</code>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* LUD06 (LNURL) */}
|
|
{profile.lud06 && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
LNURL
|
|
</div>
|
|
<code className="text-sm font-mono break-all">
|
|
{profile.lud06}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|