5920523b57
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.
1169 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|