9 changed files with 299 additions and 8 deletions
@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
|
||||
import JsonViewDialog from '@/components/JsonViewDialog' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '@/components/ui/dropdown-menu' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||
import { normalizeTopic } from '@/lib/discussion-topics' |
||||
import { toNoteList } from '@/lib/link' |
||||
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSmartHashtagNavigation } from '@/PageManager' |
||||
import { useInterestList } from '@/providers/InterestListProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import client from '@/services/client.service' |
||||
import { Code, MoreVertical, Trash2 } from 'lucide-react' |
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import NotFoundPage from '../NotFoundPage' |
||||
|
||||
const INTEREST_LIST_KIND = 10015 |
||||
|
||||
const InterestListPage = forwardRef( |
||||
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||
const { navigateToHashtag } = useSmartHashtagNavigation() |
||||
const { profile, pubkey, interestListEvent, updateInterestListEvent } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const { subscribedTopics, subscribe, unsubscribe, changing } = useInterestList() |
||||
const [topicInput, setTopicInput] = useState('') |
||||
const [jsonOpen, setJsonOpen] = useState(false) |
||||
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||
|
||||
const topicsSorted = useMemo( |
||||
() => [...subscribedTopics].sort((a, b) => a.localeCompare(b)), |
||||
[subscribedTopics] |
||||
) |
||||
|
||||
const refreshFromRelays = useCallback(async () => { |
||||
if (!pubkey) return |
||||
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
let latest = |
||||
(await fetchLatestReplaceableListEvent(pubkey, INTEREST_LIST_KIND, comprehensiveRelays)) ?? null |
||||
if (!latest) { |
||||
latest = (await client.fetchInterestListEvent(pubkey)) ?? null |
||||
} |
||||
if (latest) await updateInterestListEvent(latest) |
||||
}, [pubkey, favoriteRelays, blockedRelays, updateInterestListEvent]) |
||||
|
||||
const openJson = useCallback(() => { |
||||
setJsonPayload({ |
||||
interestListEvent: interestListEvent ?? null, |
||||
derivedTopics: topicsSorted, |
||||
note: 'Interest list is kind 10015; subscribed topics are stored as `t` tags.' |
||||
}) |
||||
setJsonOpen(true) |
||||
}, [interestListEvent, topicsSorted]) |
||||
|
||||
useEffect(() => { |
||||
if (!hideTitlebar) { |
||||
registerPrimaryPanelRefresh(null) |
||||
return |
||||
} |
||||
registerPrimaryPanelRefresh(() => { |
||||
void refreshFromRelays() |
||||
}) |
||||
return () => registerPrimaryPanelRefresh(null) |
||||
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays]) |
||||
|
||||
const onAddTopic = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
const raw = topicInput.trim().replace(/^#+/u, '') |
||||
const normalized = normalizeTopic(raw) |
||||
if (!normalized) { |
||||
toast.error(t('Interest topic invalid')) |
||||
return |
||||
} |
||||
await subscribe(normalized) |
||||
setTopicInput('') |
||||
} |
||||
|
||||
if (!profile || !pubkey) { |
||||
return <NotFoundPage /> |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={ |
||||
hideTitlebar |
||||
? undefined |
||||
: t("username's interest topics", { |
||||
username: profile.username, |
||||
defaultValue: `${profile.username}'s interest topics` |
||||
}) |
||||
} |
||||
hideBackButton={hideTitlebar} |
||||
controls={ |
||||
hideTitlebar ? undefined : ( |
||||
<div className="flex items-center gap-0"> |
||||
<RefreshButton onClick={() => void refreshFromRelays()} /> |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button variant="ghost" size="icon" aria-label={t('More options')}> |
||||
<MoreVertical className="size-4" /> |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent align="end"> |
||||
<DropdownMenuItem onClick={() => openJson()}> |
||||
<Code className="mr-2 size-4" /> |
||||
{t('View JSON')} |
||||
</DropdownMenuItem> |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
</div> |
||||
) |
||||
} |
||||
displayScrollToTopButton |
||||
> |
||||
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||
<div className="min-w-0 space-y-3 px-3 pb-4 pt-2"> |
||||
<p className="text-sm text-muted-foreground">{t('Interests list section subtitle')}</p> |
||||
<form onSubmit={(ev) => void onAddTopic(ev)} className="flex flex-wrap items-center gap-2"> |
||||
<Input |
||||
value={topicInput} |
||||
onChange={(ev) => setTopicInput(ev.target.value)} |
||||
placeholder={t('Interest topic placeholder')} |
||||
className="min-w-[12rem] flex-1" |
||||
disabled={changing} |
||||
aria-label={t('Interest topic placeholder')} |
||||
/> |
||||
<Button type="submit" disabled={changing || !topicInput.trim()}> |
||||
{t('Interest list add topic')} |
||||
</Button> |
||||
</form> |
||||
{topicsSorted.length === 0 ? ( |
||||
<p className="pt-2 text-center text-sm text-muted-foreground">{t('No interest topics in list')}</p> |
||||
) : ( |
||||
<ul className="space-y-1"> |
||||
{topicsSorted.map((topic) => ( |
||||
<li |
||||
key={topic} |
||||
className={cn( |
||||
'flex min-h-[48px] items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2' |
||||
)} |
||||
> |
||||
<button |
||||
type="button" |
||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline" |
||||
onClick={() => navigateToHashtag(toNoteList({ hashtag: topic }))} |
||||
> |
||||
#{topic} |
||||
</button> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="shrink-0" |
||||
disabled={changing} |
||||
title={t('Remove from interest list')} |
||||
aria-label={t('Remove from interest list')} |
||||
onClick={() => void unsubscribe(topic)} |
||||
> |
||||
<Trash2 className="size-4" /> |
||||
</Button> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
InterestListPage.displayName = 'InterestListPage' |
||||
export default InterestListPage |
||||
Loading…
Reference in new issue