Files
eranos/src/components/ContentSettings.tsx
T
Chad Curtis 5920523b57 Flatten Home Feed settings; merge mutes inline; drop jargon
Reduce cognitive load on the Content settings page by collapsing the
two-section toggle layout, group sub-headers, sub-kind rows, kind
badges, and column headers into a single flat list of 14 toggles
ordered by importance: Posts, Replies, Reposts, Articles, Highlights,
Photos, Videos, Voice Messages, Events, Polls, Organizations, Badges,
Reactions, Zaps.

Each row is now a plain label + one-line description + switch. No
content-kind icons, no [1234] kind-number badges, no Media / Social /
Whimsy sub-headers, no Normal/Short video or Badge Definitions /
Profile Badges / Badge Awards sub-rows (the parent toggle now governs
all sub-kinds together).

Combine kind 6 (Reposted Notes) and kind 16 (Reposted Other Content)
into a single "Reposts" toggle via extraFeedKinds: [16]. The old
feedIncludeGenericReposts flag stays in the schema for backwards
compat but no longer surfaces in UI.

Rename "Comments" -> "Replies" — Nostr's NIP-22 threading is most
naturally called replies.

Strip NIP / kind-number references from all curated descriptions
(NIP-22, NIP-52, NIP-58, NIP-68, NIP-71, NIP-72, NIP-84, NIP-A0,
"kind 30009", etc.). Plain English only.

Merge the standalone /settings/content page (mutes + sensitive
content) into /settings/feed as inline sections under the toggle
list, since both are about "what you see in the feed." Delete
ContentPage.tsx and its route; remove the Content entry from the
settings index. Drop the giant ShieldAlert icon from the sensitive
content intro.

Rename "Home Feed Tabs" -> "Saved Feeds" in the page section heading.
2026-05-21 13:48:19 -05:00

1169 lines
40 KiB
TypeScript

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 (
<div className="space-y-8">
<HomePageSetting />
<Section title="Saved Feeds">
<FeedTabsSection />
</Section>
<Section title="Content in Home Feed">
<FlatContentList />
</Section>
<Section title="Muted">
<MuteSettingsInternals />
</Section>
<Section title="Sensitive Content">
<SensitiveContentSection />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
<div className="pt-2">{children}</div>
</section>
);
}
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 (
<ul className="divide-y divide-border">
{rows.map((def) => (
<li key={def.id}>
<ContentTypeRow def={def} />
</li>
))}
</ul>
);
}
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<FeedSettings> = { [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 (
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
<div className="min-w-0">
<span className="text-sm font-medium">{def.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
</div>
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
</div>
);
}
// 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 (
<div>
{/* Intro section for Feed Tabs */}
<div className="px-3 pt-3 pb-4">
<h3 className="text-sm font-semibold">Feed Navigation</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage which feed tabs appear in your navigation and follow communities by domain.
</p>
</div>
{/* Feed Tab Toggles */}
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">Show replies in feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Include replies from people you follow, not just top-level posts</p>
</div>
<Switch
checked={feedSettings.followsFeedShowReplies}
onCheckedChange={async (checked) => {
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"
/>
</div>
</div>
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">World Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show posts from all countries around the world</p>
</div>
<Switch
checked={showAgoraFeed}
onCheckedChange={handleToggleAgoraFeed}
className="shrink-0"
/>
</div>
</div>
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">Global Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">Show posts from all users across the network</p>
</div>
<Switch
checked={showGlobalFeed}
onCheckedChange={handleToggleGlobalFeed}
className="shrink-0"
/>
</div>
</div>
<div className="border-b border-border">
<div className="flex items-center justify-between py-3.5 px-3">
<div className="min-w-0">
<Label className="text-sm font-medium">Community Feed</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{community
? `Show "${community.label}" tab for ${community.domain} users`
: 'Set a community below to enable this feed'}
</p>
</div>
<Switch
checked={showCommunityFeed}
onCheckedChange={handleToggleCommunityFeed}
className="shrink-0"
disabled={!community}
/>
</div>
</div>
<div className="px-3 py-4 space-y-4">
{/* Community Management */}
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Users className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Community</Label>
</div>
<p className="text-xs text-muted-foreground">
Set a community domain. We'll download the NIP-05 user list to show posts only from verified members.
</p>
</div>
{!community ? (
<div className="flex gap-2">
<Input
placeholder="agora.spot"
value={communityDomain}
onChange={(e) => setCommunityDomain(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleDownloadCommunity();
}
}}
className="h-9"
disabled={isDownloading}
/>
<Button
onClick={handleDownloadCommunity}
disabled={isDownloading || !communityDomain.trim()}
size="sm"
className="h-9"
>
{isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{community.label}</p>
<p className="text-xs text-muted-foreground truncate">
{community.domain} • {community.userCount} {community.userCount === 1 ? 'user' : 'users'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRemoveCommunity}
className="shrink-0 h-8 w-8 p-0"
>
<X className="h-4 w-4 text-destructive" />
</Button>
</div>
)}
</div>
</div>
{/* Saved Feeds */}
<SavedFeedsSection />
{/* Interests (hashtag & geotag tabs) */}
<InterestsSection />
</div>
);
}
// ─── 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 (
<div className="px-3 py-4 space-y-4 border-t border-border">
<div>
<h3 className="text-sm font-semibold">Interest Tabs</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Hashtags and locations you follow appear as tabs on the home feed.
</p>
</div>
{/* Hashtags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
</div>
<div className="flex gap-2">
<Input
placeholder="ditto"
value={newHashtag}
onChange={(e) => setNewHashtag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
className="h-9"
disabled={addHashtag.isPending}
/>
<Button
onClick={handleAddHashtag}
disabled={addHashtag.isPending || !newHashtag.trim()}
size="sm"
className="h-9"
>
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : hashtags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
) : (
<div className="space-y-1.5">
{hashtags.map((tag) => (
<div
key={`hashtag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveHashtag(tag)}
disabled={removeHashtag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove #${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Geotags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
</div>
<div className="flex gap-2">
<Input
placeholder="geohash (e.g. u4pru)"
value={newGeotag}
onChange={(e) => setNewGeotag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
className="h-9"
disabled={addGeotag.isPending}
/>
<Button
onClick={handleAddGeotag}
disabled={addGeotag.isPending || !newGeotag.trim()}
size="sm"
className="h-9"
>
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : geotags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
) : (
<div className="space-y-1.5">
{geotags.map((tag) => (
<div
key={`geotag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveGeotag(tag)}
disabled={removeGeotag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove ${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ─── Saved Feeds Section ─────────────────────────────────────────────────────
function SavedFeedsSection() {
const { savedFeeds, addSavedFeed, removeSavedFeed, updateSavedFeed, isPending } = useSavedFeeds();
const { toast } = useToast();
const [addFeedModalOpen, setAddFeedModalOpen] = useState(false);
const [editingFeed, setEditingFeed] = useState<SavedFeed | null>(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 (
<div className="px-3 py-4 space-y-3 border-t border-border">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Home Feed Tabs</h3>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setAddFeedModalOpen(true)}
>
<Plus className="size-3.5" />
Add tab
</Button>
</div>
{feedTabs.length === 0 ? (
<p className="text-xs text-muted-foreground">
No custom tabs yet. Save a search from the search page, or add one above.
</p>
) : (
<div className="space-y-1.5">
{feedTabs.map((feed) => (
<SavedFeedRow
key={feed.id}
feed={feed}
onEdit={() => setEditingFeed(feed)}
onRemove={() => handleRemove(feed)}
isPending={isPending}
/>
))}
</div>
)}
<FeedEditModal
open={addFeedModalOpen}
onOpenChange={setAddFeedModalOpen}
onSave={handleAddFeed}
isPending={isPending}
/>
<FeedEditModal
key={editingFeed?.id ?? 'edit'}
open={editingFeed !== null}
onOpenChange={(o) => { if (!o) setEditingFeed(null); }}
initialLabel={editingFeed?.label}
initialFilter={editingFeed?.filter}
onSave={handleEditFeed}
isPending={isPending}
/>
</div>
);
}
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 (
<div className="rounded-lg border border-border/50 bg-secondary/30 group">
<div className="flex items-center gap-2 py-2 px-2.5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{feed.label}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{search && (
<span className="text-[10px] text-muted-foreground bg-secondary rounded px-1 py-0.5 truncate max-w-[140px]">"{search}"</span>
)}
{scopeLabel && (
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
<Globe className="size-2.5" />{scopeLabel}
</span>
)}
{kindLabel && (
<span className="text-[10px] text-muted-foreground bg-secondary rounded px-1 py-0.5">{kindLabel}</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onEdit}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
aria-label="Edit"
>
<Pencil className="size-3.5" />
</button>
<button
onClick={onRemove}
disabled={isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label="Remove"
>
<X className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
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 (
<div>
{/* Intro */}
<div className="px-3 pt-3 pb-4">
<p className="text-xs text-muted-foreground leading-relaxed">
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
</p>
</div>
{/* Policy options — consistent row style with other settings */}
<RadioGroup
value={config.contentWarningPolicy}
onValueChange={handlePolicyChange}
className="gap-0"
>
{CW_POLICY_OPTIONS.map((option) => (
<label
key={option.value}
className="flex items-center justify-between py-3.5 px-3 border-b border-border last:border-b-0 cursor-pointer hover:bg-muted/20 transition-colors"
>
<div className="min-w-0">
<span className="text-sm font-medium">{option.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
{option.description}
</p>
</div>
<RadioGroupItem value={option.value} className="shrink-0" />
</label>
))}
</RadioGroup>
</div>
);
}
const MUTE_TYPE_CONFIG = {
pubkey: {
icon: <UserX className="size-5" />,
label: 'Users',
description: 'Hide posts from specific users',
inputLabel: 'Public Key (hex or npub)',
placeholder: 'npub1... or hex pubkey',
},
hashtag: {
icon: <Hash className="size-5" />,
label: 'Hashtags',
description: 'Hide posts with specific hashtags',
inputLabel: 'Hashtag (without #)',
placeholder: 'bitcoin',
},
word: {
icon: <MessageSquareOff className="size-5" />,
label: 'Words',
description: 'Hide posts containing specific words or phrases',
inputLabel: 'Word or Phrase',
placeholder: 'spam word',
},
thread: {
icon: <MessageSquareOff className="size-5" />,
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<MuteListItem['type']>('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 (
<div>
{/* Add mute section */}
<div className="px-3 py-4 space-y-3 border-b border-border">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="mute-type" className="text-xs font-medium">Type</Label>
<Select value={newMuteType} onValueChange={(value) => setNewMuteType(value as MuteListItem['type'])}>
<SelectTrigger id="mute-type" className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pubkey">
<div className="flex items-center gap-2">
<UserX className="h-4 w-4" />
User
</div>
</SelectItem>
<SelectItem value="hashtag">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4" />
Hashtag
</div>
</SelectItem>
<SelectItem value="word">
<div className="flex items-center gap-2">
<MessageSquareOff className="h-4 w-4" />
Word/Phrase
</div>
</SelectItem>
<SelectItem value="thread">
<div className="flex items-center gap-2">
<MessageSquareOff className="h-4 w-4" />
Thread
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mute-value" className="text-xs font-medium">
{MUTE_TYPE_CONFIG[newMuteType].inputLabel}
</Label>
<Input
id="mute-value"
value={newMuteValue}
onChange={(e) => setNewMuteValue(e.target.value)}
placeholder={MUTE_TYPE_CONFIG[newMuteType].placeholder}
className="h-9"
/>
</div>
</div>
<Button
onClick={handleAddMute}
disabled={addMute.isPending}
size="sm"
className="w-full sm:w-auto"
>
<Plus className="mr-2 h-4 w-4" />
Add Mute
</Button>
</div>
{/* Muted items list */}
{isLoading ? (
<div className="space-y-2 px-3 py-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : muteItems.length === 0 ? (
<p className="text-muted-foreground text-center py-8 text-sm">
No muted items yet
</p>
) : (
<>
{Object.entries(groupedMutes).map(([type, items]) => {
if (items.length === 0) return null;
const config = MUTE_TYPE_CONFIG[type as MuteListItem['type']];
return (
<MuteTypeSection
key={type}
type={type as MuteListItem['type']}
config={config}
items={items}
onRemove={handleRemoveMute}
isPending={removeMute.isPending}
/>
);
})}
</>
)}
</div>
);
}
/** 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 (
<div className="flex items-center gap-2.5 min-w-0">
<Skeleton className="size-7 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-24" />
</div>
);
}
return (
<div className="flex items-center gap-2.5 min-w-0">
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
<span className="text-sm truncate">{displayName}</span>
</div>
);
}
/** 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 (
<Link
to={`/${nevent}`}
className="flex items-center gap-1.5 text-xs font-mono text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3 shrink-0" />
<span className="truncate">{shortId}</span>
</Link>
);
}
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 (
<div className="border-b border-border last:border-b-0">
<div className="flex items-center gap-3 px-3 py-3.5">
<span className="text-muted-foreground shrink-0">{config.icon}</span>
<div className="min-w-0">
<span className="text-sm font-medium">{config.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
{items.length} {items.length === 1 ? 'item' : 'items'} {config.description}
</p>
</div>
</div>
<div className="divide-y divide-border">
{items.map((item, index) => (
<div
key={`${item.type}-${item.value}-${index}`}
className="flex items-center justify-between py-2.5 px-3 pl-12 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === 'pubkey' ? (
<MutedUserProfile pubkey={item.value} />
) : item.type === 'thread' ? (
<MutedThreadLink eventId={item.value} />
) : (
<code className="text-xs truncate font-mono bg-muted px-2 py-1 rounded">
{item.value}
</code>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(item)}
disabled={isPending}
className="shrink-0 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
</div>
);
}
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 (
<div className="px-3 pb-4">
<div className="flex items-center justify-between py-3.5">
<div className="min-w-0 flex-1">
<Label className="text-sm font-medium flex items-center gap-2">
<Home className="size-4" />
Homepage
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Choose which page to display when you open the app
</p>
</div>
<Select value={config.homePage} onValueChange={handleHomePageChange}>
<SelectTrigger className="w-[160px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIDEBAR_ITEMS.map((item) => (
<SelectItem key={item.id} value={item.id}>
<span className="flex items-center gap-2">
{item.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="h-px bg-border" />
</div>
);
}