mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 22:41:46 +02:00
* 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>
128 lines
5.2 KiB
Markdown
128 lines
5.2 KiB
Markdown
# Apps
|
|
|
|
An app is a component that renders inside a window. It receives its window's parameters,
|
|
can rename its window and can rewrite its own parameters — and knows nothing else about
|
|
the OS.
|
|
|
|
## The registry
|
|
|
|
`src/os/registry.ts` is the single source of truth. Adding an entry there is all it takes
|
|
for an app to appear on the desktop, in the **Go** menu, in the command palette and in the
|
|
About app. There is no router change, no menu update, no icon grid to edit.
|
|
|
|
```ts
|
|
interface AppDefinition {
|
|
id: string; // 'feed' — also the ?app= value and the window id prefix
|
|
title: string; // default window title
|
|
description: string; // one line, shown in the palette and About
|
|
icon: LucideIcon;
|
|
category: 'social' | 'tools' | 'system';
|
|
component: LazyExoticComponent<ComponentType<AppProps>>;
|
|
defaultSize: Size; // capped to the viewport by fitSize()
|
|
minSize: Size; // enforced while resizing
|
|
resizable?: boolean; // default true
|
|
singleton?: boolean; // default true — a second open focuses the existing window
|
|
showOnDesktop?: boolean; // default true
|
|
requiresAuth?: boolean;
|
|
}
|
|
```
|
|
|
|
Every `component` is a `React.lazy(() => import('@/apps/<id>'))`, so each app is its own
|
|
code-split chunk and the initial bundle contains only the shell. `WindowFrame` supplies
|
|
the `<Suspense>` skeleton and an `ErrorBoundary` around it — a crashing app takes down its
|
|
own window, not the desktop.
|
|
|
|
## The contract
|
|
|
|
```ts
|
|
interface AppProps {
|
|
windowId: string;
|
|
params: AppParams; // Record<string, string>
|
|
setTitle: (title: string) => void; // truncated to 48 chars by the reducer
|
|
setParams: (params: AppParams) => void;
|
|
}
|
|
```
|
|
|
|
**`params` is the app's navigation state, not local state.** Putting the current
|
|
selection in `params` rather than `useState` buys three things at once: the URL describes
|
|
what is on screen, a reload restores it, and the state survives the remount when the
|
|
window switches between the desktop and mobile shells. The Reader does this with the
|
|
selected article; the Feed keeps its scope in local state because a tab choice is not
|
|
worth a URL.
|
|
|
|
`setTitle` is normally called from an effect once the content is known:
|
|
|
|
```tsx
|
|
useEffect(() => {
|
|
setTitle(name ? `Profile — ${name}` : 'Profile');
|
|
}, [name, setTitle]);
|
|
```
|
|
|
|
## Adding an app
|
|
|
|
1. Create `src/apps/<id>/index.tsx` with a **default export** taking `AppProps`.
|
|
2. Build the UI from the `AppChrome` primitives (see [`styleguide.md`](./styleguide.md)) so
|
|
it fills its window instead of centring a column like a web page.
|
|
3. Add an entry to `APPS` in `src/os/registry.ts`.
|
|
4. If the app should be reachable by a NIP-19 identifier, map that identifier to it in
|
|
`src/pages/NIP19Page.tsx`.
|
|
5. Run `npm run test`.
|
|
|
|
A minimal app:
|
|
|
|
```tsx
|
|
import { useEffect } from 'react';
|
|
import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome';
|
|
import type { AppProps } from '@/os/types';
|
|
|
|
export default function ExampleApp({ setTitle }: AppProps) {
|
|
useEffect(() => setTitle('Example'), [setTitle]);
|
|
|
|
return (
|
|
<AppLayout>
|
|
<AppToolbar>
|
|
<span className="text-[13px] font-medium">Example</span>
|
|
</AppToolbar>
|
|
<AppBody className="p-4">…</AppBody>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
```
|
|
|
|
## The eight apps
|
|
|
|
| App | `id` | Params | Notes |
|
|
|---|---|---|---|
|
|
| Feed | `feed` | — | kind 1 timeline, Following/Global, composer (⌘↵ publishes) |
|
|
| 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
|
|
list back before writing, or the edit would silently drop everyone else.
|
|
|
|
### Relay latency is a real round trip
|
|
|
|
A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE`
|
|
cycle (`{ kinds: [1], limit: 1 }`, 5s timeout). It is the only honest number available.
|
|
|
|
`useRelayStatus` samples socket state once a second — sockets have no change event to
|
|
subscribe to — and feeds both the Relays app and the menu bar indicator, so the two can
|
|
never disagree.
|
|
|
|
A **closed** socket is the resting state, not a fault: relays are opened on demand and
|
|
dropped after idling. That is why nothing turns red at "0 connected"; only a connection
|
|
that keeps trying to establish itself gets an amber dot.
|