diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx
deleted file mode 100644
index 4ef656f1..00000000
--- a/src/pages/AIChatPage.tsx
+++ /dev/null
@@ -1,534 +0,0 @@
-import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import Markdown from 'react-markdown';
-import rehypeSanitize from 'rehype-sanitize';
-import { Bot, Loader2, Send, Square, Trash2 } from 'lucide-react';
-
-import { PageHeader } from '@/components/PageHeader';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useAIChatSession } from '@/hooks/useAIChatSession';
-import { LoginArea } from '@/components/auth/LoginArea';
-import { Button } from '@/components/ui/button';
-import { Textarea } from '@/components/ui/textarea';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
-import { cn } from '@/lib/utils';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-
-import type { DisplayMessage, ToolCall } from '@/lib/aiChatTools';
-
-// ─── Slash Commands ───
-
-const SLASH_COMMANDS = [
- { command: '/clear', description: 'Clear conversation history' },
- { command: '/new', description: 'Start a new conversation' },
- { command: '/tools', description: 'List available tools' },
-];
-
-// ─── Page Component ───
-
-export function AIChatPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
-
- useSeoMeta({
- title: `Agent | ${config.appName}`,
- description: 'Chat with your AI agent',
- });
-
- useLayoutOptions({ noOverscroll: true });
-
- if (!user) {
- return (
-
-
-
-
-
Agent
-
Log in with your Nostr account to start using the Agent.
-
-
-
-
- );
- }
-
- return ;
-}
-
-// ─── Chat View ───
-
-function AgentChatView() {
- const {
- messages,
- input,
- setInput,
- isStreaming,
- streamingText,
- selectedModel,
- apiLoading,
- apiError,
- messagesEndRef,
- capacity,
- lastPromptTokens,
- contextWindow,
- storageBytes,
- maxStorageBytes,
- handleSend,
- handleStop,
- handleKeyDown,
- handleClear,
- } = useAIChatSession();
-
- return (
-
- {/* Header */}
-
-
- Agent
-
- }>
-
-
-
-
-
-
-
-
- {/* Messages Area */}
- {messages.length === 0 && !streamingText ? (
-
-
-
- ) : (
-
-
- {messages.map((msg) => (
- msg.role !== 'tool_result' &&
- ))}
-
- {/* Streaming text */}
- {streamingText && (
-
-
-
-
-
- {streamingText}
-
-
-
-
-
- )}
-
- {/* Loading indicator */}
- {(isStreaming || apiLoading) && !streamingText &&
}
-
- {/* Error display */}
- {apiError && (
- apiError.includes('run out of credits') ? (
-
- ) : apiError.includes('Rate limited') ? (
-
- ) : null
- )}
-
-
-
-
- )}
-
- {/* Input Area */}
-
-
-
- {isStreaming ? (
-
-
-
- ) : (
- handleSend()}
- disabled={!input.trim() || !selectedModel}
- size="icon"
- className="size-11 shrink-0 rounded-xl"
- >
-
-
- )}
-
-
-
- );
-}
-
-// ─── Sub-Components ───
-
-function ThinkingIndicator() {
- return (
-
- );
-}
-
-function ErrorBanner({ heading, body }: { heading: string; body: string }) {
- return (
-
- );
-}
-
-const AGENT_GREETINGS = [
- "How can I help you today?",
- "What would you like to know?",
- "Ready when you are.",
-];
-
-const SUGGESTIONS = [
- "What are my friends talking about?",
- "What's happening in the world?",
-];
-
-function EmptyState({ onSuggestion }: { onSuggestion: (text: string) => void }) {
- const greeting = useMemo(() => AGENT_GREETINGS[Math.floor(Math.random() * AGENT_GREETINGS.length)], []);
-
- return (
-
-
-
-
-
- {SUGGESTIONS.map((s) => (
- onSuggestion(s)}
- className="px-4 py-2 rounded-full text-sm border border-border bg-secondary/40 hover:bg-secondary/80 text-foreground transition-colors"
- >
- {s}
-
- ))}
-
-
- );
-}
-
-function MessageBubble({ message }: { message: DisplayMessage }) {
- const isUser = message.role === 'user';
-
- // System notices — info vs error styling
- if (message.noticeVariant) {
- const isError = message.noticeVariant === 'error';
- return (
-
-
-
-
-
- {message.content}
-
-
-
-
-
- );
- }
-
- return (
-
-
- {/* Hide the bubble entirely when the assistant message is empty (tool-only turn) */}
- {(isUser || message.content.trim()) && (
-
- {isUser ? (
-
{message.content}
- ) : (
-
-
- {message.content}
-
-
- )}
-
- )}
-
- {/* Tool call indicators */}
- {message.toolCalls && message.toolCalls.length > 0 && (
-
- {message.toolCalls.map((tc) => (
-
- ))}
-
- )}
-
-
- {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
-
-
-
- );
-}
-
-/** Text input with a slash-command autocomplete dropdown. */
-function SlashCommandInput({ value, onChange, onKeyDown, onSend, placeholder, disabled }: {
- value: string;
- onChange: (v: string) => void;
- onKeyDown: (e: React.KeyboardEvent) => void;
- onSend: (override?: string) => void;
- placeholder?: string;
- disabled?: boolean;
-}) {
- const wrapperRef = useRef(null);
- const [selectedIndex, setSelectedIndex] = useState(0);
- const [menuDismissed, setMenuDismissed] = useState(false);
-
- // Filter commands based on input
- const matches = useMemo(() => {
- if (!value.startsWith('/') || menuDismissed) return [];
- const typed = value.toLowerCase();
- return SLASH_COMMANDS.filter((c) => c.command.startsWith(typed));
- }, [value, menuDismissed]);
-
- const showMenu = matches.length > 0 && !disabled;
-
- // Reset selection when matches change
- useEffect(() => {
- setSelectedIndex(0);
- }, [matches.length]);
-
- // Un-dismiss when input stops being a slash command or is cleared
- useEffect(() => {
- if (!value.startsWith('/')) setMenuDismissed(false);
- }, [value]);
-
- // Close menu on outside click
- useEffect(() => {
- if (!showMenu) return;
- const handler = (e: MouseEvent) => {
- if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
- setMenuDismissed(true);
- }
- };
- document.addEventListener('mousedown', handler);
- return () => document.removeEventListener('mousedown', handler);
- }, [showMenu]);
-
- const selectCommand = useCallback((cmd: string) => {
- onChange(cmd);
- setMenuDismissed(true);
- // Auto-send slash commands immediately
- onSend(cmd);
- }, [onChange, onSend]);
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (showMenu) {
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- setSelectedIndex((i) => (i - 1 + matches.length) % matches.length);
- return;
- }
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setSelectedIndex((i) => (i + 1) % matches.length);
- return;
- }
- if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
- e.preventDefault();
- selectCommand(matches[selectedIndex].command);
- return;
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- setMenuDismissed(true);
- return;
- }
- }
- // Fall through to parent handler (Enter → send, etc.)
- onKeyDown(e);
- }, [showMenu, matches, selectedIndex, selectCommand, onKeyDown]);
-
- return (
-
- {/* Autocomplete menu */}
- {showMenu && (
-
- {matches.map((cmd, i) => (
- setSelectedIndex(i)}
- onMouseDown={(e) => {
- e.preventDefault(); // Keep textarea focus
- selectCommand(cmd.command);
- }}
- >
- {cmd.command}
- {cmd.description}
-
- ))}
-
- )}
-
- );
-}
-
-/** Conversation capacity ring — appears at ≥75% usage. */
-function CapacityRing({ capacity, promptTokens, contextWindow, storageBytes, maxStorageBytes }: {
- capacity: number;
- promptTokens: number;
- contextWindow: number;
- storageBytes: number;
- maxStorageBytes: number;
-}) {
- if (capacity < 0.75) return null;
-
- const pct = Math.min(capacity * 100, 100);
- const size = 20;
- const strokeWidth = 2;
- const radius = (size - strokeWidth) / 2;
- const circumference = 2 * Math.PI * radius;
- const offset = circumference - (pct / 100) * circumference;
-
- // Color: amber at 75-89%, red at 90%+
- const ringColor = pct >= 90 ? 'text-destructive' : 'text-amber-500';
-
- const tokenPct = contextWindow > 0 ? ((promptTokens / contextWindow) * 100).toFixed(0) : '\u2014';
- const storageMB = (storageBytes / (1024 * 1024)).toFixed(1);
- const maxMB = (maxStorageBytes / (1024 * 1024)).toFixed(0);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- Tokens: {promptTokens.toLocaleString()} / {contextWindow.toLocaleString()} ({tokenPct}%)
-
- Storage: {storageMB} / {maxMB} MB
-
-
-
-
- );
-}
-
-function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
- let resultParsed: { success?: boolean; error?: string } = {};
- try {
- resultParsed = JSON.parse(toolCall.result || '{}');
- } catch {
- // ignore
- }
-
- const isSuccess = resultParsed.success === true || !resultParsed.error;
-
- const TOOL_LABELS: Record = {
- get_feed: 'Read feed',
- search_users: 'Search users',
- search_follow_packs: 'Search follow packs',
- fetch_page: 'Fetch page',
- fetch_event: 'Fetch event',
- };
-
- return (
-
- {resultParsed.error || TOOL_LABELS[toolCall.name] || toolCall.name}
-
- );
-}
diff --git a/src/pages/ArchivePage.tsx b/src/pages/ArchivePage.tsx
deleted file mode 100644
index 5a230833..00000000
--- a/src/pages/ArchivePage.tsx
+++ /dev/null
@@ -1,741 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { Archive, ArrowLeft, Gamepad2, Film, Mic, Monitor, Sparkles, Play, ExternalLink, Clock, Search, X, Loader2 } from 'lucide-react';
-
-import { Input } from '@/components/ui/input';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
-import { cn } from '@/lib/utils';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-interface ArchiveItem {
- /** archive.org item identifier */
- identifier: string;
- /** Display title */
- title: string;
- /** Year (optional) */
- year?: string;
- /** Brief one-liner */
- tagline: string;
- /** Category for filtering */
- category: Category;
-}
-
-type Category = 'games' | 'films' | 'audio' | 'software' | 'animation' | 'tv';
-
-interface CategoryMeta {
- label: string;
- icon: React.ReactNode;
- gradient: string;
- accent: string;
-}
-
-// ---------------------------------------------------------------------------
-// Data
-// ---------------------------------------------------------------------------
-
-const CATEGORIES: Record = {
- games: {
- label: 'Classic Games',
- icon: ,
- gradient: 'from-violet-500/20 to-fuchsia-500/20',
- accent: 'text-violet-500 dark:text-violet-400',
- },
- films: {
- label: 'Public Domain Films',
- icon: ,
- gradient: 'from-amber-500/20 to-orange-500/20',
- accent: 'text-amber-600 dark:text-amber-400',
- },
- audio: {
- label: 'Audio Treasures',
- icon: ,
- gradient: 'from-emerald-500/20 to-teal-500/20',
- accent: 'text-emerald-600 dark:text-emerald-400',
- },
- tv: {
- label: 'Classic Television',
- icon: ,
- gradient: 'from-sky-500/20 to-cyan-500/20',
- accent: 'text-sky-600 dark:text-sky-400',
- },
- animation: {
- label: 'Classic Cartoons',
- icon: ,
- gradient: 'from-pink-500/20 to-rose-500/20',
- accent: 'text-pink-500 dark:text-pink-400',
- },
- software: {
- label: 'Software History',
- icon: ,
- gradient: 'from-blue-500/20 to-indigo-500/20',
- accent: 'text-blue-600 dark:text-blue-400',
- },
-};
-
-const ITEMS: ArchiveItem[] = [
- // ── Classic Games ────────────────────────────────────────────
- {
- identifier: 'msdos_Oregon_Trail_The_1990',
- title: 'The Oregon Trail',
- year: '1990',
- tagline: 'You have died of dysentery. The game that traumatized a generation.',
- category: 'games',
- },
- {
- identifier: 'msdos_Prince_of_Persia_1990',
- title: 'Prince of Persia',
- year: '1990',
- tagline: 'Rotoscoped beauty. 60 minutes to save the princess.',
- category: 'games',
- },
- {
- identifier: 'msdos_Wolfenstein_3D_1992',
- title: 'Wolfenstein 3D',
- year: '1992',
- tagline: 'The grandfather of first-person shooters.',
- category: 'games',
- },
- {
- identifier: 'msdos_SimCity_1989',
- title: 'SimCity',
- year: '1989',
- tagline: 'Build your dream city. Watch it get destroyed by Godzilla.',
- category: 'games',
- },
- {
- identifier: 'msdos_Pac-Man_1983',
- title: 'Pac-Man',
- year: '1983',
- tagline: 'Waka waka waka waka waka.',
- category: 'games',
- },
- {
- identifier: 'Doom-2',
- title: 'Doom II',
- year: '1994',
- tagline: 'Rip and tear, until it is done.',
- category: 'games',
- },
- {
- identifier: 'msdos_Donkey_Kong_1983',
- title: 'Donkey Kong',
- year: '1983',
- tagline: 'The game that introduced the world to Mario.',
- category: 'games',
- },
- {
- identifier: 'msdos_Where_in_the_World_is_Carmen_Sandiego_Enhanced_1989',
- title: 'Where in the World is Carmen Sandiego?',
- year: '1989',
- tagline: 'Geography class never felt this cool.',
- category: 'games',
- },
- {
- identifier: 'msdos_Golden_Axe_1990',
- title: 'Golden Axe',
- year: '1990',
- tagline: 'Hack, slash, and ride dragons through a fantasy realm.',
- category: 'games',
- },
- {
- identifier: 'msdos_Scorched_Earth_1991',
- title: 'Scorched Earth',
- year: '1991',
- tagline: 'The mother of all games. Nuclear tanks on pixel hills.',
- category: 'games',
- },
- {
- identifier: 'msdos_Dune_2_-_The_Building_of_a_Dynasty_1992',
- title: 'Dune II',
- year: '1992',
- tagline: 'The game that invented real-time strategy.',
- category: 'games',
- },
- {
- identifier: 'msdos_Leisure_Suit_Larry_1_-_Land_of_the_Lounge_Lizards_1987',
- title: 'Leisure Suit Larry',
- year: '1987',
- tagline: 'The most awkward adventure in gaming history.',
- category: 'games',
- },
-
- // ── Flash Games ──────────────────────────────────────────────
- {
- identifier: 'stick-rpg-complete',
- title: 'Stick RPG Complete',
- tagline: 'The Flash game that consumed entire afternoons.',
- category: 'games',
- },
- {
- identifier: 'the-binding-of-isaac_202111',
- title: 'The Binding of Isaac (Flash)',
- tagline: 'Edmund McMillen\'s dark roguelike masterpiece, original Flash version.',
- category: 'games',
- },
-
- // ── Public Domain Films ──────────────────────────────────────
- {
- identifier: 'Nosferatu_most_complete_version_93_mins.',
- title: 'Nosferatu',
- year: '1922',
- tagline: 'The unauthorized Dracula adaptation that became an immortal classic.',
- category: 'films',
- },
- {
- identifier: 'Night.Of.The.Living.Dead_1080p',
- title: 'Night of the Living Dead',
- year: '1968',
- tagline: 'George Romero invented an entire genre in one night.',
- category: 'films',
- },
- {
- identifier: 'his_girl_friday',
- title: 'His Girl Friday',
- year: '1940',
- tagline: 'Rapid-fire dialogue. Cary Grant at his most charming.',
- category: 'films',
- },
- {
- identifier: 'house_on_haunted_hill_ipod',
- title: 'House on Haunted Hill',
- year: '1959',
- tagline: 'Vincent Price offers $10,000 to anyone who survives the night.',
- category: 'films',
- },
- {
- identifier: 'Sita_Sings_the_Blues',
- title: 'Sita Sings the Blues',
- year: '2008',
- tagline: 'Ancient Indian epic meets 1920s jazz. A creative commons triumph.',
- category: 'films',
- },
- {
- identifier: '774-plan-9-from-outer-space',
- title: 'Plan 9 from Outer Space',
- year: '1957',
- tagline: 'The "worst movie ever made" is the best movie ever made.',
- category: 'films',
- },
-
- // ── Classic TV ───────────────────────────────────────────────
- {
- identifier: 'theloneranger_201705',
- title: 'The Lone Ranger',
- tagline: 'Hi-yo, Silver! Away! Justice rides on horseback.',
- category: 'tv',
- },
- {
- identifier: 'get-smart',
- title: 'Get Smart',
- tagline: 'Would you believe... the funniest spy show ever made?',
- category: 'tv',
- },
- {
- identifier: 'GreenAcresCompleteSeries',
- title: 'Green Acres',
- tagline: 'A New York lawyer moves to the country. Chaos follows.',
- category: 'tv',
- },
-
- // ── Audio Treasures ──────────────────────────────────────────
- {
- identifier: 'gd77-05-08.sbd.hicks.4982.sbeok.shnf',
- title: 'Grateful Dead - Cornell \'77',
- year: '1977',
- tagline: 'The greatest live concert recording of all time. No debate.',
- category: 'audio',
- },
- {
- identifier: 'alice_in_wonderland_librivox',
- title: 'Alice\'s Adventures in Wonderland',
- tagline: 'Lewis Carroll\'s masterpiece, read aloud for free. Down the rabbit hole.',
- category: 'audio',
- },
- {
- identifier: 'art_of_war_librivox',
- title: 'The Art of War',
- tagline: 'Sun Tzu\'s timeless strategy treatise. The most downloaded audiobook on the internet.',
- category: 'audio',
- },
- {
- identifier: 'ird059',
- title: 'The Conet Project',
- tagline: 'Recordings of mysterious shortwave numbers stations. Pure Cold War eeriness.',
- category: 'audio',
- },
- {
- identifier: 'adventures_holmes',
- title: 'The Adventures of Sherlock Holmes',
- tagline: 'Elementary, my dear Watson. 12 stories of deduction.',
- category: 'audio',
- },
-
- // ── Classic Cartoons ─────────────────────────────────────────
- {
- identifier: 'BettyBoopCartoons',
- title: 'Betty Boop Cartoons',
- tagline: 'Boop-Oop-a-Doop! Pre-code animation at its most daring.',
- category: 'animation',
- },
- {
- identifier: 'superman_the_mechanical_monsters',
- title: 'Superman: The Mechanical Monsters',
- year: '1941',
- tagline: 'Fleischer Studios\' gorgeous Art Deco Superman. Still jaw-dropping.',
- category: 'animation',
- },
- {
- identifier: 'popeye_patriotic_popeye',
- title: 'Patriotic Popeye',
- tagline: 'I yam what I yam! Spinach-fueled heroics.',
- category: 'animation',
- },
- {
- identifier: 'bb_minnie_the_moocher',
- title: 'Betty Boop: Minnie the Moocher',
- tagline: 'Cab Calloway rotoscoped into a ghost walrus. Peak surrealism.',
- category: 'animation',
- },
- {
- identifier: 'woody_woodpecker_pantry_panic',
- title: 'Woody Woodpecker: Pantry Panic',
- tagline: 'Ha-ha-ha-HA-ha! The bird who drove everyone insane.',
- category: 'animation',
- },
-
- // ── Flash Animations ─────────────────────────────────────────
- {
- identifier: 'flash_badger',
- title: 'Badger Badger Badger',
- tagline: 'Mushroom! MUSHROOM! A snake! Peak early internet.',
- category: 'animation',
- },
- {
- identifier: 'peanut-butter-jelly-time',
- title: 'Peanut Butter Jelly Time',
- tagline: 'A dancing banana changed the internet forever.',
- category: 'animation',
- },
-
- // ── Prelinger Archives / Educational Films ───────────────────
- {
- identifier: 'DuckandC1951',
- title: 'Duck and Cover',
- year: '1951',
- tagline: 'Bert the Turtle taught kids to survive nuclear war. (Spoiler: no.)',
- category: 'films',
- },
-
- // ── Software History ─────────────────────────────────────────
- {
- identifier: 'win95_in_dosbox',
- title: 'Windows 95 in Your Browser',
- tagline: 'The startup sound that changed personal computing. Press Start.',
- category: 'software',
- },
- {
- identifier: 'win3_stock',
- title: 'Windows 3.11',
- tagline: 'Program Manager, File Manager, Solitaire. The holy trinity.',
- category: 'software',
- },
-];
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function thumbnailUrl(identifier: string): string {
- return `https://archive.org/services/img/${identifier}`;
-}
-
-function archiveUrl(identifier: string): string {
- return `https://archive.org/details/${identifier}`;
-}
-
-function dittoUrl(identifier: string): string {
- return `/i/${encodeURIComponent(archiveUrl(identifier))}`;
-}
-
-// ---------------------------------------------------------------------------
-// Components
-// ---------------------------------------------------------------------------
-
-function CategoryPill({ category, active, onClick }: {
- category: Category | 'all';
- active: boolean;
- onClick: () => void;
-}) {
- const meta = category === 'all'
- ? { label: 'All', icon: , accent: 'text-primary' }
- : CATEGORIES[category];
-
- return (
-
- {meta.icon}
- {meta.label}
-
- );
-}
-
-function ArchiveCard({ item }: { item: ArchiveItem }) {
- const meta = CATEGORIES[item.category];
-
- return (
-
- {/* Thumbnail */}
-
-
{
- e.currentTarget.style.display = 'none';
- }}
- />
-
- {/* Hover play overlay */}
-
-
- {/* Year badge */}
- {item.year && (
-
-
- {item.year}
-
- )}
-
- {/* Category badge */}
-
- {meta.icon}
- {meta.label}
-
-
-
- {/* Content */}
-
-
- {item.title}
-
-
- {item.tagline}
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Search bar
-// ---------------------------------------------------------------------------
-
-/** Maps archive.org mediatype to a human-friendly label. */
-function mediatypeLabel(mediatype: string): string {
- switch (mediatype) {
- case 'software': return 'Software';
- case 'movies': return 'Video';
- case 'audio': return 'Audio';
- case 'etree': return 'Live Music';
- case 'texts': return 'Text';
- default: return mediatype;
- }
-}
-
-function ArchiveSearchBar() {
- const navigate = useNavigate();
- const inputRef = useRef(null);
- const containerRef = useRef(null);
-
- const [query, setQuery] = useState('');
- const [debouncedQuery, setDebouncedQuery] = useState('');
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const debounceRef = useRef | undefined>(undefined);
-
- const { data: results, isFetching } = useArchiveSearch(debouncedQuery);
-
- // 400ms debounce (slightly longer than book search since archive.org can be slower)
- const handleChange = useCallback((value: string) => {
- setQuery(value);
- clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- setDebouncedQuery(value.trim());
- }, 400);
- }, []);
-
- useEffect(() => {
- return () => clearTimeout(debounceRef.current);
- }, []);
-
- // Open dropdown when we have results
- useEffect(() => {
- if (debouncedQuery.length >= 2 && results && results.length > 0) {
- setDropdownOpen(true);
- } else if (debouncedQuery.length >= 2 && results && results.length === 0 && !isFetching) {
- setDropdownOpen(true);
- }
- }, [debouncedQuery, results, isFetching]);
-
- // Close on outside click
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
- setDropdownOpen(false);
- }
- }
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- const handleSelect = useCallback((identifier: string) => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.blur();
- navigate(dittoUrl(identifier));
- }, [navigate]);
-
- const handleClear = useCallback(() => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.focus();
- }, []);
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- setDropdownOpen(false);
- inputRef.current?.blur();
- }
- if (e.key === 'Enter' && results && results.length > 0) {
- e.preventDefault();
- handleSelect(results[0].identifier);
- }
- }, [results, handleSelect]);
-
- return (
-
-
-
- handleChange(e.target.value)}
- onFocus={() => {
- if (debouncedQuery.length >= 2) setDropdownOpen(true);
- }}
- onKeyDown={handleKeyDown}
- className="pl-9 pr-9 h-9 text-base md:text-sm"
- />
- {query ? (
-
-
-
- ) : null}
-
-
- {/* Search results dropdown */}
- {dropdownOpen && debouncedQuery.length >= 2 && (
-
- {isFetching && (!results || results.length === 0) ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : results && results.length > 0 ? (
-
- {results.map((result) => (
-
- ))}
-
- ) : (
-
- No results found for “{debouncedQuery}”
-
- )}
-
- {/* Loading indicator when results exist but we're refetching */}
- {isFetching && results && results.length > 0 && (
-
-
-
- )}
-
- )}
-
- );
-}
-
-function ArchiveSearchResultItem({ result, onSelect }: { result: ArchiveSearchResult; onSelect: (id: string) => void }) {
- return (
- onSelect(result.identifier)}
- >
- { (e.currentTarget as HTMLElement).style.display = 'none'; }}
- />
-
-
{result.title}
-
- {mediatypeLabel(result.mediatype)}
- {result.downloads > 0 && <> · {formatDownloads(result.downloads)} downloads>}
-
-
-
- );
-}
-
-/** Format a download count into a compact human-readable string. */
-function formatDownloads(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
- if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
- return String(n);
-}
-
-// ---------------------------------------------------------------------------
-// Page
-// ---------------------------------------------------------------------------
-
-const CATEGORY_ORDER: (Category | 'all')[] = ['all', 'games', 'films', 'animation', 'audio', 'tv', 'software'];
-
-export function ArchivePage() {
- const { config } = useAppContext();
- const [activeCategory, setActiveCategory] = useState('all');
-
- useSeoMeta({
- title: `Archive | ${config.appName}`,
- description: 'Explore the best of the Internet Archive — classic games, films, music, and more.',
- });
-
- const filtered = useMemo(() => {
- if (activeCategory === 'all') return ITEMS;
- return ITEMS.filter((item) => item.category === activeCategory);
- }, [activeCategory]);
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
Archive
-
Treasures from the Internet Archive
-
-
-
-
-
-
-
- {/* Search bar */}
-
-
- {/* Category filter pills */}
-
-
- {CATEGORY_ORDER.map((cat) => (
- setActiveCategory(cat)}
- />
- ))}
-
-
-
- {/* Grid of items */}
-
-
- {filtered.map((item) => (
-
- ))}
-
-
- {filtered.length === 0 && (
-
-
-
No items in this category yet.
-
- )}
-
-
- {/* Attribution footer */}
-
-
-
- Content provided by the{' '}
-
- Internet Archive
-
- , a non-profit digital library. All items are in the public domain or freely available.
-
-
-
-
- );
-}
diff --git a/src/pages/ArticleEditorPage.tsx b/src/pages/ArticleEditorPage.tsx
deleted file mode 100644
index 9f3c8636..00000000
--- a/src/pages/ArticleEditorPage.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useSearchParams, useParams } from 'react-router-dom';
-import { useNostr } from '@nostrify/react';
-import { nip19 } from 'nostr-tools';
-import type { AddressPointer } from 'nostr-tools/nip19';
-import { Loader2 } from 'lucide-react';
-
-import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { getLocalDrafts } from '@/lib/localDrafts';
-import { parseArticleEvent } from '@/lib/articleHelpers';
-
-/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
-export function ArticleEditorPage() {
- useLayoutOptions({ showFAB: false, hasSubHeader: true });
-
- const [searchParams] = useSearchParams();
- const { naddr: naddrParam } = useParams<{ naddr: string }>();
- const { nostr } = useNostr();
- const { user } = useCurrentUser();
-
- const draftSlug = searchParams.get('draft');
-
- const [initialData, setInitialData] = useState<(Partial & { publishedAt?: number }) | undefined>(undefined);
- const [editMode, setEditMode] = useState(false);
- const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
-
- // Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=
- useEffect(() => {
- if (!draftSlug) return;
-
- const loadDraft = async () => {
- if (user?.signer.nip44) {
- try {
- const events = await nostr.query([
- { kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
- ]);
- if (events.length > 0 && events[0].content.trim()) {
- const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
- const inner = JSON.parse(decrypted) as Record;
- const tags = (inner.tags ?? []) as string[][];
- const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
- const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
- setInitialData({
- title: getTag('title'),
- summary: getTag('summary'),
- content: (inner.content as string) || '',
- image: getTag('image'),
- tags: getTags('t'),
- slug: getTag('d'),
- });
- setLoading(false);
- return;
- }
- } catch {
- // Fall through to localStorage
- }
- }
-
- // Fallback to localStorage
- const drafts = getLocalDrafts();
- const draft = drafts.find((d) => d.slug === draftSlug);
- if (draft) {
- setInitialData({
- title: draft.title,
- summary: draft.summary,
- content: draft.content,
- image: draft.image,
- tags: draft.tags,
- slug: draft.slug,
- });
- }
- setLoading(false);
- };
-
- loadDraft();
- }, [draftSlug, user, nostr]);
-
- // Load existing article for editing if /articles/edit/:naddr
- useEffect(() => {
- if (!naddrParam) return;
-
- let decoded: { type: string; data: AddressPointer };
- try {
- decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
- if (decoded.type !== 'naddr') {
- setLoading(false);
- return;
- }
- } catch {
- setLoading(false);
- return;
- }
-
- const addr = decoded.data;
-
- // Only allow editing your own articles
- if (user && addr.pubkey !== user.pubkey) {
- setLoading(false);
- return;
- }
-
- nostr
- .query([
- {
- kinds: [addr.kind],
- authors: [addr.pubkey],
- '#d': [addr.identifier],
- limit: 1,
- },
- ])
- .then((events) => {
- if (events.length > 0) {
- setInitialData(parseArticleEvent(events[0]));
- setEditMode(true);
- }
- })
- .catch((err) => {
- console.error('Failed to load article for editing:', err);
- })
- .finally(() => {
- setLoading(false);
- });
- }, [naddrParam, nostr, user]);
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return ;
-}
diff --git a/src/pages/BadgesPage.tsx b/src/pages/BadgesPage.tsx
deleted file mode 100644
index f39096f0..00000000
--- a/src/pages/BadgesPage.tsx
+++ /dev/null
@@ -1,1198 +0,0 @@
-import type { NostrEvent } from "@nostrify/nostrify";
-import { useNostr } from "@nostrify/react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useSeoMeta } from "@unhead/react";
-import {
- Award,
- Check,
- ChevronLeft,
- ChevronRight,
- Clock,
- ExternalLink,
- Loader2,
- MoreVertical,
- Pencil,
- RotateCcw,
- Trash2,
- Upload,
- Users,
- X,
-} from "lucide-react";
-import { nip19 } from "nostr-tools";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Link } from "react-router-dom";
-import { AwardBadgeDialog } from "@/components/AwardBadgeDialog";
-import { LoginArea } from "@/components/auth/LoginArea";
-import { BadgeContent } from "@/components/BadgeContent";
-import { parseBadgeDefinition, type BadgeData } from "@/lib/parseBadgeDefinition";
-import { BadgeRecoveryDialog } from "@/components/BadgeRecoveryDialog";
-import { BadgeThumbnail } from "@/components/BadgeThumbnail";
-import { CreateBadgeDialog } from "@/components/CreateBadgeDialog";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { NoteCard } from "@/components/NoteCard";
-import { FeedCard } from "@/components/FeedCard";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { SortableList, SortableItem } from "@/components/SortableList";
-import { SubHeaderBar } from "@/components/SubHeaderBar";
-import { TabButton } from "@/components/TabButton";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Textarea } from "@/components/ui/textarea";
-import { useLayoutOptions } from "@/contexts/LayoutContext";
-import { useAcceptBadge } from "@/hooks/useAcceptBadge";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useAuthor } from "@/hooks/useAuthor";
-import {
- type BadgeDefinition,
- useBadgeDefinitions,
-} from "@/hooks/useBadgeDefinitions";
-import { useBadgeFeed } from "@/hooks/useBadgeFeed";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
-import { useNostrPublish } from "@/hooks/useNostrPublish";
-import { type PendingBadge, usePendingBadges } from "@/hooks/usePendingBadges";
-import { useProfileBadges } from "@/hooks/useProfileBadges";
-import { useRemoveBadge } from "@/hooks/useRemoveBadge";
-import { useReorderBadges } from "@/hooks/useReorderBadges";
-import { useToast } from "@/hooks/useToast";
-import { useUploadFile } from "@/hooks/useUploadFile";
-import { BADGE_AWARD_KIND, BADGE_DEFINITION_KIND, getBadgeATag } from "@/lib/badgeUtils";
-import { deduplicateEvents } from "@/lib/deduplicateEvents";
-import { genUserName } from "@/lib/genUserName";
-
-// ─── Types ─────────────────────────────────────────────────────────────────────
-
-type BadgesTab = "mine" | "follows";
-
-interface ParsedBadge {
- event: NostrEvent;
- badge: BadgeData;
- aTag: string;
-}
-
-// ─── NoteCard Skeleton ─────────────────────────────────────────────────────────
-
-function NoteCardSkeleton() {
- return (
-
- );
-}
-
-// ─── Page ──────────────────────────────────────────────────────────────────────
-
-export function BadgesPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const queryClient = useQueryClient();
- const [createDialogOpen, setCreateDialogOpen] = useState(false);
-
- // Fetch pending badges at page level so we can show a counter on the tab
- const {
- pendingBadges,
- count: pendingCount,
- isLoading: isLoadingPending,
- } = usePendingBadges(user?.pubkey);
-
- useLayoutOptions({
- showFAB: true,
- onFabClick: () => setCreateDialogOpen(true),
- fabIcon: ,
- hasSubHeader: !!user,
- });
-
- const [activeTab, setActiveTab] = useFeedTab("badges", [
- "mine",
- "follows",
- ]);
-
- useSeoMeta({
- title: `Badges | ${config.appName}`,
- description:
- "Discover badges, create new ones, and show them off on your profile",
- });
-
- return (
-
- } />
-
- {/* Follows / My Badges tabs */}
- {user && (
-
- setActiveTab("follows")}
- />
- setActiveTab("mine")}
- >
-
- My Badges
- {pendingCount > 0 && (
-
- {pendingCount}
-
- )}
-
-
-
- )}
-
- {/* Tab content */}
- {activeTab === "mine" ? (
-
- ) : (
-
- queryClient.invalidateQueries({
- queryKey: ["badge-feed", "follows"],
- })
- }
- />
- )}
-
-
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════════════════
-// My Badges Tab
-// ═══════════════════════════════════════════════════════════════════════════════
-
-interface MyBadgesTabProps {
- pendingBadges: PendingBadge[];
- pendingCount: number;
- isLoadingPending: boolean;
-}
-
-function MyBadgesTab({
- pendingBadges,
- pendingCount,
- isLoadingPending,
-}: MyBadgesTabProps) {
- const { user } = useCurrentUser();
-
- if (!user) {
- return (
-
-
-
-
Manage your badges
-
- Log in to view, accept, and organize your Nostr badges.
-
-
-
-
- );
- }
-
- return (
-
- );
-}
-
-function MyBadgesContent({
- pendingBadges,
- pendingCount,
- isLoadingPending,
-}: MyBadgesTabProps) {
- const { user } = useCurrentUser();
- const { nostr } = useNostr();
- const [recoveryOpen, setRecoveryOpen] = useState(false);
-
- // Accepted badges
- const { refs, isLoading: isLoadingRefs } = useProfileBadges(user?.pubkey);
- const { badgeMap, isLoading: isLoadingDefs } = useBadgeDefinitions(refs);
-
- // Pending badges (data passed down from parent to share with tab counter)
- const pendingRefs = useMemo(
- () =>
- pendingBadges.map((p) => ({
- pubkey: p.issuerPubkey,
- identifier: p.identifier,
- })),
- [pendingBadges],
- );
- const { badgeMap: pendingBadgeMap, isLoading: isLoadingPendingDefs } =
- useBadgeDefinitions(pendingRefs);
-
- // Created badges
- const { data: rawCreatedEvents, isLoading: isLoadingCreated } = useQuery({
- queryKey: ["my-created-badges", user?.pubkey ?? ""],
- queryFn: async ({ signal }) => {
- if (!user) return [];
- return nostr.query(
- [
- {
- kinds: [BADGE_DEFINITION_KIND],
- authors: [user.pubkey],
- limit: 200,
- },
- ],
- { signal },
- );
- },
- enabled: !!user,
- staleTime: 60_000,
- });
-
- const createdBadges = useMemo(() => {
- if (!rawCreatedEvents) return [];
- const parsed: ParsedBadge[] = [];
- for (const event of rawCreatedEvents) {
- const badge = parseBadgeDefinition(event);
- if (!badge) continue;
- parsed.push({ event, badge, aTag: getBadgeATag(event) });
- }
- return parsed.sort((a, b) => b.event.created_at - a.event.created_at);
- }, [rawCreatedEvents]);
-
- // Optimistic local ordering state for accepted badges
- const [localRefs, setLocalRefs] = useState(refs);
- useEffect(() => {
- setLocalRefs(refs);
- }, [refs]);
-
- const isLoadingAccepted = isLoadingRefs || isLoadingDefs;
-
- return (
-
- {/* ── Pending Badges ── */}
- {(pendingCount > 0 || isLoadingPending) && (
-
- }
- />
- {isLoadingPending || isLoadingPendingDefs ? (
-
- {Array.from({ length: 2 }).map((_, i) => (
-
- ))}
-
- ) : (
-
- )}
-
- )}
-
- {/* ── Accepted Badges ── */}
-
- }
- action={
- setRecoveryOpen(true)}
- >
-
- Recovery
-
- }
- />
-
-
-
- {/* ── Created Badges ── */}
-
-
-
-
- );
-}
-
-// ─── Section Header ────────────────────────────────────────────────────────────
-
-function SectionHeader({
- title,
- count,
- icon,
- action,
-}: {
- title: string;
- count?: number;
- icon: React.ReactNode;
- action?: React.ReactNode;
-}) {
- return (
-
-
{icon}
-
{title}
- {count !== undefined && (
-
- {count}
-
- )}
- {action &&
{action}
}
-
- );
-}
-
-// ─── Pending Badge Carousel ────────────────────────────────────────────────────
-
-function PendingBadgeList({
- pendingBadges,
- badgeMap,
-}: {
- pendingBadges: PendingBadge[];
- badgeMap: Map;
-}) {
- const [dismissedATags, setDismissedATags] = useState>(new Set());
- const [currentIndex, setCurrentIndex] = useState(0);
- const { toast } = useToast();
- const { mutate: acceptBadge, isPending: isAccepting } = useAcceptBadge();
-
- const visibleBadges = pendingBadges.filter(
- (p) => !dismissedATags.has(p.aTag),
- );
-
- // Clamp index when badges are dismissed
- useEffect(() => {
- if (currentIndex >= visibleBadges.length && visibleBadges.length > 0) {
- setCurrentIndex(visibleBadges.length - 1);
- }
- }, [currentIndex, visibleBadges.length]);
-
- const handleDismiss = useCallback((aTag: string) => {
- setDismissedATags((prev) => new Set(prev).add(aTag));
- }, []);
-
- if (visibleBadges.length === 0) return null;
-
- const current = visibleBadges[currentIndex];
- const badge = badgeMap.get(current.aTag);
- const hasPrev = currentIndex > 0;
- const hasNext = currentIndex < visibleBadges.length - 1;
-
- const handleAccept = () => {
- acceptBadge(
- { aTag: current.aTag, awardEventId: current.awardEvent.id },
- {
- onSuccess: () => toast({ title: "Badge accepted!" }),
- onError: () =>
- toast({ title: "Failed to accept badge", variant: "destructive" }),
- },
- );
- };
-
- const badgeNaddr = nip19.naddrEncode({
- kind: BADGE_DEFINITION_KIND,
- pubkey: current.issuerPubkey,
- identifier: current.identifier,
- });
-
- return (
-
- {/* Badge showcase -- notification-style presentation */}
-
- {badge?.event ? (
-
- ) : (
-
- )}
-
-
- {/* Issuer line */}
-
- from
-
-
-
- {/* Actions */}
-
-
- {isAccepting ? (
-
- ) : (
- <>
-
- Accept Badge
- >
- )}
-
-
handleDismiss(current.aTag)}
- disabled={isAccepting}
- >
- Dismiss
-
-
-
- {/* Navigation bar */}
- {visibleBadges.length > 1 && (
-
- setCurrentIndex((i) => i - 1)}
- disabled={!hasPrev}
- aria-label="Previous badge"
- >
-
-
-
- {currentIndex + 1} of {visibleBadges.length}
-
- setCurrentIndex((i) => i + 1)}
- disabled={!hasNext}
- aria-label="Next badge"
- >
-
-
-
- )}
-
- );
-}
-
-function PendingBadgeSkeleton() {
- return (
-
- );
-}
-
-// ─── Accepted Badge List ───────────────────────────────────────────────────────
-
-interface AcceptedRef {
- aTag: string;
- eTag?: string;
- kind: number;
- pubkey: string;
- identifier: string;
-}
-
-function AcceptedBadgeList({
- refs,
- setRefs,
- badgeMap,
- isLoading,
-}: {
- refs: AcceptedRef[];
- setRefs: React.Dispatch>;
- badgeMap: Map;
- isLoading: boolean;
-}) {
- const { toast } = useToast();
- const { mutate: reorderBadges, isPending: isReordering } = useReorderBadges();
- const { mutate: removeBadge } = useRemoveBadge();
-
- const handleReorder = useCallback(
- (newRefs: AcceptedRef[]) => {
- setRefs(newRefs);
- reorderBadges(
- newRefs.map((r) => ({ aTag: r.aTag, eTag: r.eTag })),
- {
- onError: () =>
- toast({
- title: "Failed to reorder badges",
- variant: "destructive",
- }),
- },
- );
- },
- [setRefs, reorderBadges, toast],
- );
-
- const handleRemove = useCallback(
- (aTag: string) => {
- setRefs((prev) => prev.filter((r) => r.aTag !== aTag));
- removeBadge(aTag, {
- onError: () =>
- toast({ title: "Failed to remove badge", variant: "destructive" }),
- });
- },
- [setRefs, removeBadge, toast],
- );
-
- return (
-
- {isReordering && (
-
-
-
- )}
-
-
- {isLoading ? (
- Array.from({ length: 3 }).map((_, i) => (
-
- ))
- ) : refs.length === 0 ? (
-
-
- No accepted badges yet. When you accept a badge, it will appear here.
-
-
- ) : (
-
ref.aTag}
- onReorder={handleReorder}
- className="space-y-1.5"
- renderItem={(ref, index) => (
-
-
-
- {index + 1}
-
- {badgeMap.get(ref.aTag) ? (
-
- ) : (
-
- )}
-
-
- {badgeMap.get(ref.aTag)?.name ?? ref.identifier}
-
-
-
-
handleRemove(ref.aTag)}
- />
-
-
- )}
- />
- )}
-
-
-
- );
-}
-
-function AcceptedBadgeSkeleton() {
- return (
-
- );
-}
-
-// ─── Created Badge List ────────────────────────────────────────────────────────
-
-function CreatedBadgeList({ badges, isLoading }: { badges: ParsedBadge[]; isLoading: boolean }) {
- const [editingBadge, setEditingBadge] = useState(null);
-
- if (editingBadge) {
- return (
-
-
- setEditingBadge(null)}
- >
-
- Cancel
-
-
- Editing: {editingBadge.badge.name}
-
-
-
setEditingBadge(null)}
- />
-
- );
- }
-
- return (
-
-
- {isLoading ? (
- Array.from({ length: 2 }).map((_, i) => (
-
- ))
- ) : badges.length === 0 ? (
-
-
- You haven't created any badges yet.
-
-
- ) : (
- badges.map((badge) => (
-
- ))
- )}
-
-
- );
-}
-
-function CreatedBadgeRow({
- badge,
- onEdit,
-}: {
- badge: ParsedBadge;
- onEdit: (badge: ParsedBadge) => void;
-}) {
- const { toast } = useToast();
- const { nostr } = useNostr();
- const queryClient = useQueryClient();
- const { mutateAsync: publishEvent } = useNostrPublish();
- const [awardOpen, setAwardOpen] = useState(false);
- const [deleteOpen, setDeleteOpen] = useState(false);
-
- const deleteMutation = useMutation({
- mutationFn: async () => {
- const badgeATag = `${BADGE_DEFINITION_KIND}:${badge.event.pubkey}:${badge.badge.identifier}`;
-
- // Query all award events (kind 8) the user published for this badge
- const awardEvents = await nostr.query([
- { kinds: [BADGE_AWARD_KIND], authors: [badge.event.pubkey], '#a': [badgeATag], limit: 500 },
- ]);
-
- // Build deletion tags: badge definition + all awards
- const tags: string[][] = [
- ["e", badge.event.id],
- ["a", badgeATag],
- ["k", BADGE_DEFINITION_KIND.toString()],
- ];
-
- for (const award of awardEvents) {
- tags.push(["e", award.id]);
- }
-
- if (awardEvents.length > 0) {
- tags.push(["k", BADGE_AWARD_KIND.toString()]);
- }
-
- await publishEvent({
- kind: 5,
- content: "",
- tags,
- } as Omit);
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["my-created-badges"] });
- toast({
- title: "Deletion requested",
- description: "The badge and all its awards have been requested for deletion.",
- });
- },
- onError: () => {
- toast({ title: "Failed to delete", variant: "destructive" });
- },
- });
-
- const naddr = nip19.naddrEncode({
- kind: BADGE_DEFINITION_KIND,
- pubkey: badge.event.pubkey,
- identifier: badge.badge.identifier,
- });
-
- return (
- <>
-
-
-
-
-
{badge.badge.name}
- {badge.badge.description && (
-
- {badge.badge.description}
-
- )}
-
- {badge.badge.identifier}
-
-
-
setAwardOpen(true)}
- onEdit={() => onEdit(badge)}
- onDelete={() => setDeleteOpen(true)}
- />
-
-
-
-
-
- Delete "{badge.badge.name}"?
-
-
- This publishes a deletion request (NIP-09). Relays should
- remove the badge definition and all awards you issued
- for it.
-
-
-
- Cancel
- deleteMutation.mutate()}
- disabled={deleteMutation.isPending}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {deleteMutation.isPending ? (
-
- ) : null}
- Delete
-
-
-
-
-
-
-
-
- >
- );
-}
-
-function CreatedBadgeSkeleton() {
- return (
-
- );
-}
-
-// ─── Edit Badge Form (inline) ──────────────────────────────────────────────────
-
-function EditBadgeForm({
- badge,
- onClose,
-}: {
- badge: ParsedBadge;
- onClose: () => void;
-}) {
- const { toast } = useToast();
- const queryClient = useQueryClient();
- const { mutateAsync: publishEvent } = useNostrPublish();
- const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
- const fileInputRef = useRef(null);
-
- const [name, setName] = useState(badge.badge.name);
- const [description, setDescription] = useState(badge.badge.description ?? "");
- const [imageUrl, setImageUrl] = useState(badge.badge.image ?? "");
- const [imagePreview, setImagePreview] = useState(badge.badge.image ?? "");
- const [isSaving, setIsSaving] = useState(false);
-
- const identifier = badge.badge.identifier;
-
- const handleFileSelect = useCallback(
- async (file: File) => {
- if (!file.type.startsWith("image/")) {
- toast({
- title: "Invalid file",
- description: "Please select an image file.",
- variant: "destructive",
- });
- return;
- }
- const reader = new FileReader();
- reader.onload = (e) => setImagePreview(e.target?.result as string);
- reader.readAsDataURL(file);
- try {
- const [[, url]] = await uploadFile(file);
- setImageUrl(url);
- } catch {
- toast({ title: "Upload failed", variant: "destructive" });
- }
- },
- [uploadFile, toast],
- );
-
- const handleSave = useCallback(async () => {
- if (!name.trim()) return;
- setIsSaving(true);
- try {
- const newTags: string[][] = [];
- newTags.push(["d", identifier]);
- newTags.push(["name", name.trim()]);
- if (description.trim()) newTags.push(["description", description.trim()]);
- if (imageUrl) newTags.push(["image", imageUrl]);
- for (const tag of badge.event.tags) {
- if (["d", "name", "description", "image", "thumb"].includes(tag[0]))
- continue;
- newTags.push(tag);
- }
- await publishEvent({
- kind: BADGE_DEFINITION_KIND,
- content: "",
- tags: newTags,
- } as Omit);
- queryClient.invalidateQueries({ queryKey: ["my-created-badges"] });
- toast({ title: "Badge updated!" });
- onClose();
- } catch {
- toast({ title: "Failed to update badge", variant: "destructive" });
- } finally {
- setIsSaving(false);
- }
- }, [
- name,
- description,
- imageUrl,
- identifier,
- badge.event.tags,
- publishEvent,
- queryClient,
- toast,
- onClose,
- ]);
-
- return (
-
-
-
Image
-
fileInputRef.current?.click()}
- >
- {imagePreview ? (
-
- ) : (
-
-
-
- )}
-
- {isUploading && (
-
-
-
- )}
-
-
- e.target.files?.[0] && handleFileSelect(e.target.files[0])
- }
- />
-
- Recommended aspect ratio is 1:1 (max 1024x1024 px).
-
-
-
-
- Name
-
- setName(e.target.value)}
- />
-
-
- Identifier
-
-
-
-
- Description
-
-
-
-
- Cancel
-
-
- {isSaving ? : null}
- Save Changes
-
-
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════════════════
-// Follows Feed Tab
-// ═══════════════════════════════════════════════════════════════════════════════
-
-function FollowsFeedTab({ onRefresh }: { onRefresh: () => Promise }) {
- const { user } = useCurrentUser();
- const feedQuery = useBadgeFeed("follows");
-
- const {
- data: rawData,
- isPending,
- isLoading,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = feedQuery;
-
- const { scrollRef } = useInfiniteScroll({
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- pageCount: rawData?.pages?.length,
- });
-
- const feedEvents = deduplicateEvents(rawData?.pages as NostrEvent[][]);
-
- const showSkeleton = isPending || (isLoading && !rawData);
-
- return (
-
- {showSkeleton ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ) : feedEvents.length > 0 ? (
- <>
-
- {feedEvents.map((event) => (
-
- ))}
-
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
-
-
- )}
-
- )}
- >
- ) : (
-
- )}
-
- );
-}
-
-// ═══════════════════════════════════════════════════════════════════════════════
-// Shared
-// ═══════════════════════════════════════════════════════════════════════════════
-
-function IssuerName({ pubkey }: { pubkey: string }) {
- const author = useAuthor(pubkey);
- const name = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey);
- return (
- by {name}
- );
-}
-
-/** Shared overflow menu for accepted and created badge rows. */
-function BadgeOverflowMenu({
- naddr,
- onAward,
- onEdit,
- onDelete,
- onRemove,
-}: {
- naddr: string;
- onAward?: () => void;
- onEdit?: () => void;
- onDelete?: () => void;
- onRemove?: () => void;
-}) {
- return (
-
-
-
-
-
-
-
-
-
-
- View
-
-
- {onAward && (
-
-
- Award
-
- )}
- {onEdit && (
-
-
- Edit
-
- )}
- {(onDelete || onRemove) && }
- {onRemove && (
-
-
- Remove
-
- )}
- {onDelete && (
-
-
- Delete
-
- )}
-
-
- );
-}
diff --git a/src/pages/BlueskyPage.tsx b/src/pages/BlueskyPage.tsx
deleted file mode 100644
index 67e1fc94..00000000
--- a/src/pages/BlueskyPage.tsx
+++ /dev/null
@@ -1,629 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { useInView } from 'react-intersection-observer';
-import {
- ArrowLeft,
- ExternalLink,
- FlameKindling,
- Info,
- Loader2,
- MessageCircle,
- Repeat2,
- Search,
- Share2,
- X,
-} from 'lucide-react';
-
-import { Input } from '@/components/ui/input';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
-import { ReplyComposeModal } from '@/components/ReplyComposeModal';
-import { ExternalReactionButton } from '@/components/ExternalReactionButton';
-import { FeedCard } from '@/components/FeedCard';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useBlueskyTrending, type BlueskyPost } from '@/hooks/useBlueskyTrending';
-import { useBlueskyActorSearch, type BlueskyActorResult } from '@/hooks/useBlueskyActorSearch';
-import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
-import { shareOrCopy } from '@/lib/share';
-import { parseExternalUri } from '@/lib/externalContent';
-import { useShareOrigin } from '@/hooks/useShareOrigin';
-import { useToast } from '@/hooks/useToast';
-import { cn } from '@/lib/utils';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-/** Convert an AT URI (at://did/collection/rkey) into a bsky.app web URL. */
-function postWebUrl(uri: string, handle: string): string {
- const parts = uri.split('/');
- const rkey = parts[parts.length - 1];
- return `https://bsky.app/profile/${handle}/post/${rkey}`;
-}
-
-/** Convert a bsky.app post URL into our /i/ route. */
-function dittoUrl(url: string): string {
- return `/i/${encodeURIComponent(url)}`;
-}
-
-function formatCount(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
- return String(n);
-}
-
-function timeAgo(dateStr: string): string {
- const now = Date.now();
- const then = new Date(dateStr).getTime();
- const diff = now - then;
- const minutes = Math.floor(diff / 60000);
- if (minutes < 1) return 'just now';
- if (minutes < 60) return `${minutes}m`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h`;
- const days = Math.floor(hours / 24);
- if (days < 30) return `${days}d`;
- const months = Math.floor(days / 30);
- return `${months}mo`;
-}
-
-// ---------------------------------------------------------------------------
-// Components
-// ---------------------------------------------------------------------------
-
-// ---------------------------------------------------------------------------
-// Post card — feed-style (vertical, like NoteCard)
-// ---------------------------------------------------------------------------
-
-function BlueskyFeedPost({ post }: { post: BlueskyPost }) {
- const navigate = useNavigate();
- const { toast } = useToast();
- const shareOrigin = useShareOrigin();
-
- const webUrl = postWebUrl(post.uri, post.author.handle);
- const internalUrl = dittoUrl(webUrl);
- const profileUrl = dittoUrl(`https://bsky.app/profile/${post.author.handle}`);
- const images = post.embed?.$type === 'app.bsky.embed.images#view' ? (post.embed.images ?? []) : [];
- const externalEmbed = post.embed?.$type === 'app.bsky.embed.external#view' ? post.embed.external : undefined;
-
- // NIP-73 external content for the reaction button
- const externalContent = useMemo(() => parseExternalUri(webUrl), [webUrl]);
-
- const [shareOpen, setShareOpen] = useState(false);
- const [commentOpen, setCommentOpen] = useState(false);
-
- const handleComment = useCallback((e: React.MouseEvent) => {
- e.stopPropagation();
- setCommentOpen(true);
- }, []);
-
- const handleRepost = useCallback((e: React.MouseEvent) => {
- e.stopPropagation();
- setShareOpen(true);
- }, []);
-
- const handleShare = useCallback(async (e: React.MouseEvent) => {
- e.stopPropagation();
- const fullUrl = `${shareOrigin}${internalUrl}`;
- const result = await shareOrCopy(fullUrl);
- if (result === 'copied') {
- toast({ title: 'Link copied' });
- }
- }, [internalUrl, toast, shareOrigin]);
-
- const handleCardClick = useCallback(() => {
- navigate(internalUrl);
- }, [navigate, internalUrl]);
-
- return (
- <>
-
-
- {/* Avatar */}
-
e.stopPropagation()} className="shrink-0">
- {post.author.avatar ? (
-
{ e.currentTarget.style.display = 'none'; }}
- />
- ) : (
-
- {(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
-
- )}
-
-
- {/* Body */}
-
- {/* Author info */}
-
- e.stopPropagation()} className="font-semibold text-[15px] truncate leading-tight hover:underline">
- {post.author.displayName ?? post.author.handle}
-
- e.stopPropagation()} className="text-muted-foreground text-sm truncate leading-tight hover:underline">
- @{post.author.handle}
-
- ·
-
- {timeAgo(post.record.createdAt)}
-
-
-
- {/* Post text */}
-
- {post.record.text}
-
-
- {/* Image embeds */}
- {images.length > 0 && (
-
= 4 && 'grid grid-cols-2 gap-0.5',
- )}
- >
- {images.slice(0, 4).map((img, i) => (
-
-
{ e.currentTarget.style.display = 'none'; }}
- />
-
- ))}
-
- )}
-
- {/* External link embed */}
- {externalEmbed && (
-
- {externalEmbed.thumb && (
-
-
{ e.currentTarget.style.display = 'none'; }}
- />
-
- )}
-
-
- {(() => { try { return new URL(externalEmbed.uri).hostname; } catch { return externalEmbed.uri; } })()}
-
- {externalEmbed.title && (
-
{externalEmbed.title}
- )}
- {externalEmbed.description && (
-
{externalEmbed.description}
- )}
-
-
- )}
-
- {/* Action buttons */}
-
-
-
- {post.replyCount > 0 ? (
- {formatCount(post.replyCount)}
- ) : (
- Comment
- )}
-
-
-
- {post.repostCount > 0 ? (
- {formatCount(post.repostCount)}
- ) : (
- Repost
- )}
-
-
-
-
-
-
-
-
-
-
-
- {/* Comment compose modal */}
- {commentOpen && (
-
- )}
-
- {/* Share compose modal */}
- {shareOpen && (
-
- )}
- >
- );
-}
-
-// ---------------------------------------------------------------------------
-// Search bar
-// ---------------------------------------------------------------------------
-
-function BlueskySearchBar() {
- const navigate = useNavigate();
- const inputRef = useRef(null);
- const containerRef = useRef(null);
-
- const [query, setQuery] = useState('');
- const [debouncedQuery, setDebouncedQuery] = useState('');
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const debounceRef = useRef | undefined>(undefined);
-
- const { data: results, isFetching } = useBlueskyActorSearch(debouncedQuery);
-
- const handleChange = useCallback((value: string) => {
- setQuery(value);
- clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- setDebouncedQuery(value.trim());
- }, 300);
- }, []);
-
- useEffect(() => {
- return () => clearTimeout(debounceRef.current);
- }, []);
-
- useEffect(() => {
- if (debouncedQuery.length >= 1 && results && results.length > 0) {
- setDropdownOpen(true);
- } else if (debouncedQuery.length >= 1 && results && results.length === 0 && !isFetching) {
- setDropdownOpen(true);
- }
- }, [debouncedQuery, results, isFetching]);
-
- // Close on outside click
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
- setDropdownOpen(false);
- }
- }
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- const handleSelect = useCallback((result: BlueskyActorResult) => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.blur();
- navigate(dittoUrl(result.url));
- }, [navigate]);
-
- const handleClear = useCallback(() => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.focus();
- }, []);
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- setDropdownOpen(false);
- inputRef.current?.blur();
- }
- if (e.key === 'Enter' && results && results.length > 0) {
- e.preventDefault();
- handleSelect(results[0]);
- }
- }, [results, handleSelect]);
-
- return (
-
-
-
- handleChange(e.target.value)}
- onFocus={() => {
- if (debouncedQuery.length >= 1) setDropdownOpen(true);
- }}
- onKeyDown={handleKeyDown}
- className="pl-9 pr-9 h-9 text-base md:text-sm"
- />
- {query ? (
-
-
-
- ) : null}
-
-
- {/* Search results dropdown */}
- {dropdownOpen && debouncedQuery.length >= 1 && (
-
- {isFetching && (!results || results.length === 0) ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : results && results.length > 0 ? (
-
- {results.map((result) => (
-
handleSelect(result)}
- >
- {result.avatar ? (
-
- ) : (
-
-
-
- )}
-
-
- {result.displayName || result.handle}
-
-
- @{result.handle}
-
-
-
- ))}
-
- ) : (
-
- No users found for “{debouncedQuery}”
-
- )}
-
- {/* Loading indicator when results exist but we're refetching */}
- {isFetching && results && results.length > 0 && (
-
-
-
- )}
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Loading skeleton (feed-style)
-// ---------------------------------------------------------------------------
-
-function BlueskyLoadingSkeleton() {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Page
-// ---------------------------------------------------------------------------
-
-export function BlueskyPage() {
- const { config } = useAppContext();
- const {
- data,
- isLoading,
- isError,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useBlueskyTrending();
- const { ref: loadMoreRef, inView } = useInView();
-
- useSeoMeta({
- title: `Bluesky | ${config.appName}`,
- description: 'Explore popular posts from Bluesky \u2014 trending discussions, images, and links.',
- });
-
- // Flatten pages, deduplicate by URI
- const allPosts = useMemo(() => {
- if (!data?.pages) return [];
- const seen = new Set();
- return data.pages.flatMap((page) => page.posts).filter((post) => {
- if (seen.has(post.uri)) return false;
- seen.add(post.uri);
- return true;
- });
- }, [data?.pages]);
-
- // Trigger next page fetch when sentinel is in view
- useEffect(() => {
- if (inView && hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
-
Bluesky
-
Popular posts from the ATmosphere
-
-
-
-
-
-
-
-
-
-
- Content provided by{' '}
-
- Bluesky
-
- , a decentralized social network built on the AT Protocol. All posts are public and belong to their respective authors.
-
-
-
-
-
-
-
-
- {/* Search bar */}
-
-
- {/* Content */}
- {isLoading ? (
-
- ) : isError ? (
-
-
-
- Couldn't load Bluesky posts. Try again later.
-
-
- ) : allPosts.length === 0 ? (
-
- ) : (
- <>
-
- {allPosts.map((post) => (
-
- ))}
-
-
- {/* Infinite scroll sentinel */}
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
-
-
- )}
-
- )}
- >
- )}
-
- );
-}
diff --git a/src/pages/BookmarksPage.tsx b/src/pages/BookmarksPage.tsx
deleted file mode 100644
index 88216a85..00000000
--- a/src/pages/BookmarksPage.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { Bookmark } from 'lucide-react';
-import { NoteCard } from '@/components/NoteCard';
-import { FeedCard } from '@/components/FeedCard';
-import { PageHeader } from '@/components/PageHeader';
-import { PullToRefresh } from '@/components/PullToRefresh';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useBookmarks } from '@/hooks/useBookmarks';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { usePageRefresh } from '@/hooks/usePageRefresh';
-import { LoginArea } from '@/components/auth/LoginArea';
-
-export function BookmarksPage() {
- const { config } = useAppContext();
-
- useSeoMeta({
- title: `Bookmarks | ${config.appName}`,
- description: 'Your saved bookmarks on Nostr.',
- });
-
- const { user } = useCurrentUser();
- const { events, isLoading, isLoadingEvents, bookmarkedIds } = useBookmarks();
-
- const handleRefresh = usePageRefresh(['bookmarks']);
-
- return (
-
- } />
-
-
- {/* Content */}
- {!user ? (
-
-
-
-
-
-
Save posts for later
-
- Log in to bookmark posts and find them here anytime.
-
-
-
-
- ) : isLoading || (bookmarkedIds.length > 0 && isLoadingEvents) ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : events.length > 0 ? (
-
- {events.map((event) => (
-
- ))}
-
- ) : (
-
-
-
-
-
-
No bookmarks yet
-
- When you bookmark a post, it will show up here. Tap the bookmark icon on any post to save it.
-
-
-
- )}
-
-
- );
-}
-
-function BookmarkSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/BooksPage.tsx b/src/pages/BooksPage.tsx
deleted file mode 100644
index 6a2fce6f..00000000
--- a/src/pages/BooksPage.tsx
+++ /dev/null
@@ -1,348 +0,0 @@
-import { useSeoMeta } from "@unhead/react";
-import { BookMarked, Loader2, Search, X } from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useNavigate } from "react-router-dom";
-import { BookFeedItem, BookFeedItemSkeleton } from "@/components/BookFeedItem";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { KindInfoButton } from "@/components/KindInfoButton";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { SubHeaderBar } from "@/components/SubHeaderBar";
-import { TabButton } from "@/components/TabButton";
-import { Input } from "@/components/ui/input";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useLayoutOptions } from "@/contexts/LayoutContext";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useBookFeed } from "@/hooks/useBookFeed";
-import { type BookSearchResult, useBookSearch } from "@/hooks/useBookSearch";
-import { usePrefetchBookSummaries } from "@/hooks/useBookSummary";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
-import { usePageRefresh } from "@/hooks/usePageRefresh";
-import { deduplicateEvents } from "@/lib/deduplicateEvents";
-import type { ExtraKindDef } from "@/lib/extraKinds";
-
-type FeedTab = "follows" | "global";
-
-const booksDef: ExtraKindDef = {
- kind: 31985,
- id: "books",
- label: "Books",
- description: "Book reviews and discussions",
- addressable: true,
- section: "social",
- blurb:
- "Discover book reviews, ratings, and discussions from the Nostr community. Track your reading and share your thoughts using the Bookstr protocol.",
- sites: [{ url: "https://bookstr.xyz/", name: "Bookstr" }],
-};
-
-export function BooksPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
-
- const [activeTab, setActiveTab] = useFeedTab("books", [
- "follows",
- "global",
- ]);
-
- useSeoMeta({
- title: `Books | ${config.appName}`,
- description:
- "Book reviews, ratings, and discussions from the Nostr community",
- });
-
- useLayoutOptions({ hasSubHeader: !!user });
-
- const feedQuery = useBookFeed(activeTab);
-
- const {
- data: rawData,
- isPending,
- isLoading,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = feedQuery;
-
- const { scrollRef } = useInfiniteScroll({
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- pageCount: rawData?.pages?.length,
- });
-
- const handleRefresh = usePageRefresh(["book-feed", activeTab]);
-
- const events = deduplicateEvents(rawData?.pages);
-
- // Batch-prefetch book metadata for all visible ISBNs (4 concurrent requests)
- usePrefetchBookSummaries(events);
-
- const showSkeleton = isPending || (isLoading && !rawData);
-
- return (
-
- }>
- }
- />
-
-
- {/* Follows / Global tabs */}
- {user && (
-
- setActiveTab("follows")}
- />
- setActiveTab("global")}
- />
-
- )}
-
- {/* Book search bar */}
-
-
-
- {showSkeleton ? (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- ) : events.length > 0 ? (
-
- {events.map((event) => (
-
- ))}
-
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
-
-
- )}
-
- )}
-
- ) : (
- setActiveTab("global") : undefined
- }
- />
- )}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Book Search Bar
-// ---------------------------------------------------------------------------
-
-function BookSearchBar() {
- const navigate = useNavigate();
- const inputRef = useRef(null);
- const containerRef = useRef(null);
-
- const [query, setQuery] = useState("");
- const [debouncedQuery, setDebouncedQuery] = useState("");
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const debounceRef = useRef | undefined>(undefined);
-
- const { data: results, isFetching } = useBookSearch(debouncedQuery);
-
- // 300ms debounce
- const handleChange = useCallback((value: string) => {
- setQuery(value);
- clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- setDebouncedQuery(value.trim());
- }, 300);
- }, []);
-
- // Cleanup debounce timer
- useEffect(() => {
- return () => clearTimeout(debounceRef.current);
- }, []);
-
- // Open dropdown when we have results and input is focused
- useEffect(() => {
- if (debouncedQuery.length >= 2 && results && results.length > 0) {
- setDropdownOpen(true);
- } else if (
- debouncedQuery.length >= 2 &&
- results &&
- results.length === 0 &&
- !isFetching
- ) {
- setDropdownOpen(true); // show "no results"
- }
- }, [debouncedQuery, results, isFetching]);
-
- // Close dropdown on outside click
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (
- containerRef.current &&
- !containerRef.current.contains(e.target as Node)
- ) {
- setDropdownOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
-
- const handleSelect = useCallback(
- (isbn: string) => {
- setQuery("");
- setDebouncedQuery("");
- setDropdownOpen(false);
- inputRef.current?.blur();
- navigate(`/i/isbn:${isbn}`);
- },
- [navigate],
- );
-
- const handleClear = useCallback(() => {
- setQuery("");
- setDebouncedQuery("");
- setDropdownOpen(false);
- inputRef.current?.focus();
- }, []);
-
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Escape") {
- setDropdownOpen(false);
- inputRef.current?.blur();
- }
- if (e.key === "Enter" && results && results.length > 0) {
- e.preventDefault();
- handleSelect(results[0].isbn);
- }
- },
- [results, handleSelect],
- );
-
- return (
-
-
-
- handleChange(e.target.value)}
- onFocus={() => {
- if (debouncedQuery.length >= 2) setDropdownOpen(true);
- }}
- onKeyDown={handleKeyDown}
- className="pl-9 pr-9 h-9 text-base md:text-sm"
- />
- {query && (
-
-
-
- )}
-
-
- {/* Search results dropdown */}
- {dropdownOpen && debouncedQuery.length >= 2 && (
-
- {isFetching && (!results || results.length === 0) ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : results && results.length > 0 ? (
-
- {results.map((book) => (
-
- ))}
-
- ) : (
-
- No books found for “{debouncedQuery}”
-
- )}
-
- )}
-
- );
-}
-
-function BookSearchResultItem({
- book,
- onSelect,
-}: {
- book: BookSearchResult;
- onSelect: (isbn: string) => void;
-}) {
- return (
- onSelect(book.isbn)}
- >
- {book.coverUrl ? (
- {
- (e.currentTarget as HTMLElement).style.display = "none";
- }}
- />
- ) : (
-
-
-
- )}
-
-
{book.title}
- {book.authors.length > 0 && (
-
- {book.authors.join(", ")}
-
- )}
- {book.firstPublishYear && (
-
- {book.firstPublishYear}
-
- )}
-
-
- );
-}
diff --git a/src/pages/ContentSettingsPage.tsx b/src/pages/ContentSettingsPage.tsx
deleted file mode 100644
index 589bf9c5..00000000
--- a/src/pages/ContentSettingsPage.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { ContentSettings } from '@/components/ContentSettings';
-import { PageHeader } from '@/components/PageHeader';
-import { useAppContext } from '@/hooks/useAppContext';
-
-export function ContentSettingsPage() {
- const { config } = useAppContext();
-
- useSeoMeta({
- title: `Home Feed | Settings | ${config.appName}`,
- description: 'Choose what types of posts appear in your home feed',
- });
-
- return (
-
-
- Home Feed
-
- Choose what appears in your feed, manage saved searches, and hide content you don't want to see.
-
-
- }
- />
-
-
-
-
-
- );
-}
diff --git a/src/pages/CreateEventPage.tsx b/src/pages/CreateEventPage.tsx
deleted file mode 100644
index 74bb3d0e..00000000
--- a/src/pages/CreateEventPage.tsx
+++ /dev/null
@@ -1,490 +0,0 @@
-import { useMemo, useState } from 'react';
-import { Link, useNavigate, useSearchParams } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import { nip19 } from 'nostr-tools';
-import { AlertTriangle, ArrowLeft, CalendarDays, Clock, Loader2, Plus } from 'lucide-react';
-
-import { CoverImageField } from '@/components/CoverImageField';
-import { CountrySelect } from '@/components/CountrySelect';
-import { FormSection } from '@/components/FormSection';
-import { OrganizationContextChip } from '@/components/OrganizationContextChip';
-import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
-import { Alert, AlertDescription } from '@/components/ui/alert';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
-import { Textarea } from '@/components/ui/textarea';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
-import { usePublishRSVP } from '@/hooks/usePublishRSVP';
-import { useToast } from '@/hooks/useToast';
-import { getTodayDateInput } from '@/lib/dateInput';
-import { COUNTRIES } from '@/lib/countries';
-import { createCountryIdentifier } from '@/lib/countryIdentifiers';
-import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
-import { sanitizeUrl } from '@/lib/sanitizeUrl';
-import { unixSecondsInTimezone } from '@/lib/timezone';
-import { withAgoraTag } from '@/lib/agoraNoteTags';
-import { parseContentTagInput } from '@/lib/contentTags';
-
-function slugify(text: string): string {
- return text
- .toLowerCase()
- .trim()
- .replace(/[^\w\s-]/g, '')
- .replace(/[\s_]+/g, '-')
- .replace(/^-+|-+$/g, '');
-}
-
-function addDays(date: string, days: number): string {
- const parsed = new Date(`${date}T00:00:00Z`);
- parsed.setUTCDate(parsed.getUTCDate() + days);
- return parsed.toISOString().slice(0, 10);
-}
-
-export function CreateEventPage() {
- useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
-
- const { user } = useCurrentUser();
- const navigate = useNavigate();
- const [searchParams] = useSearchParams();
- const queryClient = useQueryClient();
- const { mutateAsync: publishEvent } = useNostrPublish();
- const { mutateAsync: publishRSVP } = usePublishRSVP();
- const { toast } = useToast();
-
- const browserTimezone = useMemo(
- () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
- [],
- );
- const minStartDate = useMemo(() => getTodayDateInput(), []);
- const pageCountryCode = (searchParams.get('country') || '').toUpperCase();
- const initialCountryCode = COUNTRIES[pageCountryCode] ? pageCountryCode : '';
-
- const orgParam = searchParams.get('org');
- const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
- const { data: manageableOrgs, isLoading: manageableOrgsLoading } = useManageableOrganizations();
- const authorizedOrgFromParam = useMemo(() => {
- if (!orgFromParam || !manageableOrgs) return null;
- return manageableOrgs.find((entry) => entry.community.aTag === orgFromParam.aTag) ?? null;
- }, [orgFromParam, manageableOrgs]);
- const organizationATag = authorizedOrgFromParam?.community.aTag ?? '';
-
- const [title, setTitle] = useState('');
- const [description, setDescription] = useState('');
- const [coverImage, setCoverImage] = useState('');
- const [coverUploading, setCoverUploading] = useState(false);
- const [allDay, setAllDay] = useState(true);
- const [startDate, setStartDate] = useState('');
- const [startTime, setStartTime] = useState('');
- const [endDate, setEndDate] = useState('');
- const [endTime, setEndTime] = useState('');
- const [countryCode, setCountryCode] = useState(initialCountryCode);
- const [countryQuery, setCountryQuery] = useState(initialCountryCode ? COUNTRIES[initialCountryCode].name : '');
- const [location, setLocation] = useState('');
- const [tagInput, setTagInput] = useState('');
- const [timezone, setTimezone] = useState(browserTimezone);
- const [formError, setFormError] = useState('');
-
- useSeoMeta({
- title: 'Create event | Agora',
- description: 'Create a calendar event on Agora.',
- });
-
- const minEndDate = startDate || minStartDate;
-
- const submitMutation = useMutation({
- mutationFn: async () => {
- if (!user) throw new Error('You must be logged in to create an event.');
-
- const trimmedTitle = title.trim();
- const trimmedDescription = description.trim();
- const trimmedLocation = location.trim();
- const contentTags = parseContentTagInput(tagInput);
-
- if (!trimmedTitle) throw new Error('Title is required.');
- if (!startDate) throw new Error('Start date is required.');
- if (startDate < minStartDate) throw new Error('Start date cannot be in the past.');
- if (!allDay && !startTime) throw new Error('Start time is required for timed events.');
-
- const dTag = `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
- let kind = 31922;
- const tags: string[][] = [
- ['d', dTag],
- ['title', trimmedTitle],
- ['alt', `${organizationATag ? 'Group event' : 'Calendar event'}: ${trimmedTitle}`],
- ];
-
- if (organizationATag) {
- tags.push(...createOrganizationAssociationTags(organizationATag));
- }
-
- if (trimmedDescription) {
- tags.push(['summary', trimmedDescription]);
- }
-
- if (trimmedLocation) {
- tags.push(['location', trimmedLocation]);
- }
-
- if (countryCode) {
- tags.push(['i', createCountryIdentifier(countryCode)]);
- }
-
- for (const tag of contentTags) tags.push(['t', tag]);
-
- const trimmedCoverImage = coverImage.trim();
- const sanitizedImage = trimmedCoverImage ? sanitizeUrl(trimmedCoverImage) : undefined;
- if (trimmedCoverImage && !sanitizedImage) {
- throw new Error('Cover image must be a valid https:// URL.');
- }
- if (sanitizedImage) {
- tags.push(['image', sanitizedImage]);
- }
-
- if (allDay) {
- tags.push(['start', startDate]);
- if (endDate) {
- if (endDate < startDate) throw new Error('End date must be on or after the start date.');
- if (endDate > startDate) tags.push(['end', addDays(endDate, 1)]);
- }
- } else {
- if (endDate && endDate < startDate) throw new Error('End date must be on or after the start date.');
- kind = 31923;
- const [startYear, startMonth, startDay] = startDate.split('-').map(Number);
- const [startHour, startMinute] = startTime.split(':').map(Number);
- const startTs = unixSecondsInTimezone(startYear, startMonth, startDay, startHour, startMinute, timezone);
- if (!Number.isFinite(startTs) || startTs <= 0) throw new Error('Start date or time is invalid.');
- tags.push(['start', String(startTs)]);
- tags.push(['D', String(Math.floor(startTs / 86400))]);
- tags.push(['start_tzid', timezone]);
-
- if (endDate || endTime) {
- const effectiveEndDate = endDate || startDate;
- const effectiveEndTime = endTime || startTime;
- const [endYear, endMonth, endDay] = effectiveEndDate.split('-').map(Number);
- const [endHour, endMinute] = effectiveEndTime.split(':').map(Number);
- const endTs = unixSecondsInTimezone(endYear, endMonth, endDay, endHour, endMinute, timezone);
- if (!Number.isFinite(endTs) || endTs <= startTs) {
- throw new Error('End time must be after the start time.');
- }
- tags.push(['end', String(endTs)]);
- tags.push(['end_tzid', timezone]);
- }
- }
-
- const publishedEvent = await publishEvent({
- kind,
- content: trimmedDescription,
- tags: withAgoraTag(tags),
- });
-
- const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
- publishRSVP({
- eventCoord,
- eventAuthorPubkey: user.pubkey,
- status: 'accepted',
- }).catch(() => {
- // Best-effort: event publishing has already succeeded.
- });
-
- queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: ['feed'] }),
- queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
- ...(organizationATag ? [
- queryClient.invalidateQueries({ queryKey: ['community-events', organizationATag] }),
- queryClient.invalidateQueries({ queryKey: ['organization-activity', organizationATag] }),
- queryClient.invalidateQueries({
- predicate: (q) => {
- const [root, aTagsKey] = q.queryKey;
- return root === 'community-activity-feed'
- && typeof aTagsKey === 'string'
- && aTagsKey.split(',').includes(organizationATag);
- },
- }),
- ] : []),
- ]);
-
- return nip19.naddrEncode({
- kind,
- pubkey: publishedEvent.pubkey,
- identifier: dTag,
- });
- },
- onSuccess: (naddr) => {
- toast({ title: 'Event created' });
- navigate(`/${naddr}`);
- },
- onError: (error: unknown) => {
- const msg = error instanceof Error ? error.message : String(error);
- setFormError(msg);
- toast({
- title: 'Could not create event',
- description: msg,
- variant: 'destructive',
- });
- },
- });
-
- if (!user) {
- return (
-
-
-
-
-
- Log in to create an event
-
- Events are signed Nostr events. You need a Nostr login to publish one.
-
-
- Back to events
-
-
-
-
-
- );
- }
-
- const handleAllDayChange = (checked: boolean) => {
- setAllDay(checked);
- if (checked && startDate && endDate && endDate < startDate) {
- setEndDate(startDate);
- }
- };
-
- const handleStartDateChange = (nextStartDate: string) => {
- setStartDate(nextStartDate);
- if (nextStartDate && endDate && endDate < nextStartDate) {
- setEndDate(nextStartDate);
- }
- };
-
- const canSubmit =
- title.trim().length > 0 &&
- startDate.length > 0 &&
- (allDay || startTime.length > 0) &&
- !coverUploading &&
- !submitMutation.isPending;
-
- return (
-
-
-
- );
-}
-
-export default CreateEventPage;
diff --git a/src/pages/DomainFeedPage.tsx b/src/pages/DomainFeedPage.tsx
deleted file mode 100644
index a95938c5..00000000
--- a/src/pages/DomainFeedPage.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import { useMemo } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import { useNavigate, useParams } from 'react-router-dom';
-import { useNostr } from '@nostrify/react';
-import { useQuery } from '@tanstack/react-query';
-import { NoteCard } from '@/components/NoteCard';
-import { FeedCard } from '@/components/FeedCard';
-import { ExternalFavicon } from '@/components/ExternalFavicon';
-import { PullToRefresh } from '@/components/PullToRefresh';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useFeedSettings } from '@/hooks/useFeedSettings';
-import { useMuteList } from '@/hooks/useMuteList';
-import { usePageRefresh } from '@/hooks/usePageRefresh';
-import { getEnabledFeedKinds } from '@/lib/extraKinds';
-import { isRepostKind } from '@/lib/feedUtils';
-import { isEventMuted } from '@/lib/muteHelpers';
-import { PageHeader } from '@/components/PageHeader';
-import type { NostrEvent } from '@nostrify/nostrify';
-
-/**
- * Fetches a nostr.json URL. Tries direct first, falls back to CORS proxy.
- */
-async function fetchNostrJson(url: URL, signal: AbortSignal): Promise | null> {
- try {
- const response = await fetch(url, { signal });
- if (response.ok) {
- return await response.json();
- }
- } catch {
- // fallthrough
- }
- return null;
-}
-
-/**
- * Fetches the NIP-05 JSON from a domain's .well-known/nostr.json endpoint
- * and returns the pubkeys of all users registered on that domain.
- */
-function useDomainPubkeys(domain: string | undefined) {
- return useQuery({
- queryKey: ['domain-pubkeys', domain],
- queryFn: async ({ signal }) => {
- if (!domain) return [];
- const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(800)]);
- const data = await fetchNostrJson(new URL('/.well-known/nostr.json', `https://${domain}`), fetchSignal);
- if (!data) throw new Error('Failed to fetch nostr.json');
- if (!data.names || typeof data.names !== 'object') return [];
- return Object.values(data.names).filter((pk): pk is string => typeof pk === 'string');
- },
- enabled: !!domain,
- staleTime: 5 * 60 * 1000,
- gcTime: 30 * 60 * 1000,
- });
-}
-
-export function DomainFeedPage() {
- const { config } = useAppContext();
- const { domain } = useParams<{ domain: string }>();
- const navigate = useNavigate();
- const { nostr } = useNostr();
- const { feedSettings } = useFeedSettings();
-
- const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
- const kindsKey = [...kinds].sort().join(',');
-
- useSeoMeta({
- title: domain ? `${domain} | ${config.appName}` : `Domain Feed | ${config.appName}`,
- description: domain ? `Posts from users on ${domain}` : 'Domain feed',
- });
-
- const { muteItems } = useMuteList();
-
- const refreshQueryKey = useMemo(
- () => [['domain-pubkeys', domain], ['domain-feed', domain]],
- [domain],
- );
- const handleRefresh = usePageRefresh(refreshQueryKey);
-
- const { data: pubkeys, isLoading: pubkeysLoading, isError: pubkeysError } = useDomainPubkeys(domain);
-
- const { data: events, isLoading: eventsLoading } = useQuery({
- queryKey: ['domain-feed', domain, pubkeys?.length ?? 0, kindsKey],
- queryFn: async ({ signal }) => {
- if (!pubkeys || pubkeys.length === 0) return [];
- const results = await nostr.query(
- [{ kinds, authors: pubkeys, limit: 40 }],
- { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
- );
- return results.sort((a, b) => b.created_at - a.created_at);
- },
- enabled: !!pubkeys && pubkeys.length > 0,
- });
-
- const filteredEvents = useMemo(() => {
- if (!events || muteItems.length === 0) return events;
- return events.filter((e) => !isEventMuted(e, muteItems));
- }, [events, muteItems]);
-
- const isLoading = pubkeysLoading || eventsLoading;
-
- return (
-
- window.history.length > 1 ? navigate(-1) : navigate('/')}
- titleContent={
-
-
-
-
{domain}
- {pubkeys && pubkeys.length > 0 && (
-
- {pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''}
-
- )}
-
-
- }
- />
-
-
- {pubkeysError ? (
-
-
Could not fetch users from {domain}.
-
Make sure the domain has a valid /.well-known/nostr.json
-
- ) : isLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ) : filteredEvents && filteredEvents.length > 0 ? (
-
- {filteredEvents.map((event) => )}
-
- ) : pubkeys && pubkeys.length === 0 ? (
-
- No users found on {domain}.
-
- ) : (
-
- No posts found from users on {domain}.
-
- )}
-
-
- );
-}
diff --git a/src/pages/EventsFeedPage.tsx b/src/pages/EventsFeedPage.tsx
deleted file mode 100644
index f40dce55..00000000
--- a/src/pages/EventsFeedPage.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import type { NostrEvent } from "@nostrify/nostrify";
-import { useSeoMeta } from "@unhead/react";
-import { CalendarDays, Loader2 } from "lucide-react";
-import { useMemo } from "react";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { KindInfoButton } from "@/components/KindInfoButton";
-import { NoteCard } from "@/components/NoteCard";
-import { FeedCard } from "@/components/FeedCard";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { SubHeaderBar } from "@/components/SubHeaderBar";
-import { TabButton } from "@/components/TabButton";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useLayoutOptions } from "@/contexts/LayoutContext";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useFeed } from "@/hooks/useFeed";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
-import { useMuteList } from "@/hooks/useMuteList";
-import { usePageRefresh } from "@/hooks/usePageRefresh";
-import { getExtraKindDef } from "@/lib/extraKinds";
-import { isEventMuted } from "@/lib/muteHelpers";
-import { sidebarItemIcon } from "@/lib/sidebarItems";
-
-type FeedTab = "follows" | "global";
-
-const eventsDef = getExtraKindDef("events")!;
-
-/** Extract the first value of a tag by name. */
-function getTag(tags: string[][], name: string): string | undefined {
- return tags.find(([n]) => n === name)?.[1];
-}
-
-function getEventStartTimestamp(event: NostrEvent): number {
- const start = getTag(event.tags, "start");
- if (!start) return 0;
-
- if (event.kind === 31922) {
- const timestamp = Date.parse(`${start}T00:00:00Z`);
- return Number.isNaN(timestamp) ? 0 : Math.floor(timestamp / 1000);
- }
-
- const timestamp = parseInt(start, 10);
- return Number.isNaN(timestamp) ? 0 : timestamp;
-}
-
-// ─── EventsFeedPage ───────────────────────────────────────────────────────────
-
-export function EventsFeedPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const { muteItems } = useMuteList();
-
- const [activeTab, setActiveTab] = useFeedTab("events", [
- "follows",
- "global",
- ]);
-
- useSeoMeta({ title: `Events | ${config.appName}` });
- useLayoutOptions({
- showFAB: true,
- fabHref: "/events/new",
- fabIcon: ,
- hasSubHeader: !!user,
- });
-
- // Calendar events feed
- const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
-
- const handleRefresh = usePageRefresh(useMemo(() => ["feed", activeTab], [activeTab]));
-
- const {
- data: rawData,
- isPending,
- isLoading,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = feedQuery;
-
- const { scrollRef } = useInfiniteScroll({
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- pageCount: rawData?.pages?.length,
- });
-
- // Flatten, deduplicate, filter muted, then sort: future events first
- const feedItems = useMemo(() => {
- if (!rawData?.pages) return [];
- const seen = new Set();
- const now = Math.floor(Date.now() / 1000);
-
- const items = (
- rawData.pages as { items: { event: NostrEvent; repostedBy?: string }[] }[]
- )
- .flatMap((page) => page.items)
- .filter((item) => {
- if (seen.has(item.event.id)) return false;
- seen.add(item.event.id);
- if (muteItems.length > 0 && isEventMuted(item.event, muteItems))
- return false;
- return true;
- });
-
- return items.sort((a, b) => {
- const aStart = getEventStartTimestamp(a.event);
- const bStart = getEventStartTimestamp(b.event);
- const aFuture = aStart >= now;
- const bFuture = bStart >= now;
- if (aFuture && !bFuture) return -1;
- if (!aFuture && bFuture) return 1;
- if (aFuture && bFuture) return aStart - bStart;
- return bStart - aStart;
- });
- }, [rawData?.pages, muteItems]);
-
- const showSkeleton = isPending || (isLoading && !rawData);
-
- return (
-
- }>
-
-
-
- {/* Follows / Global tabs */}
- {user && (
-
- setActiveTab("follows")}
- />
- setActiveTab("global")}
- />
-
- )}
-
-
- {showSkeleton ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : feedItems.length > 0 ? (
- <>
-
- {feedItems.map((item) => (
-
- ))}
-
-
- {hasNextPage && (
-
- {isFetchingNextPage && (
-
-
-
- )}
-
- )}
- >
- ) : (
- setActiveTab("global") : undefined
- }
- />
- )}
-
-
- );
-}
-
-// ─── Skeletons ────────────────────────────────────────────────────────────────
-
-function EventCardSkeleton() {
- return (
-
- );
-}
diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx
deleted file mode 100644
index 61798ab8..00000000
--- a/src/pages/FollowPage.tsx
+++ /dev/null
@@ -1,563 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useParams, Link, useNavigate } from 'react-router-dom';
-import { useInView } from 'react-intersection-observer';
-import { nip19 } from 'nostr-tools';
-import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react';
-
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Button } from '@/components/ui/button';
-import { Skeleton } from '@/components/ui/skeleton';
-import { NoteCard } from '@/components/NoteCard';
-import { FeedCard } from '@/components/FeedCard';
-import { useAuthor } from '@/hooks/useAuthor';
-import { useAuthors } from '@/hooks/useAuthors';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
-import { useNip05Resolve } from '@/hooks/useNip05Resolve';
-import { useToast } from '@/hooks/useToast';
-import { useProfileUrl } from '@/hooks/useProfileUrl';
-import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
-import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
-import { parsePackEvent } from '@/lib/packUtils';
-import { PackFeedTab, MemberCard, MemberCardSkeleton } from '@/components/FollowPackDetailContent';
-import { genUserName } from '@/lib/genUserName';
-import { Nip05Badge } from '@/components/Nip05Badge';
-import { SubHeaderBar } from '@/components/SubHeaderBar';
-import { TabButton } from '@/components/TabButton';
-import { TopNav } from '@/components/TopNav';
-import AuthDialog from '@/components/auth/AuthDialog';
-import type { FeedItem } from '@/lib/feedUtils';
-import type { AddressPointer } from 'nostr-tools/nip19';
-import NotFound from './NotFound';
-
-// ---------------------------------------------------------------------------
-// Profile feed (reuses useProfileFeed + NoteCard)
-// ---------------------------------------------------------------------------
-
-function ProfileFeed({ pubkey }: { pubkey: string }) {
- const {
- data: feedData,
- isPending,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useProfileFeed(pubkey, 'posts');
-
- const feedItems = useMemo(() => {
- if (!feedData?.pages) return [];
- const seen = new Set();
- const items: FeedItem[] = [];
- for (const page of feedData.pages) {
- for (const item of page.items) {
- const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
- if (!seen.has(key)) {
- seen.add(key);
- items.push(item);
- }
- }
- }
- return filterByTab(items, 'posts');
- }, [feedData?.pages]);
-
- const { ref: scrollRef, inView } = useInView({ threshold: 0 });
-
- useEffect(() => {
- if (inView && hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- if (isPending) {
- return (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- );
- }
-
- if (feedItems.length === 0) return null;
-
- return (
-
- {feedItems.map((item) => (
-
- ))}
- {hasNextPage && (
-
- {isFetchingNextPage && }
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Main follow view
-// ---------------------------------------------------------------------------
-
-function FollowView({ pubkey }: { pubkey: string }) {
- const author = useAuthor(pubkey);
- const { user } = useCurrentUser();
- const { data: followData } = useFollowList();
- const { isPending, follow } = useFollowActions();
- const { toast } = useToast();
- const navigate = useNavigate();
- const metadata = author.data?.metadata;
- const displayName = metadata?.name || genUserName(pubkey);
- const profileUrl = useProfileUrl(pubkey, metadata);
- const bannerUrl = metadata?.banner;
-
- const isOwnProfile = user && user.pubkey === pubkey;
- const isAlreadyFollowing = followData?.pubkeys.includes(pubkey) ?? false;
- const isLoggedOut = !user;
-
- const [loginOpen, setLoginOpen] = useState(false);
-
- const hasAutoFollowed = useRef(false);
- const [followDone, setFollowDone] = useState(false);
-
- useEffect(() => {
- if (!user || isOwnProfile || isAlreadyFollowing || hasAutoFollowed.current || isPending) return;
- if (!followData) return;
-
- hasAutoFollowed.current = true;
-
- follow(pubkey)
- .then(() => {
- setFollowDone(true);
- toast({ title: 'Followed!', description: `You are now following ${displayName}` });
- })
- .catch((err) => {
- console.error('Auto-follow failed:', err);
- hasAutoFollowed.current = false;
- toast({ title: 'Something went wrong', variant: 'destructive' });
- });
- }, [user, isOwnProfile, isAlreadyFollowing, followData, isPending, pubkey, follow, displayName, toast]);
-
- return (
-
-
-
-
- {/* Profile header */}
-
- {/* Banner — matches ProfilePage: clean edge, no gradient */}
-
- {author.isLoading ? (
-
- ) : bannerUrl ? (
-
- ) : (
-
- )}
-
-
- {/* Profile card */}
-
- {/* Avatar — matches ProfilePage border treatment */}
-
-
-
-
- {displayName.charAt(0).toUpperCase()}
-
-
- {(followDone || isAlreadyFollowing) && (
-
-
-
- )}
-
-
- {/* Name + NIP-05 */}
-
-
{displayName}
- {metadata?.nip05 && (
-
- )}
-
-
- {/* CTA — right under the name */}
-
- {isLoggedOut ? (
-
setLoginOpen(true)}
- className="w-full rounded-full py-3 text-base font-semibold"
- size="lg"
- >
- Follow {displayName} on Agora
-
- ) : isOwnProfile ? (
-
-
-
-
- This is your profile follow link
-
-
-
-
- View your profile
-
-
-
- ) : isPending ? (
-
- ) : followDone || isAlreadyFollowing ? (
-
-
-
-
- {isAlreadyFollowing && !followDone ? 'Already following!' : 'Now following!'}
-
-
-
-
- View profile
-
- navigate('/feed')}>
- Go to feed
-
-
-
- ) : null}
-
-
-
-
- {/* Feed */}
-
-
-
-
setLoginOpen(false)}
- />
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Immersive follow pack/set view
-// ---------------------------------------------------------------------------
-
-type PackTab = 'feed' | 'members';
-
-function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] }) {
- const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays);
- const { user } = useCurrentUser();
- const { data: followList } = useFollowList();
- const { followMany } = useFollowActions();
- const { toast } = useToast();
- const navigate = useNavigate();
- const [loginOpen, setLoginOpen] = useState(false);
- const [activeTab, setActiveTab] = useState('feed');
- const [isFollowingAll, setIsFollowingAll] = useState(false);
-
- const author = useAuthor(addr.pubkey);
- const authorMeta = author.data?.metadata;
- const authorName = authorMeta?.name || genUserName(addr.pubkey);
-
- const { title, description, image, pubkeys } = useMemo(
- () => (event ? parsePackEvent(event) : { title: 'Loading...', description: '', image: undefined, pubkeys: [] }),
- [event],
- );
-
- const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys);
-
- const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]);
- const followingCount = useMemo(
- () => pubkeys.filter((pk) => followedPubkeys.has(pk)).length,
- [pubkeys, followedPubkeys],
- );
- const allFollowed = pubkeys.length > 0 && followingCount === pubkeys.length;
- const newCount = pubkeys.length - followingCount;
-
- const bannerUrl = image || authorMeta?.banner;
-
- /** Follow All using the shared useFollowActions.followMany mutation. */
- const handleFollowAll = useCallback(async () => {
- if (!user) return;
- setIsFollowingAll(true);
- try {
- const added = await followMany(pubkeys);
-
- toast({
- title: allFollowed ? 'Already following all!' : 'Following all!',
- description: added > 0
- ? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
- : 'You were already following everyone in this pack.',
- });
- } catch (error) {
- console.error('Failed to follow all:', error);
- toast({
- title: 'Failed to follow',
- description: 'There was an error updating your follow list.',
- variant: 'destructive',
- });
- } finally {
- setIsFollowingAll(false);
- }
- }, [user, pubkeys, followMany, toast, allFollowed]);
-
- if (eventLoading) {
- return (
-
- );
- }
-
- if (!event) return ;
-
- return (
-
-
-
- {/* Header */}
-
- {/* Banner */}
-
- {bannerUrl ? (
-
- ) : (
-
- )}
-
-
- {/* Pack info */}
-
- {/* Avatar stack (first 5 members) */}
-
- {pubkeys.slice(0, 5).map((pk) => {
- const member = membersMap?.get(pk);
- const name = member?.metadata?.name || genUserName(pk);
- return (
-
-
-
- {name[0]?.toUpperCase()}
-
-
- );
- })}
- {pubkeys.length > 5 && (
-
- +{pubkeys.length - 5}
-
- )}
-
-
- {/* Title */}
-
{title}
-
- {/* Author attribution */}
-
-
-
-
- {authorName[0]?.toUpperCase()}
-
-
-
by {authorName}
-
-
- {/* Description */}
- {description && (
-
- {description}
-
- )}
-
- {/* Big CTA button */}
-
- {!user ? (
-
setLoginOpen(true)}
- className="w-full rounded-full py-3 text-base font-semibold gap-2"
- size="lg"
- >
-
- Follow {pubkeys.length} people on Agora
-
- ) : isFollowingAll ? (
-
-
- Following...
-
- ) : allFollowed ? (
-
-
-
-
Following all {pubkeys.length} people
-
-
navigate('/feed')}>
- Go to feed
-
-
- ) : (
-
-
-
- Follow All ({pubkeys.length})
-
- {followingCount > 0 && (
-
- Already following {followingCount} of {pubkeys.length}
- {' '}·{' '}
- {newCount} new
-
- )}
-
- )}
-
-
-
-
- {/* Tab bar */}
-
- setActiveTab('feed')} />
- setActiveTab('members')} />
-
-
- {/* Tab content */}
-
-
- {activeTab === 'feed' ? (
-
- ) : membersLoading ? (
-
- {Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
-
- ))}
-
- ) : (
-
- {pubkeys.map((pk) => {
- const member = membersMap?.get(pk);
- const isFollowed = followedPubkeys.has(pk);
- return (
-
- );
- })}
-
- )}
-
-
-
-
setLoginOpen(false)}
- />
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Route component
-// ---------------------------------------------------------------------------
-
-/** Kinds accepted as follow packs/sets at /follow URLs. */
-const FOLLOW_PACK_SET_KINDS = new Set([30000, 39089]);
-
-function isNip05Identifier(value: string): boolean {
- if (value.includes('@')) return true;
- return value.includes('.') && !value.startsWith('npub1') && !value.startsWith('nprofile1');
-}
-
-export function FollowPage() {
- const { npub } = useParams<{ npub: string }>();
- const isNip05 = !!npub && isNip05Identifier(npub);
- const { data: nip05Pubkey, isPending: nip05Loading } = useNip05Resolve(isNip05 ? npub : undefined);
-
- if (!npub) return ;
-
- if (isNip05) {
- if (nip05Loading) {
- return (
-
-
-
- );
- }
- return nip05Pubkey ? : ;
- }
-
- // Try decoding as a NIP-19 identifier
- let decoded;
- try {
- decoded = nip19.decode(npub);
- } catch {
- return ;
- }
-
- // Handle npub / nprofile -> individual user follow view
- if (decoded.type === 'npub') {
- return ;
- }
- if (decoded.type === 'nprofile') {
- return ;
- }
-
- // Handle naddr -> follow pack/set view
- if (decoded.type === 'naddr') {
- const addr = decoded.data as AddressPointer;
- if (!FOLLOW_PACK_SET_KINDS.has(addr.kind)) {
- return ;
- }
- return (
-
- );
- }
-
- return ;
-}
diff --git a/src/pages/KindFeedPage.tsx b/src/pages/KindFeedPage.tsx
deleted file mode 100644
index e86734d3..00000000
--- a/src/pages/KindFeedPage.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useMemo, useState } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import { Feed } from '@/components/Feed';
-import { KindInfoButton } from '@/components/KindInfoButton';
-import { PageHeader } from '@/components/PageHeader';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { EXTRA_KINDS, type ExtraKindDef } from '@/lib/extraKinds';
-
-interface KindFeedPageProps {
- kind: number | number[];
- title: string;
- icon?: React.ReactNode;
- emptyMessage?: string;
- /** Override the auto-detected ExtraKindDef (useful for pages with sub-kinds like Treasures). */
- kindDef?: ExtraKindDef;
- /** Override the back button destination (defaults to "/"). */
- backTo?: string;
- /** Always show the back button, even on desktop (default: only mobile). */
- alwaysShowBack?: boolean;
- /** If set, the FAB navigates to this URL instead of opening a compose dialog. */
- fabHref?: string;
- /** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */
- tagFilters?: Record;
- /** Unique feed ID for tab persistence. Defaults to lowercase title. */
- feedId?: string;
- /** Extra content rendered after the feed header (e.g. a custom compose dialog). */
- extra?: React.ReactNode;
- /** If set, overrides the default FAB click behavior. */
- onFabClick?: () => void;
- /** Whether to show the FAB (default: true). */
- showFAB?: boolean;
-}
-
-export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo = '/', alwaysShowBack, fabHref, tagFilters, extra, onFabClick, showFAB = true, feedId }: KindFeedPageProps) {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const primaryKind = Array.isArray(kind) ? kind[0] : kind;
-
- const resolvedDef = useMemo(
- () => kindDef ?? EXTRA_KINDS.find((def) => def.kind === primaryKind),
- [kindDef, primaryKind],
- );
-
- const [infoOpen, setInfoOpen] = useState(false);
-
- useSeoMeta({
- title: `${title} | ${config.appName}`,
- description: `${title} on Nostr`,
- });
-
- const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
- useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
-
- const kinds = Array.isArray(kind) ? kind : [kind];
-
- return (
- <>
-
- {resolvedDef && }
-
- }
- />
- {extra}
- >
- );
-}
diff --git a/src/pages/LetterComposePage.tsx b/src/pages/LetterComposePage.tsx
deleted file mode 100644
index 86b448ef..00000000
--- a/src/pages/LetterComposePage.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useNavigate, useSearchParams } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { ComposeLetterSheet } from '@/components/letter/ComposeLetterSheet';
-
-export function LetterComposePage() {
- const navigate = useNavigate();
- const [searchParams] = useSearchParams();
-
- const toPubkey = searchParams.get('to') ?? undefined;
-
- useLayoutOptions({ showFAB: false, noOverscroll: true, hasSubHeader: true });
- useSeoMeta({ title: 'Write a Letter' });
-
- return (
-
- navigate('/letters')}
- />
-
- );
-}
diff --git a/src/pages/LetterPreferencesPage.tsx b/src/pages/LetterPreferencesPage.tsx
deleted file mode 100644
index 5849cbf2..00000000
--- a/src/pages/LetterPreferencesPage.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { LetterPreferencesSection } from '@/components/letter/LetterPreferencesSection';
-
-export function LetterPreferencesPage() {
- useSeoMeta({
- title: 'Letter Preferences',
- description: 'Customize your default letter stationery, font, and inbox settings',
- });
-
- return (
-
-
-
- );
-}
diff --git a/src/pages/LettersPage.tsx b/src/pages/LettersPage.tsx
deleted file mode 100644
index 820b7d7e..00000000
--- a/src/pages/LettersPage.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { useState } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import { useNavigate } from 'react-router-dom';
-import { Settings, Loader2 } from 'lucide-react';
-import { MailboxIcon } from '@/components/icons/MailboxIcon';
-import { InkPenIcon } from '@/components/icons/InkPenIcon';
-import { Button } from '@/components/ui/button';
-import { FabButton } from '@/components/FabButton';
-
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useInbox, useSentLetters } from '@/hooks/useLetters';
-import { useLetterPreferences } from '@/hooks/useLetterPreferences';
-import { useFollowList } from '@/hooks/useFollowActions';
-import { useMutedAuthorFilter } from '@/hooks/useMutedAuthorFilter';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { LoginArea } from '@/components/auth/LoginArea';
-import { PageHeader } from '@/components/PageHeader';
-import { SubHeaderBar } from '@/components/SubHeaderBar';
-import { TabButton } from '@/components/TabButton';
-import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
-import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet';
-import type { Letter } from '@/lib/letterTypes';
-
-type Tab = 'inbox' | 'sent';
-
-/** Skeleton envelope matching the grid tile shape. */
-function EnvelopeSkeleton({ index }: { index: number }) {
- return (
-
- );
-}
-
-export function LettersPage() {
- const { user } = useCurrentUser();
- const navigate = useNavigate();
- const [tab, setTab] = useState('inbox');
- const [selectedLetter, setSelectedLetter] = useState(null);
-
- useLayoutOptions({ showFAB: false, hasSubHeader: !!user });
-
- const { prefs } = useLetterPreferences();
- const followListData = useFollowList();
- const followedPubkeys = followListData.data?.pubkeys;
- const { excludeMuted } = useMutedAuthorFilter();
-
- // If friendsOnlyInbox is enabled, only show letters from followed users
- // (minus any muted pubkeys — viewer's mute list always wins).
- const inboxFilter = prefs.friendsOnlyInbox && followedPubkeys
- ? excludeMuted(followedPubkeys)
- : undefined;
-
- const inboxQuery = useInbox(inboxFilter);
- const sentQuery = useSentLetters();
-
- const inbox = inboxQuery.data;
- const inboxLoading = inboxQuery.isLoading;
- const sent = sentQuery.data;
- const sentLoading = sentQuery.isLoading;
-
- const activeQuery = tab === 'inbox' ? inboxQuery : sentQuery;
-
- useSeoMeta({ title: 'Letters', description: 'Your private encrypted letters' });
-
- if (!user) {
- return (
-
- } backTo="/" />
-
-
-
-
-
-
Your personal inbox
-
- Send and receive beautiful encrypted letters with stationery, frames, and stickers.
-
-
-
-
-
- );
- }
-
- const activeLetters = tab === 'inbox' ? inbox : sent;
- const isLoading = tab === 'inbox' ? inboxLoading : sentLoading;
-
- return (
-
- } backTo="/" alwaysShowBack>
- navigate('/settings/letters')}
- className="p-2 rounded-full text-muted-foreground hover:text-foreground transition-colors"
- title="Letter preferences"
- >
-
-
-
-
- {/* Tabs */}
-
- setTab('inbox')} />
- setTab('sent')} />
-
-
- {/* Envelope grid */}
-
- {isLoading && (
-
- {Array.from({ length: 8 }).map((_, i) => (
-
- ))}
-
- )}
-
- {!isLoading && activeLetters && activeLetters.length === 0 && (
-
-
-
-
-
- {tab === 'inbox'
- ? prefs.friendsOnlyInbox
- ? 'no letters from friends yet'
- : 'no letters yet'
- : 'no sent letters yet'
- }
-
- {tab === 'inbox' && (
-
- ask a friend to send you a letter
-
- )}
-
- )}
-
- {!isLoading && activeLetters && activeLetters.length > 0 && (
- <>
-
- {activeLetters.map((letter, i) => (
- setSelectedLetter(letter)}
- />
- ))}
-
- {activeQuery.hasNextPage && (
-
- activeQuery.fetchNextPage()}
- disabled={activeQuery.isFetchingNextPage}
- className="gap-2"
- >
- {activeQuery.isFetchingNextPage && }
- Load more
-
-
- )}
- >
- )}
-
-
- {/* Letter detail drawer */}
- setSelectedLetter(null)}
- onReply={(npub) => {
- setSelectedLetter(null);
- navigate(`/letters/compose?to=${npub}`);
- }}
- />
-
- {/* Compose FAB */}
-
- navigate('/letters/compose')} icon={ } title="Write a letter" />
-
-
-
-
- navigate('/letters/compose')} icon={ } title="Write a letter" />
-
-
-
-
-
- );
-}
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx
deleted file mode 100644
index fd989341..00000000
--- a/src/pages/Messages.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { ArrowUpRight } from 'lucide-react';
-import { WhiteNoiseIcon } from '@/components/icons/WhiteNoiseIcon';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { openUrl } from '@/lib/downloadFile';
-
-const WHITENOISE_URL = 'https://www.whitenoise.chat/';
-
-const Messages = () => {
- useSeoMeta({
- title: 'Messages',
- description: 'Private messaging on Nostr',
- });
-
- return (
-
-
-
-
-
-
Private messaging lives elsewhere
-
- Agora doesn't handle direct messages. For end-to-end encrypted Nostr chat with strong metadata protection, we recommend White Noise.
-
-
- openUrl(WHITENOISE_URL)}
- >
- Install White Noise
-
-
-
-
-
- );
-};
-
-export default Messages;
diff --git a/src/pages/MusicFeedPage.tsx b/src/pages/MusicFeedPage.tsx
deleted file mode 100644
index b29b5a81..00000000
--- a/src/pages/MusicFeedPage.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { getExtraKindDef } from '@/lib/extraKinds';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { KindFeedPage } from './KindFeedPage';
-
-const musicDef = getExtraKindDef('music')!;
-
-export function MusicFeedPage() {
- return (
-
- );
-}
diff --git a/src/pages/MusicPage.tsx b/src/pages/MusicPage.tsx
deleted file mode 100644
index b3923640..00000000
--- a/src/pages/MusicPage.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { useState, useCallback } from 'react';
-import { Music } from 'lucide-react';
-import { useSeoMeta } from '@unhead/react';
-import { KindInfoButton } from '@/components/KindInfoButton';
-import { PageHeader } from '@/components/PageHeader';
-import { SubHeaderBar } from '@/components/SubHeaderBar';
-import { TabButton } from '@/components/TabButton';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { getExtraKindDef } from '@/lib/extraKinds';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { MusicDiscoverTab } from '@/components/music/MusicDiscoverTab';
-import { MusicTracksTab } from '@/components/music/MusicTracksTab';
-import { MusicPlaylistsTab } from '@/components/music/MusicPlaylistsTab';
-import { MusicArtistsTab } from '@/components/music/MusicArtistsTab';
-
-const musicDef = getExtraKindDef('music')!;
-
-type MusicTab = 'discover' | 'tracks' | 'playlists' | 'artists';
-
-/**
- * Dedicated music discovery page.
- *
- * Replaces the generic KindFeedPage with a tabbed layout:
- * - **Discover** (default): Curated showcase with hero, featured, genres, etc.
- * - **Tracks**: Infinite-scroll list of all music tracks with genre filter
- * - **Playlists**: Grid of music playlists
- * - **Artists**: Grid of artist profile cards
- *
- * All content is global by default. The Discover tab surfaces curated
- * content from the curator's kind 30000 `d:music-artists` follow set.
- */
-export function MusicPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const [activeTab, setActiveTab] = useState('discover');
-
- useSeoMeta({
- title: `Music | ${config.appName}`,
- description: 'Discover music on Nostr',
- });
-
- useLayoutOptions({ showFAB: false, hasSubHeader: !!user });
-
- const switchToTracks = useCallback(() => setActiveTab('tracks'), []);
- const switchToPlaylists = useCallback(() => setActiveTab('playlists'), []);
- const switchToArtists = useCallback(() => setActiveTab('artists'), []);
-
- return (
-
-
- } />
-
-
- {/* Tabs */}
-
- setActiveTab('discover')} />
- setActiveTab('tracks')} />
- setActiveTab('playlists')} />
- setActiveTab('artists')} />
-
-
- {/* Tab content */}
- {activeTab === 'discover' && (
-
- )}
- {activeTab === 'tracks' && }
- {activeTab === 'playlists' && }
- {activeTab === 'artists' && }
-
- );
-}
diff --git a/src/pages/PhotosFeedPage.tsx b/src/pages/PhotosFeedPage.tsx
deleted file mode 100644
index 2de8b972..00000000
--- a/src/pages/PhotosFeedPage.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * PhotosFeedPage — Instagram-style grid feed for NIP-68 photo events (kind 20).
- *
- * - Follows tab: useFeed (relay pool, chronological)
- * - Global tab: useInfiniteHotFeed (sort:hot via relay.ditto.pub)
- * - Infinite-scroll justified collage via the shared MediaCollage component
- */
-
-import type { NostrEvent } from "@nostrify/nostrify";
-import { useSeoMeta } from "@unhead/react";
-import { Camera } from "lucide-react";
-import { lazy, Suspense, useMemo, useCallback, useState } from "react";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { KindInfoButton } from "@/components/KindInfoButton";
-import { eventToMediaItem } from "@/lib/mediaUtils";
-import {
- MediaCollage,
- MediaCollageSkeleton,
-} from "@/components/MediaCollage";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { SubHeaderBar } from "@/components/SubHeaderBar";
-import { TabButton } from "@/components/TabButton";
-import { useLayoutOptions } from "@/contexts/LayoutContext";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useFeed } from "@/hooks/useFeed";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
-import { useMuteList } from "@/hooks/useMuteList";
-import { usePageRefresh } from "@/hooks/usePageRefresh";
-import { useInfiniteHotFeed } from "@/hooks/useTrending";
-import { getExtraKindDef } from "@/lib/extraKinds";
-import type { FeedItem } from "@/lib/feedUtils";
-import { isEventMuted } from "@/lib/muteHelpers";
-import { sidebarItemIcon } from "@/lib/sidebarItems";
-
-const PhotoComposeModal = lazy(() => import('@/components/PhotoComposeModal').then(m => ({ default: m.PhotoComposeModal })));
-
-const PHOTO_KIND = 20;
-const photosDef = getExtraKindDef("photos")!;
-
-type FeedTab = "follows" | "global";
-
-// ── Page ──────────────────────────────────────────────────────────────────────
-
-export function PhotosFeedPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const { muteItems } = useMuteList();
- const [composeOpen, setComposeOpen] = useState(false);
-
- const [activeTab, setActiveTab] = useFeedTab("photos", [
- "follows",
- "global",
- ]);
-
- const handleFabClick = useCallback(() => {
- setComposeOpen(true);
- }, []);
-
- useSeoMeta({
- title: `Photos | ${config.appName}`,
- description: "Photo posts on Nostr",
- });
- useLayoutOptions({ showFAB: true, onFabClick: handleFabClick, fabIcon: , hasSubHeader: true });
-
- // ── Follows feed (chronological) ──
- const followsQuery = useFeed("follows", { kinds: [PHOTO_KIND] });
-
- // ── Global feed (sort:hot) ──
- const globalQuery = useInfiniteHotFeed([PHOTO_KIND], activeTab === "global");
-
- const activeQuery = activeTab === "follows" ? followsQuery : globalQuery;
- const {
- data: rawData,
- isPending,
- isLoading,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = activeQuery;
-
- const { scrollRef } = useInfiniteScroll({
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- pageCount: rawData?.pages?.length,
- });
-
- const handleRefresh = usePageRefresh(['feed']);
-
- // Flatten — follows returns { items: FeedItem[] }, global returns NostrEvent[]
- const photoEvents = useMemo(() => {
- if (!rawData?.pages) return [];
- const seen = new Set();
-
- const events: NostrEvent[] =
- activeTab === "follows"
- ? (rawData.pages as unknown as { items: FeedItem[] }[])
- .flatMap((p) => p.items)
- .map((item) => item.event)
- : (rawData.pages as unknown as NostrEvent[][]).flat();
-
- return events.filter((event) => {
- if (seen.has(event.id)) return false;
- seen.add(event.id);
- if (event.kind !== PHOTO_KIND) return false;
- if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
- return eventToMediaItem(event) !== null;
- });
- }, [rawData?.pages, muteItems, activeTab]);
-
- const showSkeleton = isPending || (isLoading && !rawData);
-
- return (
- <>
-
- }>
-
-
-
- {/* Tabs */}
-
- setActiveTab("follows")}
- disabled={!user}
- />
- setActiveTab("global")}
- />
-
-
- {/* Grid */}
-
- {showSkeleton ? (
-
- ) : photoEvents.length === 0 ? (
- setActiveTab("global") : undefined
- }
- />
- ) : (
- <>
- {
- if (hasNextPage && !isFetchingNextPage) fetchNextPage();
- }}
- />
-
- >
- )}
-
-
-
- {composeOpen && (
-
-
-
- )}
- >
- );
-}
diff --git a/src/pages/PlaceholderPage.tsx b/src/pages/PlaceholderPage.tsx
deleted file mode 100644
index fde867df..00000000
--- a/src/pages/PlaceholderPage.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { PageHeader } from '@/components/PageHeader';
-import { useAppContext } from '@/hooks/useAppContext';
-
-interface PlaceholderPageProps {
- title: string;
- icon?: React.ReactNode;
- description?: string;
-}
-
-export function PlaceholderPage({ title, icon, description }: PlaceholderPageProps) {
- const { config } = useAppContext();
-
- useSeoMeta({
- title: `${title} | ${config.appName}`,
- description: description || `${title} page`,
- });
-
- return (
-
-
-
-
- );
-}
diff --git a/src/pages/PodcastsFeedPage.tsx b/src/pages/PodcastsFeedPage.tsx
deleted file mode 100644
index 8a41b5f1..00000000
--- a/src/pages/PodcastsFeedPage.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { getExtraKindDef } from '@/lib/extraKinds';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { KindFeedPage } from './KindFeedPage';
-
-const podcastsDef = getExtraKindDef('podcasts')!;
-
-export function PodcastsFeedPage() {
- return (
-
- );
-}
diff --git a/src/pages/ReceivePage.tsx b/src/pages/ReceivePage.tsx
deleted file mode 100644
index 1965b499..00000000
--- a/src/pages/ReceivePage.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { HandHeart, KeyRound, Sparkles, Wallet, Zap } from 'lucide-react';
-
-import { AgoraLogo } from '@/components/AgoraLogo';
-import { LoginArea } from '@/components/auth/LoginArea';
-import AuthDialog from '@/components/auth/AuthDialog';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-
-/**
- * Landing page reached from invite links like `https://agora.spot/receive`.
- *
- * Target audience: someone who has been told "I'm starting a fundraiser for
- * you on Agora" but doesn't have a Nostr account yet. The page's job is to
- * get them past account creation as fast as possible.
- *
- * Logged-out: hero + signup CTA (which opens the onboarding flow). A small
- * "already have a Nostr account?" footer routes them to the standard login.
- *
- * Already logged in: confirms they're set up and points them at their
- * profile + a link to claim any pending campaigns.
- */
-export function ReceivePage() {
- const { user } = useCurrentUser();
- const { config } = useAppContext();
- const [authDialogOpen, setAuthDialogOpen] = useState(false);
- const navigate = useNavigate();
-
- useSeoMeta({
- title: `Receive donations on ${config.appName}`,
- description:
- 'Create a free account and start receiving Bitcoin donations directly to your wallet. No middleman, no chargebacks.',
- });
-
- // Once the user has finished signing up / logging in, drop them at /claim
- // so they can see any campaigns that were set up for them in advance.
- useEffect(() => {
- if (user) {
- navigate('/claim', { replace: true });
- }
- }, [user, navigate]);
-
- return (
-
-
-
-
-
- {config.appName}
-
-
-
-
-
-
-
- Someone wants to fundraise for you
-
-
- Get paid in Bitcoin,{' '}
- straight to your wallet.
-
-
- Agora is a permissionless fundraising platform built on Nostr and Bitcoin. Create a
- free account in under a minute, and donations land directly in your wallet — no
- middleman, no chargebacks, no platform freezing your funds.
-
-
-
- setAuthDialogOpen(true)} className="rounded-full">
-
- Create my account
-
-
- I already have a Nostr account
-
-
-
-
-
- }
- title="Own your account"
- description="Your Nostr key is yours forever. No company can ban or freeze you."
- />
- }
- title="Direct to your wallet"
- description="Donations settle on-chain to a Bitcoin address derived from your key."
- />
- }
- title="Counted on Agora"
- description="Each donation publishes a receipt so it shows up on your campaign's progress."
- />
-
-
-
-
-
-
-
- How it works
-
-
-
- Create your free Nostr account (your key is generated locally and never leaves
- your device).
-
-
- Fill in a name, photo, and short bio so donors recognize you.
-
-
- Visit /claim to find the campaign
- that was started for you and start receiving donations.
-
-
-
-
-
-
-
- Already have a Nostr key (npub or nsec)?{' '}
-
- Sign in to claim your campaign
-
-
-
-
- setAuthDialogOpen(false)}
- />
-
- );
-}
-
-function FeatureCard({
- icon,
- title,
- description,
-}: {
- icon: React.ReactNode;
- title: string;
- description: string;
-}) {
- return (
-
-
-
- {icon}
-
- {title}
- {description}
-
-
- );
-}
-
-function Step({ n, children }: { n: number; children: React.ReactNode }) {
- return (
-
-
- {n}
-
- {children}
-
- );
-}
-
-export default ReceivePage;
diff --git a/src/pages/RelayPage.tsx b/src/pages/RelayPage.tsx
deleted file mode 100644
index 11756520..00000000
--- a/src/pages/RelayPage.tsx
+++ /dev/null
@@ -1,291 +0,0 @@
-import { useMemo, useState } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import { Globe, Info, Mail, Shield, Zap, Server, Hash } from 'lucide-react';
-import { useParams } from 'react-router-dom';
-import { useNostr } from '@nostrify/react';
-import { useQuery } from '@tanstack/react-query';
-import { NoteCard } from '@/components/NoteCard';
-import { FeedCard } from '@/components/FeedCard';
-import { PageHeader } from '@/components/PageHeader';
-import { SubHeaderBar } from '@/components/SubHeaderBar';
-import { Badge } from '@/components/ui/badge';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useFeedSettings } from '@/hooks/useFeedSettings';
-import { useMuteList } from '@/hooks/useMuteList';
-import { useRelayInfo, type RelayInfoDocument } from '@/hooks/useRelayInfo';
-import { getEnabledFeedKinds } from '@/lib/extraKinds';
-import { isRepostKind } from '@/lib/feedUtils';
-import { isEventMuted } from '@/lib/muteHelpers';
-import type { NostrEvent } from '@nostrify/nostrify';
-import NotFound from './NotFound';
-
-/** Fetch the latest events from a specific relay, filtered to supported kinds. */
-function useRelayFeed(relayUrl: string | undefined, kinds: number[]) {
- const { nostr } = useNostr();
- const kindsKey = [...kinds].sort().join(',');
-
- return useQuery({
- queryKey: ['relay-feed', relayUrl, kindsKey],
- queryFn: async ({ signal }) => {
- if (!relayUrl) return [];
- const relay = nostr.relay(relayUrl);
- return relay.query(
- [{ kinds, limit: 15 }],
- { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
- );
- },
- enabled: !!relayUrl && kinds.length > 0,
- });
-}
-
-export function RelayPage() {
- const { config } = useAppContext();
- const { '*': rawParam } = useParams();
- const { feedSettings } = useFeedSettings();
- const { muteItems } = useMuteList();
- const [infoOpen, setInfoOpen] = useState(false);
-
- const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
-
- // Support both encoded URLs (/r/wss%3A%2F%2F...) and bare URLs (/r/wss://...).
- const relayUrl = useMemo(() => {
- if (!rawParam) return undefined;
- // If the wildcard param has no "://", it's encoded — decode it.
- let decoded: string;
- try { decoded = decodeURIComponent(rawParam); } catch { decoded = rawParam; }
- const url = rawParam.includes('://') ? rawParam : decoded;
- if (url.startsWith('wss://') || url.startsWith('ws://')) {
- return url;
- }
- return `wss://${url}`;
- }, [rawParam]);
-
- // Derive a display hostname from the URL
- const hostname = useMemo(() => {
- if (!relayUrl) return '';
- try {
- return relayUrl.replace(/^wss?:\/\//, '').replace(/\/$/, '');
- } catch {
- return relayUrl;
- }
- }, [relayUrl]);
-
- const { data: info, isLoading: infoLoading, isError: infoError } = useRelayInfo(relayUrl);
- const { data: events, isLoading: eventsLoading } = useRelayFeed(relayUrl, kinds);
-
- const filteredEvents = useMemo(() => {
- if (!events || muteItems.length === 0) return events;
- return events.filter((e) => !isEventMuted(e, muteItems));
- }, [events, muteItems]);
-
- useSeoMeta({
- title: info?.name
- ? `${info.name} | ${config.appName}`
- : hostname
- ? `${hostname} | ${config.appName}`
- : `Relay | ${config.appName}`,
- description: info?.description ?? `Events from ${hostname}`,
- });
-
- useLayoutOptions({ hasSubHeader: true });
-
- if (!rawParam) {
- return ;
- }
-
- return (
-
- } className="py-2 sidebar:py-4">
- setInfoOpen((o) => !o)}
- className={`p-2 rounded-full transition-colors ${infoOpen ? 'text-foreground bg-secondary' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
- aria-label="Toggle relay info"
- >
-
-
-
-
-
- {null}
-
- {/* Feed section */}
-
- {eventsLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ) : filteredEvents && filteredEvents.length > 0 ? (
-
- {filteredEvents.map((event) => )}
-
- ) : (
-
- No events found on this relay.
-
- )}
-
-
- );
-}
-
-/** Inline expandable panel that displays NIP-11 relay information. */
-function RelayInfoPanel({ info, infoLoading, infoError, open }: {
- info: RelayInfoDocument | undefined;
- infoLoading: boolean;
- infoError: boolean;
- open: boolean;
-}) {
- return (
-
-
- {infoLoading ? (
-
- ) : info ? (
-
- {/* Banner */}
- {info.banner && (
-
-
-
- )}
-
- {/* Icon + name (when different from hostname) */}
- {info.icon && (
-
-
- {info.name && (
-
{info.name}
- )}
-
- )}
-
- {/* Description */}
- {info.description && (
-
- {info.description}
-
- )}
-
- {/* Badges: payment, auth, writes */}
-
- {info.limitation?.payment_required && (
-
-
- Paid
-
- )}
- {info.limitation?.auth_required && (
-
-
- Auth required
-
- )}
- {info.limitation?.restricted_writes && (
-
-
- Restricted writes
-
- )}
- {info.software && (
-
-
- {info.software.replace(/^https?:\/\//, '')}
- {info.version ? ` ${info.version}` : ''}
-
- )}
- {info.contact && (
-
-
- {info.contact}
-
- )}
-
-
- {/* Supported NIPs */}
- {info.supported_nips && info.supported_nips.length > 0 && (
-
-
-
- Supported NIPs
-
-
- {info.supported_nips.sort((a, b) => a - b).map((nip) => (
-
- {nip}
-
- ))}
-
-
- )}
-
- {/* Fees */}
- {info.fees && (
-
-
-
- Fees
-
-
- {info.fees.admission?.map((fee, i) => (
-
- Admission: {fee.amount / 1000} sats
-
- ))}
- {info.fees.subscription?.map((fee, i) => (
-
- Subscription: {fee.amount / 1000} sats{fee.period ? ` / ${Math.round(fee.period / 86400)}d` : ''}
-
- ))}
-
-
- )}
-
- ) : infoError ? (
-
-
-
- Could not load relay information.
-
-
- ) : null}
-
-
- );
-}
diff --git a/src/pages/StreamsFeedPage.tsx b/src/pages/StreamsFeedPage.tsx
deleted file mode 100644
index ceb7a0e1..00000000
--- a/src/pages/StreamsFeedPage.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useMemo } from 'react';
-import { Link } from 'react-router-dom';
-import { Radio, Users, Clock } from 'lucide-react';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { useSeoMeta } from '@unhead/react';
-import { nip19 } from 'nostr-tools';
-import type { NostrEvent } from '@nostrify/nostrify';
-import { useAppContext } from '@/hooks/useAppContext';
-
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Card, CardContent } from '@/components/ui/card';
-import { KindInfoButton } from '@/components/KindInfoButton';
-import { PageHeader } from '@/components/PageHeader';
-import { useAuthor } from '@/hooks/useAuthor';
-import { useStreamKind } from '@/hooks/useStreamKind';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { getDisplayName } from '@/lib/getDisplayName';
-import { useProfileUrl } from '@/hooks/useProfileUrl';
-import { useOpenPost } from '@/hooks/useOpenPost';
-import { getExtraKindDef } from '@/lib/extraKinds';
-import { timeAgo } from '@/lib/timeAgo';
-import { cn } from '@/lib/utils';
-
-const streamsDef = getExtraKindDef('streams')!;
-
-/** Extract the first value of a tag by name. */
-function getTag(tags: string[][], name: string): string | undefined {
- return tags.find(([n]) => n === name)?.[1];
-}
-
-/** Status badge config. */
-function getStatusConfig(status: string | undefined) {
- switch (status) {
- case 'live':
- return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' };
- case 'ended':
- return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' };
- case 'planned':
- return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' };
- default:
- return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' };
- }
-}
-
-export function StreamsFeedPage() {
- const { config } = useAppContext();
-
- useSeoMeta({
- title: `Streams | ${config.appName}`,
- description: 'Live streams on Nostr',
- });
-
- useLayoutOptions({ showFAB: true, fabKind: 30311 });
-
- const { events, isLoading } = useStreamKind(30311);
-
- // Sort: live first, then planned, then ended. Within each group, newest first.
- const sorted = useMemo(() => {
- const statusOrder: Record = { live: 0, planned: 1, ended: 2 };
- return [...events].sort((a, b) => {
- const aStatus = getTag(a.tags, 'status') || 'ended';
- const bStatus = getTag(b.tags, 'status') || 'ended';
- const orderDiff = (statusOrder[aStatus] ?? 3) - (statusOrder[bStatus] ?? 3);
- if (orderDiff !== 0) return orderDiff;
- return b.created_at - a.created_at;
- });
- }, [events]);
-
- return (
-
- }>
-
-
-
- {/* Feed */}
- {isLoading && events.length === 0 ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : sorted.length === 0 ? (
-
-
-
-
-
-
- No streams found. Check your relay connections or wait for new streams to start.
-
-
-
-
-
- ) : (
-
- {sorted.map((event) => (
-
- ))}
-
- )}
-
- );
-}
-
-function StreamCard({ event }: { event: NostrEvent }) {
- const title = getTag(event.tags, 'title') || 'Untitled Stream';
- const summary = getTag(event.tags, 'summary');
- const imageUrl = getTag(event.tags, 'image');
- const status = getTag(event.tags, 'status');
- const currentParticipants = getTag(event.tags, 'current_participants');
- const statusConfig = getStatusConfig(status);
-
- const naddrId = useMemo(() => {
- const dTag = getTag(event.tags, 'd') || '';
- return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
- }, [event]);
-
- const { onClick, onAuxClick } = useOpenPost(`/${naddrId}`);
-
- return (
-
- {/* Thumbnail */}
- {imageUrl && (
-
-
{
- (e.currentTarget.parentElement as HTMLElement).style.display = 'none';
- }}
- />
- {/* Status badge overlay */}
-
-
- {status === 'live' &&
}
- {statusConfig.label}
-
-
- {/* Viewer count overlay */}
- {currentParticipants && (
-
-
- {currentParticipants}
-
- )}
-
- )}
-
-
- {/* Author + meta */}
-
-
-
-
{title}
- {summary && (
-
{summary}
- )}
-
- {!imageUrl && (
-
- {status === 'live' &&
}
- {statusConfig.label}
-
- )}
- {!imageUrl && currentParticipants && (
-
-
- {currentParticipants}
-
- )}
-
-
- {timeAgo(event.created_at)}
-
-
-
-
-
-
- );
-}
-
-function StreamCardAuthor({ pubkey }: { pubkey: string }) {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const displayName = getDisplayName(metadata, pubkey);
- const profileUrl = useProfileUrl(pubkey, metadata);
-
- if (author.isLoading) {
- return ;
- }
-
- return (
- e.stopPropagation()}>
-
-
-
- {displayName[0]?.toUpperCase()}
-
-
-
- );
-}
-
-function StreamCardSkeleton() {
- return (
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/TreasuresPage.tsx b/src/pages/TreasuresPage.tsx
deleted file mode 100644
index a81038c6..00000000
--- a/src/pages/TreasuresPage.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useFeedSettings } from '@/hooks/useFeedSettings';
-import { getExtraKindDef, getPageKinds } from '@/lib/extraKinds';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { KindFeedPage } from './KindFeedPage';
-
-/** Find the Treasures definition from EXTRA_KINDS. */
-const treasuresDef = getExtraKindDef('treasures')!;
-
-export function TreasuresPage() {
- const { feedSettings } = useFeedSettings();
- const kinds = getPageKinds(treasuresDef, feedSettings);
-
- return (
- Feed.'
- : undefined
- }
- />
- );
-}
diff --git a/src/pages/TrendsPage.tsx b/src/pages/TrendsPage.tsx
deleted file mode 100644
index 0f2582d7..00000000
--- a/src/pages/TrendsPage.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { useSeoMeta } from "@unhead/react";
-import { Flame, Loader2, Swords, TrendingUp } from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
-import { useInView } from "react-intersection-observer";
-import { Link } from "react-router-dom";
-import { NoteCard } from "@/components/NoteCard";
-import { FeedCard } from "@/components/FeedCard";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useMuteList } from "@/hooks/useMuteList";
-import { usePageRefresh } from "@/hooks/usePageRefresh";
-import {
- type SortMode,
- useInfiniteSortedPosts,
- useTrendingTags,
-} from "@/hooks/useTrending";
-import { isEventMuted } from "@/lib/muteHelpers";
-import { cn } from "@/lib/utils";
-
-export function TrendsPage() {
- const { config } = useAppContext();
-
- useSeoMeta({
- title: `Trends | ${config.appName}`,
- description: "Trending hashtags and posts on Nostr",
- });
-
- const [trendSort, setTrendSort] = useState("hot");
-
- const refreshQueryKey = useMemo(
- () => [['trending-tags'], ['infinite-sorted-posts', trendSort]],
- [trendSort],
- );
- const handleRefresh = usePageRefresh(refreshQueryKey);
-
- const { data: trends, isLoading: trendsLoading } = useTrendingTags(true);
- const {
- data: sortedData,
- isPending: sortedPending,
- isLoading: sortedLoading,
- fetchNextPage: fetchNextSorted,
- hasNextPage: hasNextSorted,
- isFetchingNextPage: isFetchingNextSorted,
- } = useInfiniteSortedPosts(trendSort, true);
- const { muteItems } = useMuteList();
-
- // Flatten, deduplicate, and filter muted posts from paginated sorted results
- const sortedPosts = useMemo(() => {
- const seen = new Set();
- return (
- sortedData?.pages.flat().filter((event) => {
- if (seen.has(event.id)) return false;
- seen.add(event.id);
- if (muteItems.length > 0 && isEventMuted(event, muteItems))
- return false;
- return true;
- }) ?? []
- );
- }, [sortedData?.pages, muteItems]);
-
- // Intersection observer for infinite scroll on sorted posts
- const { ref: sortedScrollRef, inView: sortedInView } = useInView({
- threshold: 0,
- rootMargin: "400px",
- });
-
- useEffect(() => {
- if (sortedInView && hasNextSorted && !isFetchingNextSorted) {
- fetchNextSorted();
- }
- }, [sortedInView, hasNextSorted, isFetchingNextSorted, fetchNextSorted]);
-
- return (
-
- } />
-
-
- {/* Trending Hashtags */}
-
-
Trending Hashtags
-
- {trendsLoading ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ) : trends && trends.tags.length > 0 ? (
-
- {trends.tags.slice(0, 5).map((trend, index) => (
-
- ))}
-
- ) : (
-
- )}
-
- {/* Sort sub-tabs */}
-
- }
- label="Hot"
- active={trendSort === "hot"}
- onClick={() => setTrendSort("hot")}
- />
- }
- label="Rising"
- active={trendSort === "rising"}
- onClick={() => setTrendSort("rising")}
- />
- }
- label="Controversial"
- active={trendSort === "controversial"}
- onClick={() => setTrendSort("controversial")}
- />
-
-
- {/* Sorted posts — infinite scroll */}
- {(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ) : sortedPosts.length > 0 ? (
- <>
-
- {sortedPosts.map((event) => (
-
- ))}
-
- {hasNextSorted && (
-
- {isFetchingNextSorted && (
-
-
-
- )}
-
- )}
- >
- ) : (
-
- )}
-
-
- );
-}
-
-function SortTabButton({
- icon,
- label,
- active,
- onClick,
-}: {
- icon: React.ReactNode;
- label: string;
- active: boolean;
- onClick: () => void;
-}) {
- return (
-
- {icon}
- {label}
- {active && (
-
- )}
-
- );
-}
-
-function TrendItem({ trend }: { trend: { tag: string; count: number } }) {
- return (
-
- #{trend.tag}
- {trend.count > 0 && (
-
- {trend.count}
-
- )}
-
- );
-}
-
-function EmptyState({ message }: { message: string }) {
- return (
-
- );
-}
-
-function PostSkeleton() {
- return (
-
- );
-}
-
-function TrendSkeleton() {
- // Pill-shaped skeleton matching `TrendItem`'s rendered shape so the
- // page doesn't visually pop when results arrive.
- return ;
-}
diff --git a/src/pages/UserListsPage.tsx b/src/pages/UserListsPage.tsx
deleted file mode 100644
index a4d1510e..00000000
--- a/src/pages/UserListsPage.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-/**
- * UserListsPage
- *
- * Settings sub-page for managing NIP-51 Follow Sets (kind 30000).
- */
-import { useState } from 'react';
-import { Navigate, useNavigate } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import { nip19 } from 'nostr-tools';
-import {
- Info, Plus, Trash2, Scroll, Users, Pencil,
- Check, X,
-} from 'lucide-react';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Skeleton } from '@/components/ui/skeleton';
-import {
- AlertDialog, AlertDialogAction, AlertDialogCancel,
- AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
- AlertDialogHeader, AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-
-import {
- Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,
-} from '@/components/ui/dialog';
-import { PageHeader } from '@/components/PageHeader';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useUserLists } from '@/hooks/useUserLists';
-import { useAuthor } from '@/hooks/useAuthor';
-import { genUserName } from '@/lib/genUserName';
-import { toast } from '@/hooks/useToast';
-import type { UserList } from '@/hooks/useUserLists';
-
-
-// ─── Mini Avatar ──────────────────────────────────────────────────────────────
-
-function MiniAvatar({ pubkey }: { pubkey: string }) {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const displayName = metadata?.name ?? genUserName(pubkey);
- return (
-
-
-
- {displayName[0]?.toUpperCase()}
-
-
- );
-}
-
-// ─── List Row ─────────────────────────────────────────────────────────────────
-
-function ListRow({ list, onDelete }: { list: UserList; onDelete: (list: UserList) => void }) {
- const navigate = useNavigate();
- const { user } = useCurrentUser();
- const [editing, setEditing] = useState(false);
- const [renameValue, setRenameValue] = useState(list.title);
- const { renameList } = useUserLists();
-
- const handleRename = () => {
- if (!renameValue.trim() || renameValue.trim() === list.title) {
- setEditing(false);
- setRenameValue(list.title);
- return;
- }
- renameList.mutate(
- { listId: list.id, title: renameValue },
- {
- onSuccess: () => { toast({ title: 'List renamed' }); setEditing(false); },
- onError: () => { toast({ title: 'Failed to rename', variant: 'destructive' }); setEditing(false); },
- },
- );
- };
-
- const previewPubkeys = list.pubkeys.slice(0, 4);
-
- return (
- <>
- {
- if (editing || !user) return;
- const addr = nip19.naddrEncode({ kind: 30000, pubkey: user.pubkey, identifier: list.id });
- navigate(`/${addr}`);
- }}
- >
- {/* Avatar stack */}
-
- {previewPubkeys.length > 0 ? previewPubkeys.map((pk) => (
-
- )) : (
-
-
-
- )}
-
-
- {/* Label / rename input */}
-
editing && e.stopPropagation()}>
- {editing ? (
-
e.stopPropagation()}>
- setRenameValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleRename();
- if (e.key === 'Escape') { setEditing(false); setRenameValue(list.title); }
- }}
- className="h-7 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
- autoFocus
- />
-
-
-
- { setEditing(false); setRenameValue(list.title); }}>
-
-
-
- ) : (
-
- {list.title}
-
- {list.pubkeys.length} {list.pubkeys.length === 1 ? 'person' : 'people'}
-
-
- )}
-
-
- {/* Action buttons — visible on hover */}
- {!editing && (
-
e.stopPropagation()}>
-
{ setEditing(true); setRenameValue(list.title); }}
- title="Rename"
- >
-
-
-
onDelete(list)}
- title="Delete"
- >
-
-
-
- )}
-
-
- >
- );
-}
-
-// ─── Main Page ────────────────────────────────────────────────────────────────
-
-export function UserListsPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const { lists, isLoading, createList, deleteList } = useUserLists();
-
- useSeoMeta({
- title: `Lists | Settings | ${config.appName}`,
- description: 'Manage your lists on Nostr.',
- });
-
- const [newListName, setNewListName] = useState('');
- const [deleteTarget, setDeleteTarget] = useState(null);
-
- if (!user) return ;
-
- const handleCreate = () => {
- if (!newListName.trim() || createList.isPending) return;
- createList.mutate(
- { title: newListName.trim() },
- {
- onSuccess: () => {
- toast({ title: `List "${newListName.trim()}" created` });
- setNewListName('');
- },
- onError: () => {
- toast({ title: 'Failed to create list', variant: 'destructive' });
- },
- },
- );
- };
-
- const handleDeleteConfirm = () => {
- if (!deleteTarget) return;
- deleteList.mutate(
- { listId: deleteTarget.id },
- {
- onSuccess: () => {
- toast({ title: `List "${deleteTarget.title}" deleted` });
- setDeleteTarget(null);
- },
- onError: () => {
- toast({ title: 'Failed to delete list', variant: 'destructive' });
- setDeleteTarget(null);
- },
- },
- );
- };
-
- return (
-
- {/* Header */}
- }>
-
-
-
-
-
-
-
-
-
-
-
-
Lists
-
- Organize people into lists. Lists are stored on Nostr so they follow you across clients.
-
-
-
-
-
-
-
- {/* Intro block */}
-
-
Lists
-
- Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
-
-
-
- {/* Create new list */}
-
-
setNewListName(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
- disabled={createList.isPending}
- />
-
-
- Create
-
-
-
- {/* Your Lists */}
-
-
-
- Your Lists
- {!isLoading && lists.length > 0 && (
-
- {lists.length}
-
- )}
-
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : lists.length === 0 ? (
-
- No lists yet. Create one above, or add users from the … menu on notes and profiles.
-
- ) : (
-
- {lists.map((list) => (
-
- ))}
-
- )}
-
-
-
-
- Lists are stored on Nostr (NIP-51) and sync across clients.
-
-
-
- {/* Delete confirm dialog */}
- { if (!o) setDeleteTarget(null); }}>
-
-
- Delete list?
-
- This will delete "{deleteTarget?.title}" and its {deleteTarget?.pubkeys.length ?? 0} members. This action cannot be undone.
-
-
-
- Cancel
-
- Delete
-
-
-
-
-
- );
-}
diff --git a/src/pages/VerifiedPage.tsx b/src/pages/VerifiedPage.tsx
deleted file mode 100644
index cc4bdfa5..00000000
--- a/src/pages/VerifiedPage.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { BadgeCheck, Users } from 'lucide-react';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useAddrEvent } from '@/hooks/useEvent';
-import { VERIFIED_FOLLOW_PACK } from '@/lib/agoraDefaults';
-import { PageHeader } from '@/components/PageHeader';
-import { FollowPackDetailContent } from '@/components/FollowPackDetailContent';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Skeleton } from '@/components/ui/skeleton';
-
-export function VerifiedPage() {
- const { config } = useAppContext();
- const { data: event, isLoading, isError } = useAddrEvent(
- {
- kind: VERIFIED_FOLLOW_PACK.kind,
- pubkey: VERIFIED_FOLLOW_PACK.pubkey,
- identifier: VERIFIED_FOLLOW_PACK.identifier,
- },
- VERIFIED_FOLLOW_PACK.relays,
- );
-
- useSeoMeta({
- title: `Verified | ${config.appName}`,
- description: 'Discover and follow verified accounts curated for Agora.',
- });
-
- return (
-
- } />
-
- {isLoading ? (
-
-
-
-
-
-
- ) : isError || !event ? (
-
-
-
-
-
- Verified pack unavailable
-
-
-
-
- We could not load the verified follow pack right now. Please try again shortly.
-
-
-
-
- ) : (
-
- )}
-
-
- );
-}
-
-export default VerifiedPage;
diff --git a/src/pages/VideosFeedPage.tsx b/src/pages/VideosFeedPage.tsx
deleted file mode 100644
index 4e19f5fc..00000000
--- a/src/pages/VideosFeedPage.tsx
+++ /dev/null
@@ -1,1012 +0,0 @@
-/**
- * VideosFeedPage — unified video + stream feed.
- *
- * ┌─ Follows | Global tabs ─────────────────────┐
- * ├─ Live Now horizontal strip (live-only) ──────┤
- * ├─ Videos (kind 21) grid ──────────────────────┤
- * ├─ Shorts (kind 22) — inline snap-scroll ──────┤
- * │ (exactly like VinesFeedPage, within column) │
- * └──────────────────────────────────────────────┘
- *
- * Global: sort:hot (ditto relay, limit 8/page)
- * Follows: chronological (useFeed, limit 8/page via PAGE_SIZE override)
- * Streams: live-only query, limit 10
- */
-
-import type { NostrEvent } from "@nostrify/nostrify";
-import { useNostr } from "@nostrify/react";
-import { useQuery } from "@tanstack/react-query";
-import { useSeoMeta } from "@unhead/react";
-import { Eye, Film, Play, Radio } from "lucide-react";
-import { nip19 } from "nostr-tools";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Blurhash } from "react-blurhash";
-import { Link } from "react-router-dom";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { KindInfoButton } from "@/components/KindInfoButton";
-import { PageHeader } from "@/components/PageHeader";
-import { PullToRefresh } from "@/components/PullToRefresh";
-import { SubHeaderBar } from "@/components/SubHeaderBar";
-import { TabButton } from "@/components/TabButton";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useVideoThumbnail } from "@/hooks/useVideoThumbnail";
-import { useLayoutOptions } from "@/contexts/LayoutContext";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useAuthor } from "@/hooks/useAuthor";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useFeed } from "@/hooks/useFeed";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useFollowList } from "@/hooks/useFollowActions";
-import { useMuteList } from "@/hooks/useMuteList";
-import { useOpenPost } from "@/hooks/useOpenPost";
-import { useProfileUrl } from "@/hooks/useProfileUrl";
-import { usePageRefresh } from "@/hooks/usePageRefresh";
-import { useInfiniteHotFeed } from "@/hooks/useTrending";
-import { getExtraKindDef } from "@/lib/extraKinds";
-import type { FeedItem } from "@/lib/feedUtils";
-import { getDisplayName } from "@/lib/getDisplayName";
-import { isEventMuted } from "@/lib/muteHelpers";
-import { sidebarItemIcon } from "@/lib/sidebarItems";
-import { getEffectiveStreamStatus } from "@/lib/streamStatus";
-import { timeAgo } from "@/lib/timeAgo";
-import { cn } from "@/lib/utils";
-
-// Reuse the real VineCard — no re-implementation
-import { VineCard } from "@/pages/VinesFeedPage";
-
-const videosDef = getExtraKindDef("videos")!;
-
-/** Items per page for video feeds — enough to fill the horizontal row with overflow. */
-const VIDEO_PAGE_SIZE = 12;
-
-type FeedTab = "follows" | "global";
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-function getTag(tags: string[][], name: string): string | undefined {
- return tags.find(([n]) => n === name)?.[1];
-}
-
-function parseVideoImeta(tags: string[][]): {
- url?: string;
- thumbnail?: string;
- duration?: string;
- blurhash?: string;
-} {
- // Standalone fallback tags (checked after imeta)
- const standaloneThumb = getTag(tags, "thumb") ?? getTag(tags, "image");
-
- for (const tag of tags) {
- if (tag[0] !== "imeta") continue;
- const parts: Record = {};
- for (let i = 1; i < tag.length; i++) {
- const p = tag[i];
- const sp = p.indexOf(" ");
- if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
- }
- if (parts.url) {
- return {
- url: parts.url,
- // imeta uses "image" key for thumbnail; fall back to standalone tags
- thumbnail: parts.image ?? parts.thumb ?? standaloneThumb,
- duration: parts.duration,
- blurhash: parts.blurhash,
- };
- }
- }
- return { url: getTag(tags, "url"), thumbnail: standaloneThumb };
-}
-
-function fmtDuration(s: string | undefined): string | undefined {
- const n = parseFloat(s ?? "");
- if (isNaN(n) || n <= 0) return undefined;
- const h = Math.floor(n / 3600),
- m = Math.floor((n % 3600) / 60),
- sec = Math.floor(n % 60);
- return h > 0
- ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
- : `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
-}
-
-// ── Drag-to-scroll + edge-hover-scroll for horizontal strips ─────────────────
-
-const EDGE_SIZE = 64; // px from edge that triggers auto-scroll
-const EDGE_SPEED = 8; // px per animation frame
-
-function useDragScroll() {
- const ref = useRef(null);
- const dragging = useRef(false);
- const startX = useRef(0);
- const scrollLeftRef = useRef(0);
- const rafRef = useRef(null);
- const edgeDir = useRef<-1 | 0 | 1>(0);
-
- // Edge auto-scroll loop
- const startEdgeScroll = useCallback(() => {
- if (rafRef.current !== null) return;
- const tick = () => {
- const el = ref.current;
- if (el && edgeDir.current !== 0) {
- el.scrollLeft += edgeDir.current * EDGE_SPEED;
- rafRef.current = requestAnimationFrame(tick);
- } else {
- rafRef.current = null;
- }
- };
- rafRef.current = requestAnimationFrame(tick);
- }, []);
-
- const stopEdgeScroll = useCallback(() => {
- edgeDir.current = 0;
- if (rafRef.current !== null) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- }, []);
-
- const onMouseDown = useCallback(
- (e: React.MouseEvent) => {
- const el = ref.current;
- if (!el) return;
- dragging.current = true;
- startX.current = e.pageX - el.offsetLeft;
- scrollLeftRef.current = el.scrollLeft;
- el.style.cursor = "grabbing";
- el.style.userSelect = "none";
- stopEdgeScroll();
- },
- [stopEdgeScroll],
- );
-
- const onMouseMove = useCallback(
- (e: React.MouseEvent) => {
- const el = ref.current;
- if (!el) return;
-
- if (dragging.current) {
- e.preventDefault();
- const x = e.pageX - el.offsetLeft;
- el.scrollLeft = scrollLeftRef.current - (x - startX.current);
- return;
- }
-
- // Edge detection: x relative to the element's bounding box
- const rect = el.getBoundingClientRect();
- const x = e.clientX - rect.left;
- if (x < EDGE_SIZE) {
- edgeDir.current = -1;
- startEdgeScroll();
- } else if (x > rect.width - EDGE_SIZE) {
- edgeDir.current = 1;
- startEdgeScroll();
- } else {
- stopEdgeScroll();
- }
- },
- [startEdgeScroll, stopEdgeScroll],
- );
-
- const onMouseUp = useCallback(() => {
- dragging.current = false;
- if (ref.current) {
- ref.current.style.cursor = "grab";
- ref.current.style.userSelect = "";
- }
- }, []);
-
- const onMouseLeave = useCallback(() => {
- dragging.current = false;
- if (ref.current) {
- ref.current.style.cursor = "grab";
- ref.current.style.userSelect = "";
- }
- stopEdgeScroll();
- }, [stopEdgeScroll]);
-
- // Clean up on unmount
- useEffect(() => () => stopEdgeScroll(), [stopEdgeScroll]);
-
- return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave };
-}
-
-// ── Video grid card (kind 21) — YouTube-style ────────────────────────────────
-
-function VideoGridCard({ event }: { event: NostrEvent }) {
- const {
- url,
- thumbnail: imetaThumb,
- duration,
- blurhash,
- } = parseVideoImeta(event.tags);
- const title =
- getTag(event.tags, "title") ?? (event.content.slice(0, 120) || "Untitled");
- const dur = fmtDuration(duration);
- const generatedThumb = useVideoThumbnail(url ?? "", imetaThumb);
- const thumbnail = imetaThumb ?? generatedThumb;
-
- const author = useAuthor(event.pubkey);
- const metadata = author.data?.metadata;
- const displayName = getDisplayName(metadata, event.pubkey);
- const profileUrl = useProfileUrl(event.pubkey, metadata);
-
- const noteId = nip19.noteEncode(event.id);
- const { onClick, onAuxClick } = useOpenPost(`/${noteId}`);
-
- return (
- e.key === "Enter" && onClick()}
- >
- {/* Thumbnail */}
-
- {blurhash && (
-
- )}
- {thumbnail ? (
-
- ) : (
-
- )}
- {dur && (
-
- {dur}
-
- )}
-
-
- {/* Info row: avatar | title + channel + time */}
-
-
e.stopPropagation()}
- >
- {author.isLoading ? (
-
- ) : (
-
-
-
- {displayName[0]?.toUpperCase()}
-
-
- )}
-
-
-
- {title}
-
- {author.isLoading ? (
-
- ) : (
-
e.stopPropagation()}
- >
- {displayName}
-
- )}
-
- {timeAgo(event.created_at)}
-
-
-
-
- );
-}
-
-function VideoSkeleton() {
- return (
-
- );
-}
-
-// ── Live streams — fetch all statuses, classify with NIP-53 staleness heuristic ─
-
-type StreamTab = "live" | "planned" | "past";
-
-interface ClassifiedStreams {
- live: NostrEvent[];
- planned: NostrEvent[];
- past: NostrEvent[];
-}
-
-/**
- * Fetch ALL streams globally (no author filter) so every tab sees the same
- * event versions from the relay. The follows tab filters client-side.
- *
- * We use a large limit because kind 30311 is addressable — relays store at
- * most one event per pubkey+d-tag, so 200 means ~200 unique streams.
- * Not all relays support filtering by custom tags like `#status`, so we
- * fetch broadly and classify entirely client-side.
- */
-function useAllStreams(): { data: NostrEvent[]; isLoading: boolean } {
- const { nostr } = useNostr();
-
- const query = useQuery({
- queryKey: ["all-streams"],
- queryFn: async ({ signal }) => {
- const events = await nostr.query([{ kinds: [30311], limit: 200 }], {
- signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]),
- });
-
- // Deduplicate addressable events: keep the newest per pubkey+d-tag.
- const best = new Map();
- for (const e of events) {
- const d = e.tags.find(([n]) => n === "d")?.[1] ?? "";
- const key = `${e.pubkey}:${d}`;
- const existing = best.get(key);
- if (!existing || e.created_at > existing.created_at) {
- best.set(key, e);
- }
- }
- return Array.from(best.values());
- },
- staleTime: 30 * 1000,
- refetchInterval: 60 * 1000,
- });
-
- return { data: query.data ?? [], isLoading: query.isLoading };
-}
-
-/** Classify a list of stream events into live / planned / past buckets. */
-function classifyStreams(events: NostrEvent[]): ClassifiedStreams {
- const buckets: ClassifiedStreams = { live: [], planned: [], past: [] };
- const byNewest = (a: NostrEvent, b: NostrEvent) =>
- b.created_at - a.created_at;
- for (const e of events) {
- const status = getEffectiveStreamStatus(e);
- if (status === "live") buckets.live.push(e);
- else if (status === "planned") buckets.planned.push(e);
- else buckets.past.push(e);
- }
- buckets.live.sort(byNewest);
- buckets.planned.sort(byNewest);
- buckets.past.sort(byNewest);
- return buckets;
-}
-
-/**
- * Returns classified streams for the given tab.
- * Global: all streams. Follows: only streams from followed authors.
- */
-function useClassifiedStreams(tab: FeedTab): {
- data: ClassifiedStreams;
- isLoading: boolean;
-} {
- const { user } = useCurrentUser();
- const { data: followData } = useFollowList();
- const followedPubkeys = followData?.pubkeys;
-
- const { data: allEvents, isLoading } = useAllStreams();
-
- const classified = useMemo(() => {
- if (tab === "global") return classifyStreams(allEvents);
-
- // Follows tab — filter to followed authors + self, client-side.
- // Check both the event publisher AND p-tag participants, because
- // streaming services (e.g. streamstr.net) publish kind 30311 on behalf
- // of the streamer, who appears in a p tag with role "host".
- if (!followedPubkeys || !user) return { live: [], planned: [], past: [] };
- const authorSet = new Set([...followedPubkeys, user.pubkey]);
- return classifyStreams(
- allEvents.filter((e) => {
- if (authorSet.has(e.pubkey)) return true;
- return e.tags.some(([name, pk]) => name === "p" && authorSet.has(pk));
- }),
- );
- }, [allEvents, tab, followedPubkeys, user]);
-
- return { data: classified, isLoading };
-}
-
-function StreamBadge({ status }: { status: string }) {
- switch (status) {
- case "live":
- return (
-
-
- LIVE
-
- );
- case "planned":
- return (
-
- PLANNED
-
- );
- default:
- return (
-
- ENDED
-
- );
- }
-}
-
-function LiveStreamCard({ event }: { event: NostrEvent }) {
- const title = getTag(event.tags, "title") || "Untitled Stream";
- const imageUrl = getTag(event.tags, "image");
- const viewers = getTag(event.tags, "current_participants");
- const effectiveStatus = getEffectiveStreamStatus(event);
-
- const naddrId = useMemo(() => {
- const d = getTag(event.tags, "d") || "";
- return nip19.naddrEncode({
- kind: event.kind,
- pubkey: event.pubkey,
- identifier: d,
- });
- }, [event]);
-
- const { onClick, onAuxClick } = useOpenPost(`/${naddrId}`);
- const author = useAuthor(event.pubkey);
- const meta = author.data?.metadata;
- const displayName = getDisplayName(meta, event.pubkey);
-
- return (
- e.key === "Enter" && onClick()}
- >
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {viewers && effectiveStatus === "live" && (
-
-
- {viewers}
-
- )}
-
-
- {title}
-
-
- {displayName}
-
-
- );
-}
-
-function LiveStreamsStrip({ tab }: { tab: FeedTab }) {
- const { data: streams } = useClassifiedStreams(tab);
- const [streamTab, setStreamTab] = useState("live");
- const drag = useDragScroll();
-
- const totalCount =
- streams.live.length + streams.planned.length + streams.past.length;
- if (totalCount === 0) return null;
-
- // Auto-select first non-empty tab if current tab is empty
- const activeTab =
- streams[streamTab].length > 0
- ? streamTab
- : streams.live.length > 0
- ? "live"
- : streams.planned.length > 0
- ? "planned"
- : "past";
-
- const activeEvents = streams[activeTab];
-
- return (
-
- {/* Stream tab pills */}
-
- {streams.live.length > 0 && (
- setStreamTab("live")}
- className={cn(
- "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors",
- activeTab === "live"
- ? "bg-red-600 text-white"
- : "bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground",
- )}
- >
-
- Live ({streams.live.length})
-
- )}
- {streams.planned.length > 0 && (
- setStreamTab("planned")}
- className={cn(
- "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors",
- activeTab === "planned"
- ? "bg-blue-600 text-white"
- : "bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground",
- )}
- >
- Planned ({streams.planned.length})
-
- )}
- {streams.past.length > 0 && (
- setStreamTab("past")}
- className={cn(
- "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold transition-colors",
- activeTab === "past"
- ? "bg-muted text-foreground"
- : "bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground",
- )}
- >
- Past ({streams.past.length})
-
- )}
-
-
- {/* Horizontal scroll of stream cards */}
-
- {activeEvents.map((e) => (
-
- ))}
-
-
- );
-}
-
-// ── Shorts grid thumbnail ─────────────────────────────────────────────────────
-
-function ShortThumb({
- event,
- onClick,
-}: {
- event: NostrEvent;
- onClick: () => void;
-}) {
- const { url, thumbnail: imetaThumb, blurhash } = parseVideoImeta(event.tags);
- const title =
- getTag(event.tags, "title") ?? (event.content.slice(0, 60) || "Short");
- const generatedThumb = useVideoThumbnail(url ?? "", imetaThumb);
- const thumbnail = imetaThumb ?? generatedThumb;
-
- return (
-
-
- {blurhash && !thumbnail && (
-
- )}
- {thumbnail ? (
-
- ) : !blurhash ? (
-
- ) : null}
-
-
-
- {title}
-
-
- );
-}
-
-// ── Shorts full-screen player (VineCard) with back-to-grid button ─────────────
-
-function ShortsPlayer({
- events,
- startIndex,
- onClose,
-}: {
- events: NostrEvent[];
- startIndex: number;
- onClose: () => void;
-}) {
- const [activeIndex, setActiveIndex] = useState(startIndex);
- const containerRef = useRef(null);
-
- // Scroll to startIndex on mount
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
- container.scrollTo({
- top: startIndex * container.clientHeight,
- behavior: "instant",
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // IntersectionObserver syncs activeIndex as user scrolls
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
- const index = Array.from(container.children).indexOf(
- entry.target as Element,
- );
- if (index !== -1) setActiveIndex(index);
- }
- }
- },
- { root: container, threshold: 0.5 },
- );
- Array.from(container.children).forEach((child) => observer.observe(child));
- return () => observer.disconnect();
- }, [events]);
-
- // Keyboard nav + Escape to go back
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- const container = containerRef.current;
- if (e.key === "Escape") {
- onClose();
- return;
- }
- if (!container) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- const next = Math.min(activeIndex + 1, events.length - 1);
- container.scrollTo({
- top: next * container.clientHeight,
- behavior: "smooth",
- });
- setActiveIndex(next);
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- const prev = Math.max(activeIndex - 1, 0);
- container.scrollTo({
- top: prev * container.clientHeight,
- behavior: "smooth",
- });
- setActiveIndex(prev);
- }
- };
- window.addEventListener("keydown", handler);
- return () => window.removeEventListener("keydown", handler);
- }, [onClose, activeIndex, events.length]);
-
- // Same structure as VinesFeedPage: PageHeader + snap container, filling the feed column
- return (
-
-
}
- onBack={onClose}
- alwaysShowBack
- />
-
- {/* Snap-scroll VineCard column — identical sizing to VinesFeedPage */}
-
- {events.map((event, i) => (
-
- {}}
- />
-
- ))}
-
-
- );
-}
-
-// ── Shorts grid section ───────────────────────────────────────────────────────
-
-function ShortsSection({
- events,
- onOpen,
-}: {
- events: NostrEvent[];
- onOpen: (index: number) => void;
-}) {
- const drag = useDragScroll();
- if (events.length === 0) return null;
-
- return (
-
-
- Shorts
-
-
- {events.map((e, i) => (
-
- onOpen(i)} />
-
- ))}
-
-
- );
-}
-
-// ── Page ──────────────────────────────────────────────────────────────────────
-
-export function VideosFeedPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const { muteItems } = useMuteList();
-
- const [feedTab, setFeedTab] = useFeedTab("videos", [
- "follows",
- "global",
- ]);
-
- useSeoMeta({
- title: `Videos | ${config.appName}`,
- description: "Videos and live streams on Nostr",
- });
-
- const [shortsPlayerIndex, setShortsPlayerIndex] = useState(
- null,
- );
- const shortsOpen = shortsPlayerIndex !== null;
- useLayoutOptions({
- showFAB: false,
- noOverscroll: true,
- hasSubHeader: !shortsOpen,
- });
- useEffect(() => {
- setShowAllVideos(false);
- }, [feedTab]);
-
- // ── Follows: chronological, small page ──
- const followsQuery = useFeed("follows", { kinds: [21, 22] });
-
- // ── Global: sort:hot, limit 8/page ──
- const globalQuery = useInfiniteHotFeed(
- [21, 22],
- feedTab === "global",
- VIDEO_PAGE_SIZE,
- );
-
- const activeQuery = feedTab === "follows" ? followsQuery : globalQuery;
- const { data: rawData, isPending, isLoading } = activeQuery;
-
- const videoEvents = useMemo(() => {
- if (!rawData?.pages) return [];
- const seen = new Set();
-
- const events: NostrEvent[] =
- feedTab === "follows"
- ? (rawData.pages as unknown as { items: FeedItem[] }[])
- .flatMap((p) => p.items)
- .map((item) => item.event)
- : (rawData.pages as unknown as NostrEvent[][]).flat();
-
- return events.filter((event) => {
- if (seen.has(event.id)) return false;
- seen.add(event.id);
- if (![21, 22].includes(event.kind)) return false;
- if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
- return !!parseVideoImeta(event.tags).url;
- });
- }, [rawData?.pages, muteItems, feedTab]);
-
- const normalVideos = useMemo(
- () => videoEvents.filter((e) => e.kind === 21),
- [videoEvents],
- );
- const shorts = useMemo(
- () => videoEvents.filter((e) => e.kind === 22),
- [videoEvents],
- );
-
- const [showAllVideos, setShowAllVideos] = useState(false);
- const { data: streams } = useClassifiedStreams(feedTab);
- const hasStreams =
- streams.live.length + streams.planned.length + streams.past.length > 0;
- const initialVideoCount = hasStreams ? 4 : 6;
- const visibleVideos = showAllVideos
- ? normalVideos
- : normalVideos.slice(0, initialVideoCount);
-
- const showSkeleton = isPending || (isLoading && !rawData);
-
- const handleRefresh = usePageRefresh(['feed']);
-
- // When the shorts player is open, render it directly as the page root —
- // same flex-1 column that VinesFeedPage uses, fully replacing the feed UI.
- if (shortsPlayerIndex !== null) {
- return (
- setShortsPlayerIndex(null)}
- />
- );
- }
-
- return (
-
- }>
-
-
-
- {/* Follows / Global tabs */}
-
- setFeedTab("follows")}
- disabled={!user}
- />
- setFeedTab("global")}
- />
-
-
- {/* Live streams strip — follows tab filters by followed authors */}
-
-
-
- {showSkeleton ? (
-
-
- Videos
-
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
-
- ) : videoEvents.length === 0 ? (
- setFeedTab("global") : undefined
- }
- />
- ) : (
-
- {/* Normal videos — 2-column grid */}
- {normalVideos.length > 0 && (
-
-
- Videos
-
-
- {visibleVideos.map((e) => (
-
- ))}
-
- {!showAllVideos && normalVideos.length > initialVideoCount && (
-
setShowAllVideos(true)}
- >
- Show {normalVideos.length - initialVideoCount} more
-
- )}
-
- )}
-
- {/* Shorts shelf */}
- {shorts.length > 0 && (
-
- )}
-
- )}
-
-
- );
-}
diff --git a/src/pages/VinesFeedPage.tsx b/src/pages/VinesFeedPage.tsx
deleted file mode 100644
index 5c498f42..00000000
--- a/src/pages/VinesFeedPage.tsx
+++ /dev/null
@@ -1,1123 +0,0 @@
-import type { NostrEvent } from "@nostrify/nostrify";
-import { useNostr } from "@nostrify/react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useSeoMeta } from "@unhead/react";
-import {
- ArrowLeft,
- Clapperboard,
- Eye,
- Heart,
- MessageCircle,
- MoreHorizontal,
- Play,
- ShieldAlert,
- Volume2,
- VolumeX,
- Zap,
-} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { BarsStaggeredIcon } from "@/components/icons/BarsStaggeredIcon";
-import { CommentsSheet } from "@/components/CommentsSheet";
-import { FeedEmptyState } from "@/components/FeedEmptyState";
-import { RepostIcon } from "@/components/icons/RepostIcon";
-import { KindInfoButton } from "@/components/KindInfoButton";
-import { NoteMoreMenu } from "@/components/NoteMoreMenu";
-import { ProfileHoverCard } from "@/components/ProfileHoverCard";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { ZapDialog } from "@/components/ZapDialog";
-import { useLayoutOptions, useOpenDrawer } from "@/contexts/LayoutContext";
-import { useAppContext } from "@/hooks/useAppContext";
-import { useAuthor } from "@/hooks/useAuthor";
-import { useBlossomFallback } from "@/hooks/useBlossomFallback";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useDeleteEvent } from "@/hooks/useDeleteEvent";
-import { useFeedTab } from "@/hooks/useFeedTab";
-import { useFollowList } from "@/hooks/useFollowActions";
-import { useMutedAuthorFilter } from "@/hooks/useMutedAuthorFilter";
-import { useNostrPublish } from "@/hooks/useNostrPublish";
-import { useProfileUrl } from "@/hooks/useProfileUrl";
-import { useRepostStatus } from "@/hooks/useRepostStatus";
-import { useStreamKind } from "@/hooks/useStreamKind";
-import { useEventStats } from "@/hooks/useTrending";
-import type { Nip85EventStats } from "@/hooks/useNip85Stats";
-import { invalidateEventStats } from "@/lib/invalidateEventStats";
-import { useUserZap } from "@/hooks/useUserZap";
-import { useUserReaction } from "@/hooks/useUserReaction";
-import { DITTO_RELAY } from "@/lib/appRelays";
-import { getContentWarning } from "@/lib/contentWarning";
-import { EXTRA_KINDS } from "@/lib/extraKinds";
-import { getRepostKind } from "@/lib/feedUtils";
-import { formatNumber } from "@/lib/formatNumber";
-import { getDisplayName } from "@/lib/getDisplayName";
-import { impactLight } from "@/lib/haptics";
-import { cn } from "@/lib/utils";
-import { isVineMuted, setVineMuted } from "@/lib/vineGlobalMute";
-
-const VINE_KIND = 34236;
-
-type FeedTab = "follows" | "global";
-
-/** Parse imeta tags for a vine event → { url, thumbnail }. */
-function parseVineImeta(tags: string[][]): {
- url?: string;
- thumbnail?: string;
-} {
- const tag = tags.find(([n]) => n === "imeta");
- if (!tag) return {};
- const result: Record = {};
- for (let i = 1; i < tag.length; i++) {
- const part = tag[i];
- const sp = part.indexOf(" ");
- if (sp === -1) continue;
- result[part.slice(0, sp)] = part.slice(sp + 1);
- }
- return { url: result.url, thumbnail: result.image };
-}
-
-function getTag(tags: string[][], name: string): string | undefined {
- return tags.find(([n]) => n === name)?.[1];
-}
-
-// ─── Global mute state shared across vine cards ───────────────────────────────
-
-// ─── Hook: stream vine events for follows or global ──────────────────────────
-
-function useVinesFeed(tab: FeedTab) {
- const { nostr } = useNostr();
- const { user } = useCurrentUser();
- const { data: followData } = useFollowList();
- const { excludeMuted, mutedKey } = useMutedAuthorFilter();
-
- // For follows tab: finite query filtered by authors
- const followsQuery = useQuery({
- queryKey: [
- "vines-follows",
- user?.pubkey ?? "",
- followData?.pubkeys?.join(",") ?? "",
- mutedKey,
- ],
- queryFn: async ({ signal }) => {
- if (!user) return [];
- const filtered = excludeMuted(followData?.pubkeys ?? []);
- const authors = filtered.length
- ? [...filtered, user.pubkey]
- : [user.pubkey];
- const events = await nostr.query(
- [{ kinds: [VINE_KIND], authors, limit: 40 }],
- { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
- );
- return events.sort((a, b) => b.created_at - a.created_at);
- },
- enabled: tab === "follows" && !!user && followData !== undefined,
- staleTime: 60 * 1000,
- });
-
- // For global tab: streaming hook
- const { events: globalEvents, isLoading: globalLoading } = useStreamKind(
- tab === "global" ? VINE_KIND : [],
- );
-
- if (tab === "follows") {
- return {
- events: followsQuery.data ?? [],
- isLoading: followsQuery.isPending,
- };
- }
-
- return { events: globalEvents, isLoading: globalLoading };
-}
-
-// ─── VineHeartButton ─────────────────────────────────────────────────────────
-
-export function VineHeartButton({
- event,
- label,
- noBackground,
-}: {
- event: NostrEvent;
- label?: string;
- noBackground?: boolean;
-}) {
- const { user } = useCurrentUser();
- const userReaction = useUserReaction(event.id);
- const { mutate: publishEvent } = useNostrPublish();
- const queryClient = useQueryClient();
- const { config } = useAppContext();
- const statsPubkey = config.nip85StatsPubkey;
- const statsKey = ["nip85-event-stats", event.id, statsPubkey] as const;
- const hasReacted = !!userReaction;
-
- const handleClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (!user || hasReacted) return;
- impactLight();
-
- // Optimistically update stats cache
- const prevStats = queryClient.getQueryData(statsKey);
- if (prevStats) {
- queryClient.setQueryData(statsKey, {
- ...prevStats,
- reactionCount: prevStats.reactionCount + 1,
- });
- }
- // Optimistically mark user as having reacted
- queryClient.setQueryData(["user-reaction", event.id], { content: "👍" });
-
- publishEvent(
- {
- kind: 7,
- content: "+",
- tags: [
- ["e", event.id],
- ["p", event.pubkey],
- ["k", String(event.kind)],
- ],
- },
- {
- onSuccess: () => {
- setTimeout(() => {
- invalidateEventStats(queryClient, event, statsPubkey);
- }, 3000);
- },
- onError: () => {
- // Revert optimistic updates
- if (prevStats) {
- queryClient.setQueryData(statsKey, prevStats);
- }
- queryClient.removeQueries({ queryKey: ["user-reaction", event.id] });
- },
- },
- );
- };
-
- return (
-
-
-
-
-
- );
-}
-
-// ─── VineRepostButton ────────────────────────────────────────────────────────
-
-export function VineRepostButton({
- event,
- label,
-}: {
- event: NostrEvent;
- label?: string;
-}) {
- const { user } = useCurrentUser();
- const { mutate: publishEvent } = useNostrPublish();
- const { mutate: deleteEvent } = useDeleteEvent();
- const queryClient = useQueryClient();
- const { config } = useAppContext();
- const statsPubkey = config.nip85StatsPubkey;
- const statsKey = ["nip85-event-stats", event.id, statsPubkey] as const;
- const repostEventId = useRepostStatus(event.id);
- const isReposted = !!repostEventId;
-
- const handleClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (!user) return;
- impactLight();
-
- const repostKind = getRepostKind(event.kind);
- const prevStats = queryClient.getQueryData(statsKey);
-
- if (isReposted && repostEventId) {
- // Undo repost
- if (prevStats) {
- queryClient.setQueryData(statsKey, {
- ...prevStats,
- repostCount: Math.max(0, prevStats.repostCount - 1),
- });
- }
- const prevRepostStatus = queryClient.getQueryData([
- "user-repost",
- event.id,
- ]);
- queryClient.setQueryData(["user-repost", event.id], null);
-
- deleteEvent(
- { eventId: repostEventId, eventKind: repostKind },
- {
- onSuccess: () => {
- setTimeout(() => {
- invalidateEventStats(queryClient, event, statsPubkey);
- }, 3000);
- },
- onError: () => {
- if (prevStats)
- queryClient.setQueryData(statsKey, prevStats);
- queryClient.setQueryData(
- ["user-repost", event.id],
- prevRepostStatus,
- );
- },
- },
- );
- } else {
- // Repost
- if (prevStats) {
- queryClient.setQueryData(statsKey, {
- ...prevStats,
- repostCount: prevStats.repostCount + 1,
- });
- }
- queryClient.setQueryData(["user-repost", event.id], "optimistic");
-
- const tags: string[][] = [
- ["e", event.id, DITTO_RELAY],
- ["p", event.pubkey],
- ];
- if (repostKind === 16) {
- tags.push(["k", String(event.kind)]);
- if (event.kind >= 30000 && event.kind < 40000) {
- const dTag = event.tags.find(([name]) => name === "d")?.[1] ?? "";
- tags.push(["a", `${event.kind}:${event.pubkey}:${dTag}`]);
- }
- }
-
- publishEvent(
- { kind: repostKind, content: "", tags },
- {
- onSuccess: () => {
- setTimeout(() => {
- invalidateEventStats(queryClient, event, statsPubkey);
- }, 3000);
- },
- onError: () => {
- if (prevStats)
- queryClient.setQueryData(statsKey, prevStats);
- queryClient.setQueryData(["user-repost", event.id], null);
- },
- },
- );
- }
- };
-
- return (
-
-
-
-
-
- );
-}
-
-// ─── VineCard ────────────────────────────────────────────────────────────────
-
-export interface VineCardProps {
- event: NostrEvent;
- isActive: boolean;
- /** True for the card immediately before or after the active one — used to preload video. */
- isNearActive: boolean;
- onCommentClick: () => void;
- /** Called when the active card's playing state changes. */
- onPlayingChange?: (playing: boolean) => void;
-}
-
-export function VineCard({
- event,
- isActive,
- isNearActive,
- onCommentClick,
- onPlayingChange,
-}: VineCardProps) {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
- const author = useAuthor(event.pubkey);
- const metadata = author.data?.metadata;
- const displayName = getDisplayName(metadata, event.pubkey);
- const profileUrl = useProfileUrl(event.pubkey, metadata);
- const { data: stats } = useEventStats(event.id, event);
- const canZapAuthor = !!user && user.pubkey !== event.pubkey;
- const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
-
- const [moreMenuOpen, setMoreMenuOpen] = useState(false);
- const [isPlaying, setIsPlaying] = useState(false);
- const [hasStarted, setHasStarted] = useState(false);
- const [isAttemptingPlay, setIsAttemptingPlay] = useState(isActive);
- const [isMuted, setIsMuted] = useState(isVineMuted);
- // true once the browser has decoded enough to render the first frame
- const [isVideoReady, setIsVideoReady] = useState(false);
- // true when the video is stalling / rebuffering mid-playback
- const [isBuffering, setIsBuffering] = useState(false);
-
- // Content warning state
- const contentWarning = getContentWarning(event);
- const hasCW = contentWarning !== undefined;
- const cwPolicy = config.contentWarningPolicy;
- const [cwRevealed, setCwRevealed] = useState(false);
- const showCwOverlay = hasCW && cwPolicy === "blur" && !cwRevealed;
-
- const videoRef = useRef(null);
-
- const imeta = useMemo(() => parseVineImeta(event.tags), [event.tags]);
- const title = getTag(event.tags, "title");
- const hashtags = event.tags.filter(([n]) => n === "t").map(([, v]) => v);
-
- const { src, onError: onBlossomError } = useBlossomFallback(imeta.url ?? "");
-
- // Reset ready/buffering state when the active vine changes (new src loaded)
- useEffect(() => {
- setIsVideoReady(false);
- setIsBuffering(false);
- setHasStarted(false);
- setIsPlaying(false);
- }, [src]);
-
- // Auto-play / auto-pause based on active state.
- // Vines always autoplay when active — the autoplayVideos setting only
- // applies to inline video players in normal feeds, not snap-scroll vines.
- useEffect(() => {
- const video = videoRef.current;
- if (!video || !imeta.url) return;
- if (isActive && !showCwOverlay) {
- video.currentTime = 0;
- video.muted = isVineMuted();
- setIsMuted(isVineMuted());
- setIsAttemptingPlay(true);
- video.play().catch(() => {
- // Autoplay blocked — leave paused, user can tap
- setIsAttemptingPlay(false);
- });
- } else {
- video.pause();
- video.currentTime = 0;
- setIsAttemptingPlay(false);
- setIsBuffering(false);
- }
- }, [isActive, imeta.url, showCwOverlay]);
-
- const togglePlay = useCallback((e: React.MouseEvent) => {
- e.stopPropagation();
- const video = videoRef.current;
- if (!video) return;
- if (video.paused) {
- video.play();
- } else {
- video.pause();
- }
- }, []);
-
- const toggleMute = useCallback((e: React.MouseEvent) => {
- e.stopPropagation();
- const video = videoRef.current;
- if (!video) return;
- const next = !video.muted;
- video.muted = next;
- setVineMuted(next);
- setIsMuted(next);
- }, []);
-
- // Content warning: full-screen dark overlay matching vine aesthetic
- if (showCwOverlay) {
- return (
-
-
-
-
-
-
Content Warning
- {contentWarning ? (
-
- “{contentWarning}”
-
- ) : (
-
- The author flagged this video as sensitive.
-
- )}
-
-
{
- e.stopPropagation();
- setCwRevealed(true);
- }}
- >
-
- Show Video
-
-
- );
- }
-
- return (
-
- {/* ── Video ────────────────────────────────────────────────────── */}
- {imeta.url ? (
- <>
-
setIsVideoReady(true)}
- onPlay={() => {
- setIsPlaying(true);
- setHasStarted(true);
- setIsAttemptingPlay(false);
- setIsBuffering(false);
- onPlayingChange?.(true);
- }}
- onPause={() => {
- setIsPlaying(false);
- setIsAttemptingPlay(false);
- onPlayingChange?.(false);
- }}
- onWaiting={() => {
- if (hasStarted) setIsBuffering(true);
- }}
- onStalled={() => {
- if (hasStarted) setIsBuffering(true);
- }}
- onPlaying={() => setIsBuffering(false)}
- onError={onBlossomError}
- onClick={togglePlay}
- />
-
- {/* Thumbnail — shown until the first frame is decoded and ready */}
- {!isVideoReady && imeta.thumbnail && (
-
- )}
-
- {/* Solid bg fallback when there's no thumbnail and video isn't ready */}
- {!isVideoReady && !imeta.thumbnail && (
-
- )}
-
- {/* Big play overlay before first play — only shown once video is ready and autoplay isn't attempting */}
- {isVideoReady && !hasStarted && !isAttemptingPlay && (
-
- )}
-
- {/* Tap-to-pause overlay (after first play, while paused) */}
- {hasStarted && !isPlaying && !isBuffering && (
-
- )}
-
- {/* Buffering spinner — shown when rebuffering mid-playback */}
- {isBuffering && (
-
- )}
- >
- ) : (
-
- No video
-
- )}
-
- {/* ── Gradient overlays — only rendered once video UI is visible ── */}
- {isVideoReady && (
- <>
-
-
- >
- )}
-
- {/* ── Mute toggle (bottom-right) — only shown once video is ready ──── */}
- {isVideoReady && (
-
- {isMuted ? (
-
- ) : (
-
- )}
-
- )}
-
- {/* ── Right action sidebar — only shown once video is ready ─────── */}
- {isVideoReady && (
-
- {/* Author avatar */}
-
- e.stopPropagation()}
- className="block"
- >
- {author.isLoading ? (
-
- ) : (
-
-
-
- {displayName[0]?.toUpperCase()}
-
-
- )}
-
-
-
- {/* React */}
-
-
- {/* Reply */}
-
}
- label={stats?.replies ? formatNumber(stats.replies) : undefined}
- onClick={(e) => {
- e.stopPropagation();
- onCommentClick();
- }}
- className="text-white hover:text-blue-400"
- />
-
- {/* Repost */}
-
-
- {/* Zap */}
- {canZapAuthor && (
-
-
- }
- label={
- stats?.zapAmount ? formatNumber(stats.zapAmount) : undefined
- }
- className={
- isZapped
- ? "text-amber-400 hover:text-amber-300"
- : "text-white hover:text-amber-400"
- }
- />
-
- )}
-
- {/* More */}
-
}
- onClick={(e) => {
- e.stopPropagation();
- setMoreMenuOpen(true);
- }}
- className="text-white/80 hover:text-white"
- />
-
- )}
-
- {/* ── Bottom info strip — only shown once video is ready ────────── */}
- {isVideoReady && (
-
-
- e.stopPropagation()}
- >
-
- {displayName}
-
-
-
-
- {title && (
-
- {title}
-
- )}
-
- {hashtags.length > 0 && (
-
- {hashtags.slice(0, 4).map((tag) => (
- e.stopPropagation()}
- >
- #{tag}
-
- ))}
-
- )}
-
- )}
-
- {/* ── Modals ───────────────────────────────────────────────────── */}
-
-
- );
-}
-
-// ─── VineActionButton ─────────────────────────────────────────────────────────
-
-export interface VineActionButtonProps {
- icon?: React.ReactNode;
- label?: string;
- onClick?: (e: React.MouseEvent) => void;
- className?: string;
- children?: React.ReactNode;
-}
-
-export function VineActionButton({
- icon,
- label,
- onClick,
- className,
- children,
-}: VineActionButtonProps) {
- return (
-
- {children ?? (
-
- {icon}
-
- )}
- {label && (
-
- {label}
-
- )}
-
- );
-}
-
-// ─── VinesFeedPage ────────────────────────────────────────────────────────────
-
-const vineKindDef = EXTRA_KINDS.find((d) => d.kind === VINE_KIND);
-
-export function VinesFeedPage() {
- const { config } = useAppContext();
- const { user } = useCurrentUser();
-
- const [tab, setTab] = useFeedTab("vines", ["follows", "global"]);
- const [infoOpen, setInfoOpen] = useState(false);
-
- const { events, isLoading } = useVinesFeed(tab);
- const [activeIndex, setActiveIndex] = useState(0);
- const [commentsOpen, setCommentsOpen] = useState(false);
- const [activeVinePlaying, setActiveVinePlaying] = useState(false);
- const [scrollContainer, setScrollContainer] = useState(
- null,
- );
- const containerRef = useRef(null);
-
- const handleCommentClick = useCallback(() => {
- setCommentsOpen(true);
- }, []);
-
- // Callback ref that wires up both the mutable ref and state for layout context
- const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
- containerRef.current = node;
- setScrollContainer(node);
- }, []);
-
- useSeoMeta({
- title: `Divines | ${config.appName}`,
- description: "Short-form videos on Nostr",
- });
-
- // Filter to events that have a video URL, and exclude CW events when policy is "hide"
- const hideCW = config.contentWarningPolicy === "hide";
- const vines = useMemo(
- () => events.filter((e) => {
- if (!parseVineImeta(e.tags).url) return false;
- if (hideCW && getContentWarning(e) !== undefined) return false;
- return true;
- }),
- [events, hideCW],
- );
-
- // Reset active index when tab changes
- useEffect(() => {
- setActiveIndex(0);
- }, [tab]);
-
- const activeVine = vines[activeIndex];
-
- useLayoutOptions({
- showFAB: false,
- scrollContainer,
- noOverscroll: true,
- hasSubHeader: false,
- hideTopBar: true,
- hideBottomNav: activeVinePlaying,
- });
-
- // On mobile the top bar and bottom nav are both hidden, so vines fill the
- // full viewport. On desktop the sidebar breakpoint class overrides the height.
- const vineHeightClass = "vine-slide-height";
-
- // Lock body scroll when mobile comments are open
- useEffect(() => {
- if (commentsOpen) {
- document.body.style.overflow = "hidden";
- document.body.style.touchAction = "none";
- } else {
- document.body.style.overflow = "";
- document.body.style.touchAction = "";
- }
- return () => {
- document.body.style.overflow = "";
- document.body.style.touchAction = "";
- };
- }, [commentsOpen]);
-
- // Sync activeIndex when CSS snap settles (touch swipe, mouse wheel, trackpad)
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
- const index = Array.from(container.children).indexOf(
- entry.target as Element,
- );
- if (index !== -1) setActiveIndex(index);
- }
- }
- },
- { root: container, threshold: 0.5 },
- );
- Array.from(container.children).forEach((child) => observer.observe(child));
- return () => observer.disconnect();
- }, [vines]);
-
- // Keyboard arrow navigation
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- const container = containerRef.current;
- if (!container) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- const next = Math.min(activeIndex + 1, vines.length - 1);
- container.scrollTo({
- top: next * container.clientHeight,
- behavior: "smooth",
- });
- setActiveIndex(next);
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- const prev = Math.max(activeIndex - 1, 0);
- container.scrollTo({
- top: prev * container.clientHeight,
- behavior: "smooth",
- });
- setActiveIndex(prev);
- }
- };
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [activeIndex, vines.length]);
-
- // ── Loading state ────────────────────────────────────────────────────────
- if (isLoading) {
- return (
-
- {/* Immersive skeleton — matches the full-viewport vine layout */}
-
-
- {/* Floating tab bar skeleton */}
-
- {/* Top gradient */}
-
- {/* Bottom gradient */}
-
- {/* Bottom info strip */}
-
- {/* Right action buttons */}
-
-
-
-
-
-
-
- {/* Mute button */}
-
-
-
-
- );
- }
-
- // ── Empty state ──────────────────────────────────────────────────────────
- if (!isLoading && vines.length === 0) {
- return (
-
-
-
- {/* Floating tab bar over empty state */}
-
-
- setTab("global") : undefined
- }
- />
-
-
-
-
- );
- }
-
- return (
-
- {/* ── Scroll + floating overlay wrapper ───────────────────────── */}
-
- {/* ── Floating tab bar — overlays on top of video ──────────── */}
-
-
- {/* ── Scroll container ──────────────────────────────────────── */}
-
- {vines.map((event, i) => (
-
-
-
- ))}
-
-
-
- {/* ── Comments sheet ───────────────────────────────────────────── */}
-
setCommentsOpen(false)}
- />
-
- );
-}
-
-// ─── VinesFloatingTabBar (overlays on video like TikTok) ─────────────────────
-
-interface VinesFloatingTabBarProps {
- tab: FeedTab;
- onTabChange: (tab: FeedTab) => void;
- hasUser: boolean;
- infoOpen: boolean;
- onInfoOpenChange: (open: boolean) => void;
-}
-
-function VinesFloatingTabBar({
- tab,
- onTabChange,
- hasUser,
- infoOpen,
- onInfoOpenChange,
-}: VinesFloatingTabBarProps) {
- const openDrawer = useOpenDrawer();
- const navigate = useNavigate();
-
- return (
-
-
- {/* Menu button on mobile, back button on desktop */}
-
-
-
-
navigate("/")}
- aria-label="Go back"
- >
-
-
-
-
- {hasUser && (
-
onTabChange("follows")}
- >
- Follows
- {tab === "follows" && (
-
- )}
-
- )}
-
onTabChange("global")}
- >
- Global
- {tab === "global" && (
-
- )}
-
-
-
- {/* Info button */}
-
- {vineKindDef && (
- }
- open={infoOpen}
- onOpenChange={onInfoOpenChange}
- />
- )}
-
-
-
- );
-}
diff --git a/src/pages/WebxdcFeedPage.tsx b/src/pages/WebxdcFeedPage.tsx
deleted file mode 100644
index 46437518..00000000
--- a/src/pages/WebxdcFeedPage.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useState, useCallback } from 'react';
-import { getExtraKindDef } from '@/lib/extraKinds';
-import { sidebarItemIcon } from '@/lib/sidebarItems';
-import { KindFeedPage } from '@/pages/KindFeedPage';
-import { WebxdcUploadDialog } from '@/components/WebxdcUploadDialog';
-
-const webxdcDef = getExtraKindDef('webxdc')!;
-const TAG_FILTERS = { '#m': ['application/x-webxdc'] };
-
-export function WebxdcFeedPage() {
- const [uploadOpen, setUploadOpen] = useState(false);
-
- const handleFabClick = useCallback(() => {
- setUploadOpen(true);
- }, []);
-
- return (
- }
- />
- );
-}
diff --git a/src/pages/WikipediaPage.tsx b/src/pages/WikipediaPage.tsx
deleted file mode 100644
index c1b3fa24..00000000
--- a/src/pages/WikipediaPage.tsx
+++ /dev/null
@@ -1,786 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { useSeoMeta } from '@unhead/react';
-import {
- ArrowLeft,
- BookOpen,
- Calendar,
- ExternalLink,
- Eye,
- FlameKindling,
- Loader2,
- Newspaper,
- Search,
- Star,
- TrendingUp,
- X,
-} from 'lucide-react';
-
-import { Input } from '@/components/ui/input';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAppContext } from '@/hooks/useAppContext';
-import {
- useWikipediaFeatured,
- type WikiPage,
- type OnThisDayEvent,
- type NewsItem,
-} from '@/hooks/useWikipediaFeatured';
-import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch';
-import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
-import { cn } from '@/lib/utils';
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-type Section = 'featured' | 'mostread' | 'news' | 'onthisday';
-
-interface SectionMeta {
- label: string;
- icon: React.ReactNode;
-}
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-const SECTIONS: Record = {
- featured: { label: 'Featured', icon: },
- mostread: { label: 'Trending', icon: },
- news: { label: 'In the News', icon: },
- onthisday: { label: 'On This Day', icon: },
-};
-
-const SECTION_ORDER: Section[] = ['featured', 'mostread', 'news', 'onthisday'];
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function wikiPageUrl(page: WikiPage): string {
- return page.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${page.title}`;
-}
-
-function dittoUrl(url: string): string {
- return `/i/${encodeURIComponent(url)}`;
-}
-
-function dittoWikiUrl(page: WikiPage): string {
- return dittoUrl(wikiPageUrl(page));
-}
-
-function formatViews(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
- if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
- return String(n);
-}
-
-function truncateExtract(text: string, maxLen = 150): string {
- if (text.length <= maxLen) return text;
- return text.slice(0, maxLen).replace(/\s+\S*$/, '') + '\u2026';
-}
-
-// ---------------------------------------------------------------------------
-// Scrollspy hook
-// ---------------------------------------------------------------------------
-
-function useScrollspy(
- sectionRefs: Record>,
- navBarRef: RefObject,
-) {
- const [active, setActive] = useState(SECTION_ORDER[0]);
- // Guard against scroll-into-view triggering the observer
- const isScrollingRef = useRef(false);
-
- useEffect(() => {
- const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
- // Trigger when a section crosses just below the sticky nav bar
- const rootMargin = `-${navBarHeight + 8}px 0px -60% 0px`;
-
- const observer = new IntersectionObserver(
- (entries) => {
- if (isScrollingRef.current) return;
- // Pick the first visible section in DOM order
- const visible = entries
- .filter((e) => e.isIntersecting)
- .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
- if (visible.length > 0) {
- const id = visible[0].target.getAttribute('data-section') as Section;
- if (id) setActive(id);
- }
- },
- { rootMargin, threshold: 0 },
- );
-
- for (const key of SECTION_ORDER) {
- const el = sectionRefs[key].current;
- if (el) observer.observe(el);
- }
-
- return () => observer.disconnect();
- }, [sectionRefs, navBarRef]);
-
- const scrollTo = useCallback((section: Section) => {
- const el = sectionRefs[section].current;
- if (!el) return;
- const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
- const top = el.getBoundingClientRect().top + window.scrollY - navBarHeight - 8;
- isScrollingRef.current = true;
- setActive(section);
- window.scrollTo({ top, behavior: 'smooth' });
- // Release the guard after the smooth scroll finishes
- setTimeout(() => { isScrollingRef.current = false; }, 800);
- }, [sectionRefs, navBarRef]);
-
- return { active, scrollTo };
-}
-
-// ---------------------------------------------------------------------------
-// Section pill
-// ---------------------------------------------------------------------------
-
-function SectionPill({ section, active, onClick }: {
- section: Section;
- active: boolean;
- onClick: () => void;
-}) {
- const meta = SECTIONS[section];
- const pillRef = useRef(null);
-
- // Auto-scroll the pill into view when it becomes active
- useEffect(() => {
- if (active && pillRef.current) {
- pillRef.current.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
- }
- }, [active]);
-
- return (
-
- {meta.icon}
- {meta.label}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Article card (used for featured, most-read, on-this-day, news links)
-// ---------------------------------------------------------------------------
-
-function ArticleCard({ page, badge, badgeIcon }: {
- page: WikiPage;
- badge?: string;
- badgeIcon?: React.ReactNode;
-}) {
- return (
-
- {/* Thumbnail */}
-
- {page.thumbnail ? (
-
{ e.currentTarget.style.display = 'none'; }}
- />
- ) : (
-
-
-
- )}
-
- {/* Top-right badge */}
- {badge && (
-
- {badgeIcon}
- {badge}
-
- )}
-
-
- {/* Content */}
-
-
- {page.normalizedtitle ?? page.titles?.normalized ?? page.title.replace(/_/g, ' ')}
-
-
- {page.description ?? truncateExtract(page.extract)}
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Featured article (hero card)
-// ---------------------------------------------------------------------------
-
-function FeaturedArticleCard({ page }: { page: WikiPage }) {
- return (
-
-
- {page.thumbnail ? (
-
{ e.currentTarget.style.display = 'none'; }}
- />
- ) : (
-
-
-
- )}
-
-
-
-
-
-
- {page.normalizedtitle ?? page.titles?.normalized ?? page.title.replace(/_/g, ' ')}
-
-
- {truncateExtract(page.extract, 280)}
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// On This Day entry
-// ---------------------------------------------------------------------------
-
-function OnThisDayCard({ event }: { event: OnThisDayEvent }) {
- const mainPage = event.pages[0];
-
- return (
-
-
- {/* Year pill */}
-
- {event.year}
-
-
-
{event.text}
- {mainPage && (
-
-
- {mainPage.normalizedtitle ?? mainPage.title.replace(/_/g, ' ')}
-
- )}
-
- {mainPage?.thumbnail && (
-
-
{ e.currentTarget.style.display = 'none'; }}
- />
-
- )}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// News card
-// ---------------------------------------------------------------------------
-
-function NewsCard({ item }: { item: NewsItem }) {
- // Extract clean text from HTML story
- const storyText = useMemo(() => {
- return item.story
- .replace(//g, '') // remove comments
- .replace(/<\/?[^>]+(>|$)/g, '') // strip HTML tags
- .trim();
- }, [item.story]);
-
- const mainLink = item.links[0];
-
- return (
-
-
-
-
-
-
-
{storyText}
- {mainLink && (
-
-
- {mainLink.normalizedtitle ?? mainLink.title.replace(/_/g, ' ')}
-
- )}
-
- {mainLink?.thumbnail && (
-
-
{ e.currentTarget.style.display = 'none'; }}
- />
-
- )}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Search bar
-// ---------------------------------------------------------------------------
-
-function WikipediaSearchBar() {
- const navigate = useNavigate();
- const inputRef = useRef(null);
- const containerRef = useRef(null);
-
- const [query, setQuery] = useState('');
- const [debouncedQuery, setDebouncedQuery] = useState('');
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const debounceRef = useRef | undefined>(undefined);
-
- const { data: results, isFetching } = useWikipediaSearch(debouncedQuery);
-
- const handleChange = useCallback((value: string) => {
- setQuery(value);
- clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- setDebouncedQuery(value.trim());
- }, 300);
- }, []);
-
- useEffect(() => {
- return () => clearTimeout(debounceRef.current);
- }, []);
-
- useEffect(() => {
- if (debouncedQuery.length >= 2 && results && results.length > 0) {
- setDropdownOpen(true);
- } else if (debouncedQuery.length >= 2 && results && results.length === 0 && !isFetching) {
- setDropdownOpen(true);
- }
- }, [debouncedQuery, results, isFetching]);
-
- // Close on outside click
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
- setDropdownOpen(false);
- }
- }
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- const handleSelect = useCallback((result: WikipediaSearchResult) => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.blur();
- navigate(dittoUrl(result.url));
- }, [navigate]);
-
- const handleClear = useCallback(() => {
- setQuery('');
- setDebouncedQuery('');
- setDropdownOpen(false);
- inputRef.current?.focus();
- }, []);
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- setDropdownOpen(false);
- inputRef.current?.blur();
- }
- if (e.key === 'Enter' && results && results.length > 0) {
- e.preventDefault();
- handleSelect(results[0]);
- }
- }, [results, handleSelect]);
-
- return (
-
-
-
- handleChange(e.target.value)}
- onFocus={() => {
- if (debouncedQuery.length >= 2) setDropdownOpen(true);
- }}
- onKeyDown={handleKeyDown}
- className="pl-9 pr-9 h-9 text-base md:text-sm"
- />
- {query ? (
-
-
-
- ) : null}
-
-
- {/* Search results dropdown */}
- {dropdownOpen && debouncedQuery.length >= 2 && (
-
- {isFetching && (!results || results.length === 0) ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : results && results.length > 0 ? (
-
- {results.map((result) => (
-
handleSelect(result)}
- >
- {result.thumbnail ? (
-
- ) : (
-
-
-
- )}
-
-
{result.title}
-
{result.description}
-
-
- ))}
-
- ) : (
-
- No results found for “{debouncedQuery}”
-
- )}
-
- {isFetching && results && results.length > 0 && (
-
-
-
- )}
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Section heading
-// ---------------------------------------------------------------------------
-
-function SectionHeading({ icon, title, subtitle }: {
- icon: React.ReactNode;
- title: string;
- subtitle?: string;
-}) {
- return (
-
-
- {icon}
-
-
-
{title}
- {subtitle &&
{subtitle}
}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Loading skeleton
-// ---------------------------------------------------------------------------
-
-function WikipediaLoadingSkeleton() {
- return (
-
- {/* Featured skeleton */}
-
-
- {/* Grid skeleton */}
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
- {/* List skeleton */}
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Page
-// ---------------------------------------------------------------------------
-
-export function WikipediaPage() {
- const { config } = useAppContext();
- const { data: feed, isLoading, isError } = useWikipediaFeatured();
-
- useSeoMeta({
- title: `Wikipedia | ${config.appName}`,
- description: 'Explore today\'s featured Wikipedia content \u2014 trending articles, on this day, in the news, and more.',
- });
-
- // Section refs for scrollspy
- const navBarRef = useRef(null);
- const featuredRef = useRef(null);
- const mostreadRef = useRef(null);
- const newsRef = useRef(null);
- const onthisdayRef = useRef(null);
- const sectionRefs: Record> = {
- featured: featuredRef,
- mostread: mostreadRef,
- news: newsRef,
- onthisday: onthisdayRef,
- };
-
- const { active, scrollTo } = useScrollspy(sectionRefs, navBarRef);
-
- // Filter most-read to remove "Main Page" and "Special:" pages
- const mostReadArticles = useMemo(() => {
- if (!feed?.mostread?.articles) return [];
- return feed.mostread.articles
- .filter((a) => a.title !== 'Main_Page' && !a.title.startsWith('Special:'))
- .slice(0, 12);
- }, [feed?.mostread?.articles]);
-
- const onThisDayEvents = useMemo(() => {
- if (!feed?.onthisday) return [];
- return feed.onthisday.slice(0, 8);
- }, [feed?.onthisday]);
-
- const newsItems = useMemo(() => {
- return feed?.news ?? [];
- }, [feed?.news]);
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
-
Wikipedia
-
Today's featured content
-
-
-
-
-
-
-
- {/* Search bar */}
-
-
- {/* Scrollspy navigation pills */}
-
-
- {SECTION_ORDER.map((s) => (
- scrollTo(s)}
- />
- ))}
-
-
-
- {/* Content */}
- {isLoading ? (
-
- ) : isError ? (
-
-
-
- Couldn't load today's Wikipedia content. Try again later.
-
-
- ) : (
-
- {/* Today's Featured Article */}
- {feed?.tfa && (
-
- }
- title="Today's Featured Article"
- />
-
-
- )}
-
- {/* Most Read */}
- {mostReadArticles.length > 0 && (
-
-
}
- title="Trending"
- subtitle="Most read articles today"
- />
-
- {mostReadArticles.map((page) => (
-
}
- />
- ))}
-
-
- )}
-
- {/* In the News */}
- {newsItems.length > 0 && (
-
-
}
- title="In the News"
- />
-
- {newsItems.map((item, i) => (
-
- ))}
-
-
- )}
-
- {/* On This Day */}
- {onThisDayEvents.length > 0 && (
-
-
}
- title="On This Day"
- subtitle={new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}
- />
-
- {onThisDayEvents.map((event, i) => (
-
- ))}
-
-
- )}
-
- )}
-
- {/* Attribution footer */}
-
-
- );
-}
diff --git a/src/pages/WorldPage.tsx b/src/pages/WorldPage.tsx
deleted file mode 100644
index 947e67b1..00000000
--- a/src/pages/WorldPage.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
-import { useSeoMeta } from '@unhead/react';
-import { Skeleton } from '@/components/ui/skeleton';
-import { useAppContext } from '@/hooks/useAppContext';
-import { useGlobalActivity, useTopCountryHashtags } from '@/hooks/useGlobalActivity';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-import { WorldDiscoveryDrawer } from '@/components/world/WorldDiscoveryDrawer';
-import { WorldDiscoveryPanel } from '@/components/world/WorldDiscoveryPanel';
-
-// Lazy-load the map: react-leaflet + leaflet pull in ~150 KB of JS that we
-// don't want to ship with the rest of the app shell.
-const WorldMap = lazy(() => import('@/components/world/WorldMap'));
-
-/**
- * Breakpoint at which the world page has room for a docked right column
- * (`WorldDiscoveryPanel`) alongside the left sidebar and a usable map.
- * Below this width we fall back to the floating discovery launcher +
- * modal so the map isn't crushed.
- *
- * Matches the `xl` Tailwind breakpoint (1280px) — the same threshold the
- * default `WidgetSidebar` uses. The earlier `sidebar` breakpoint (900px)
- * left only ~540px of map between the 300px left rail and the 360px
- * discovery panel, which was too cramped to be useful.
- */
-const SIDEBAR_MEDIA_QUERY = '(min-width: 1280px)';
-
-function useHasSidebar(): boolean {
- const [hasSidebar, setHasSidebar] = useState(() =>
- typeof window !== 'undefined' && window.matchMedia(SIDEBAR_MEDIA_QUERY).matches,
- );
- useEffect(() => {
- const mq = window.matchMedia(SIDEBAR_MEDIA_QUERY);
- const handler = (e: MediaQueryListEvent) => setHasSidebar(e.matches);
- mq.addEventListener('change', handler);
- setHasSidebar(mq.matches);
- return () => mq.removeEventListener('change', handler);
- }, []);
- return hasSidebar;
-}
-
-export function WorldPage() {
- const { config } = useAppContext();
- const hasSidebar = useHasSidebar();
-
- useSeoMeta({
- title: `World | ${config.appName}`,
- description: 'Explore community activity around the world',
- });
-
- const { data: activities } = useGlobalActivity();
- const { data: topHashtags } = useTopCountryHashtags();
-
- // Memoise the activities/hashtags fallbacks too — `new Map()` literals
- // produce a fresh reference every render, which causes WorldMap's
- // `activityMarkers` useMemo (and downstream popover refs) to invalidate
- // even when the underlying data hasn't changed.
- const safeActivities = useMemo(() => activities ?? new Map(), [activities]);
- const safeTopHashtags = useMemo(() => topHashtags ?? new Map(), [topHashtags]);
-
- // `fullBleed: true` is the new reusable preset for edge-to-edge pages —
- // see `LayoutOptions.fullBleed` for the full list of flags it expands to.
- // We override `rightSidebar` with our discovery panel; the panel hides
- // itself below the sidebar breakpoint via Tailwind's `hidden sidebar:flex`,
- // and the bottom drawer takes over there.
- useLayoutOptions({
- fullBleed: true,
- rightSidebar: ,
- });
-
- return (
- // The height must account for the sticky TopNav (h-16 = 4rem) so the
- // map fills exactly the remaining viewport. Using `h-dvh` (100dvh)
- // would make the page scrollable and let the sticky header overlap the
- // top of the map — hiding the Leaflet zoom controls. The calc keeps
- // the page at exactly one viewport with no scroll.
-
-
-
-
- }
- >
-
-
-
-
-
- {/* Below the sidebar breakpoint, surface the discovery experience as
- a floating button + modal. Above it, the docked
- `WorldDiscoveryPanel` (rendered as the layout's right sidebar)
- takes over and this component is unmounted. */}
- {!hasSidebar && }
-
- );
-}