import { Link } from 'react-router-dom'; import { Plus, Pencil, Check, SeparatorHorizontal, Search, ChevronDown, ChevronUp, LinkIcon } from 'lucide-react'; import React, { useState, useRef, useEffect, useCallback } from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems'; import type { HiddenSidebarItem } from '@/hooks/useFeedSettings'; import { nip19 } from 'nostr-tools'; import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain'; interface SidebarMoreMenuProps { editing: boolean; hiddenItems: HiddenSidebarItem[]; onDoneEditing: () => void; onStartEditing: () => void; onAdd: (id: string) => void; onAddDivider: () => void; onNavigate?: () => void; open: boolean; onOpenChange: (open: boolean) => void; /** Sidebar item ID configured as the homepage. */ homePage?: string; } function useScrollCarets(centerOnOpen = false) { const scrollRef = useRef(null); const intervalRef = useRef | null>(null); const roRef = useRef(null); const [canScrollUp, setCanScrollUp] = useState(false); const [canScrollDown, setCanScrollDown] = useState(false); const update = useCallback(() => { const el = scrollRef.current; if (!el) return; setCanScrollUp(el.scrollTop > 0); setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1); }, []); const refCallback = useCallback((el: HTMLDivElement | null) => { // Disconnect previous observer if any roRef.current?.disconnect(); roRef.current = null; (scrollRef as React.MutableRefObject).current = el; if (!el) return; if (centerOnOpen) { el.scrollTop = (el.scrollHeight - el.clientHeight) / 2; } const ro = new ResizeObserver(update); ro.observe(el); roRef.current = ro; update(); }, [centerOnOpen, update]); const stopScroll = useCallback(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }, []); const startScroll = useCallback((direction: 'up' | 'down') => { stopScroll(); intervalRef.current = setInterval(() => { const el = scrollRef.current; if (!el) return stopScroll(); el.scrollBy({ top: direction === 'up' ? -8 : 8 }); update(); // stop automatically when the limit is reached const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1; if (atLimit) stopScroll(); }, 16); }, [update, stopScroll]); // clean up interval on unmount useEffect(() => stopScroll, [stopScroll]); return { scrollRef, refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll }; } function ScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) { return ( ); } function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id: string) => void; onClose: () => void }) { return (
); } export function SidebarMoreMenu({ editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, open, onOpenChange, homePage, }: SidebarMoreMenuProps) { const [query, setQuery] = useState(''); const [addMenuOpen, setAddMenuOpen] = useState(false); const [addQuery, setAddQuery] = useState(''); const [linkInput, setLinkInput] = useState(false); const [linkValue, setLinkValue] = useState(''); const [linkError, setLinkError] = useState(''); const filtered = hiddenItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); const addFiltered = hiddenItems.filter((item) => item.label.toLowerCase().includes(addQuery.toLowerCase())); const handleAddLink = () => { const raw = linkValue.trim(); if (!raw) return; // External content: URLs if (raw.startsWith('https://') || raw.startsWith('http://')) { onAdd(raw); setLinkInput(false); setLinkValue(''); setLinkError(''); return; } // External content: iso3166 codes if (raw.startsWith('iso3166:')) { const code = raw.slice('iso3166:'.length); if (!/^[A-Za-z]{2}(-[A-Za-z0-9]+)?$/.test(code)) { setLinkError('Invalid country/region code'); return; } onAdd(raw); setLinkInput(false); setLinkValue(''); setLinkError(''); return; } // External content: isbn if (raw.startsWith('isbn:')) { onAdd(raw); setLinkInput(false); setLinkValue(''); setLinkError(''); return; } // Nsite URI: nsite:// if (raw.startsWith('nsite://')) { const subdomain = raw.slice('nsite://'.length); const parsed = parseNsiteSubdomain(subdomain); if (!parsed || parsed.kind !== 35128) { setLinkError('Invalid nsite identifier (only named sites are supported)'); return; } onAdd(raw); setLinkInput(false); setLinkValue(''); setLinkError(''); return; } // Nostr: strip "nostr:" prefix if present for validation const bech32 = raw.startsWith('nostr:') ? raw.slice(6) : raw; // Validate it's a valid NIP-19 identifier try { const decoded = nip19.decode(bech32); const validTypes = ['npub', 'nprofile', 'note', 'nevent', 'naddr']; if (!validTypes.includes(decoded.type)) { setLinkError('Unsupported identifier type'); return; } } catch { setLinkError('Invalid identifier'); return; } // Normalize to "nostr:" prefixed form const nostrUri = `nostr:${bech32}`; onAdd(nostrUri); setLinkInput(false); setLinkValue(''); setLinkError(''); }; const main = useScrollCarets(true); const add = useScrollCarets(); if (editing) { return (
{ setAddMenuOpen(o); if (!o) setAddQuery(''); }}>
setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
{add.canScrollUp && add.startScroll('up')} onMouseLeave={add.stopScroll} />}
{addFiltered.map((item) => setAddMenuOpen(false)} />)} {addFiltered.length === 0 &&

No results

}
{add.canScrollDown && add.startScroll('down')} onMouseLeave={add.stopScroll} />} {linkInput ? (
{ setLinkValue(e.target.value); setLinkError(''); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddLink(); } else if (e.key === 'Escape') { setLinkInput(false); setLinkValue(''); setLinkError(''); } }} placeholder="URL, npub1..., nsite://..., ..." className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
{linkError &&

{linkError}

}
) : ( )}
); } return ( { onOpenChange(o); if (!o) setQuery(''); }}>
setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
{main.canScrollUp && main.startScroll('up')} onMouseLeave={main.stopScroll} />}
{filtered.map((item) => (
{ onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors"> {sidebarItemIcon(item.id, 'size-5 shrink-0')} {item.label}
))} {filtered.length === 0 &&

No results

}
{main.canScrollDown && main.startScroll('down')} onMouseLeave={main.stopScroll} />}
); }