import { useState, type ReactNode } from 'react'; import { Users, Download, Loader2, X, Pencil, Home, Globe, MapPin, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { useToast } from '@/hooks/useToast'; import { useSavedFeeds } from '@/hooks/useSavedFeeds'; import { useInterests } from '@/hooks/useInterests'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useMuteList, type MuteListItem } from '@/hooks/useMuteList'; import { useAuthor } from '@/hooks/useAuthor'; import { FeedEditModal } from '@/components/FeedEditModal'; import { buildKindOptions } from '@/lib/feedFilterUtils'; import { genUserName } from '@/lib/genUserName'; import { EXTRA_KINDS } from '@/lib/extraKinds'; import { SIDEBAR_ITEMS } from '@/lib/sidebarItems'; import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ExtraKindDef } from '@/lib/extraKinds'; export function ContentSettings() { return (
); } function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } function FlatContentList() { // Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges. const orderedIds = [ 'posts', 'replies', 'reposts', 'articles', 'highlights', 'photos', 'videos', 'voice', 'events', 'polls', 'communities', 'badges', 'reactions', 'zaps', ]; const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def])); // Replies is id 'comments' in the registry; alias here for readability. byId.set('replies', byId.get('comments')!); const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora); return ( ); } function ContentTypeRow({ def }: { def: ExtraKindDef }) { const { feedSettings, updateFeedSettings } = useFeedSettings(); const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); // Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key // for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds). const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey; if (!toggleKey) return null; const checked = feedSettings[toggleKey] !== false; const handleToggle = async (value: boolean) => { const next: Partial = { [toggleKey]: value }; // Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the // single parent switch governs everything below it. if (def.subKinds) { for (const sub of def.subKinds) { next[sub.feedKey] = value; } } updateFeedSettings(next); if (user) { await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } }); } }; return (
{def.label}

{def.description}

); } // Feed Tabs Section Component function FeedTabsSection() { const { toast } = useToast(); const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const { feedSettings, updateFeedSettings } = useFeedSettings(); const [communityDomain, setCommunityDomain] = useState(''); const [isDownloading, setIsDownloading] = useState(false); const [community, setCommunity] = useState<{ domain: string; userCount: number; label: string } | null>(() => { const stored = localStorage.getItem('ditto:community'); return stored ? JSON.parse(stored) : null; }); const [showAgoraFeed, setShowAgoraFeed] = useState(() => { const stored = localStorage.getItem('agora:showWorldFeed'); return stored !== null ? stored === 'true' : true; // Default to true }); const [showGlobalFeed, setShowGlobalFeed] = useState(() => { const stored = localStorage.getItem('ditto:showGlobalFeed'); return stored !== null ? stored === 'true' : false; // Default to false }); const [showCommunityFeed, setShowCommunityFeed] = useState(() => { const stored = localStorage.getItem('ditto:showCommunityFeed'); return stored !== null ? stored === 'true' : false; // Default to false }); const handleToggleAgoraFeed = async (checked: boolean) => { setShowAgoraFeed(checked); localStorage.setItem('agora:showWorldFeed', String(checked)); toast({ title: checked ? 'World feed enabled' : 'World feed disabled', description: checked ? 'The World feed tab will appear in your navigation' : 'The World feed tab will be hidden', }); }; const handleToggleGlobalFeed = async (checked: boolean) => { setShowGlobalFeed(checked); localStorage.setItem('ditto:showGlobalFeed', String(checked)); if (user) { await updateSettings.mutateAsync({ showGlobalFeed: checked }); } toast({ title: checked ? 'Global feed enabled' : 'Global feed disabled', description: checked ? 'The Global feed tab will appear in your navigation' : 'The Global feed tab will be hidden', }); }; const handleToggleCommunityFeed = async (checked: boolean) => { setShowCommunityFeed(checked); localStorage.setItem('ditto:showCommunityFeed', String(checked)); if (user) { await updateSettings.mutateAsync({ showCommunityFeed: checked }); } toast({ title: checked ? 'Community feed enabled' : 'Community feed disabled', description: checked ? 'The Community feed tab will appear in your navigation' : 'The Community feed tab will be hidden', }); }; const handleDownloadCommunity = async () => { if (!communityDomain.trim()) { toast({ title: 'Domain required', description: 'Please enter a domain name', variant: 'destructive', }); return; } // Clean up domain input let domain = communityDomain.trim().toLowerCase(); // Remove protocol if present domain = domain.replace(/^https?:\/\//, ''); // Remove trailing slash domain = domain.replace(/\/$/, ''); setIsDownloading(true); try { // Fetch the NIP-05 JSON const response = await fetch(`https://${domain}/.well-known/nostr.json`); if (!response.ok) { throw new Error(`Failed to fetch: ${response.statusText}`); } const data = await response.json(); if (!data.names || typeof data.names !== 'object') { throw new Error('Invalid NIP-05 JSON format'); } const userCount = Object.keys(data.names).length; // Extract label from domain (hostname without TLD) // ditto.pub -> Ditto, spinster.xyz -> Spinster, etc. const domainParts = domain.split('.'); const hostname = domainParts[0]; // Get first part const label = hostname.charAt(0).toUpperCase() + hostname.slice(1); // Capitalize // Store in localStorage (single community only) const newCommunity = { domain, userCount, label }; setCommunity(newCommunity); localStorage.setItem('ditto:community', JSON.stringify(newCommunity)); // Store the actual JSON data for later use localStorage.setItem('ditto:communityData', JSON.stringify(data)); // Auto-enable the Community feed tab setShowCommunityFeed(true); localStorage.setItem('ditto:showCommunityFeed', 'true'); // Sync to encrypted settings if (user) { await updateSettings.mutateAsync({ communityData: { domain, label, userCount, nip05: data.names }, showCommunityFeed: true, }); } toast({ title: 'Community set', description: `${label} with ${userCount} users`, }); setCommunityDomain(''); } catch (error) { console.error('Failed to download community:', error); toast({ title: 'Failed to download', description: error instanceof Error ? error.message : 'Could not fetch community data', variant: 'destructive', }); } finally { setIsDownloading(false); } }; const handleRemoveCommunity = async () => { setCommunity(null); localStorage.removeItem('ditto:community'); localStorage.removeItem('ditto:communityData'); // Also disable the community feed tab setShowCommunityFeed(false); localStorage.setItem('ditto:showCommunityFeed', 'false'); if (user) { await updateSettings.mutateAsync({ communityData: undefined, showCommunityFeed: false }); } toast({ title: 'Community removed', description: 'Community feed cleared', }); }; return (
{/* Intro section for Feed Tabs */}

Feed Navigation

Manage which feed tabs appear in your navigation and follow communities by domain.

{/* Feed Tab Toggles */}

Include replies from people you follow, not just top-level posts

{ updateFeedSettings({ followsFeedShowReplies: checked }); if (user) { await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, followsFeedShowReplies: checked } }); } toast({ title: checked ? 'Replies shown' : 'Replies hidden', description: checked ? 'Replies from people you follow will appear in your feed' : 'Only top-level posts will appear in your follows feed', }); }} className="shrink-0" />

Show posts from all countries around the world

Show posts from all users across the network

{community ? `Show "${community.label}" tab for ${community.domain} users` : 'Set a community below to enable this feed'}

{/* Community Management */}

Set a community domain. We'll download the NIP-05 user list to show posts only from verified members.

{!community ? (
setCommunityDomain(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleDownloadCommunity(); } }} className="h-9" disabled={isDownloading} />
) : (

{community.label}

{community.domain} • {community.userCount} {community.userCount === 1 ? 'user' : 'users'}

)}
{/* Saved Feeds */} {/* Interests (hashtag & geotag tabs) */}
); } // ─── Interests Section ─────────────────────────────────────────────────────── function InterestsSection() { const { toast } = useToast(); const { user } = useCurrentUser(); const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t'); const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g'); const [newHashtag, setNewHashtag] = useState(''); const [newGeotag, setNewGeotag] = useState(''); const isLoading = isLoadingHashtags || isLoadingGeotags; if (!user) return null; const handleRemoveHashtag = async (tag: string) => { await removeHashtag.mutateAsync(tag); toast({ title: `#${tag} removed from feed tabs` }); }; const handleRemoveGeotag = async (tag: string) => { await removeGeotag.mutateAsync(tag); toast({ title: `${tag} removed from feed tabs` }); }; const handleAddHashtag = async () => { const tag = newHashtag.trim().toLowerCase().replace(/^#/, ''); if (!tag) return; if (hashtags.includes(tag)) { toast({ title: `#${tag} is already followed`, variant: 'destructive' }); return; } await addHashtag.mutateAsync(tag); setNewHashtag(''); toast({ title: `#${tag} added to feed tabs` }); }; const handleAddGeotag = async () => { const tag = newGeotag.trim().toLowerCase(); if (!tag) return; if (geotags.includes(tag)) { toast({ title: `${tag} is already followed`, variant: 'destructive' }); return; } await addGeotag.mutateAsync(tag); setNewGeotag(''); toast({ title: `${tag} added to feed tabs` }); }; return (

Interest Tabs

Hashtags and locations you follow appear as tabs on the home feed.

{/* Hashtags */}
Hashtags
setNewHashtag(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }} className="h-9" disabled={addHashtag.isPending} />
{isLoading ? (
) : hashtags.length === 0 ? (

No followed hashtags yet.

) : (
{hashtags.map((tag) => (
{tag}
))}
)}
{/* Geotags */}
Locations
setNewGeotag(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }} className="h-9" disabled={addGeotag.isPending} />
{isLoading ? (
) : geotags.length === 0 ? (

No followed locations yet.

) : (
{geotags.map((tag) => (
{tag}
))}
)}
); } // ─── Saved Feeds Section ───────────────────────────────────────────────────── function SavedFeedsSection() { const { savedFeeds, addSavedFeed, removeSavedFeed, updateSavedFeed, isPending } = useSavedFeeds(); const { toast } = useToast(); const [addFeedModalOpen, setAddFeedModalOpen] = useState(false); const [editingFeed, setEditingFeed] = useState(null); const feedTabs = savedFeeds; const handleAddFeed = async (label: string, filter: TabFilter, vars: SavedFeed['vars']) => { await addSavedFeed(label, filter, vars); toast({ title: `"${label}" added to home feed tabs` }); }; const handleEditFeed = async (label: string, filter: TabFilter, vars: SavedFeed['vars']) => { if (!editingFeed) return; await updateSavedFeed(editingFeed.id, { label, filter, vars }); toast({ title: 'Feed updated' }); setEditingFeed(null); }; const handleRemove = async (feed: SavedFeed) => { await removeSavedFeed(feed.id); toast({ title: `"${feed.label}" removed` }); }; return (

Home Feed Tabs

{feedTabs.length === 0 ? (

No custom tabs yet. Save a search from the search page, or add one above.

) : (
{feedTabs.map((feed) => ( setEditingFeed(feed)} onRemove={() => handleRemove(feed)} isPending={isPending} /> ))}
)} { if (!o) setEditingFeed(null); }} initialLabel={editingFeed?.label} initialFilter={editingFeed?.filter} onSave={handleEditFeed} isPending={isPending} />
); } const kindOptions = buildKindOptions(); function SavedFeedRow({ feed, onEdit, onRemove, isPending, }: { feed: SavedFeed; onEdit: () => void; onRemove: () => void; isPending: boolean; }) { const search = typeof feed.filter.search === 'string' ? feed.filter.search : ''; const authors = Array.isArray(feed.filter.authors) ? feed.filter.authors as string[] : []; const kinds = Array.isArray(feed.filter.kinds) ? feed.filter.kinds as number[] : []; const scopeLabel = authors.includes('$follows') ? 'Follows' : authors.length > 0 ? `${authors.length} author${authors.length > 1 ? 's' : ''}` : null; const kindLabel = kinds.length === 0 ? null : kinds.length === 1 ? (kindOptions.find((o) => o.value === String(kinds[0]))?.label ?? `Kind ${kinds[0]}`) : `${kinds.length} kinds`; return (

{feed.label}

{search && ( "{search}" )} {scopeLabel && ( {scopeLabel} )} {kindLabel && ( {kindLabel} )}
); } const CW_POLICY_OPTIONS: { value: ContentWarningPolicy; label: string; description: string }[] = [ { value: 'blur', label: 'Blur until revealed', description: 'Content is hidden behind a warning. Media is not loaded until you choose to view it.', }, { value: 'hide', label: 'Hide completely', description: 'Posts with content warnings are removed from your feed entirely.', }, { value: 'show', label: 'Always show', description: 'Ignore content warnings and display everything normally.', }, ]; export function SensitiveContentSection() { const { config, updateConfig } = useAppContext(); const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const handlePolicyChange = async (value: string) => { const policy = value as ContentWarningPolicy; updateConfig((current) => ({ ...current, contentWarningPolicy: policy })); if (user) { await updateSettings.mutateAsync({ contentWarningPolicy: policy }); } }; return (
{/* Intro */}

Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.

{/* Policy options — consistent row style with other settings */} {CW_POLICY_OPTIONS.map((option) => ( ))}
); } const MUTE_TYPE_CONFIG = { pubkey: { icon: , label: 'Users', description: 'Hide posts from specific users', inputLabel: 'Public Key (hex or npub)', placeholder: 'npub1... or hex pubkey', }, hashtag: { icon: , label: 'Hashtags', description: 'Hide posts with specific hashtags', inputLabel: 'Hashtag (without #)', placeholder: 'bitcoin', }, word: { icon: , label: 'Words', description: 'Hide posts containing specific words or phrases', inputLabel: 'Word or Phrase', placeholder: 'spam word', }, thread: { icon: , label: 'Threads', description: 'Hide entire conversation threads', inputLabel: 'Event ID (hex or note)', placeholder: 'note1... or hex event ID', }, }; export function MuteSettingsInternals() { const { muteItems, isLoading, addMute, removeMute } = useMuteList(); const { toast } = useToast(); const [newMuteType, setNewMuteType] = useState('pubkey'); const [newMuteValue, setNewMuteValue] = useState(''); const handleAddMute = async () => { if (!newMuteValue.trim()) { toast({ title: 'Error', description: 'Please enter a value', variant: 'destructive' }); return; } try { await addMute.mutateAsync({ type: newMuteType, value: newMuteValue.trim(), }); toast({ title: 'Success', description: 'Mute added successfully' }); setNewMuteValue(''); } catch (error) { toast({ title: 'Error', description: error instanceof Error ? error.message : 'Failed to add mute', variant: 'destructive', }); } }; const handleRemoveMute = async (item: MuteListItem) => { try { await removeMute.mutateAsync(item); toast({ title: 'Success', description: 'Mute removed successfully' }); } catch (error) { toast({ title: 'Error', description: error instanceof Error ? error.message : 'Failed to remove mute', variant: 'destructive', }); } }; const groupedMutes = { pubkey: muteItems.filter((item) => item.type === 'pubkey'), hashtag: muteItems.filter((item) => item.type === 'hashtag'), word: muteItems.filter((item) => item.type === 'word'), thread: muteItems.filter((item) => item.type === 'thread'), }; return (
{/* Add mute section */}
setNewMuteValue(e.target.value)} placeholder={MUTE_TYPE_CONFIG[newMuteType].placeholder} className="h-9" />
{/* Muted items list */} {isLoading ? (
) : muteItems.length === 0 ? (

No muted items yet

) : ( <> {Object.entries(groupedMutes).map(([type, items]) => { if (items.length === 0) return null; const config = MUTE_TYPE_CONFIG[type as MuteListItem['type']]; return ( ); })} )}
); } /** Renders a muted user's avatar and display name instead of a raw hex pubkey. */ function MutedUserProfile({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(pubkey); if (author.isLoading) { return (
); } return (
{displayName[0]?.toUpperCase() ?? '?'} {displayName}
); } /** Renders a muted thread as a clickable link using the nevent identifier. */ function MutedThreadLink({ eventId }: { eventId: string }) { const nevent = nip19.neventEncode({ id: eventId }); const shortId = eventId.slice(0, 8) + '…' + eventId.slice(-8); return ( e.stopPropagation()} > {shortId} ); } function MuteTypeSection({ type: _type, config, items, onRemove, isPending, }: { type: MuteListItem['type']; config: typeof MUTE_TYPE_CONFIG[keyof typeof MUTE_TYPE_CONFIG]; items: MuteListItem[]; onRemove: (item: MuteListItem) => void; isPending: boolean; }) { return (
{config.icon}
{config.label}

{items.length} {items.length === 1 ? 'item' : 'items'} • {config.description}

{items.map((item, index) => (
{item.type === 'pubkey' ? ( ) : item.type === 'thread' ? ( ) : ( {item.value} )}
))}
); } function HomePageSetting() { const { config, updateConfig } = useAppContext(); const { user } = useCurrentUser(); const { updateSettings } = useEncryptedSettings(); const { toast } = useToast(); const handleHomePageChange = async (value: string) => { updateConfig((c) => ({ ...c, homePage: value })); if (user) { await updateSettings.mutateAsync({ homePage: value }); } const item = SIDEBAR_ITEMS.find((s) => s.id === value); toast({ title: `Homepage set to ${item?.label ?? value}` }); }; return (

Choose which page to display when you open the app

); }