900 lines
31 KiB
TypeScript
900 lines
31 KiB
TypeScript
import { useState } from 'react';
|
|
import { IntroImage } from '@/components/IntroImage';
|
|
import { ChevronDown, ChevronUp, Users, Download, Loader2, X } 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
export function ContentSettings() {
|
|
const [notesOpen, setNotesOpen] = useState(true);
|
|
const [otherStuffOpen, setOtherStuffOpen] = useState(true);
|
|
const [feedTabsOpen, setFeedTabsOpen] = useState(false);
|
|
|
|
return (
|
|
<div>
|
|
{/* Intro */}
|
|
<div className="px-3 pt-2 pb-4">
|
|
<h2 className="text-sm font-semibold">What You See</h2>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Customize your feed, choose what content appears, and control what you want to hide.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Feed Tabs Section */}
|
|
<div>
|
|
<Collapsible open={feedTabsOpen} onOpenChange={setFeedTabsOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">Feed Tabs</span>
|
|
{feedTabsOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="pb-4">
|
|
<FeedTabsSection />
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* Notes Section */}
|
|
<div>
|
|
<Collapsible open={notesOpen} onOpenChange={setNotesOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">Notes</span>
|
|
{notesOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="pb-4">
|
|
<div className="px-3 pt-3 pb-4">
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
Core content types that appear in your feed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Column headers */}
|
|
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
|
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
|
</div>
|
|
|
|
<NotesFeedSettings />
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* Other Stuff Section */}
|
|
<div>
|
|
<Collapsible open={otherStuffOpen} onOpenChange={setOtherStuffOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">Other Stuff</span>
|
|
{otherStuffOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="pb-4">
|
|
{/* Intro section for Other Stuff */}
|
|
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
|
<IntroImage src="/feed-intro.png" />
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold">Other Stuff</h3>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Column headers */}
|
|
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
|
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
|
</div>
|
|
|
|
{/* Content type rows - reuse the internals from FeedSettingsForm */}
|
|
<FeedSettingsFormInternals />
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Import the internals from FeedSettingsForm (we'll need to export them)
|
|
import { Palette } from 'lucide-react';
|
|
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
|
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
|
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
|
|
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
|
|
|
function KindBadge({ kind }: { kind: number }) {
|
|
return (
|
|
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
|
|
[{kind}]
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SubKindRow({ sub }: { sub: SubKindDef }) {
|
|
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
|
const { updateSettings } = useEncryptedSettings();
|
|
const { user } = useCurrentUser();
|
|
|
|
const handleToggle = async (key: string, value: boolean) => {
|
|
updateFeedSettings({ [key]: value });
|
|
if (user) {
|
|
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
|
|
<div className="min-w-0">
|
|
<span className="text-sm">{sub.label}</span>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
<KindBadge kind={sub.kind} />{' '}{sub.description}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="w-[52px] flex justify-center">
|
|
<Switch
|
|
checked={feedSettings[sub.feedKey]}
|
|
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
|
|
className="scale-90"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
|
|
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
|
const { updateSettings } = useEncryptedSettings();
|
|
const { user } = useCurrentUser();
|
|
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
|
|
const icon = <IconComponent className="size-5" />;
|
|
const hasSubKinds = !!def.subKinds;
|
|
|
|
const handleToggle = async (key: string, value: boolean) => {
|
|
updateFeedSettings({ [key]: value });
|
|
if (user) {
|
|
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border-b border-border last:border-b-0">
|
|
<div className="flex items-center justify-between py-3.5 px-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span className="text-muted-foreground shrink-0">{icon}</span>
|
|
<div className="min-w-0">
|
|
<span className="text-sm font-medium">{def.label}</span>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
<KindBadge kind={def.kind} />{' '}{def.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="w-[52px] flex justify-center">
|
|
{!hasSubKinds && def.feedKey ? (
|
|
<Switch
|
|
checked={feedSettings[def.feedKey]}
|
|
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
|
|
/>
|
|
) : !hasSubKinds && def.feedOnly && def.showKey ? (
|
|
<Switch
|
|
checked={feedSettings[def.showKey] !== false}
|
|
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
|
|
<SubKindRow
|
|
key={sub.showKey}
|
|
sub={sub}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotesFeedSettings() {
|
|
return (
|
|
<>
|
|
{FEED_KINDS.map((def) => (
|
|
<ContentTypeRow key={def.feedKey ?? String(def.kind)} def={def} />
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function FeedSettingsFormInternals() {
|
|
return (
|
|
<>
|
|
{SECTION_ORDER.map((section) => {
|
|
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
|
|
if (sectionKinds.length === 0) return null;
|
|
return (
|
|
<div key={section}>
|
|
<div className="px-3 pt-4 pb-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{SECTION_LABELS[section]}
|
|
</span>
|
|
</div>
|
|
{sectionKinds.map((def) => (
|
|
<ContentTypeRow key={def.feedKey ?? def.showKey ?? String(def.kind)} def={def} />
|
|
))}
|
|
</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 [showGlobalFeed, setShowGlobalFeed] = useState(() => {
|
|
const stored = localStorage.getItem('ditto:showGlobalFeed');
|
|
return stored !== null ? stored === 'true' : true; // Default to true
|
|
});
|
|
|
|
const [showCommunityFeed, setShowCommunityFeed] = useState(() => {
|
|
const stored = localStorage.getItem('ditto:showCommunityFeed');
|
|
return stored !== null ? stored === 'true' : false; // Default to false
|
|
});
|
|
|
|
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="flex items-center gap-4 px-3 pt-3 pb-4">
|
|
<IntroImage src="/community-intro.png" />
|
|
<div className="min-w-0">
|
|
<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>
|
|
</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">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="ditto.pub"
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sensitive content settings section
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import type { ContentWarningPolicy } from '@/contexts/AppContext';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { ShieldAlert } from 'lucide-react';
|
|
|
|
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="flex items-center gap-4 px-3 pt-3 pb-4">
|
|
<div className="w-40 shrink-0 flex items-center justify-center">
|
|
<ShieldAlert className="size-16 text-muted-foreground/40" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold">Content Warnings</h3>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
|
|
</p>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// Mute settings internals (without the intro/image)
|
|
import { Trash2, Plus, UserX, Hash, MessageSquareOff } from 'lucide-react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export function ThemePreferencesSection() {
|
|
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
|
const { updateSettings } = useEncryptedSettings();
|
|
const { user } = useCurrentUser();
|
|
|
|
const showOnProfiles = feedSettings.showCustomProfileThemes !== false;
|
|
|
|
const handleProfileThemeToggle = async (value: boolean) => {
|
|
updateFeedSettings({ showCustomProfileThemes: value });
|
|
if (user) {
|
|
const updatedFeedSettings = { ...feedSettings, showCustomProfileThemes: value };
|
|
await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-sm font-medium">Show custom profile themes</Label>
|
|
<p className="text-xs text-muted-foreground">Display other users' custom themes when visiting their profiles</p>
|
|
</div>
|
|
<Switch
|
|
checked={showOnProfiles}
|
|
onCheckedChange={handleProfileThemeToggle}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|