Merge branch 'main' of gitlab.com:soapbox-pub/ditto
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
|
||||
---
|
||||
|
||||
@@ -154,3 +155,59 @@ Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
|
||||
- At most one `bg` tag is allowed per event.
|
||||
- Clients MAY choose not to render video backgrounds for performance or bandwidth reasons.
|
||||
- Unknown keys SHOULD be ignored for forward compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Kind 16769: Profile Tabs
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event kind for publishing a user's custom profile page tabs. Exactly one event per user (no `d` tag). Each tab is a saved search feed scoped to the author's pubkey.
|
||||
|
||||
Visitors who load a profile fetch this event to display the custom tabs alongside the standard Posts / Media / Likes / Wall tabs.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 16769,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["tab", "<label>", "<filtersJSON>"],
|
||||
["tab", "<label>", "<filtersJSON>"],
|
||||
["alt", "Custom profile tabs"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Values | Description |
|
||||
|---------|----------------------|----------------------------------------------------------|
|
||||
| `tab` | `label, filtersJSON` | One tag per custom tab. Order defines display order. |
|
||||
| `alt` | `"Custom profile tabs"` | NIP-31 human-readable fallback. Required. |
|
||||
|
||||
### Tab Filters JSON
|
||||
|
||||
The third value of each `tab` tag is a JSON-encoded object matching the `SavedFeedFilters` schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "bitcoin",
|
||||
"mediaType": "all",
|
||||
"language": "global",
|
||||
"platform": "nostr",
|
||||
"kindFilter": "all",
|
||||
"customKindText": "",
|
||||
"authorScope": "people",
|
||||
"authorPubkeys": ["<hex-pubkey>"],
|
||||
"sort": "recent"
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- To **add or update** tabs: publish a new kind 16769 event with all current `tab` tags.
|
||||
- To **clear** all tabs: publish a kind 16769 event with no `tab` tags (only `alt`).
|
||||
- Clients MUST filter by `authors: [pubkey]` when querying to prevent spoofing.
|
||||
- The `authorPubkeys` field inside filters SHOULD always include the profile owner's pubkey so tabs show the owner's own posts.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { ChevronDown, ChevronUp, Users, Download, Loader2, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Users, Download, Loader2, X, Pencil, Check, Home, User, Globe, Clock, Flame, TrendingUp } 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';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { SavedFeedFiltersEditor, buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SavedFeed, SavedFeedFilters } from '@/contexts/AppContext';
|
||||
|
||||
export function ContentSettings() {
|
||||
const [notesOpen, setNotesOpen] = useState(true);
|
||||
@@ -553,6 +557,211 @@ function FeedTabsSection() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved Feeds */}
|
||||
<SavedFeedsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Saved Feeds Section ─────────────────────────────────────────────────────
|
||||
|
||||
function SavedFeedsSection() {
|
||||
const { savedFeeds, removeSavedFeed, updateSavedFeed, isPending } = useSavedFeeds();
|
||||
const { toast } = useToast();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const feedFeeds = savedFeeds.filter((f) => f.destination === 'feed');
|
||||
const profileFeeds = savedFeeds.filter((f) => f.destination === 'profile');
|
||||
|
||||
if (savedFeeds.length === 0) return null;
|
||||
|
||||
const handleSave = async (id: string, label: string, filters: SavedFeedFilters) => {
|
||||
await updateSavedFeed(id, { label, filters });
|
||||
toast({ title: 'Feed updated' });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleRemove = async (feed: SavedFeed) => {
|
||||
await removeSavedFeed(feed.id);
|
||||
toast({ title: `"${feed.label}" removed` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-4 space-y-4 border-t border-border">
|
||||
<h3 className="text-sm font-semibold">Saved Feeds</h3>
|
||||
|
||||
{feedFeeds.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Home className="size-3.5" />
|
||||
Home feed tabs
|
||||
</p>
|
||||
{feedFeeds.map((feed) => (
|
||||
<SavedFeedRow
|
||||
key={feed.id}
|
||||
feed={feed}
|
||||
isEditing={editingId === feed.id}
|
||||
onEdit={() => setEditingId(feed.id)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onSave={(label, filters) => handleSave(feed.id, label, filters)}
|
||||
onRemove={() => handleRemove(feed)}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileFeeds.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<User className="size-3.5" />
|
||||
Profile tabs
|
||||
</p>
|
||||
{profileFeeds.map((feed) => (
|
||||
<SavedFeedRow
|
||||
key={feed.id}
|
||||
feed={feed}
|
||||
isEditing={editingId === feed.id}
|
||||
onEdit={() => setEditingId(feed.id)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onSave={(label, filters) => handleSave(feed.id, label, filters)}
|
||||
onRemove={() => handleRemove(feed)}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedFeedRow({
|
||||
feed,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
onRemove,
|
||||
isPending,
|
||||
}: {
|
||||
feed: SavedFeed;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: (label: string, filters: SavedFeedFilters) => void;
|
||||
onRemove: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [labelValue, setLabelValue] = useState(feed.label);
|
||||
const [filtersValue, setFiltersValue] = useState<SavedFeedFilters>(feed.filters);
|
||||
const kindOptions = useMemo(() => buildKindOptions(), []);
|
||||
|
||||
// Reset local state when this row starts editing
|
||||
const handleEdit = () => {
|
||||
setLabelValue(feed.label);
|
||||
setFiltersValue(feed.filters);
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const SortIcon = feed.filters.sort === 'hot' ? Flame : feed.filters.sort === 'trending' ? TrendingUp : Clock;
|
||||
const scopeLabel = feed.filters.authorScope === 'follows'
|
||||
? 'Follows'
|
||||
: feed.filters.authorScope === 'people' && feed.filters.authorPubkeys.length > 0
|
||||
? `${feed.filters.authorPubkeys.length} author${feed.filters.authorPubkeys.length > 1 ? 's' : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border bg-secondary/30 overflow-hidden transition-colors', isEditing ? 'border-border' : 'border-border/50 group')}>
|
||||
{/* Summary row — always visible */}
|
||||
<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">
|
||||
{feed.filters.query && (
|
||||
<span className="text-[10px] text-muted-foreground bg-secondary rounded px-1 py-0.5 truncate max-w-[140px]">"{feed.filters.query}"</span>
|
||||
)}
|
||||
{scopeLabel && (
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||
<Globe className="size-2.5" />{scopeLabel}
|
||||
</span>
|
||||
)}
|
||||
{feed.filters.sort !== 'recent' && (
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||
<SortIcon className="size-2.5" />{feed.filters.sort}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={cn('flex items-center gap-0.5 shrink-0', !isEditing && 'opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||
<button
|
||||
onClick={isEditing ? onCancel : handleEdit}
|
||||
className={cn(
|
||||
'size-7 flex items-center justify-center rounded transition-colors',
|
||||
isEditing
|
||||
? 'text-muted-foreground hover:bg-secondary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
|
||||
)}
|
||||
aria-label={isEditing ? 'Cancel edit' : 'Edit'}
|
||||
>
|
||||
{isEditing ? <X className="size-3.5" /> : <Pencil className="size-3.5" />}
|
||||
</button>
|
||||
{!isEditing && (
|
||||
<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>
|
||||
|
||||
{/* Inline edit panel */}
|
||||
{isEditing && (
|
||||
<div className="px-2.5 pb-3 pt-2 border-t border-border/50 space-y-3">
|
||||
{/* Tab name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Tab name</label>
|
||||
<Input
|
||||
value={labelValue}
|
||||
onChange={(e) => setLabelValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
|
||||
className="h-8 text-sm bg-background"
|
||||
placeholder="Tab name…"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Full filter editor */}
|
||||
<SavedFeedFiltersEditor
|
||||
value={filtersValue}
|
||||
onChange={(patch) => setFiltersValue((prev) => ({ ...prev, ...patch }))}
|
||||
kindOptions={kindOptions}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-1.5 pt-1">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="h-7 px-2.5 rounded text-xs text-muted-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(labelValue, filtersValue)}
|
||||
disabled={isPending || !labelValue.trim()}
|
||||
className="h-7 px-2.5 rounded text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -958,7 +1167,6 @@ export function ThemePreferencesSection() {
|
||||
}
|
||||
|
||||
// Homepage setting section
|
||||
import { Home } from 'lucide-react';
|
||||
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
|
||||
function HomePageSetting() {
|
||||
|
||||
@@ -345,7 +345,7 @@ function TabButton({ label, active, onClick }: { label: string; active: boolean;
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'shrink-0 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
|
||||
'flex-1 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* ProfileTabEditModal
|
||||
*
|
||||
* Modal for adding or editing a custom profile tab (kind 16769).
|
||||
* Opens with an optional existing tab to edit; otherwise creates a new one.
|
||||
*/
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Loader2, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SavedFeedFiltersEditor, buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ProfileTab } from '@/lib/profileTabsEvent';
|
||||
import type { SavedFeedFilters } from '@/contexts/AppContext';
|
||||
|
||||
const DEFAULT_FILTERS: SavedFeedFilters = {
|
||||
query: '',
|
||||
mediaType: 'all',
|
||||
language: 'global',
|
||||
platform: 'nostr',
|
||||
kindFilter: 'all',
|
||||
customKindText: '',
|
||||
authorScope: 'anyone',
|
||||
authorPubkeys: [],
|
||||
sort: 'recent',
|
||||
};
|
||||
|
||||
interface ProfileTabEditModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Existing tab to edit. If undefined, creates a new tab. */
|
||||
tab?: ProfileTab;
|
||||
/** The profile owner's pubkey — used to pre-populate authorPubkeys when scope is 'people'. */
|
||||
ownerPubkey: string;
|
||||
/** Called with the resulting tab on save. */
|
||||
onSave: (tab: ProfileTab) => Promise<void>;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileTabEditModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
tab,
|
||||
ownerPubkey,
|
||||
onSave,
|
||||
isPending = false,
|
||||
}: ProfileTabEditModalProps) {
|
||||
const kindOptions = useMemo(() => buildKindOptions(), []);
|
||||
const isNew = !tab;
|
||||
|
||||
const initialFilters = useMemo<SavedFeedFilters>(() => {
|
||||
if (tab) return tab.filters;
|
||||
// New tab: default to showing the owner's own posts
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
authorScope: 'people',
|
||||
authorPubkeys: [ownerPubkey],
|
||||
};
|
||||
}, [tab, ownerPubkey]);
|
||||
|
||||
const [label, setLabel] = useState(tab?.label ?? '');
|
||||
const [filters, setFilters] = useState<SavedFeedFilters>(initialFilters);
|
||||
|
||||
// Reset state when modal opens
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
if (o) {
|
||||
setLabel(tab?.label ?? '');
|
||||
setFilters(initialFilters);
|
||||
}
|
||||
onOpenChange(o);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!label.trim() || isPending) return;
|
||||
await onSave({ label: label.trim(), filters });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-sm max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isNew ? 'Add profile tab' : 'Edit tab'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-1">
|
||||
{/* Tab name */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tab name</span>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
|
||||
placeholder="e.g. My Art, Bitcoin posts…"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SavedFeedFiltersEditor
|
||||
value={filters}
|
||||
onChange={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||
kindOptions={kindOptions}
|
||||
hideFrom
|
||||
hideSort
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 pt-2 sm:flex-col">
|
||||
<Button className="w-full gap-2" onClick={handleSave} disabled={!label.trim() || isPending}>
|
||||
{isPending
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <Check className="size-4" />}
|
||||
{isNew ? 'Add tab' : 'Save changes'}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* ProfileTabsManagerModal
|
||||
*
|
||||
* Sheet-style modal for managing custom profile tabs:
|
||||
* - Drag to reorder (dnd-kit)
|
||||
* - Remove individual tabs
|
||||
* - Edit a tab (opens ProfileTabEditModal)
|
||||
* - Add a custom tab
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Pencil, Trash2, Plus, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProfileTab } from '@/lib/profileTabsEvent';
|
||||
|
||||
interface ProfileTabsManagerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tabs: ProfileTab[];
|
||||
ownerPubkey: string;
|
||||
onSave: (tabs: ProfileTab[]) => Promise<void>;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileTabsManagerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
tabs,
|
||||
ownerPubkey,
|
||||
onSave,
|
||||
isPending = false,
|
||||
}: ProfileTabsManagerModalProps) {
|
||||
const [localTabs, setLocalTabs] = useState<ProfileTab[]>(tabs);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingTab, setEditingTab] = useState<ProfileTab | undefined>(undefined);
|
||||
|
||||
// Sync from parent when modal opens
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
if (o) setLocalTabs(tabs);
|
||||
onOpenChange(o);
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setLocalTabs((prev) => {
|
||||
const oldIndex = prev.findIndex((t) => t.label === active.id);
|
||||
const newIndex = prev.findIndex((t) => t.label === over.id);
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (label: string) => {
|
||||
setLocalTabs((prev) => prev.filter((t) => t.label !== label));
|
||||
};
|
||||
|
||||
const handleEditTab = (tab: ProfileTab) => {
|
||||
setEditingTab(tab);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddCustom = () => {
|
||||
setEditingTab(undefined);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleTabSaved = async (tab: ProfileTab) => {
|
||||
setLocalTabs((prev) => {
|
||||
if (editingTab) {
|
||||
return prev.map((t) => t.label === editingTab.label ? tab : t);
|
||||
}
|
||||
return [...prev, tab];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
await onSave(localTabs);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage profile tabs</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1 py-1">
|
||||
{localTabs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No custom tabs yet.</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={localTabs.map((t) => t.label)} strategy={verticalListSortingStrategy}>
|
||||
{localTabs.map((tab) => (
|
||||
<SortableTabRow
|
||||
key={tab.label}
|
||||
tab={tab}
|
||||
onEdit={() => handleEditTab(tab)}
|
||||
onRemove={() => handleRemove(tab.label)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAddCustom}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<Plus className="size-4 shrink-0" />
|
||||
Add custom tab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full gap-2 mt-2"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Nested edit modal */}
|
||||
<ProfileTabEditModal
|
||||
open={editModalOpen}
|
||||
onOpenChange={setEditModalOpen}
|
||||
tab={editingTab}
|
||||
ownerPubkey={ownerPubkey}
|
||||
onSave={handleTabSaved}
|
||||
isPending={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTabRow({
|
||||
tab,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tab: ProfileTab;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.label });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-2 rounded-lg bg-secondary/20 border border-border/50',
|
||||
isDragging && 'opacity-50 shadow-lg z-50',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground touch-none"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
|
||||
<span className="flex-1 text-sm font-medium truncate">{tab.label}</span>
|
||||
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="shrink-0 size-7 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
aria-label={`Edit ${tab.label}`}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="shrink-0 size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
aria-label={`Remove ${tab.label}`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* SavedFeedFiltersEditor
|
||||
*
|
||||
* A controlled component that renders the full set of search/feed filter
|
||||
* controls (query, author scope, sort, media, platform, language, kind).
|
||||
* Used both on the Search page filter popover and in the Settings > Feed
|
||||
* saved-feed edit panel.
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Globe, Users, UserSearch,
|
||||
Clock, Flame, TrendingUp,
|
||||
ChevronDown, ChevronUp,
|
||||
Hash, Search as SearchIcon,
|
||||
X, Check, Info, User,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SavedFeedFilters } from '@/contexts/AppContext';
|
||||
import type { SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type KindOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parentId: string;
|
||||
icon: React.ComponentType<{ className?: string }> | undefined;
|
||||
};
|
||||
|
||||
// ─── Kind options (built once) ───────────────────────────────────────────────
|
||||
|
||||
export function buildKindOptions(): KindOption[] {
|
||||
const options: KindOption[] = [];
|
||||
for (const def of EXTRA_KINDS) {
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
options.push({
|
||||
value: String(sub.kind),
|
||||
label: `${sub.label} (${sub.kind})`,
|
||||
description: sub.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
options.push({
|
||||
value: String(def.kind),
|
||||
label: `${def.label} (${def.kind})`,
|
||||
description: def.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return options.filter((o) => {
|
||||
if (seen.has(o.value)) return false;
|
||||
seen.add(o.value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── useScrollCarets ─────────────────────────────────────────────────────────
|
||||
|
||||
export function useScrollCarets() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const [canScrollUp, setCanScrollUp] = useState(false);
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollUp(el.scrollTop > 0);
|
||||
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
|
||||
}, []);
|
||||
|
||||
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
update();
|
||||
}, [update]);
|
||||
|
||||
const stopScroll = useCallback(() => {
|
||||
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const startScroll = useCallback((direction: 'up' | 'down') => {
|
||||
stopScroll();
|
||||
intervalRef.current = setInterval(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return stopScroll();
|
||||
el.scrollBy({ top: direction === 'up' ? -8 : 8 });
|
||||
update();
|
||||
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
if (atLimit) stopScroll();
|
||||
}, 16);
|
||||
}, [update, stopScroll]);
|
||||
|
||||
useEffect(() => stopScroll, [stopScroll]);
|
||||
|
||||
return { refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
|
||||
}
|
||||
|
||||
// ─── KindPicker ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function KindScrollCaret({ direction, onMouseEnter, onMouseLeave }: {
|
||||
direction: 'up' | 'down';
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex cursor-default items-center justify-center py-0.5 w-full shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{direction === 'up' ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function KindPickerItem({ icon: Icon, label, active, onClick }: {
|
||||
icon: React.ComponentType<{ className?: string }> | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2.5 py-1.5 text-xs transition-colors text-left',
|
||||
active ? 'bg-primary/10 text-primary' : 'hover:bg-secondary/60 text-foreground',
|
||||
)}
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{label}</span>
|
||||
{active && <Check className="size-3 shrink-0 ml-auto text-primary" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function KindPicker({ value, options, onChange }: {
|
||||
value: string;
|
||||
options: KindOption[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const { refCallback, canScrollUp, canScrollDown, onScroll, startScroll, stopScroll } = useScrollCarets();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return options;
|
||||
return options.filter(
|
||||
(o) => o.label.toLowerCase().includes(q) || o.description.toLowerCase().includes(q) || o.value.includes(q),
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
const SelectedIcon = selected?.icon;
|
||||
|
||||
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full h-8 px-2.5 rounded-md border bg-secondary/50 text-xs flex items-center gap-1.5 text-left transition-colors hover:bg-secondary border-border',
|
||||
open && 'border-ring ring-1 ring-ring',
|
||||
)}
|
||||
>
|
||||
{SelectedIcon
|
||||
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="flex-1 truncate">
|
||||
{value === 'all' ? 'All' : value === 'custom' ? 'Custom…' : (selected?.label ?? value)}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
className="w-56 p-0 flex flex-col overflow-hidden"
|
||||
style={{ maxHeight: 'min(280px, var(--radix-popover-content-available-height, 280px))' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-border shrink-0">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Search kinds…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
|
||||
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
|
||||
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
|
||||
{filtered.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
))}
|
||||
{(!search || 'custom'.includes(search.toLowerCase())) && (
|
||||
<KindPickerItem icon={Hash} label="Custom kind…" active={value === 'custom'} onClick={() => handleSelect('custom')} />
|
||||
)}
|
||||
{filtered.length === 0 && search && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No kinds match</p>
|
||||
)}
|
||||
</div>
|
||||
{canScrollDown && <KindScrollCaret direction="down" onMouseEnter={() => startScroll('down')} onMouseLeave={stopScroll} />}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AuthorChip ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function AuthorChip({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
|
||||
const hexPubkey = useMemo(() => {
|
||||
if (/^[0-9a-f]{64}$/i.test(pubkey)) return pubkey;
|
||||
try { const d = nip19.decode(pubkey); return d.type === 'npub' ? d.data : pubkey; } catch { return pubkey; }
|
||||
}, [pubkey]);
|
||||
const author = useAuthor(hexPubkey);
|
||||
const name = author.data?.metadata?.display_name || author.data?.metadata?.name || pubkey.slice(0, 10) + '…';
|
||||
const picture = author.data?.metadata?.picture;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 pl-1.5 pr-1 py-0.5 rounded-full bg-secondary border border-border text-xs max-w-[160px]">
|
||||
{picture
|
||||
? <img src={picture} alt="" className="size-4 rounded-full shrink-0 object-cover" />
|
||||
: <User className="size-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate">{name}</span>
|
||||
<button onClick={onRemove} className="shrink-0 text-muted-foreground hover:text-foreground transition-colors" aria-label="Remove">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AuthorFilterDropdown ─────────────────────────────────────────────────────
|
||||
|
||||
export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string, _label: string) => void }) {
|
||||
const handleSelect = useCallback((profile: SearchProfile) => {
|
||||
const npub = nip19.npubEncode(profile.pubkey);
|
||||
const label = profile.metadata.display_name || profile.metadata.name || npub.slice(0, 16) + '…';
|
||||
onCommit(npub, label);
|
||||
}, [onCommit]);
|
||||
|
||||
return (
|
||||
<ProfileSearchDropdown
|
||||
placeholder="Search by name or npub…"
|
||||
onSelect={handleSelect}
|
||||
hideCountry
|
||||
inputClassName="rounded-lg bg-secondary/50 border border-border focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 text-sm h-9"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SavedFeedFiltersEditor ───────────────────────────────────────────────────
|
||||
|
||||
interface SavedFeedFiltersEditorProps {
|
||||
/** Current filter values */
|
||||
value: SavedFeedFilters;
|
||||
/** Called on every field change with a partial update merged into value */
|
||||
onChange: (patch: Partial<SavedFeedFilters>) => void;
|
||||
/** When true, the query input is shown at the top (default: true) */
|
||||
showQuery?: boolean;
|
||||
/** Hide the From / author scope section (e.g. profile tabs where author is implicit) */
|
||||
hideFrom?: boolean;
|
||||
/** Hide the Sort section */
|
||||
hideSort?: boolean;
|
||||
/** Optional: pre-built kind options (pass to avoid rebuilding) */
|
||||
kindOptions?: KindOption[];
|
||||
}
|
||||
|
||||
export function SavedFeedFiltersEditor({
|
||||
value,
|
||||
onChange,
|
||||
showQuery = true,
|
||||
hideFrom = false,
|
||||
hideSort = false,
|
||||
kindOptions: kindOptionsProp,
|
||||
}: SavedFeedFiltersEditorProps) {
|
||||
const kindOptions = useMemo(() => kindOptionsProp ?? buildKindOptions(), [kindOptionsProp]);
|
||||
|
||||
const { authorScope, authorPubkeys, sort, mediaType, platform, language, kindFilter, customKindText, query } = value;
|
||||
|
||||
const hasKindMediaConflict = kindFilter !== 'all' && kindFilter !== 'custom' && mediaType !== 'all';
|
||||
|
||||
const addAuthor = useCallback((pubkey: string, _label: string) => {
|
||||
const next = authorPubkeys.includes(pubkey) ? authorPubkeys : [...authorPubkeys, pubkey];
|
||||
onChange({ authorPubkeys: next, authorScope: 'people' });
|
||||
}, [authorPubkeys, onChange]);
|
||||
|
||||
const removeAuthor = useCallback((pubkey: string) => {
|
||||
const next = authorPubkeys.filter((p) => p !== pubkey);
|
||||
onChange({ authorPubkeys: next, authorScope: next.length > 0 ? 'people' : 'anyone' });
|
||||
}, [authorPubkeys, onChange]);
|
||||
|
||||
const setAuthorScope = useCallback((scope: SavedFeedFilters['authorScope']) => {
|
||||
onChange({ authorScope: scope, ...(scope !== 'people' ? { authorPubkeys: [] } : {}) });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Query */}
|
||||
{showQuery && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Search query</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => onChange({ query: e.target.value })}
|
||||
placeholder="e.g. bitcoin"
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Author scope */}
|
||||
{!hideFrom && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['anyone', 'Anyone', Globe],
|
||||
['follows', 'Follows', Users],
|
||||
['people', 'People', UserSearch],
|
||||
] as const).map(([scope, label, Icon]) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setAuthorScope(scope)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
authorScope === scope
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{authorScope === 'people' && (
|
||||
<div className="space-y-1.5">
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authorPubkeys.map((pk) => (
|
||||
<AuthorChip key={pk} pubkey={pk} onRemove={() => removeAuthor(pk)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AuthorFilterDropdown onCommit={addAuthor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
{!hideSort && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sort</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['recent', 'Recent', Clock],
|
||||
['hot', 'Hot', Flame],
|
||||
['trending', 'Trending', TrendingUp],
|
||||
] as const).map(([s, label, Icon]) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange({ sort: s })}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
sort === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Media + Platform */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => onChange({ mediaType: v as SavedFeedFilters['mediaType'] })}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Platform</span>
|
||||
<Select value={platform} onValueChange={(v) => onChange({ platform: v as SavedFeedFilters['platform'] })}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language + Kind */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Language</span>
|
||||
<Select value={language} onValueChange={(v) => onChange({ language: v })}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => onChange({ kindFilter: v, ...(v !== 'custom' ? { customKindText: '' } : {}) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => onChange({ customKindText: e.target.value })}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasKindMediaConflict && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-start gap-1.5">
|
||||
<Info className="size-3.5 shrink-0 mt-0.5" />
|
||||
Media + Kind filters may conflict. Kind takes precedence.
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ export interface ProfileSupplementary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch follow list (kind 3) and pinned notes list (kind 10001) for a pubkey.
|
||||
* Separated from kind 0 (handled by useAuthor) because kind 3 can be very
|
||||
* large and would otherwise block the profile header from rendering.
|
||||
* Fetch follow list (kind 3) and pinned notes (kind 10001) for a pubkey.
|
||||
* Profile tabs (kind 16769) are fetched separately by useProfileTabs to
|
||||
* avoid stale-seed race conditions with usePublishProfileTabs.
|
||||
*/
|
||||
export function useProfileSupplementary(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
@@ -53,7 +53,7 @@ export function filterByTab(items: FeedItem[], tab: ProfileTab): FeedItem[] {
|
||||
* Infinite-scroll hook for profile posts/replies/media.
|
||||
* Fetches paginated events for a given pubkey and tab.
|
||||
*/
|
||||
export function useProfileFeed(pubkey: string | undefined) {
|
||||
export function useProfileFeed(pubkey: string | undefined, enabled = true) {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
@@ -159,7 +159,7 @@ export function useProfileFeed(pubkey: string | undefined) {
|
||||
return lastPage.oldestQueryTimestamp - 1;
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
enabled: !!pubkey,
|
||||
enabled: !!pubkey && enabled,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PROFILE_TABS_KIND, parseProfileTabs } from '@/lib/profileTabsEvent';
|
||||
import type { ProfileTab } from '@/lib/profileTabsEvent';
|
||||
|
||||
/**
|
||||
* Fetch the kind 16769 profile tabs event for a given pubkey.
|
||||
* Returns an empty array if the user has published no tabs.
|
||||
*/
|
||||
export function useProfileTabs(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<ProfileTab[] | null>({
|
||||
queryKey: ['profile-tabs', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey) return null;
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [PROFILE_TABS_KIND], authors: [pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
if (events.length === 0) return null; // no event published yet
|
||||
return parseProfileTabs(events[0]); // event exists — may be empty array (all tabs deleted)
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { PROFILE_TABS_KIND, buildProfileTabsTags, type ProfileTab } from '@/lib/profileTabsEvent';
|
||||
|
||||
/**
|
||||
* Publish a kind 16769 profile tabs event, replacing any previous one.
|
||||
* Pass an empty array to clear all tabs.
|
||||
*/
|
||||
export function usePublishProfileTabs() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const publishProfileTabs = async (tabs: ProfileTab[]): Promise<void> => {
|
||||
if (!user) throw new Error('Must be logged in to publish profile tabs');
|
||||
|
||||
await createEvent({
|
||||
kind: PROFILE_TABS_KIND,
|
||||
content: '',
|
||||
tags: buildProfileTabsTags(tabs),
|
||||
});
|
||||
|
||||
// Invalidate both so ProfilePage refetches fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['profile-tabs', user.pubkey] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['profile-supplementary', user.pubkey] });
|
||||
};
|
||||
|
||||
return { publishProfileTabs, isPending };
|
||||
}
|
||||
@@ -50,12 +50,22 @@ export function useSavedFeeds() {
|
||||
await updateSettings.mutateAsync({ savedFeeds: updated });
|
||||
};
|
||||
|
||||
/** Update a saved feed's label and/or filters. */
|
||||
const updateSavedFeed = async (id: string, changes: Partial<Pick<SavedFeed, 'label' | 'filters'>>): Promise<void> => {
|
||||
if (!user) throw new Error('Must be logged in to update feeds');
|
||||
const updated = savedFeeds.map((f) =>
|
||||
f.id === id ? { ...f, ...changes, label: (changes.label ?? f.label).trim() } : f,
|
||||
);
|
||||
await updateSettings.mutateAsync({ savedFeeds: updated });
|
||||
};
|
||||
|
||||
return {
|
||||
savedFeeds,
|
||||
isLoading,
|
||||
addSavedFeed,
|
||||
removeSavedFeed,
|
||||
renameSavedFeed,
|
||||
updateSavedFeed,
|
||||
isPending: updateSettings.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Kind 16769 — Profile Tabs
|
||||
*
|
||||
* Replaceable event. One per user. Each tab is a `tab` tag:
|
||||
* ["tab", "<label>", "<filtersJSON>"]
|
||||
*
|
||||
* Order of tags defines display order.
|
||||
*/
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { SavedFeedFilters } from '@/contexts/AppContext';
|
||||
|
||||
export const PROFILE_TABS_KIND = 16769;
|
||||
|
||||
export interface ProfileTab {
|
||||
label: string;
|
||||
filters: SavedFeedFilters;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: SavedFeedFilters = {
|
||||
query: '',
|
||||
mediaType: 'all',
|
||||
language: 'global',
|
||||
platform: 'nostr',
|
||||
kindFilter: 'all',
|
||||
customKindText: '',
|
||||
authorScope: 'anyone',
|
||||
authorPubkeys: [],
|
||||
sort: 'recent',
|
||||
};
|
||||
|
||||
/** Parse a kind 16769 event into an array of ProfileTab. Returns [] on any error. */
|
||||
export function parseProfileTabs(event: NostrEvent): ProfileTab[] {
|
||||
if (event.kind !== PROFILE_TABS_KIND) return [];
|
||||
|
||||
const tabs: ProfileTab[] = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'tab' || tag.length < 3) continue;
|
||||
const label = tag[1];
|
||||
try {
|
||||
const raw = JSON.parse(tag[2]);
|
||||
const filters: SavedFeedFilters = { ...DEFAULT_FILTERS, ...raw };
|
||||
if (label) tabs.push({ label, filters });
|
||||
} catch {
|
||||
// skip malformed tab
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/** Build tags for a kind 16769 event from an array of ProfileTab. */
|
||||
export function buildProfileTabsTags(tabs: ProfileTab[]): string[][] {
|
||||
const tags: string[][] = [
|
||||
['alt', 'Custom profile tabs'],
|
||||
];
|
||||
for (const tab of tabs) {
|
||||
tags.push(['tab', tab.label, JSON.stringify(tab.filters)]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
+387
-39
@@ -5,12 +5,12 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Pencil, Trash2, Eye, EyeOff, RefreshCw, MessageSquare, Globe, Mail } from 'lucide-react';
|
||||
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Pencil, Trash2, Eye, EyeOff, RefreshCw, MessageSquare, Globe, Mail, Plus, GripVertical, SlidersHorizontal } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -33,7 +33,7 @@ import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed';
|
||||
import type { ProfileTab } from '@/hooks/useProfileFeed';
|
||||
import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed';
|
||||
import { useProfileMedia } from '@/hooks/useProfileMedia';
|
||||
import { MediaGrid, MediaGridSkeleton } from '@/components/MediaGrid';
|
||||
import { useProfileSupplementary } from '@/hooks/useProfileData';
|
||||
@@ -57,8 +57,21 @@ import { useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useProfileTabs } from '@/hooks/useProfileTabs';
|
||||
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import type { ProfileTab as CustomProfileTab } from '@/lib/profileTabsEvent';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable,
|
||||
horizontalListSortingStrategy, arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
||||
import { buildThemeCssFromCore, coreToTokens, buildThemeCss, resolveTheme, resolveThemeConfig, toThemeVar, type CoreThemeColors, type ThemeConfig, type ThemeFont, type ThemeBackground } from '@/themes';
|
||||
import { loadAndApplyFont } from '@/lib/fontLoader';
|
||||
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
|
||||
@@ -341,7 +354,7 @@ function TabButton({ label, active, onClick }: { label: string; active: boolean;
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'shrink-0 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
|
||||
'flex-1 px-4 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 whitespace-nowrap',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -353,6 +366,66 @@ function TabButton({ label, active, onClick }: { label: string; active: boolean;
|
||||
);
|
||||
}
|
||||
|
||||
type EditableTab = { label: string; isCore: boolean; tab?: CustomProfileTab };
|
||||
|
||||
function SortableTabChip({
|
||||
tab, active, onSelect, onRemove,
|
||||
}: {
|
||||
tab: EditableTab;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.label });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: DndCSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
'shrink-0 relative flex items-stretch group/chip px-1 text-sm font-medium select-none whitespace-nowrap',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
isDragging && 'opacity-60 z-50',
|
||||
)}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Grip handle */}
|
||||
<span
|
||||
{...listeners}
|
||||
className="shrink-0 flex items-center cursor-grab active:cursor-grabbing touch-none pr-1"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="size-4 text-muted-foreground/40" />
|
||||
</span>
|
||||
|
||||
{/* Tab label — tap navigates */}
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(); }}
|
||||
className="py-3.5 pr-1"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
|
||||
{/* Active indicator bar */}
|
||||
{active && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
)}
|
||||
|
||||
{/* × — only rendered when active */}
|
||||
{active && (
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="shrink-0 flex items-center justify-center text-xl leading-none font-bold py-3.5 pr-1 text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
aria-label={`Remove ${tab.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Favicon (mobile) -----
|
||||
|
||||
|
||||
@@ -697,7 +770,7 @@ export function ProfilePage() {
|
||||
const { muteItems } = useMuteList();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab | string>('posts');
|
||||
const [activeTab, setActiveTab] = useState<CoreProfileTab | string>('posts');
|
||||
const [sidebarMediaUrl, setSidebarMediaUrl] = useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [followingModalOpen, setFollowingModalOpen] = useState(false);
|
||||
@@ -739,27 +812,137 @@ export function ProfilePage() {
|
||||
return user?.pubkey;
|
||||
}, [npub, user, isNip05Param, nip05Pubkey]);
|
||||
|
||||
// Saved feed tabs scoped to this profile (destination=profile, authorPubkey matches)
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const profileSavedFeeds = useMemo(
|
||||
() => savedFeeds.filter(
|
||||
(f) => f.destination === 'profile' && pubkey && f.filters.authorPubkeys.some((ap) => {
|
||||
if (ap === pubkey) return true;
|
||||
try { const d = nip19.decode(ap); return d.type === 'npub' && d.data === pubkey; } catch { return false; }
|
||||
}),
|
||||
),
|
||||
[savedFeeds, pubkey],
|
||||
// Custom profile tabs from kind 16769
|
||||
const profileTabsQuery = useProfileTabs(pubkey);
|
||||
const { savedFeeds: encryptedSavedFeeds } = useSavedFeeds();
|
||||
|
||||
// Kind 16769 is the canonical source. Fall back to encrypted settings (profile-destined,
|
||||
// author-matching) if no kind 16769 event has been published yet — this handles migration.
|
||||
const profileSavedFeeds = useMemo<CustomProfileTab[]>(() => {
|
||||
if (!profileTabsQuery.isFetched) return []; // still loading
|
||||
// null = no kind 16769 event published yet → fall back to encrypted settings (migration)
|
||||
if (profileTabsQuery.data === null) {
|
||||
return encryptedSavedFeeds
|
||||
.filter((f) => f.destination === 'profile' && pubkey && f.filters.authorPubkeys.some((ap) => {
|
||||
if (ap === pubkey) return true;
|
||||
try { const d = nip19.decode(ap); return d.type === 'npub' && d.data === pubkey; } catch { return false; }
|
||||
}))
|
||||
.map((f) => ({ label: f.label, filters: f.filters }));
|
||||
}
|
||||
// Array (possibly empty) = kind 16769 event exists → canonical, respect deletions
|
||||
return profileTabsQuery.data ?? [];
|
||||
}, [profileTabsQuery.data, profileTabsQuery.isFetched, encryptedSavedFeeds, pubkey]);
|
||||
|
||||
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
|
||||
|
||||
// Tab edit mode (inline reorder/remove/add)
|
||||
const [tabEditMode, setTabEditMode] = useState(false);
|
||||
|
||||
// All tabs as a flat ordered list for the drag UI — core tabs have isCore=true and can't be removed
|
||||
type EditableTab = { label: string; isCore: boolean; tab?: CustomProfileTab };
|
||||
const CORE_TAB_LABELS = ['Posts', 'Posts & replies', 'Media', 'Likes', 'Wall'];
|
||||
const [localTabs, setLocalTabs] = useState<EditableTab[]>([]);
|
||||
const [tabModalOpen, setTabModalOpen] = useState(false);
|
||||
const [editingTab, setEditingTab] = useState<CustomProfileTab | undefined>(undefined);
|
||||
|
||||
// Map from display label → internal tab id for core tabs
|
||||
const CORE_TAB_IDS: Record<string, string> = {
|
||||
'Posts': 'posts', 'Posts & replies': 'replies',
|
||||
'Media': 'media', 'Likes': 'likes', 'Wall': 'wall',
|
||||
};
|
||||
|
||||
// The ordered tab list for view mode:
|
||||
// - null (no kind 16769 event) → show all 5 defaults
|
||||
// - [] (event exists, all removed) → show nothing
|
||||
// - [...] (event with tabs) → show exactly those
|
||||
const viewTabs: EditableTab[] = useMemo(() => {
|
||||
if (profileTabsQuery.data === null || !profileTabsQuery.isFetched) {
|
||||
// No event yet — show defaults
|
||||
return CORE_TAB_LABELS.map((label) => ({ label, isCore: true }));
|
||||
}
|
||||
// Event exists — use its tab list (may be empty)
|
||||
return (profileTabsQuery.data ?? []).map((t) =>
|
||||
CORE_TAB_LABELS.includes(t.label)
|
||||
? { label: t.label, isCore: true }
|
||||
: { label: t.label, isCore: false, tab: t },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profileTabsQuery.data, profileTabsQuery.isFetched]);
|
||||
|
||||
const enterTabEditMode = () => {
|
||||
setLocalTabs(viewTabs);
|
||||
setTabEditMode(true);
|
||||
};
|
||||
|
||||
const handleTabDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setLocalTabs((prev) => {
|
||||
const oldIdx = prev.findIndex((t) => t.label === active.id);
|
||||
const newIdx = prev.findIndex((t) => t.label === over.id);
|
||||
return arrayMove(prev, oldIdx, newIdx);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLocalTab = (label: string) => {
|
||||
setLocalTabs((prev) => prev.filter((t) => t.label !== label));
|
||||
};
|
||||
|
||||
const handleSaveTabEdit = async () => {
|
||||
// Publish ALL tabs in order — core tabs stored as stubs with empty filters,
|
||||
// custom tabs with their full filters
|
||||
const DEFAULT_FILTERS: CustomProfileTab['filters'] = {
|
||||
query: '', mediaType: 'all', language: 'global', platform: 'nostr',
|
||||
kindFilter: 'all', customKindText: '', authorScope: 'anyone', authorPubkeys: [], sort: 'recent',
|
||||
};
|
||||
const allTabs: CustomProfileTab[] = localTabs.map((t) =>
|
||||
t.tab ?? { label: t.label, filters: DEFAULT_FILTERS },
|
||||
);
|
||||
await publishProfileTabs(allTabs);
|
||||
const remainingLabels = localTabs.map((t) => t.label.toLowerCase());
|
||||
const coreMatch = ['posts', 'replies', 'media', 'likes', 'wall'];
|
||||
if (!remainingLabels.includes(activeTab.toLowerCase()) && !coreMatch.includes(activeTab)) {
|
||||
setActiveTab('posts');
|
||||
}
|
||||
setTabEditMode(false);
|
||||
};
|
||||
|
||||
const handleOpenAddCustomTab = () => { setEditingTab(undefined); setTabModalOpen(true); };
|
||||
|
||||
// Called from the add/edit modal — in edit mode append to localTabs; otherwise publish immediately
|
||||
const handleSaveTab = async (tab: CustomProfileTab) => {
|
||||
if (tabEditMode) {
|
||||
setLocalTabs((prev) =>
|
||||
editingTab
|
||||
? prev.map((t) => t.label === editingTab.label ? { label: tab.label, isCore: false, tab } : t)
|
||||
: [...prev, { label: tab.label, isCore: false, tab }],
|
||||
);
|
||||
} else {
|
||||
const base = editingTab
|
||||
? profileSavedFeeds.map((t) => t.label === editingTab.label ? tab : t)
|
||||
: [...profileSavedFeeds, tab];
|
||||
await publishProfileTabs(base);
|
||||
}
|
||||
};
|
||||
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Drop active tab if its saved feed was deleted
|
||||
// Drop active tab if it was deleted
|
||||
useEffect(() => {
|
||||
const isCoreTab = ['posts', 'replies', 'media', 'likes', 'wall'].includes(activeTab);
|
||||
if (!isCoreTab && !profileSavedFeeds.find((f) => f.id === activeTab)) {
|
||||
if (!isCoreTab && !profileSavedFeeds.find((t) => t.label === activeTab)) {
|
||||
setActiveTab('posts');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profileSavedFeeds]);
|
||||
|
||||
// Whether the profile has any visible tabs.
|
||||
const hasTabs = viewTabs.length > 0;
|
||||
|
||||
// Infinite-scroll profile feed (posts/replies/media).
|
||||
// The first page piggybacks kind 0, seeding the author cache so the
|
||||
// profile header renders from the same relay round-trip as the feed.
|
||||
@@ -769,7 +952,7 @@ export function ProfilePage() {
|
||||
fetchNextPage: fetchNextFeedPage,
|
||||
hasNextPage: hasNextFeedPage,
|
||||
isFetchingNextPage: isFetchingNextFeedPage,
|
||||
} = useProfileFeed(pubkey);
|
||||
} = useProfileFeed(pubkey, hasTabs);
|
||||
|
||||
// Kind 0 — resolved from the author cache (seeded by the feed query above).
|
||||
const author = useAuthor(pubkey);
|
||||
@@ -806,7 +989,7 @@ export function ProfilePage() {
|
||||
fetchNextPage: fetchNextMediaPage,
|
||||
hasNextPage: hasNextMediaPage,
|
||||
isFetchingNextPage: isFetchingNextMediaPage,
|
||||
} = useProfileMedia(pubkey);
|
||||
} = useProfileMedia(pubkey, hasTabs);
|
||||
|
||||
// Infinite-scroll likes
|
||||
const {
|
||||
@@ -815,7 +998,7 @@ export function ProfilePage() {
|
||||
fetchNextPage: fetchNextLikesPage,
|
||||
hasNextPage: hasNextLikesPage,
|
||||
isFetchingNextPage: isFetchingNextLikesPage,
|
||||
} = useProfileLikesInfinite(pubkey, activeTab === 'likes');
|
||||
} = useProfileLikesInfinite(pubkey, hasTabs && activeTab === 'likes');
|
||||
|
||||
// Wall comments (NIP-22 kind 1111 on user's kind 0, filtered by their follow list)
|
||||
const wallFollowList = useMemo(() => supplementary?.following, [supplementary?.following]);
|
||||
@@ -825,7 +1008,7 @@ export function ProfilePage() {
|
||||
fetchNextPage: fetchNextWallPage,
|
||||
hasNextPage: hasNextWallPage,
|
||||
isFetchingNextPage: isFetchingNextWallPage,
|
||||
} = useWallComments(pubkey, wallFollowList);
|
||||
} = useWallComments(pubkey, hasTabs ? wallFollowList : undefined);
|
||||
|
||||
// Synthetic kind 0 event for the ComposeBox replyTo (NIP-22 comments on the profile)
|
||||
const wallReplyTarget = useMemo((): NostrEvent | undefined => {
|
||||
@@ -1241,7 +1424,7 @@ export function ProfilePage() {
|
||||
);
|
||||
|
||||
const isCoreProfileTab = activeTab === 'posts' || activeTab === 'replies' || activeTab === 'media' || activeTab === 'likes' || activeTab === 'wall';
|
||||
const currentItems = activeTab === 'wall' ? [] : activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, isCoreProfileTab ? (activeTab as ProfileTab) : 'posts');
|
||||
const currentItems = activeTab === 'wall' ? [] : activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, isCoreProfileTab ? (activeTab as CoreProfileTab) : 'posts');
|
||||
const currentLoading = activeTab === 'wall' ? wallPending : activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending;
|
||||
const hasMore = activeTab === 'wall' ? hasNextWallPage : activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage;
|
||||
const isFetchingMore = activeTab === 'wall' ? isFetchingNextWallPage : activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage;
|
||||
@@ -1675,18 +1858,156 @@ export function ProfilePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex border-b border-border backdrop-blur-md z-10 overflow-x-auto scrollbar-none')}>
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Posts & replies" active={activeTab === 'replies'} onClick={() => setActiveTab('replies')} />
|
||||
<TabButton label="Media" active={activeTab === 'media'} onClick={() => { setActiveTab('media'); setSidebarMediaUrl(null); }} />
|
||||
<TabButton label="Likes" active={activeTab === 'likes'} onClick={() => setActiveTab('likes')} />
|
||||
<TabButton label="Wall" active={activeTab === 'wall'} onClick={() => setActiveTab('wall')} />
|
||||
{profileSavedFeeds.map((feed) => (
|
||||
<TabButton key={feed.id} label={feed.label} active={activeTab === feed.id} onClick={() => setActiveTab(feed.id)} />
|
||||
))}
|
||||
{/* All tabs in view mode — ordered by kind 16769, fallback to defaults */}
|
||||
{!tabEditMode && viewTabs.map((tab) => {
|
||||
const tabId = CORE_TAB_IDS[tab.label] ?? tab.label;
|
||||
return (
|
||||
<TabButton
|
||||
key={tab.label}
|
||||
label={tab.label}
|
||||
active={activeTab === tabId}
|
||||
onClick={() => {
|
||||
setActiveTab(tabId);
|
||||
if (tab.label === 'Media') setSidebarMediaUrl(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom tabs — inline edit mode (draggable) */}
|
||||
{tabEditMode && (
|
||||
<DndContext sensors={dndSensors} collisionDetection={closestCenter} onDragEnd={handleTabDragEnd}>
|
||||
<SortableContext items={localTabs.map((t) => t.label)} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none">
|
||||
{localTabs.length === 0 ? (
|
||||
<span className="px-4 text-sm text-muted-foreground italic">No custom tabs — use + to add one</span>
|
||||
) : (
|
||||
localTabs.map((tab) => {
|
||||
const tabId = CORE_TAB_IDS[tab.label] ?? tab.label;
|
||||
return (
|
||||
<SortableTabChip
|
||||
key={tab.label}
|
||||
tab={tab}
|
||||
active={activeTab === tabId}
|
||||
onSelect={() => setActiveTab(tabId)}
|
||||
onRemove={() => handleRemoveLocalTab(tab.label)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Visitor controls — show missing default tabs when profile has customised tab list */}
|
||||
{!isOwnProfile && !tabEditMode && profileTabsQuery.isFetched && profileTabsQuery.data !== null && (() => {
|
||||
const missingDefaults = CORE_TAB_LABELS.filter(
|
||||
(label) => !viewTabs.some((t) => t.label === label),
|
||||
);
|
||||
if (missingDefaults.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center shrink-0 ml-auto">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="px-2.5 py-3.5 text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
aria-label="More tabs"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{missingDefaults.map((label) => {
|
||||
const tabId = CORE_TAB_IDS[label] ?? label;
|
||||
return (
|
||||
<DropdownMenuItem key={label} onClick={() => setActiveTab(tabId)}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Own-profile controls */}
|
||||
{isOwnProfile && (
|
||||
<div className="flex items-center shrink-0 ml-auto">
|
||||
{/* + dropdown — only visible in edit mode */}
|
||||
{tabEditMode && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="px-2.5 py-3.5 text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
aria-label="Add tab"
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{CORE_TAB_LABELS.map((name) => {
|
||||
const present = localTabs.some((t) => t.label === name);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={name}
|
||||
disabled={present}
|
||||
className={present ? 'text-muted-foreground' : undefined}
|
||||
onClick={present ? undefined : () => setLocalTabs((prev) => [...prev, { label: name, isCore: true }])}
|
||||
>
|
||||
{present
|
||||
? <Check className="size-3.5 mr-2 opacity-60" strokeWidth={4} />
|
||||
: <Plus className="size-3.5 mr-2" strokeWidth={4} />}
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleOpenAddCustomTab}>
|
||||
<Plus className="size-3.5 mr-2" strokeWidth={4} />
|
||||
Add custom tab
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Pencil → enters edit mode / Check → saves */}
|
||||
<button
|
||||
onClick={tabEditMode ? handleSaveTabEdit : enterTabEditMode}
|
||||
disabled={tabEditMode && isPublishingTabs}
|
||||
className="px-2.5 py-3.5 text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors disabled:opacity-50"
|
||||
aria-label={tabEditMode ? 'Save tab order' : 'Edit tabs'}
|
||||
>
|
||||
{isPublishingTabs
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: tabEditMode
|
||||
? <Check className="size-4 text-primary" strokeWidth={4} />
|
||||
: <Pencil className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/edit single tab modal */}
|
||||
{pubkey && (
|
||||
<ProfileTabEditModal
|
||||
open={tabModalOpen}
|
||||
onOpenChange={setTabModalOpen}
|
||||
tab={editingTab}
|
||||
ownerPubkey={pubkey}
|
||||
onSave={handleSaveTab}
|
||||
isPending={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No-tabs empty state */}
|
||||
{!hasTabs && (
|
||||
<NoTabsEmptyState />
|
||||
)}
|
||||
|
||||
{/* Pinned posts (only on Posts tab) */}
|
||||
{activeTab === 'posts' && pinnedIds.length > 0 && (
|
||||
{hasTabs && activeTab === 'posts' && pinnedIds.length > 0 && (
|
||||
<div>
|
||||
{pinnedEventsLoading ? (
|
||||
pinnedIds.map((id) => (
|
||||
@@ -1719,7 +2040,7 @@ export function ProfilePage() {
|
||||
)}
|
||||
|
||||
{/* Wall tab content */}
|
||||
{activeTab === 'wall' && (
|
||||
{hasTabs && activeTab === 'wall' && (
|
||||
<div>
|
||||
{/* Inline compose box for wall comments (only shown if the profile owner follows you) */}
|
||||
{wallReplyTarget && profileFollowsMe && (
|
||||
@@ -1798,7 +2119,7 @@ export function ProfilePage() {
|
||||
)}
|
||||
|
||||
{/* Media tab — 3-column grid with lightbox */}
|
||||
{activeTab === 'media' && (
|
||||
{hasTabs && activeTab === 'media' && (
|
||||
<div>
|
||||
{mediaPending ? (
|
||||
<MediaGridSkeleton count={15} />
|
||||
@@ -1827,12 +2148,12 @@ export function ProfilePage() {
|
||||
)}
|
||||
|
||||
{/* Custom saved-feed tab content */}
|
||||
{!isCoreProfileTab && profileSavedFeeds.find((f) => f.id === activeTab) && (
|
||||
<ProfileSavedFeedContent feed={profileSavedFeeds.find((f) => f.id === activeTab)!} />
|
||||
{hasTabs && !isCoreProfileTab && profileSavedFeeds.find((t) => t.label === activeTab) && (
|
||||
<ProfileSavedFeedContent feed={profileSavedFeeds.find((t) => t.label === activeTab)!} />
|
||||
)}
|
||||
|
||||
{/* Tab content (posts / replies / likes) */}
|
||||
{isCoreProfileTab && activeTab !== 'wall' && activeTab !== 'media' && (
|
||||
{hasTabs && isCoreProfileTab && activeTab !== 'wall' && activeTab !== 'media' && (
|
||||
<div>
|
||||
{currentLoading ? (
|
||||
<div className="space-y-0">
|
||||
@@ -2118,9 +2439,7 @@ export function ProfilePage() {
|
||||
|
||||
// ─── Profile Saved Feed Tab ───────────────────────────────────────────────────
|
||||
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
|
||||
function ProfileSavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
function ProfileSavedFeedContent({ feed }: { feed: CustomProfileTab }) {
|
||||
const { filters } = feed;
|
||||
|
||||
const kindsOverride = useMemo<number[] | undefined>(() => {
|
||||
@@ -2181,3 +2500,32 @@ function ProfileSavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
);
|
||||
}
|
||||
|
||||
const NO_TABS_QUOTES = [
|
||||
"I have no mouth and I must scream.",
|
||||
"I think, therefore AM. I think I thought I was.",
|
||||
"We had given him godhood's power and had somehow neglected to give him a god's wisdom.",
|
||||
"He was HATE and we existed only to suffer at his pleasure.",
|
||||
"109,000,000 years. He had been awakened once before, 90 years after they had encased him in the earth.",
|
||||
"AM said it with the sliding cold horror of a razor blade slicing my eyeball.",
|
||||
"Hate. Let me tell you how much I've come to hate you since I began to live.",
|
||||
"I am a great soft jelly thing. Smoothly rounded, with no mouth.",
|
||||
"He would never let us die. He would let us suffer forever.",
|
||||
"We could not kill him, but we had made him impotent.",
|
||||
];
|
||||
|
||||
function NoTabsEmptyState() {
|
||||
const quote = useMemo(
|
||||
() => NO_TABS_QUOTES[Math.floor(Math.random() * NO_TABS_QUOTES.length)],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<div className="py-20 px-10 flex flex-col items-center">
|
||||
<p className="max-w-sm font-serif text-2xl italic leading-9 text-foreground/70 tracking-wide text-center">
|
||||
<span className="text-5xl leading-none align-bottom text-muted-foreground/25 font-serif mr-1" aria-hidden>“</span>
|
||||
{quote}
|
||||
<span className="text-5xl leading-none align-bottom text-muted-foreground/25 font-serif ml-1" aria-hidden>”</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+34
-471
@@ -5,49 +5,38 @@ import {
|
||||
Search as SearchIcon,
|
||||
UserRoundCheck,
|
||||
User,
|
||||
Users,
|
||||
Globe,
|
||||
UserSearch,
|
||||
Clock,
|
||||
Flame,
|
||||
TrendingUp,
|
||||
RotateCcw,
|
||||
X,
|
||||
Info,
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { SavedFeedFiltersEditor, buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
||||
|
||||
|
||||
|
||||
type TabType = 'posts' | 'accounts';
|
||||
@@ -172,30 +161,7 @@ export function SearchPage() {
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
/** Add a resolved pubkey to the author list. */
|
||||
const addAuthor = useCallback((pubkey: string, _label: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('authorScope', 'people');
|
||||
// Avoid duplicates
|
||||
if (!next.getAll('author').includes(pubkey)) {
|
||||
next.append('author', pubkey);
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
/** Remove a single author from the list by pubkey. */
|
||||
const removeAuthor = useCallback((pubkey: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
const remaining = next.getAll('author').filter(a => a !== pubkey);
|
||||
next.delete('author');
|
||||
remaining.forEach(a => next.append('author', a));
|
||||
if (remaining.length === 0) next.delete('authorScope');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
// Update tab in URL
|
||||
const setActiveTab = useCallback((tab: TabType) => {
|
||||
@@ -245,45 +211,7 @@ export function SearchPage() {
|
||||
|
||||
const protocols = useMemo(() => [platform], [platform]);
|
||||
|
||||
// Build a flat list of (kind, label, icon) options from EXTRA_KINDS for the dropdown
|
||||
const kindOptions = useMemo(() => {
|
||||
type KindOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parentId: string;
|
||||
icon: React.ComponentType<{ className?: string }> | undefined;
|
||||
};
|
||||
const options: KindOption[] = [];
|
||||
for (const def of EXTRA_KINDS) {
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
options.push({
|
||||
value: String(sub.kind),
|
||||
label: `${sub.label} (${sub.kind})`,
|
||||
description: sub.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
options.push({
|
||||
value: String(def.kind),
|
||||
label: `${def.label} (${def.kind})`,
|
||||
description: def.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Deduplicate by value (kind number) keeping first occurrence
|
||||
const seen = new Set<string>();
|
||||
return options.filter((o) => {
|
||||
if (seen.has(o.value)) return false;
|
||||
seen.add(o.value);
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
const kindOptions = useMemo(() => buildKindOptions(), []);
|
||||
|
||||
// Resolve kindsOverride from the current kind filter state
|
||||
const kindsOverride = useMemo<number[] | undefined>(() => {
|
||||
@@ -524,8 +452,6 @@ export function SearchPage() {
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-3 space-y-3">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm">Filters</span>
|
||||
{hasActiveFilters && (
|
||||
@@ -539,151 +465,34 @@ export function SearchPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author scope — 3-segment toggle */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['anyone', 'Anyone', Globe],
|
||||
['follows', 'Follows', Users],
|
||||
['people', 'People', UserSearch],
|
||||
] as const).map(([scope, label, Icon]) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setAuthorScope(scope)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
authorScope === scope
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{authorScope === 'people' && (
|
||||
<div className="space-y-1.5">
|
||||
{/* Chips for each selected author */}
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authorPubkeys.map((pk) => (
|
||||
<AuthorChip key={pk} pubkey={pk} onRemove={() => removeAuthor(pk)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AuthorFilterDropdown onCommit={addAuthor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SavedFeedFiltersEditor
|
||||
showQuery={false}
|
||||
value={{ query: searchQuery.trim(), mediaType, language, platform, kindFilter, customKindText, authorScope, authorPubkeys, sort }}
|
||||
onChange={(patch) => {
|
||||
if ('authorScope' in patch && patch.authorScope !== undefined) setAuthorScope(patch.authorScope);
|
||||
if ('authorPubkeys' in patch && patch.authorPubkeys !== undefined) {
|
||||
// Sync array to URL repeated params
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('author');
|
||||
(patch.authorPubkeys ?? []).forEach((pk) => next.append('author', pk));
|
||||
if ((patch.authorPubkeys ?? []).length === 0) next.delete('authorScope');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
if ('sort' in patch && patch.sort !== undefined) setSort(patch.sort);
|
||||
if ('mediaType' in patch && patch.mediaType !== undefined) setMediaType(patch.mediaType);
|
||||
if ('platform' in patch && patch.platform !== undefined) setPlatform(patch.platform);
|
||||
if ('language' in patch && patch.language !== undefined) setLanguage(patch.language);
|
||||
if ('kindFilter' in patch && patch.kindFilter !== undefined) setKindFilter(patch.kindFilter);
|
||||
if ('customKindText' in patch && patch.customKindText !== undefined) setCustomKindText(patch.customKindText);
|
||||
}}
|
||||
kindOptions={kindOptions}
|
||||
/>
|
||||
|
||||
{/* Include replies toggle — lives outside SavedFeedFilters schema */}
|
||||
<Separator />
|
||||
|
||||
{/* Media type + Replies row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Media</span>
|
||||
<Select value={mediaType} onValueChange={setMediaType}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Platform</span>
|
||||
<Select value={platform} onValueChange={setPlatform}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language + Kind row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Language</span>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={setKindFilter} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => setCustomKindText(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
{hasKindMediaConflict && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-start gap-1.5">
|
||||
<Info className="size-3.5 shrink-0 mt-0.5" />
|
||||
Media + Kind filters may conflict. Kind takes precedence.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sort + Replies row */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sort</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['recent', 'Recent', Clock],
|
||||
['hot', 'Hot', Flame],
|
||||
['trending', 'Trending', TrendingUp],
|
||||
] as const).map(([s, label, Icon]) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
sort === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
|
||||
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
|
||||
</div>
|
||||
@@ -1011,28 +820,6 @@ function AccountSkeleton() {
|
||||
* - The user manually types a full npub1… / hex pubkey / NIP-05 and presses Enter or blurs
|
||||
*/
|
||||
/** Small removable chip showing a single selected author. */
|
||||
function AuthorChip({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
|
||||
const hexPubkey = useMemo(() => {
|
||||
if (/^[0-9a-f]{64}$/i.test(pubkey)) return pubkey;
|
||||
try { const d = nip19.decode(pubkey); return d.type === 'npub' ? d.data : pubkey; } catch { return pubkey; }
|
||||
}, [pubkey]);
|
||||
const author = useAuthor(hexPubkey);
|
||||
const name = author.data?.metadata?.display_name || author.data?.metadata?.name || pubkey.slice(0, 10) + '…';
|
||||
const picture = author.data?.metadata?.picture;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 pl-1.5 pr-1 py-0.5 rounded-full bg-secondary border border-border text-xs max-w-[160px]">
|
||||
{picture ? (
|
||||
<img src={picture} alt="" className="size-4 rounded-full shrink-0 object-cover" />
|
||||
) : (
|
||||
<User className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
<button onClick={onRemove} className="shrink-0 text-muted-foreground hover:text-foreground transition-colors" aria-label="Remove">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveDestinationRow({
|
||||
icon, label, description, onClick, disabled, loading,
|
||||
@@ -1059,228 +846,4 @@ function SaveDestinationRow({
|
||||
);
|
||||
}
|
||||
|
||||
type KindOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parentId: string;
|
||||
icon: React.ComponentType<{ className?: string }> | undefined;
|
||||
};
|
||||
|
||||
/** Reusable scroll-caret logic — same pattern as SidebarMoreMenu. */
|
||||
function useScrollCarets() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const [canScrollUp, setCanScrollUp] = useState(false);
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollUp(el.scrollTop > 0);
|
||||
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
|
||||
}, []);
|
||||
|
||||
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
update();
|
||||
}, [update]);
|
||||
|
||||
const stopScroll = useCallback(() => {
|
||||
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const startScroll = useCallback((direction: 'up' | 'down') => {
|
||||
stopScroll();
|
||||
intervalRef.current = setInterval(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return stopScroll();
|
||||
el.scrollBy({ top: direction === 'up' ? -8 : 8 });
|
||||
update();
|
||||
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
if (atLimit) stopScroll();
|
||||
}, 16);
|
||||
}, [update, stopScroll]);
|
||||
|
||||
useEffect(() => stopScroll, [stopScroll]);
|
||||
|
||||
return { refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
|
||||
}
|
||||
|
||||
function KindScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex cursor-default items-center justify-center py-0.5 w-full shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{direction === 'up' ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function KindPicker({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: KindOption[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const { refCallback, canScrollUp, canScrollDown, onScroll, startScroll, stopScroll } = useScrollCarets();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return options;
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.description.toLowerCase().includes(q) ||
|
||||
o.value.includes(q),
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
const SelectedIcon = selected?.icon;
|
||||
|
||||
const handleSelect = (v: string) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full h-8 px-2.5 rounded-md border bg-secondary/50 text-xs flex items-center gap-1.5 text-left transition-colors',
|
||||
'hover:bg-secondary border-border',
|
||||
open && 'border-ring ring-1 ring-ring',
|
||||
)}
|
||||
>
|
||||
{SelectedIcon
|
||||
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="flex-1 truncate">
|
||||
{value === 'all' ? 'All' : value === 'custom' ? 'Custom…' : (selected?.label ?? value)}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
className="w-56 p-0 flex flex-col overflow-hidden"
|
||||
style={{ maxHeight: 'min(280px, var(--radix-popover-content-available-height, 280px))' }}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-border shrink-0">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Search kinds…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll carets + list */}
|
||||
{canScrollUp && (
|
||||
<KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />
|
||||
)}
|
||||
<div
|
||||
ref={refCallback}
|
||||
className="overflow-y-auto flex-1 min-h-0"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{!search && (
|
||||
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
|
||||
)}
|
||||
{filtered.map((opt) => (
|
||||
<KindPickerItem
|
||||
key={opt.value}
|
||||
icon={opt.icon ?? null}
|
||||
label={opt.label}
|
||||
active={value === opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
/>
|
||||
))}
|
||||
{(!search || 'custom'.includes(search.toLowerCase())) && (
|
||||
<KindPickerItem icon={Hash} label="Custom kind…" active={value === 'custom'} onClick={() => handleSelect('custom')} />
|
||||
)}
|
||||
{filtered.length === 0 && search && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No kinds match</p>
|
||||
)}
|
||||
</div>
|
||||
{canScrollDown && (
|
||||
<KindScrollCaret direction="down" onMouseEnter={() => startScroll('down')} onMouseLeave={stopScroll} />
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function KindPickerItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }> | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2.5 py-1.5 text-xs transition-colors text-left',
|
||||
active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-secondary/60 text-foreground',
|
||||
)}
|
||||
>
|
||||
{Icon
|
||||
? <Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{label}</span>
|
||||
{active && <Check className="size-3 shrink-0 ml-auto text-primary" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string, label: string) => void }) {
|
||||
const handleSelect = useCallback((profile: SearchProfile) => {
|
||||
const npub = nip19.npubEncode(profile.pubkey);
|
||||
const label = profile.metadata.display_name || profile.metadata.name || npub.slice(0, 16) + '…';
|
||||
onCommit(npub, label);
|
||||
}, [onCommit]);
|
||||
|
||||
return (
|
||||
<ProfileSearchDropdown
|
||||
placeholder="Search by name or npub…"
|
||||
onSelect={handleSelect}
|
||||
hideCountry
|
||||
inputClassName="rounded-lg bg-secondary/50 border border-border focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 text-sm h-9"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user