feat: bookmarks for notes and articles (NIP-51) (#29)

* feat: bookmarks for notes and articles (NIP-51)

Adds a Bookmarks app backed by a kind 10003 NIP-51 bookmark list:
- BookmarkButton toggles a note (`e` tag) or article (`a` tag) in and
  out of the signed-in user's list, reading it back before publishing
  so an update never clobbers other entries — the same whole-list
  replacement trap follow lists have.
- Wired into NoteCard's action row and the Reader's article toolbar.
- The new Bookmarks app lists saved notes and articles, opening
  articles back in the Reader.

Closes #21

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: address review feedback and add a Bookmarked tab to the Reader

Per review:
- useToggleBookmark now fetches the bookmark list fresh from relays
  right before writing instead of trusting the query cache (60s
  staleTime), which could otherwise clobber concurrent edits from
  another tab or device.
- The article BookmarkButton only renders when the article actually
  has a `d` tag, instead of falling back to an unresolvable
  "kind:pubkey:" address.
- Bookmarked note/article ids are filtered for a non-empty tag value
  before use, and article addresses are parsed properly (kind,
  author, `d`) instead of a naive split(':')[2] — the relay query is
  now also constrained by kind and author, not just `d`, and
  identifiers containing ':' round-trip correctly.
- BookmarkButton sets type="button" so it can't misbehave as a form
  submit button.

Per a reviewer comment: added a "Recent" / "Bookmarked" tab to the
Reader's sidebar (src/apps/articles/index.tsx) so bookmarked articles
are reachable without leaving the app — the dedicated Bookmarks app
stays as-is. Both now share useMyBookmarkedArticles from
src/hooks/useBookmarks.ts rather than duplicating the address-parsing
logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: reject empty-identifier addresses and surface bookmark load errors

Per review:
- parseAddress() now rejects an empty d-identifier as malformed
  (e.g. "30023:<pubkey>:") instead of producing a "#d: ['']" relay
  query and an unopenable bookmark.
- useMyBookmarkedArticles() filters out matched events with empty
  content, the same non-renderable criteria the Reader's own list
  uses, so a broken/blank article can't land in the Bookmarked view.
- BookmarksApp now distinguishes "the query failed" from "there are
  no bookmarks" — React Query leaves data undefined in both cases, so
  a relay/network failure no longer reads as an empty list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-06 18:45:21 +02:00
committed by GitHub
parent e7cf9c4677
commit 179d3682f8
7 changed files with 485 additions and 16 deletions

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The seven apps
## The eight apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -97,10 +97,17 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` |
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
### Bookmarks are one whole-list replacement, like follow lists
kind 10003 is a replaceable event: publishing it replaces the entire list. `useToggleBookmark`
(`src/hooks/useBookmarks.ts`) therefore reads the current list back before publishing an
update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement) have.
### Follow lists are a whole-list replacement
kind 3 replaces the entire contact list. The follow button therefore reads the current

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, Link2 } from 'lucide-react';
import { Bookmark, ChevronLeft, Link2, Rss } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import {
AppBody,
@@ -13,11 +13,14 @@ import {
EmptyState,
} from '@/components/os/AppChrome';
import { AuthorLine } from '@/components/nostr/AuthorLine';
import { BookmarkButton } from '@/components/nostr/BookmarkButton';
import { Markdown } from './Markdown';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyBookmarkedArticles } from '@/hooks/useBookmarks';
import {
absoluteTime,
decodeRelayHints,
@@ -32,6 +35,8 @@ import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import type { AppParams, AppProps } from '@/os/types';
type ListScope = 'recent' | 'bookmarked';
const ARTICLE_KIND = 30023;
/** A long-form event is only useful with a body and a `d` identifier (NIP-23). */
@@ -84,6 +89,13 @@ function useArticle(
export default function ArticlesApp({ params, setTitle, setParams }: AppProps) {
const isMobile = useIsMobile();
const { user } = useCurrentUser();
// Signing out mid-session must not strand the reader on a "Bookmarked" tab
// it can no longer see anything in, so the effective scope is derived
// rather than corrected after render — same reasoning as the Feed.
const [requestedScope, setRequestedScope] = useState<ListScope>('recent');
const scope: ListScope = user ? requestedScope : 'recent';
// The selection lives in the window's params rather than local state, so the
// URL, a reload and the switch between the desktop and mobile shells all
@@ -102,7 +114,9 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) {
[setParams],
);
const list = useRecentArticles();
const recent = useRecentArticles();
const bookmarked = useMyBookmarkedArticles();
const list = scope === 'recent' ? recent : bookmarked;
const article = useArticle(
selected?.pubkey,
selected?.identifier,
@@ -116,13 +130,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) {
}, [title, setTitle]);
const listPane = (
<ArticleList
query={list}
selected={selected}
onSelect={(event, identifier) =>
select({ pubkey: event.pubkey, identifier, kind: String(event.kind) })
}
/>
<div className="flex h-full min-h-0 flex-col">
<ListScopeTabs scope={requestedScope} disabled={!user} onChange={setRequestedScope} />
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
<ArticleList
query={list}
scope={scope}
selected={selected}
onSelect={(event, identifier) =>
select({ pubkey: event.pubkey, identifier, kind: String(event.kind) })
}
/>
</div>
</div>
);
const readerPane = !selected ? (
@@ -173,7 +193,19 @@ export default function ArticlesApp({ params, setTitle, setParams }: AppProps) {
<span className="truncate text-[13px] font-medium">
{title ?? 'Long-form articles'}
</span>
{article.data && <CopyArticleLink event={article.data} />}
{article.data && (
<div className="ml-auto flex items-center gap-2">
{tagValue(article.data, 'd') && (
<BookmarkButton
target={{
type: 'a',
value: `${article.data.kind}:${article.data.pubkey}:${tagValue(article.data, 'd')}`,
}}
/>
)}
<CopyArticleLink event={article.data} />
</div>
)}
</AppToolbar>
<AppSplit>
@@ -192,7 +224,7 @@ function CopyArticleLink({ event }: { event: NostrEvent }) {
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 shrink-0 gap-1.5 px-2 text-xs"
className="h-7 shrink-0 gap-1.5 px-2 text-xs"
onClick={async () => {
try {
const naddr = nip19.naddrEncode({
@@ -214,12 +246,74 @@ function CopyArticleLink({ event }: { event: NostrEvent }) {
);
}
function ListScopeTabs({
scope,
disabled,
onChange,
}: {
scope: ListScope;
disabled: boolean;
onChange: (scope: ListScope) => void;
}) {
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border p-2">
<ScopeTab
active={scope === 'recent'}
onClick={() => onChange('recent')}
icon={<Rss className="size-3.5" aria-hidden />}
label="Recent"
/>
<ScopeTab
active={scope === 'bookmarked'}
disabled={disabled}
onClick={() => onChange('bookmarked')}
icon={<Bookmark className="size-3.5" aria-hidden />}
label="Bookmarked"
/>
</div>
);
}
function ScopeTab({
active,
disabled,
onClick,
icon,
label,
}: {
active: boolean;
disabled?: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
'disabled:cursor-not-allowed disabled:opacity-40',
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted',
)}
>
{icon}
{label}
</button>
);
}
function ArticleList({
query,
scope,
selected,
onSelect,
}: {
query: ReturnType<typeof useRecentArticles>;
scope: ListScope;
selected: { pubkey: string; identifier: string } | null;
onSelect: (event: NostrEvent, identifier: string) => void;
}) {
@@ -236,14 +330,14 @@ function ArticleList({
if (!query.data || query.data.length === 0) {
return (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
No articles on your relays.
{scope === 'bookmarked' ? 'You havent bookmarked any articles yet.' : 'No articles on your relays.'}
</p>
);
}
return (
<>
<AppSectionTitle>Recent</AppSectionTitle>
<AppSectionTitle>{scope === 'bookmarked' ? 'Bookmarked' : 'Recent'}</AppSectionTitle>
<ul className="pb-2">
{query.data.map((event) => {
const identifier = tagValue(event, 'd')!;

View File

@@ -0,0 +1,142 @@
import { useEffect } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { AppBody, AppLayout, AppSectionTitle, AppToolbar, EmptyState } from '@/components/os/AppChrome';
import { LoginRequired } from '@/components/nostr/LoginRequired';
import { NoteCard } from '@/components/nostr/NoteCard';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookmarkedNoteIds, useMyBookmarkedArticles } from '@/hooks/useBookmarks';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useWindowManager } from '@/os/useWindowManager';
import { displayName, relativeTime, tagValue } from '@/lib/nostrUtils';
import type { AppProps } from '@/os/types';
function useBookmarkedNotes(ids: string[]) {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'bookmarked-notes', ids.join(',')],
enabled: ids.length > 0,
queryFn: async ({ signal }) => {
const events = await nostr.query([{ ids, limit: ids.length }], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
});
const order = new Map(ids.map((id, index) => [id, index]));
return [...events].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
},
staleTime: 60_000,
});
}
export default function BookmarksApp({ setTitle }: AppProps) {
const { user } = useCurrentUser();
const { openApp } = useWindowManager();
useEffect(() => setTitle('Bookmarks'), [setTitle]);
const noteIds = useBookmarkedNoteIds();
const notes = useBookmarkedNotes(noteIds);
const articles = useMyBookmarkedArticles();
if (!user) {
return <LoginRequired action="see your bookmarks" />;
}
const isLoading = (noteIds.length > 0 && notes.isLoading) || articles.isLoading;
// React Query leaves `data` undefined on a failed query too, so an error
// must be checked before treating "no data" as "no bookmarks" — otherwise
// a relay/network failure reads as an empty list.
const isError = notes.isError || articles.isError;
const isEmpty =
!isLoading && !isError && (notes.data?.length ?? 0) === 0 && (articles.data?.length ?? 0) === 0;
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Bookmarks</span>
</AppToolbar>
<AppBody>
{isLoading ? (
<div className="space-y-3 p-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-16 w-full" />
))}
</div>
) : isError ? (
<EmptyState
title="Couldn't load your bookmarks"
hint="None of your relays responded. Check the Relays app or try again."
action={
<Button
size="sm"
variant="outline"
onClick={() => {
notes.refetch();
articles.refetch();
}}
>
Try again
</Button>
}
/>
) : isEmpty ? (
<EmptyState
title="No bookmarks yet"
hint="Bookmark a note or an article and it will show up here."
/>
) : (
<>
{notes.data && notes.data.length > 0 && (
<>
<AppSectionTitle>Notes</AppSectionTitle>
{notes.data.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</>
)}
{articles.data && articles.data.length > 0 && (
<>
<AppSectionTitle>Articles</AppSectionTitle>
{articles.data.map((event) => (
<ArticleRow
key={event.id}
event={event}
onOpen={() =>
openApp('articles', {
pubkey: event.pubkey,
identifier: tagValue(event, 'd') ?? '',
kind: String(event.kind),
})
}
/>
))}
</>
)}
</>
)}
</AppBody>
</AppLayout>
);
}
function ArticleRow({ event, onOpen }: { event: NostrEvent; onOpen: () => void }) {
const author = useAuthor(event.pubkey);
const title = tagValue(event, 'title') ?? tagValue(event, 'd') ?? 'Untitled';
return (
<button
type="button"
onClick={onOpen}
className="w-full border-b border-border px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-muted/40"
>
<span className="block truncate text-[14px] font-medium">{title}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayName(event.pubkey, author.data?.metadata)} · {relativeTime(event.created_at)}
</span>
</button>
);
}

View File

@@ -0,0 +1,51 @@
import { Bookmark, BookmarkCheck, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { isBookmarked, useBookmarkedTargets, useToggleBookmark, type BookmarkTarget } from '@/hooks/useBookmarks';
import { cn } from '@/lib/utils';
/** Toggles `target` in and out of the signed-in user's NIP-51 bookmark list. */
export function BookmarkButton({ target, className }: { target: BookmarkTarget; className?: string }) {
const { user } = useCurrentUser();
const targets = useBookmarkedTargets();
const toggle = useToggleBookmark();
const { toast } = useToast();
if (!user) return null;
const bookmarked = isBookmarked(targets, target);
const handleClick = async () => {
try {
await toggle.mutateAsync(target);
} catch (error) {
toast({
title: bookmarked ? 'Could not remove bookmark' : 'Could not bookmark',
description: error instanceof Error ? error.message : 'No relay accepted the update.',
variant: 'destructive',
});
}
};
return (
<Button
type="button"
variant="ghost"
size="sm"
className={cn('h-7 gap-1.5 px-2 text-xs text-muted-foreground', bookmarked && 'text-primary', className)}
onClick={handleClick}
disabled={toggle.isPending}
aria-pressed={bookmarked}
>
{toggle.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : bookmarked ? (
<BookmarkCheck className="size-3.5" aria-hidden />
) : (
<Bookmark className="size-3.5" aria-hidden />
)}
{bookmarked ? 'Bookmarked' : 'Bookmark'}
</Button>
);
}

View File

@@ -3,6 +3,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { AuthorLine } from './AuthorLine';
import { NoteContent } from './NoteContent';
import { BookmarkButton } from './BookmarkButton';
import { Button } from '@/components/ui/button';
import { useWindowManager } from '@/os/useWindowManager';
import { useToast } from '@/hooks/useToast';
@@ -69,6 +70,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
<Repeat2 className="size-3.5" aria-hidden />
Copy link
</Button>
<BookmarkButton target={{ type: 'e', value: event.id }} />
</div>
)}
</div>

163
src/hooks/useBookmarks.ts Normal file
View File

@@ -0,0 +1,163 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { tagValue } from '@/lib/nostrUtils';
const ARTICLE_KIND = 30023;
/** NIP-51 "Bookmarks": an uncategorized, global, replaceable list per user. */
export const BOOKMARK_LIST_KIND = 10003;
export interface BookmarkTarget {
/** `e` for a kind-1 note, `a` for an addressable event (e.g. a NIP-23 article). */
type: 'e' | 'a';
/** An event id for `e`, or `kind:pubkey:d-identifier` for `a`. */
value: string;
}
function bookmarkQueryKey(pubkey: string | undefined) {
return ['nostr', 'bookmarks', pubkey ?? ''] as const;
}
async function fetchBookmarkList(
nostr: ReturnType<typeof useNostr>['nostr'],
pubkey: string,
signal?: AbortSignal,
): Promise<NostrEvent | null> {
const [event] = await nostr.query(
[{ kinds: [BOOKMARK_LIST_KIND], authors: [pubkey], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)].filter((s): s is AbortSignal => Boolean(s))) },
);
return event ?? null;
}
/** The current user's kind 10003 bookmark list, or null if they don't have one yet. */
export function useBookmarkList() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery<NostrEvent | null>({
queryKey: bookmarkQueryKey(user?.pubkey),
enabled: Boolean(user),
queryFn: ({ signal }) => fetchBookmarkList(nostr, user!.pubkey, signal),
staleTime: 60_000,
});
}
/** The list's public `e`/`a` entries, in the shape a `BookmarkButton` checks against. */
export function useBookmarkedTargets(): BookmarkTarget[] {
const { data } = useBookmarkList();
if (!data) return [];
return data.tags
.filter((tag): tag is [string, string] => (tag[0] === 'e' || tag[0] === 'a') && Boolean(tag[1]))
.map(([type, value]) => ({ type: type as 'e' | 'a', value }));
}
export function isBookmarked(targets: BookmarkTarget[], target: BookmarkTarget): boolean {
return targets.some((t) => t.type === target.type && t.value === target.value);
}
/**
* Adds or removes one target from the bookmark list. Fetches the list fresh
* from relays right before writing — kind 10003 is a whole-list replacement,
* so publishing against a stale cached copy (the query's staleTime is 60s)
* could silently drop entries added from another tab or device in the
* meantime, the same trap NIP-02 follow lists have.
*/
export function useToggleBookmark() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const publish = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (target: BookmarkTarget) => {
if (!user) throw new Error('Sign in to bookmark');
const current = await fetchBookmarkList(nostr, user.pubkey);
const currentTags = current?.tags ?? [];
const already = currentTags.some(([name, value]) => name === target.type && value === target.value);
const tags = already
? currentTags.filter(([name, value]) => !(name === target.type && value === target.value))
: [...currentTags, [target.type, target.value]];
return publish.mutateAsync({
kind: BOOKMARK_LIST_KIND,
content: current?.content ?? '',
tags,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bookmarkQueryKey(user?.pubkey) });
},
});
}
interface ParsedAddress {
kind: number;
pubkey: string;
identifier: string;
}
/**
* Parses a NIP-01 `kind:pubkey:d-identifier` address tag value, or null if
* malformed — including an empty identifier, which would otherwise produce
* a `#d: ['']` relay query and a bookmark nothing can reliably resolve.
*/
function parseAddress(address: string): ParsedAddress | null {
const [kindPart, pubkey, ...rest] = address.split(':');
const kind = Number(kindPart);
const identifier = rest.join(':');
if (!Number.isInteger(kind) || !pubkey || !identifier) return null;
return { kind, pubkey, identifier };
}
/** The current user's bookmarked note ids (`e` tags), skipping any malformed entries. */
export function useBookmarkedNoteIds(): string[] {
const targets = useBookmarkedTargets();
return useMemo(
() => targets.filter((t) => t.type === 'e').map((t) => t.value),
[targets],
);
}
/**
* The current user's bookmarked NIP-23 articles, fetched and narrowed back
* down to the exact `kind:pubkey:d` triples bookmarked — the relay filter
* can only constrain by kind/author/`d`, not the full address.
*/
export function useMyBookmarkedArticles() {
const { nostr } = useNostr();
const targets = useBookmarkedTargets();
const addresses = useMemo(() => targets.filter((t) => t.type === 'a').map((t) => t.value), [targets]);
const parsed = useMemo(
() =>
addresses
.map(parseAddress)
.filter((a): a is ParsedAddress => a !== null && a.kind === ARTICLE_KIND),
[addresses],
);
const authors = useMemo(() => [...new Set(parsed.map((a) => a.pubkey))], [parsed]);
const dTags = useMemo(() => [...new Set(parsed.map((a) => a.identifier))], [parsed]);
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'bookmarked-articles', addresses.join(',')],
enabled: parsed.length > 0,
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ kinds: [ARTICLE_KIND], authors, '#d': dTags, limit: parsed.length * 2 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
);
const wanted = new Set(addresses);
return events.filter(
(event) =>
wanted.has(`${event.kind}:${event.pubkey}:${tagValue(event, 'd')}`) &&
event.content.trim().length > 0,
);
},
staleTime: 60_000,
});
}

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -49,6 +49,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 780, height: 760 },
minSize: { width: 360, height: 320 },
},
{
id: 'bookmarks',
title: 'Bookmarks',
description: 'Notes and articles you have saved',
icon: Bookmark,
category: 'social',
component: lazy(() => import('@/apps/bookmarks')),
defaultSize: { width: 600, height: 660 },
minSize: { width: 340, height: 300 },
},
{
id: 'relays',
title: 'Relays',