Merge branch 'main' of gitlab.com:soapbox-pub/ditto

This commit is contained in:
Alex Gleason
2026-03-05 23:29:45 -06:00
14 changed files with 1669 additions and 519 deletions
+57
View File
@@ -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.
+211 -3
View File
@@ -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() {
+1 -1
View File
@@ -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',
)}
>
+131
View File
@@ -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>
);
}
+217
View File
@@ -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>
);
}
+499
View File
@@ -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>
);
}
+3 -3
View File
@@ -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();
+2 -2
View File
@@ -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,
});
}
+28
View File
@@ -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,
});
}
+30
View File
@@ -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 };
}
+10
View File
@@ -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,
};
}
+59
View File
@@ -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
View File
@@ -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>&ldquo;</span>
{quote}
<span className="text-5xl leading-none align-bottom text-muted-foreground/25 font-serif ml-1" aria-hidden>&rdquo;</span>
</p>
</div>
);
}
+34 -471
View File
@@ -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"
/>
);
}