Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54706b23f4 | |||
| d41bbe34d8 | |||
| 291d32aecc | |||
| 10a835074e | |||
| 80fcdcc821 | |||
| 0d3f374de0 | |||
| b1ed8a7796 | |||
| 8451236208 | |||
| 41b0772c98 | |||
| 82188757fa | |||
| 2c87f1c84c | |||
| fb7923d8b2 | |||
| b58191ef58 | |||
| 2f726d4022 | |||
| 8b4a222351 | |||
| 7a597155af | |||
| 93da505ab7 | |||
| 6a15cfe643 | |||
| 07f701a1c3 | |||
| af0e028cde | |||
| cb2d6618e8 | |||
| f0af458e10 | |||
| ae5d26a84c | |||
| 85bdefdbea | |||
| d9c51e9a4d | |||
| b4ffde202c | |||
| d6934c7c02 | |||
| b8b14ab4f0 | |||
| 2558743dae | |||
| 645560077f | |||
| aaa69912c0 | |||
| 42be4051e4 | |||
| 30043b1b1f | |||
| 14dd050386 | |||
| a0ac403618 | |||
| 2cd0387322 | |||
| a54b5a6193 | |||
| ddd4dd7660 | |||
| 4ff1738aa6 | |||
| 4dfa978286 | |||
| 6caf9ea668 | |||
| ece1253903 | |||
| 3a91073dbd | |||
| d37345fa75 | |||
| 9ab186fd70 | |||
| 31ee06dd3b | |||
| 9ff991671b | |||
| 08991a3ee5 | |||
| 1b484df7c6 | |||
| c9ed33b71f | |||
| 5c051f0407 | |||
| 5263b1fd01 | |||
| 07c03b1a37 | |||
| 00252d7ab1 | |||
| b317fdb566 | |||
| 0be2428678 | |||
| 9941d67810 | |||
| c5f659eb0e | |||
| e01e266c31 | |||
| 48e8ee1de9 | |||
| 6069811372 | |||
| 203fe43ca9 | |||
| 64c1cf3642 | |||
| 20e560a68c | |||
| ff8898eecf | |||
| 75e9f465c5 | |||
| b04be19f75 | |||
| dc9cba6651 | |||
| 09b15bce21 | |||
| f6fc10324e | |||
| 3a742c19f7 | |||
| f8c5316eed | |||
| fcc6d79bb7 | |||
| 57f1c912e0 | |||
| 1774741678 | |||
| 20d0594c47 | |||
| dc66420cce | |||
| d350f4f271 | |||
| 012c84ab56 | |||
| cf1baf2865 | |||
| de3a4dfb4f | |||
| f6677d1e5d | |||
| 3e01e7f53d | |||
| 3fa00abaa0 | |||
| 8c2d95f9db | |||
| d6dc3546eb | |||
| bcc72c9159 | |||
| a6b241aecc | |||
| 15557ef523 | |||
| 576473f1c2 | |||
| 05a4ce6d68 | |||
| cb6614b42e | |||
| d395ca2079 | |||
| 0fbf59b436 | |||
| 32539c0aee | |||
| a64575a13c | |||
| 9d9425c0b9 | |||
| 133b9a7227 | |||
| 9c5807f49d | |||
| e27d928e38 | |||
| 7dfad82a9b | |||
| 472c8e943f | |||
| b615e9d395 | |||
| 9a5944fff8 | |||
| a8a9cbcaf1 | |||
| d4e518bcbe | |||
| b55c37f510 | |||
| 94f034c1f0 | |||
| 1daa9a2715 | |||
| 95eb55133f | |||
| 4d4381310c | |||
| 4d2ae05c4b | |||
| 4ff019e9cf | |||
| 7cb244e8a9 | |||
| 5152ead795 | |||
| 31c4dd3f78 | |||
| 7b214ea5b2 | |||
| 47044752b9 | |||
| f35b4e7bd2 | |||
| 8bc0393b51 | |||
| 8164ccafa5 | |||
| e54b9908fa | |||
| 98560717d6 | |||
| b8124d5069 | |||
| 52b3755727 | |||
| 31baa83fa3 | |||
| 41016780c2 | |||
| 01209dbce4 | |||
| f734d682fc | |||
| 1512630878 | |||
| bffa8c58b4 | |||
| 389962cb47 | |||
| 3db50579fd | |||
| 6a8e2acf4b | |||
| 4242aa2b50 | |||
| dea74fa9ef | |||
| df9755c6b3 | |||
| 575f65d803 | |||
| 8c4a8f469d | |||
| 7a43e418e2 | |||
| 44363efabd | |||
| 1b9adf76ad | |||
| 5b2495bd63 | |||
| 5b7ac2c655 | |||
| 781593dc4f | |||
| b828f7bddb | |||
| 46ed3deb5c | |||
| 18019e7989 |
@@ -6,6 +6,7 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 777 | Spell | Portable Nostr relay query (saved feed / custom feed) |
|
||||
| 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) |
|
||||
@@ -30,6 +31,91 @@ These event kinds were created by community contributors and are supported by Di
|
||||
|
||||
---
|
||||
|
||||
## Kind 777: Spell (NIP-A7)
|
||||
|
||||
### Summary
|
||||
|
||||
Regular (non-replaceable) event that encodes a Nostr relay query filter as a portable, shareable event. Spells function as saved feeds — users can publish, discover, and execute them across clients.
|
||||
|
||||
See [NIP-A7](https://github.com/nostr-protocol/nips) for the full specification.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 777,
|
||||
"content": "Notes about Bitcoin from my contacts",
|
||||
"tags": [
|
||||
["cmd", "REQ"],
|
||||
["name", "Bitcoin from contacts"],
|
||||
["alt", "Spell: Bitcoin from contacts"],
|
||||
["k", "1"],
|
||||
["authors", "$contacts"],
|
||||
["tag", "t", "bitcoin"],
|
||||
["since", "7d"],
|
||||
["limit", "50"],
|
||||
["media", "images"],
|
||||
["language", "en"],
|
||||
["sort", "trending"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field contains a human-readable description of the query in plain text. It MAY be an empty string.
|
||||
|
||||
### Filter Tags
|
||||
|
||||
| Tag | Values | Description |
|
||||
|-----------|-----------------------------------------|------------------------------------------|
|
||||
| `cmd` | `REQ` or `COUNT` | Query command type (required) |
|
||||
| `k` | `<kind number>` | Kind filter — one tag per kind |
|
||||
| `authors` | `<pubkey1>`, `<pubkey2>`, ... | Author filter (supports `$me`, `$contacts`) |
|
||||
| `ids` | `<id1>`, `<id2>`, ... | Event ID filter |
|
||||
| `tag` | `<letter>`, `<val1>`, `<val2>`, ... | Tag filter → `#<letter>` in NostrFilter |
|
||||
| `limit` | `<integer>` | Max results |
|
||||
| `since` | `<timestamp>` or `<relative>` | Start time (supports relative: `7d`, `2w`, `1mo`, `1y`) |
|
||||
| `until` | `<timestamp>` or `<relative>` | End time (same format as since) |
|
||||
| `search` | `<query string>` | NIP-50 full-text search |
|
||||
| `relays` | `<wss://url1>`, `<wss://url2>`, ... | Target relay URLs |
|
||||
|
||||
### Client-Hint Tags
|
||||
|
||||
These tags instruct clients how to build NIP-50 search extensions. They are NOT part of the NIP-01 filter — they are metadata that clients use to construct search strings and apply client-side filters.
|
||||
|
||||
| Tag | Values | Description |
|
||||
|------------------|-------------------------------------------|----------------------------------------------------|
|
||||
| `media` | `images`, `videos`, `vines`, `none` | Media type filter (omit for all) |
|
||||
| `language` | ISO 639-1 code (e.g. `en`, `ja`) | Language filter |
|
||||
| `platform` | `nostr`, `activitypub`, `atproto` | Protocol filter (omit for `nostr`) |
|
||||
| `sort` | `hot`, `trending` | Sort preference (omit for `recent`) |
|
||||
| `include-replies`| `false` | Exclude replies (omit to include) |
|
||||
|
||||
Client-hint tags that use NIP-50 extensions (`media`, `language`, `platform`, `sort`) require a relay that supports these extensions (e.g. Ditto relay). Clients SHOULD route queries with these extensions to a compatible relay.
|
||||
|
||||
### Metadata Tags
|
||||
|
||||
| Tag | Values | Description |
|
||||
|------------------|------------|------------------------------------------------|
|
||||
| `name` | `<string>` | Human-readable spell name |
|
||||
| `alt` | `<string>` | NIP-31 alternative text |
|
||||
| `t` | `<topic>` | Topic tag for categorization |
|
||||
| `close-on-eose` | none | Close subscription after EOSE |
|
||||
|
||||
### Runtime Variables
|
||||
|
||||
| Variable | Resolves to |
|
||||
|-------------|-------------------------------------------------------|
|
||||
| `$me` | The executing user's pubkey |
|
||||
| `$contacts` | All pubkeys from the executing user's kind 3 contact list |
|
||||
|
||||
### Relative Timestamps
|
||||
|
||||
`since` and `until` support relative durations: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months/30d), `y` (years/365d). `now` = current timestamp.
|
||||
|
||||
---
|
||||
|
||||
## Kind 36767: Theme Definition
|
||||
|
||||
### Summary
|
||||
@@ -196,7 +282,7 @@ Format: `["bg", "url <url>", "mode <mode>", "m <mime-type>", ...]`
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event kind for publishing a user's custom profile page tabs. Exactly one event per user (no `d` tag). Each tab defines a Nostr filter (NIP-01) that clients execute to populate the tab's content.
|
||||
Replaceable event kind for publishing a user's custom profile page tabs. Exactly one event per user (no `d` tag). Each tab stores a kind:777 spell event (JSON-encoded) that clients execute to populate the tab's content.
|
||||
|
||||
Visitors who load a profile fetch this event to display the custom tabs alongside the standard Posts / Media / Likes / Wall tabs.
|
||||
|
||||
@@ -207,10 +293,9 @@ Visitors who load a profile fetch this event to display the custom tabs alongsid
|
||||
"kind": 16769,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["var", "$follows", "p", "a:3:$me:"],
|
||||
["tab", "Bitcoin Posts", "{\"kinds\":[1],\"authors\":[\"$me\"],\"search\":\"bitcoin\"}"],
|
||||
["tab", "Feed", "{\"kinds\":[1,6],\"authors\":[\"$follows\"],\"limit\":40}"],
|
||||
["alt", "Custom profile tabs"]
|
||||
["alt", "Custom profile tabs"],
|
||||
["tab", "Bitcoin Posts", "{\"kind\":777,\"tags\":[[\"cmd\",\"REQ\"],[\"name\",\"Bitcoin Posts\"],[\"k\",\"1\"],[\"authors\",\"$me\"],[\"search\",\"bitcoin\"],[\"alt\",\"Spell: Bitcoin Posts\"]],\"content\":\"\",\"id\":\"\",\"pubkey\":\"\",\"created_at\":0,\"sig\":\"\"}"],
|
||||
["tab", "Feed", "{\"kind\":777,\"tags\":[[\"cmd\",\"REQ\"],[\"name\",\"Feed\"],[\"k\",\"1\"],[\"k\",\"6\"],[\"authors\",\"$contacts\"],[\"alt\",\"Spell: Feed\"]],\"content\":\"\",\"id\":\"\",\"pubkey\":\"\",\"created_at\":0,\"sig\":\"\"}"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -223,68 +308,101 @@ The `content` field is unused and MUST be an empty string (`""`).
|
||||
|
||||
| Tag | Format | Description |
|
||||
|-------|------------------------------------------------|----------------------------------------------------------------|
|
||||
| `tab` | `["tab", "<label>", "<filterJSON>"]` | One tag per custom tab. Order defines display order. |
|
||||
| `var` | `["var", "<$name>", "<tag>", "<pointer>"]` | Variable definition. See [Variable Tags](#variable-tags). |
|
||||
| `tab` | `["tab", "<label>", "<spellJSON>"]` | One tag per custom tab. Order defines display order. |
|
||||
| `alt` | `["alt", "Custom profile tabs"]` | NIP-31 human-readable fallback. Required. |
|
||||
|
||||
### Tab Filter JSON
|
||||
### Tab Spell JSON
|
||||
|
||||
The third element of each `tab` tag is a JSON-encoded **NIP-01 filter object**, optionally extended with the NIP-50 `search` field. Variable placeholders (strings starting with `$`) may appear wherever a string value is expected.
|
||||
The third element of each `tab` tag is a JSON-encoded **kind:777 spell event** (see [Kind 777](#kind-777-spell-nip-a7)). The spell event contains all filter parameters, runtime variables (`$me`, `$contacts`), and client hints (media type, language, sort, etc.) in its tags.
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [1],
|
||||
"authors": ["$me"],
|
||||
"search": "bitcoin",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
Supported filter fields: `ids`, `authors`, `kinds`, `#<tag>` (e.g. `#t`, `#e`, `#p`), `since`, `until`, `limit`, `search`.
|
||||
|
||||
### Variable Tags
|
||||
|
||||
Variable tags define named placeholders that are resolved before the filter is executed. Each `var` tag extracts tag values from a referenced Nostr event.
|
||||
|
||||
Format: `["var", "$name", "<tag-to-extract>", "<event-pointer>"]`
|
||||
|
||||
| Index | Description |
|
||||
|-------|--------------------------------------------------------------------------------------------------|
|
||||
| 0 | Tag name: `"var"` |
|
||||
| 1 | Variable name, starting with `$` (e.g. `"$follows"`) |
|
||||
| 2 | Tag name to extract values from in the referenced event (e.g. `"p"`) |
|
||||
| 3 | Event pointer: `e:<event-id>` for a specific event, or `a:<kind>:<pubkey>:<d-tag>` for an addressable/replaceable event coordinate. Variables like `$me` may appear in the pubkey position. |
|
||||
|
||||
Example — extract follow list pubkeys:
|
||||
```json
|
||||
["var", "$follows", "p", "a:3:$me:"]
|
||||
```
|
||||
|
||||
This means: fetch the kind 3 event authored by `$me`, extract all `p` tag values, and bind them to `$follows`.
|
||||
|
||||
### Reserved Variable: `$me`
|
||||
|
||||
The `$me` variable is the only runtime-provided variable. It resolves to the **profile owner's pubkey** (the author of the kind 16769 event). It does not require a `var` tag definition.
|
||||
|
||||
### Variable Resolution
|
||||
|
||||
When a variable appears in a filter field that expects an array (e.g. `authors`, `ids`, `#p`), the variable is **expanded in-place** (spliced into the array). Literal values may be mixed with variables.
|
||||
|
||||
```json
|
||||
["tab", "Mixed", "{\"authors\":[\"$follows\",\"abc123...\"],\"kinds\":[1]}"]
|
||||
```
|
||||
|
||||
After resolution (assuming `$follows` = `["pk1", "pk2"]`):
|
||||
```json
|
||||
{"authors": ["pk1", "pk2", "abc123..."], "kinds": [1]}
|
||||
```
|
||||
The spell does not need to be signed or published to relays -- it is stored inline as a structural template. The `id`, `pubkey`, `created_at`, and `sig` fields may be empty strings or zero.
|
||||
|
||||
### Behavior
|
||||
|
||||
- To **add or update** tabs: publish a new kind 16769 event with all current `tab` and `var` tags.
|
||||
- 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.
|
||||
- `var` tags are shared across all `tab` tags in the same event.
|
||||
|
||||
---
|
||||
|
||||
## Kind 30078: Buddy AI Agent Identity
|
||||
|
||||
### Summary
|
||||
|
||||
Uses NIP-78 (Application-specific data, kind 30078) to store a user's personal AI agent ("Buddy") identity. The event is signed by the user (owner), linking the agent to their account. The agent has its own Nostr keypair and kind 0 profile.
|
||||
|
||||
Public metadata is in tags. The agent's secret key and soul (personality/behavior description) are NIP-44 encrypted to the owner in the `content` field.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<owner-pubkey>",
|
||||
"tags": [
|
||||
["d", "<appId>/buddy"],
|
||||
["p", "<agent-pubkey>"],
|
||||
["alt", "Buddy AI agent identity"],
|
||||
["client", "Ditto", "<optional-nip89-addr>"]
|
||||
],
|
||||
"content": "<NIP-44 encrypted to owner: { nsec, soul }>"
|
||||
}
|
||||
```
|
||||
|
||||
### Content (Encrypted)
|
||||
|
||||
The `content` field contains a NIP-44 payload encrypted to the owner's own pubkey (encrypt-to-self). When decrypted, it yields a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"nsec": "<agent-secret-key-hex>",
|
||||
"name": "Sparkles",
|
||||
"soul": "A witty space explorer who explains everything with analogies and never takes itself too seriously."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|--------|----------|-----------------------------------------------------------------------------|
|
||||
| `nsec` | Yes | Agent's secret key as a 64-character hex string |
|
||||
| `name` | Yes | The buddy's canonical name (source of truth; the agent's kind 0 may use nicknames) |
|
||||
| `soul` | Yes | Free-form text describing the agent's personality, injected into the system prompt via `{{SOUL}}` |
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|-----------------------------------------------------------------------------|
|
||||
| `d` | Yes | `<appId>/buddy` — one buddy per user per app (e.g. `ditto/buddy`) |
|
||||
| `p` | Yes | Agent's public key (hex) — links to the agent's kind 0 profile |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback |
|
||||
| `client` | Yes | NIP-89 client tag identifying the publishing application |
|
||||
|
||||
### Agent Profile (Kind 0)
|
||||
|
||||
The buddy agent has its own kind 0 event signed with its own keypair. This is a standard Nostr profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 0,
|
||||
"pubkey": "<agent-pubkey>",
|
||||
"content": "{\"name\":\"Sparkles\",\"about\":\"A witty space explorer...\",\"bot\":true}"
|
||||
}
|
||||
```
|
||||
|
||||
The `bot` field SHOULD be set to `true` per NIP-24 to indicate the profile is automated.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- On **creation**: generate a keypair, store nsec in localStorage for fast access, publish kind 0 (agent profile) and kind 30078 (identity event).
|
||||
- On **page load**: read nsec from localStorage first. If missing, fetch kind 30078 from relays, decrypt, and restore nsec to localStorage.
|
||||
- On **soul update**: re-encrypt and republish the kind 30078 event. Also update the agent's kind 0 `about` field.
|
||||
- On **reset**: clear localStorage, publish an empty kind 30078 event to overwrite on relays.
|
||||
- The `soul` text is injected into the AI system prompt template at the `{{SOUL}}` placeholder. The base system prompt (tool instructions, etc.) is maintained in application code, not in the event.
|
||||
|
||||
### Security
|
||||
|
||||
- The kind 30078 event MUST be queried with `authors: [ownerPubkey]` to prevent spoofing.
|
||||
- The nsec is NIP-44 encrypted — only the owner can decrypt it.
|
||||
- The agent's keypair is separate from the user's keypair. Compromise of the agent key does not affect the user's identity.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+22
@@ -21,6 +21,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@fontsource-variable/comfortaa": "^5.2.8",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/fredoka": "^5.2.10",
|
||||
@@ -1297,6 +1298,21 @@
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.19",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
|
||||
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
@@ -13411,6 +13427,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@fontsource-variable/comfortaa": "^5.2.8",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/fredoka": "^5.2.10",
|
||||
|
||||
+9
-1
@@ -24,6 +24,8 @@ import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { ScreenEffectProvider } from "@/contexts/ScreenEffectContext";
|
||||
import { ScreenEffectRenderer } from "@/components/ScreenEffectRenderer";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -121,9 +123,10 @@ const hardcodedConfig: AppConfig = {
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
"search",
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"discover",
|
||||
"blobbi",
|
||||
"badges",
|
||||
"emojis",
|
||||
@@ -156,6 +159,8 @@ const hardcodedConfig: AppConfig = {
|
||||
{ id: 'hot-posts' },
|
||||
{ id: 'wikipedia' },
|
||||
],
|
||||
aiModel: '',
|
||||
aiSystemPrompt: '',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -212,13 +217,16 @@ export function App() {
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<ScreenEffectProvider>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<ScreenEffectRenderer />
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
</TooltipProvider>
|
||||
</EmotionDevProvider>
|
||||
</ScreenEffectProvider>
|
||||
</DMProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
|
||||
+4
-1
@@ -67,6 +67,7 @@ const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => (
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
|
||||
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
@@ -160,7 +161,8 @@ export function AppRouter() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/discover" element={<SearchPage />} />
|
||||
<Route path="/search" element={<Navigate to="/discover" replace />} />
|
||||
<Route path="/trends" element={<TrendsPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
<Route path="/t/:tag" element={<HashtagPage />} />
|
||||
@@ -267,6 +269,7 @@ export function AppRouter() {
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Palette, Type } from 'lucide-react';
|
||||
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { DisplayMessage, ToolCall } from '@/lib/aiChatTools';
|
||||
|
||||
// ─── Thinking Animation ───
|
||||
|
||||
export const BUDDY_ANIMATION = [
|
||||
'<[o_o]>',
|
||||
'>[-_-]<',
|
||||
'<[0_0]>',
|
||||
'>[-_-]<',
|
||||
];
|
||||
|
||||
export function BuddyThinking() {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre className="text-sm font-mono text-muted-foreground leading-none">{BUDDY_ANIMATION[frame]}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message Bubble ───
|
||||
|
||||
export function MessageBubble({ message }: { message: DisplayMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start', isUser && 'justify-end')}>
|
||||
<div className={cn('flex flex-col gap-1 max-w-[85%] min-w-0', isUser && 'items-end')}>
|
||||
{/* Hide the bubble entirely when the assistant message is empty (tool-only turn) */}
|
||||
{(isUser || message.content.trim()) && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl px-4 py-2.5 text-sm',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-tr-md'
|
||||
: 'bg-secondary border border-border rounded-tl-md',
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-blockquote:text-muted-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-code:text-primary prose-code:before:content-none prose-code:after:content-none"
|
||||
style={{ '--tw-prose-links': 'hsl(var(--primary))', '--tw-prose-quote-borders': 'hsl(var(--border))' } as React.CSSProperties}
|
||||
>
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Nostr event (e.g. a spell created by a tool) */}
|
||||
{message.nostrEvent && (
|
||||
<div className="w-full rounded-xl overflow-hidden border border-border mt-1 bg-background">
|
||||
<NoteCard event={message.nostrEvent} compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call indicators */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{message.toolCalls.map((tc) => (
|
||||
<ToolCallBadge key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground/60 px-1">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tool Call Badge ───
|
||||
|
||||
export function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
|
||||
let resultParsed: {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
colors?: { background?: string; text?: string; primary?: string };
|
||||
font?: string;
|
||||
background?: { url?: string; mode?: string };
|
||||
} = {};
|
||||
try {
|
||||
resultParsed = JSON.parse(toolCall.result || '{}');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const isSuccess = resultParsed.success === true;
|
||||
const colors = resultParsed.colors;
|
||||
|
||||
if (toolCall.name !== 'set_theme' || !isSuccess) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium',
|
||||
isSuccess
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20'
|
||||
: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border border-orange-500/20',
|
||||
)}>
|
||||
<Palette className="size-3" />
|
||||
{resultParsed.error || toolCall.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-[11px] font-medium bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20">
|
||||
{/* Color swatches */}
|
||||
{colors && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.background})` }} />
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.text})` }} />
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.primary})` }} />
|
||||
</span>
|
||||
)}
|
||||
Theme applied
|
||||
{resultParsed.font && (
|
||||
<span className="inline-flex items-center gap-0.5 opacity-80">
|
||||
<Type className="size-2.5" />
|
||||
{resultParsed.font}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
import { MessageBubble, BuddyThinking } from '@/components/AIChat/AIChatComponents';
|
||||
import { useBuddyOnboarding } from '@/hooks/useBuddyOnboarding';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BuddyOnboardingProps {
|
||||
/** Additional class names for the outer wrapper. */
|
||||
className?: string;
|
||||
/** Inline styles for the outer wrapper (e.g. dynamic padding). */
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Called when buddy creation is complete.
|
||||
* The parent can use this to close a sheet, navigate, etc.
|
||||
* If not provided the component simply unmounts itself.
|
||||
*/
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversational buddy-creation flow powered by "Dork".
|
||||
*
|
||||
* Renders the message list + input bar. Does NOT include a page shell
|
||||
* or header — the parent is responsible for wrapping it in whatever
|
||||
* layout it needs (full page, sheet, etc.).
|
||||
*/
|
||||
export function BuddyOnboarding({ className, style, onComplete }: BuddyOnboardingProps) {
|
||||
const {
|
||||
messages, handleSend, isCreating, isDone, placeholder, error,
|
||||
} = useBuddyOnboarding();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useMemo(() => ({ current: null as HTMLDivElement | null }), []);
|
||||
|
||||
const onSend = useCallback(() => {
|
||||
if (!input.trim() || isCreating) return;
|
||||
handleSend(input);
|
||||
setInput('');
|
||||
}, [input, isCreating, handleSend]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}, [onSend]);
|
||||
|
||||
// Buddy created — notify parent or silently disappear
|
||||
if (isDone) {
|
||||
onComplete?.();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col overflow-hidden', className)} style={style}>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
{messages.filter((msg) => msg.role !== 'tool_result').map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
{isCreating && <BuddyThinking />}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={(el) => { messagesEndRef.current = el; }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isCreating}
|
||||
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || isCreating}
|
||||
size="icon"
|
||||
className="size-11 shrink-0 rounded-xl"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { MessageBubble, BUDDY_ANIMATION } from '@/components/AIChat/AIChatComponents';
|
||||
import { BuddyOnboarding } from '@/components/AIChat/BuddyOnboarding';
|
||||
import { useAIChatSession } from '@/hooks/useAIChatSession';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MobileBuddySheetProps {
|
||||
hidden: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileBuddySheet({ hidden, onClose }: MobileBuddySheetProps) {
|
||||
const { buddy, hasBuddy } = useBuddy();
|
||||
|
||||
// Show the onboarding flow when no buddy exists yet
|
||||
if (!hasBuddy) {
|
||||
return (
|
||||
<div className={cn('fixed inset-0 z-[49] sidebar:hidden flex flex-col overflow-hidden', hidden && 'hidden')}>
|
||||
<BuddyOnboarding
|
||||
className="flex-1"
|
||||
style={{ paddingBottom: 'calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px))' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MobileBuddyChat buddy={buddy!} hidden={hidden} onClose={onClose} />;
|
||||
}
|
||||
|
||||
// ─── Chat View (buddy exists) ───
|
||||
|
||||
import type { BuddyIdentity } from '@/hooks/useBuddy';
|
||||
|
||||
function MobileBuddyChat({ buddy, hidden, onClose }: { buddy: BuddyIdentity; hidden: boolean; onClose: () => void }) {
|
||||
const {
|
||||
messages, input, setInput, isStreaming, streamingText, selectedModel,
|
||||
apiLoading, messagesEndRef,
|
||||
handleSend, handleStop,
|
||||
} = useAIChatSession({ buddyName: buddy.name, buddySoul: buddy.soul });
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [animFrame, setAnimFrame] = useState(0);
|
||||
|
||||
// Animate the toggle button when streaming
|
||||
useEffect(() => {
|
||||
if (!isStreaming) { setAnimFrame(0); return; }
|
||||
const interval = setInterval(() => {
|
||||
setAnimFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Focus input when shown
|
||||
useEffect(() => {
|
||||
if (!hidden) {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 80);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [hidden]);
|
||||
|
||||
// Scroll to bottom when messages change or streaming text updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingText, messagesEndRef]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (isStreaming) {
|
||||
handleStop();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}, [onClose, handleSend, handleStop, isStreaming]);
|
||||
|
||||
const visibleMessages = messages.filter((msg) => msg.role !== 'tool_result');
|
||||
const displayName = buddy.name;
|
||||
|
||||
return (
|
||||
<div className={cn('fixed inset-0 z-[49] sidebar:hidden flex flex-col overflow-hidden', hidden && 'hidden')} onClick={onClose}>
|
||||
|
||||
{/* Messages area — fills from top, scrollable, padded at bottom to clear the fixed input bar.
|
||||
stopPropagation on the content wrapper so clicking a bubble doesn't close the sheet. */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overscroll-contain px-6 pt-4"
|
||||
style={{ paddingBottom: 'calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px) + 70px)' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{visibleMessages.map((msg) => (
|
||||
<div key={msg.id} onClick={(e) => e.stopPropagation()}>
|
||||
<MessageBubble message={msg} />
|
||||
</div>
|
||||
))}
|
||||
{streamingText && (isStreaming || apiLoading) && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<MessageBubble message={{ id: 'streaming', role: 'assistant', content: streamingText, timestamp: new Date() }} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar — pinned to bottom-mobile-nav position */}
|
||||
<div className="flex items-center px-6 py-3 bottom-mobile-nav fixed left-0 right-0 z-[49]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
|
||||
<Bot className="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask ${displayName}...`}
|
||||
disabled={!selectedModel}
|
||||
className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground disabled:opacity-50"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isStreaming) {
|
||||
handleStop();
|
||||
} else {
|
||||
setAnimFrame((f) => (f + 1) % BUDDY_ANIMATION.length);
|
||||
}
|
||||
}}
|
||||
className="shrink-0 font-mono text-xs text-primary transition-colors"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{BUDDY_ANIMATION[animFrame]}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+350
-286
@@ -1,33 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useShakespeare, type Model } from '@/hooks/useShakespeare';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
|
||||
export function AdvancedSettings() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{user && <BuddySettingsSection />}
|
||||
<SystemSettingsSection />
|
||||
<SentrySettingsSection />
|
||||
{user && <DangerSettingsSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section components ────────────────────────────────────────────────────────
|
||||
|
||||
function SettingsSection({
|
||||
title, icon, open, onOpenChange, accentColor, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; open: boolean; onOpenChange: (v: boolean) => void;
|
||||
accentColor?: string; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className={`flex items-center gap-2 text-base font-semibold ${accentColor ?? ''}`}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1 rounded-full ${accentColor === 'text-destructive' ? 'bg-destructive' : 'bg-primary'}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuddySettingsSection() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { getAvailableModels } = useShakespeare();
|
||||
const { buddy, hasBuddy, updateSoul } = useBuddy();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiModels, setAiModels] = useState<Model[]>([]);
|
||||
const [aiModelsLoading, setAiModelsLoading] = useState(false);
|
||||
const [soulDraft, setSoulDraft] = useState('');
|
||||
const [soulSaving, setSoulSaving] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
|
||||
useEffect(() => {
|
||||
if (buddy?.soul) setSoulDraft(buddy.soul);
|
||||
}, [buddy?.soul]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !user || aiModels.length > 0) return;
|
||||
let cancelled = false;
|
||||
setAiModelsLoading(true);
|
||||
getAvailableModels()
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const sorted = response.data.sort((a, b) => {
|
||||
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
||||
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
||||
return costA - costB;
|
||||
});
|
||||
setAiModels(sorted);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setAiModelsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, user, aiModels.length, getAvailableModels]);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Buddy" open={open} onOpenChange={setOpen}>
|
||||
<div className="px-4 py-4 space-y-4 border-b border-border">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">Model</Label>
|
||||
<Select
|
||||
value={config.aiModel || (aiModels.length > 0 ? aiModels[0].id : '')}
|
||||
onValueChange={(value) => {
|
||||
updateConfig(() => ({ aiModel: value }));
|
||||
toast({ title: 'AI model updated' });
|
||||
}}
|
||||
disabled={aiModelsLoading || aiModels.length === 0}
|
||||
>
|
||||
<SelectTrigger id="ai-model">
|
||||
<SelectValue placeholder={aiModelsLoading ? 'Loading models...' : 'Select model'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiModels.map((model) => {
|
||||
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
|
||||
const isFree = totalCost === 0;
|
||||
return (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{model.name}
|
||||
{isFree && (
|
||||
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
|
||||
FREE
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which AI model your buddy uses for chat responses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasBuddy && buddy && (
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<Label className="text-sm font-medium">Identity</Label>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-12 shrink-0">Name</span>
|
||||
<span className="font-medium">{buddy.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-12 shrink-0">npub</span>
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">{nip19.npubEncode(buddy.pubkey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="buddy-soul">Soul</Label>
|
||||
<Textarea
|
||||
id="buddy-soul"
|
||||
value={soulDraft}
|
||||
onChange={(e) => setSoulDraft(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = soulDraft.trim();
|
||||
if (trimmed && trimmed !== buddy.soul) {
|
||||
setSoulSaving(true);
|
||||
try {
|
||||
await updateSoul.mutateAsync(trimmed);
|
||||
toast({ title: 'Buddy soul updated' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update soul', variant: 'destructive' });
|
||||
} finally {
|
||||
setSoulSaving(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Describe your buddy's personality..."
|
||||
className="min-h-[100px] max-h-[400px] resize-y font-mono text-xs leading-relaxed"
|
||||
disabled={soulSaving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your buddy's personality and behavior. Changes are saved when you click away.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasBuddy && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No buddy configured. Visit the Buddy page to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label htmlFor="ai-system-prompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="ai-system-prompt"
|
||||
value={systemPromptDraft}
|
||||
onChange={(e) => setSystemPromptDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = systemPromptDraft.trim();
|
||||
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
|
||||
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
|
||||
if (valueToStore !== config.aiSystemPrompt) {
|
||||
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
|
||||
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
|
||||
}
|
||||
}}
|
||||
className="min-h-[120px] max-h-[400px] resize-y font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The base system prompt sent to the AI. Use <code className="bg-muted px-1 rounded">{'{{NAME}}'}</code> and <code className="bg-muted px-1 rounded">{'{{SOUL}}'}</code> as placeholders for your buddy's identity.
|
||||
</p>
|
||||
{config.aiSystemPrompt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
updateConfig(() => ({ aiSystemPrompt: '' }));
|
||||
toast({ title: 'System prompt reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset to default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemSettingsSection() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { toast } = useToast();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const [systemOpen, setSystemOpen] = useState(true);
|
||||
const [sentryOpen, setSentryOpen] = useState(false);
|
||||
const [dangerOpen, setDangerOpen] = useState(false);
|
||||
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
|
||||
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
|
||||
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
|
||||
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
|
||||
|
||||
const handleStatsPubkeyChange = (value: string) => {
|
||||
setStatsPubkey(value);
|
||||
@@ -41,287 +262,130 @@ export function AdvancedSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* System Section (includes Stats Source) */}
|
||||
<div>
|
||||
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="text-base font-semibold">System</span>
|
||||
{systemOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
|
||||
{/* Stats Source */}
|
||||
<div>
|
||||
<Label htmlFor="stats-pubkey" className="text-sm font-medium">
|
||||
NIP-85 Stats Pubkey
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Trusted pubkey for pre-computed engagement stats (likes, reposts, comments).
|
||||
</p>
|
||||
<Input
|
||||
id="stats-pubkey"
|
||||
value={statsPubkey}
|
||||
onChange={(e) => handleStatsPubkeyChange(e.target.value)}
|
||||
placeholder="Enter 64-character hex pubkey"
|
||||
className="font-mono text-base md:text-sm"
|
||||
maxLength={64}
|
||||
/>
|
||||
{statsPubkey && statsPubkey.length !== 64 && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
Pubkey must be exactly 64 hexadecimal characters
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
<span className="font-medium">Default: </span>
|
||||
<span className="font-mono break-all">5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Favicon URL */}
|
||||
<div>
|
||||
<Label htmlFor="favicon-url" className="text-sm font-medium">
|
||||
Favicon URL
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
|
||||
</p>
|
||||
<Input
|
||||
id="favicon-url"
|
||||
value={faviconUrl}
|
||||
onChange={(e) => setFaviconUrl(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = faviconUrl.trim();
|
||||
if (trimmed && trimmed !== config.faviconUrl) {
|
||||
updateConfig(() => ({ faviconUrl: trimmed }));
|
||||
if (user) await updateSettings.mutateAsync({ faviconUrl: trimmed });
|
||||
toast({ title: 'Favicon URL updated' });
|
||||
}
|
||||
}}
|
||||
placeholder="https://ditto.pub/api/favicon/{hostname}"
|
||||
className="font-mono text-base md:text-sm"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
<span className="font-medium">Default: </span>
|
||||
<span className="font-mono break-all">https://ditto.pub/api/favicon/{'{hostname}'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Preview URL */}
|
||||
<div>
|
||||
<Label htmlFor="link-preview-url" className="text-sm font-medium">
|
||||
Link Preview URL
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
URI template for fetching link previews (returns OEmbed JSON). Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{url}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
|
||||
</p>
|
||||
<Input
|
||||
id="link-preview-url"
|
||||
value={linkPreviewUrl}
|
||||
onChange={(e) => setLinkPreviewUrl(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = linkPreviewUrl.trim();
|
||||
if (trimmed && trimmed !== config.linkPreviewUrl) {
|
||||
updateConfig(() => ({ linkPreviewUrl: trimmed }));
|
||||
if (user) await updateSettings.mutateAsync({ linkPreviewUrl: trimmed });
|
||||
toast({ title: 'Link preview URL updated' });
|
||||
}
|
||||
}}
|
||||
placeholder="https://ditto.pub/api/link-preview/{url}"
|
||||
className="font-mono text-base md:text-sm"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
<span className="font-medium">Default: </span>
|
||||
<span className="font-mono break-all">https://ditto.pub/api/link-preview/{'{url}'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CORS Proxy */}
|
||||
<div>
|
||||
<Label htmlFor="cors-proxy" className="text-sm font-medium">
|
||||
CORS Proxy
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Proxy for cross-origin requests (NIP-05 fallback). Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the target URL.
|
||||
</p>
|
||||
<Input
|
||||
id="cors-proxy"
|
||||
value={corsProxy}
|
||||
onChange={(e) => setCorsProxy(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = corsProxy.trim();
|
||||
if (trimmed && trimmed !== config.corsProxy) {
|
||||
updateConfig(() => ({ corsProxy: trimmed }));
|
||||
if (user) await updateSettings.mutateAsync({ corsProxy: trimmed });
|
||||
toast({ title: 'CORS proxy updated' });
|
||||
}
|
||||
}}
|
||||
placeholder="https://proxy.shakespeare.diy/?url={href}"
|
||||
className="font-mono text-base md:text-sm"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
<span className="font-medium">Default: </span>
|
||||
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Error Reporting Section */}
|
||||
<div>
|
||||
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold">
|
||||
<Bug className="h-4 w-4" />
|
||||
Error Reporting
|
||||
</span>
|
||||
{sentryOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
|
||||
{/* Share error reports toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sentry-enabled" className="text-sm font-medium">
|
||||
Share error reports
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Help improve this app by automatically sending crash and error reports.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sentry-enabled"
|
||||
checked={config.sentryEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig((current) => ({ ...current, sentryEnabled: checked }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sentry DSN */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="sentry-dsn" className="text-sm font-medium">
|
||||
Sentry DSN
|
||||
{sentryDsn !== DEFAULT_SENTRY_DSN && (
|
||||
<span className="ml-2 inline-block w-2 h-2 rounded-full bg-yellow-400" title="Modified from default" />
|
||||
)}
|
||||
</Label>
|
||||
{sentryDsn !== DEFAULT_SENTRY_DSN && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to default"
|
||||
onClick={async () => {
|
||||
setSentryDsn(DEFAULT_SENTRY_DSN);
|
||||
updateConfig((current) => ({ ...current, sentryDsn: DEFAULT_SENTRY_DSN }));
|
||||
if (user) await updateSettings.mutateAsync({ sentryDsn: DEFAULT_SENTRY_DSN });
|
||||
toast({ title: 'Sentry DSN restored to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Sentry Data Source Name (DSN) for error reporting. Leave empty to disable Sentry.
|
||||
</p>
|
||||
<Input
|
||||
id="sentry-dsn"
|
||||
value={sentryDsn}
|
||||
onChange={(e) => setSentryDsn(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const trimmed = sentryDsn.trim();
|
||||
if (trimmed !== config.sentryDsn) {
|
||||
updateConfig((current) => ({ ...current, sentryDsn: trimmed }));
|
||||
if (user) await updateSettings.mutateAsync({ sentryDsn: trimmed });
|
||||
toast({ title: trimmed ? 'Sentry DSN updated' : 'Sentry DSN cleared' });
|
||||
}
|
||||
}}
|
||||
placeholder="https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
className="font-mono text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone Section — only when logged in */}
|
||||
{user && (
|
||||
<SettingsSection title="System" open={open} onOpenChange={setOpen}>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
<div>
|
||||
<Collapsible open={dangerOpen} onOpenChange={setDangerOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Danger Zone
|
||||
</span>
|
||||
{dangerOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-destructive rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<RequestToVanishDialog
|
||||
open={vanishDialogOpen}
|
||||
onOpenChange={setVanishDialogOpen}
|
||||
/>
|
||||
<Label htmlFor="stats-pubkey" className="text-sm font-medium">NIP-85 Stats Pubkey</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">Trusted pubkey for pre-computed engagement stats (likes, reposts, comments).</p>
|
||||
<Input id="stats-pubkey" value={statsPubkey} onChange={(e) => handleStatsPubkeyChange(e.target.value)} placeholder="Enter 64-character hex pubkey" className="font-mono text-base md:text-sm" maxLength={64} />
|
||||
{statsPubkey && statsPubkey.length !== 64 && <p className="text-xs text-destructive mt-1">Pubkey must be exactly 64 hexadecimal characters</p>}
|
||||
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="favicon-url" className="text-sm font-medium">Favicon URL</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.</p>
|
||||
<Input id="favicon-url" value={faviconUrl} onChange={(e) => setFaviconUrl(e.target.value)} onBlur={async () => { const trimmed = faviconUrl.trim(); if (trimmed && trimmed !== config.faviconUrl) { updateConfig(() => ({ faviconUrl: trimmed })); if (user) await updateSettings.mutateAsync({ faviconUrl: trimmed }); toast({ title: 'Favicon URL updated' }); } }} placeholder="https://ditto.pub/api/favicon/{hostname}" className="font-mono text-base md:text-sm" />
|
||||
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://ditto.pub/api/favicon/{'{hostname}'}</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="link-preview-url" className="text-sm font-medium">Link Preview URL</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">URI template for fetching link previews (returns OEmbed JSON). Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{url}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.</p>
|
||||
<Input id="link-preview-url" value={linkPreviewUrl} onChange={(e) => setLinkPreviewUrl(e.target.value)} onBlur={async () => { const trimmed = linkPreviewUrl.trim(); if (trimmed && trimmed !== config.linkPreviewUrl) { updateConfig(() => ({ linkPreviewUrl: trimmed })); if (user) await updateSettings.mutateAsync({ linkPreviewUrl: trimmed }); toast({ title: 'Link preview URL updated' }); } }} placeholder="https://ditto.pub/api/link-preview/{url}" className="font-mono text-base md:text-sm" />
|
||||
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://ditto.pub/api/link-preview/{'{url}'}</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="cors-proxy" className="text-sm font-medium">CORS Proxy</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">Proxy for cross-origin requests (NIP-05 fallback). Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the target URL.</p>
|
||||
<Input id="cors-proxy" value={corsProxy} onChange={(e) => setCorsProxy(e.target.value)} onBlur={async () => { const trimmed = corsProxy.trim(); if (trimmed && trimmed !== config.corsProxy) { updateConfig(() => ({ corsProxy: trimmed })); if (user) await updateSettings.mutateAsync({ corsProxy: trimmed }); toast({ title: 'CORS proxy updated' }); } }} placeholder="https://proxy.shakespeare.diy/?url={href}" className="font-mono text-base md:text-sm" />
|
||||
<div className="text-xs text-muted-foreground mt-2"><span className="font-medium">Default: </span><span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SentrySettingsSection() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { toast } = useToast();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Error Reporting" icon={<Bug className="h-4 w-4" />} open={open} onOpenChange={setOpen}>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sentry-enabled" className="text-sm font-medium">Share error reports</Label>
|
||||
<p className="text-xs text-muted-foreground">Help improve this app by automatically sending crash and error reports.</p>
|
||||
</div>
|
||||
<Switch id="sentry-enabled" checked={config.sentryEnabled} onCheckedChange={(checked) => { updateConfig((current) => ({ ...current, sentryEnabled: checked })); }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="sentry-dsn" className="text-sm font-medium">
|
||||
Sentry DSN
|
||||
{sentryDsn !== DEFAULT_SENTRY_DSN && <span className="ml-2 inline-block w-2 h-2 rounded-full bg-yellow-400" title="Modified from default" />}
|
||||
</Label>
|
||||
{sentryDsn !== DEFAULT_SENTRY_DSN && (
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Restore to default" onClick={async () => { setSentryDsn(DEFAULT_SENTRY_DSN); updateConfig((current) => ({ ...current, sentryDsn: DEFAULT_SENTRY_DSN })); if (user) await updateSettings.mutateAsync({ sentryDsn: DEFAULT_SENTRY_DSN }); toast({ title: 'Sentry DSN restored to default' }); }}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">Sentry Data Source Name (DSN) for error reporting. Leave empty to disable Sentry.</p>
|
||||
<Input id="sentry-dsn" value={sentryDsn} onChange={(e) => setSentryDsn(e.target.value)} onBlur={async () => { const trimmed = sentryDsn.trim(); if (trimmed !== config.sentryDsn) { updateConfig((current) => ({ ...current, sentryDsn: trimmed })); if (user) await updateSettings.mutateAsync({ sentryDsn: trimmed }); toast({ title: trimmed ? 'Sentry DSN updated' : 'Sentry DSN cleared' }); } }} placeholder="https://examplePublicKey@o0.ingest.sentry.io/0" className="font-mono text-base md:text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerSettingsSection() {
|
||||
const { toast } = useToast();
|
||||
const { hasBuddy, resetBuddy } = useBuddy();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Danger Zone" icon={<AlertTriangle className="h-4 w-4" />} accentColor="text-destructive" open={open} onOpenChange={setOpen}>
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
{hasBuddy && (
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Reset Buddy</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Delete your buddy's identity and start over. The buddy's Nostr keypair and soul
|
||||
will be wiped from this device and relays. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={async () => { try { await resetBuddy.mutateAsync(); toast({ title: 'Buddy has been reset' }); } catch { toast({ title: 'Failed to reset buddy', variant: 'destructive' }); } }}
|
||||
disabled={resetBuddy.isPending}
|
||||
>
|
||||
{resetBuddy.isPending ? 'Resetting...' : 'Reset Buddy'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<RequestToVanishDialog open={vanishDialogOpen} onOpenChange={setVanishDialogOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -831,9 +831,9 @@ function SavedFeedRow({
|
||||
onRemove: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const search = typeof feed.filter.search === 'string' ? feed.filter.search : '';
|
||||
const authors = Array.isArray(feed.filter.authors) ? feed.filter.authors as string[] : [];
|
||||
const kinds = Array.isArray(feed.filter.kinds) ? feed.filter.kinds as number[] : [];
|
||||
const search = typeof feed.filter?.search === 'string' ? feed.filter.search : '';
|
||||
const authors = Array.isArray(feed.filter?.authors) ? feed.filter.authors as string[] : [];
|
||||
const kinds = Array.isArray(feed.filter?.kinds) ? (feed.filter.kinds as number[]) : [];
|
||||
|
||||
const scopeLabel = authors.includes('$follows')
|
||||
? 'Follows'
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X, Globe, BookOpen } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Globe, BookOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SortableItemShell } from '@/components/SortableItemShell';
|
||||
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
@@ -18,6 +17,9 @@ export interface ExternalContentSidebarItemProps {
|
||||
active: boolean;
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onAdd?: (id: string) => void;
|
||||
/** True when this item is below the "More..." separator (hidden zone). */
|
||||
belowMore?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** Extra classes on the link. */
|
||||
linkClassName?: string;
|
||||
@@ -66,34 +68,17 @@ function ExternalSidebarLabel({ id }: { id: string }) {
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExternalContentSidebarItem({
|
||||
id, active, editing, onRemove, onClick, linkClassName,
|
||||
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
|
||||
}: ExternalContentSidebarItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
const path = `/i/${encodeURIComponent(id)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore}>
|
||||
<Link
|
||||
to={path}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
@@ -106,16 +91,6 @@ export function ExternalContentSidebarItem({
|
||||
<ExternalSidebarLabel id={id} />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{editing && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SortableItemShell>
|
||||
);
|
||||
}
|
||||
|
||||
+150
-132
@@ -6,15 +6,15 @@ import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { LandingHero } from '@/components/LandingHero';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { NoteCardSkeleton } from '@/components/NoteCardSkeleton';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Loader2, MapPin } from 'lucide-react';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
@@ -22,15 +22,14 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
|
||||
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { diversifyFeedPages } from '@/lib/feedDiversity';
|
||||
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
@@ -38,6 +37,22 @@ import type { SavedFeed } from '@/contexts/AppContext';
|
||||
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto';
|
||||
type FeedTab = CoreFeedTab | string; // string = saved feed id
|
||||
|
||||
/** Curated kinds for the logged-out homepage: unique Ditto content types. */
|
||||
const LANDING_KINDS = [
|
||||
36767, // Themes
|
||||
37381, // Magic Decks
|
||||
3367, // Color Moments
|
||||
37516, // Treasures
|
||||
7516, // Treasures (Found Logs)
|
||||
30030, // Emoji Packs
|
||||
30009, // Badge Definitions
|
||||
10008, // Profile Badges
|
||||
30008, // Profile Badges (legacy)
|
||||
];
|
||||
|
||||
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
|
||||
const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
|
||||
|
||||
interface FeedProps {
|
||||
/** Override the kinds list instead of using feed settings. */
|
||||
kinds?: number[];
|
||||
@@ -59,8 +74,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const { hashtags } = useInterests();
|
||||
const { hashtags: geotags } = useInterests('g');
|
||||
const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList();
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
const stored = localStorage.getItem('ditto:showGlobalFeed');
|
||||
@@ -136,17 +149,21 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
|
||||
);
|
||||
|
||||
// Curated Ditto feed: latest content from the curator's follow list.
|
||||
const topQuery = useCuratedDittoFeed(
|
||||
curatorFollowList,
|
||||
// "Hot" sorted feed query (used when logged out on the home page, or on the Ditto tab)
|
||||
// Shows curated "otherstuff" kinds instead of kind 1. Webxdc needs a
|
||||
// separate filter with a MIME-type tag constraint.
|
||||
const topQuery = useInfiniteHotFeed(
|
||||
LANDING_KINDS,
|
||||
useTopFeedForLoggedOut || !!useDittoTab,
|
||||
undefined,
|
||||
[LANDING_WEBXDC_FILTER],
|
||||
);
|
||||
|
||||
// Unify the two query shapes behind a single interface
|
||||
const useDittoQuery = useTopFeedForLoggedOut || useDittoTab;
|
||||
const activeQuery = useDittoQuery ? topQuery : feedQuery;
|
||||
const queryKey = useMemo(
|
||||
() => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab],
|
||||
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
|
||||
[useDittoQuery, activeTab],
|
||||
);
|
||||
|
||||
@@ -186,25 +203,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (useDittoQuery) {
|
||||
// Deduplicate and filter each page independently, then diversify
|
||||
// page-by-page so earlier pages never change when new pages arrive.
|
||||
const dedupedPages = (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.map((page) =>
|
||||
page
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })),
|
||||
);
|
||||
|
||||
// Reorder for content-type diversity: cap any single type at 20%
|
||||
// per page and enforce a minimum gap of 4 positions between same-type
|
||||
// items, with gap state carrying across page boundaries.
|
||||
return diversifyFeedPages(dedupedPages);
|
||||
return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.flat()
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
}
|
||||
|
||||
return (rawData.pages as unknown as { items: FeedItem[] }[])
|
||||
@@ -219,9 +227,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
});
|
||||
}, [rawData?.pages, muteItems, useDittoQuery]);
|
||||
|
||||
// Show skeletons while loading, but not if the curator list query errored
|
||||
// (that would leave logged-out users staring at infinite skeletons).
|
||||
const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError);
|
||||
const showSkeleton = isPending || (isLoading && !rawData);
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
|
||||
@@ -290,9 +296,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
{/* Feed content — saved feed tab gets its own stream */}
|
||||
{user && <div style={{ height: ARC_OVERHANG_PX }} />}
|
||||
{activeHashtag ? (
|
||||
<HashtagFeedContent tag={activeHashtag} />
|
||||
<TagFeedContent tagKey="#t" tag={activeHashtag} emptyMessage={`No posts found with #${activeHashtag}.`} />
|
||||
) : activeGeotag ? (
|
||||
<GeotagFeedContent tag={activeGeotag} />
|
||||
<TagFeedContent tagKey="#g" tag={activeGeotag} emptyMessage={`No posts found near ${activeGeotag}.`} />
|
||||
) : activeSavedFeed ? (
|
||||
<SavedFeedContent feed={activeSavedFeed} />
|
||||
) : (
|
||||
@@ -355,9 +361,97 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a saved search feed using useStreamPosts (live streaming). */
|
||||
/** Renders a saved search feed using useStreamPosts (live streaming).
|
||||
* When the feed has a spellId, the spell event is fetched and passed
|
||||
* directly to useStreamPosts({ spell }) — the same path SpellRunPage uses —
|
||||
* so all filter hints, tag filters, and variables resolve identically. */
|
||||
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
return feed.spellId
|
||||
? <SpellFeedContent feed={feed} spellId={feed.spellId} />
|
||||
: <LegacyFeedContent feed={feed} />;
|
||||
}
|
||||
|
||||
/** Spell-driven saved feed: fetches the kind:777 event and streams via spell mode. */
|
||||
function SpellFeedContent({ feed, spellId }: { feed: SavedFeed; spellId: string }) {
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Fetch the spell event by ID
|
||||
const { data: spellEvent, isLoading: isLoadingSpell } = useQuery<NostrEvent | null>({
|
||||
queryKey: ['spell-event', spellId],
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{ ids: [spellId], kinds: [777], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
|
||||
);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Use the exact same streaming path as SpellRunPage
|
||||
const { posts, isLoading: isStreamLoading, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
spell: spellEvent ?? undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasMore && !isLoadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
}, [inView, hasMore, isLoadingMore, loadMore]);
|
||||
|
||||
const isLoading = isLoadingSpell || (isStreamLoading && posts.length === 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". The search may return results as new content arrives.`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{newPostCount > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
flushStreamBuffer();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="w-full py-2 text-sm text-primary hover:bg-muted/50 border-b border-border transition-colors"
|
||||
>
|
||||
{newPostCount} new {newPostCount === 1 ? 'post' : 'posts'}
|
||||
</button>
|
||||
)}
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy saved feed without a spell ID: resolves filter variables and streams. */
|
||||
function LegacyFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const { ref: scrollRef } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -372,27 +466,30 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined;
|
||||
const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined;
|
||||
|
||||
// Read client-hint fields persisted by the save paths (_media, _language, etc.)
|
||||
const rawFilter = feed.filter as Record<string, unknown>;
|
||||
const mediaType = (typeof rawFilter._media === 'string' ? rawFilter._media : 'all') as 'all' | 'images' | 'videos' | 'vines' | 'none';
|
||||
const language = typeof rawFilter._language === 'string' ? rawFilter._language : undefined;
|
||||
const platform = typeof rawFilter._platform === 'string' ? rawFilter._platform : undefined;
|
||||
const sort = (typeof rawFilter._sort === 'string' ? rawFilter._sort : undefined) as 'recent' | 'hot' | 'trending' | undefined;
|
||||
const includeReplies = rawFilter._includeReplies === false ? false : true;
|
||||
|
||||
const { posts, isLoading: isStreamLoading } = useStreamPosts(search, {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
includeReplies,
|
||||
mediaType,
|
||||
language,
|
||||
protocols: platform ? [platform] : undefined,
|
||||
sort,
|
||||
kindsOverride,
|
||||
authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined,
|
||||
});
|
||||
|
||||
const isLoading = isResolving || isStreamLoading;
|
||||
|
||||
// useStreamPosts doesn't use TanStack Query, so refresh by invalidating the
|
||||
// resolution query and letting the stream reconnect via remount.
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['resolve-tab-filter'] });
|
||||
}, [queryClient]);
|
||||
|
||||
// Simple scroll-based load more isn't available with useStreamPosts (it's a stream),
|
||||
// but we still wire the ref for future pagination support
|
||||
useEffect(() => {
|
||||
// intentionally empty — useStreamPosts handles its own streaming
|
||||
}, [inView]);
|
||||
|
||||
if (isLoading && posts.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
@@ -423,80 +520,23 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a feed of posts tagged with a specific hashtag. */
|
||||
function HashtagFeedContent({ tag }: { tag: string }) {
|
||||
/** Renders a feed of posts matching a single-letter tag filter (#t for hashtags, #g for geotags). */
|
||||
function TagFeedContent({ tagKey, tag, emptyMessage }: { tagKey: '#t' | '#g'; tag: string; emptyMessage: string }) {
|
||||
const { nostr } = useNostr();
|
||||
const { muteItems } = useMuteList();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
|
||||
const kindsKey = [...kinds].sort().join(',');
|
||||
|
||||
const queryKey = useMemo(() => ['hashtag-feed', tag, kindsKey], [tag, kindsKey]);
|
||||
const feedType = tagKey === '#t' ? 'hashtag' : 'geotag';
|
||||
const queryKey = useMemo(() => [`${feedType}-feed`, tag, kindsKey], [feedType, tag, kindsKey]);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
const { data: events, isLoading } = useQuery<NostrEvent[]>({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
const ditto = nostr.group(DITTO_RELAYS);
|
||||
return ditto.query(
|
||||
[{ kinds, '#t': [tag.toLowerCase()], limit: 40 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredEvents = useMemo((): NostrEvent[] => {
|
||||
if (!events) return [];
|
||||
if (muteItems.length === 0) return events;
|
||||
return events.filter((e) => !isEventMuted(e, muteItems));
|
||||
}, [events, muteItems]);
|
||||
|
||||
if (isLoading && filteredEvents.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredEvents.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found with #${tag}.`} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{filteredEvents.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a feed of posts tagged with a specific geohash. */
|
||||
function GeotagFeedContent({ tag }: { tag: string }) {
|
||||
const { nostr } = useNostr();
|
||||
const { muteItems } = useMuteList();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
|
||||
const kindsKey = [...kinds].sort().join(',');
|
||||
|
||||
const queryKey = useMemo(() => ['geotag-feed', tag, kindsKey], [tag, kindsKey]);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
const { data: events, isLoading } = useQuery<NostrEvent[]>({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
const ditto = nostr.group(DITTO_RELAYS);
|
||||
const filter = { kinds, limit: 40 } as Record<string, unknown>;
|
||||
filter['#g'] = [tag];
|
||||
const filter = { kinds, limit: 40, [tagKey]: [tagKey === '#t' ? tag.toLowerCase() : tag] } as Record<string, unknown>;
|
||||
return ditto.query([filter as Parameters<typeof ditto.query>[0][number]], {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]),
|
||||
});
|
||||
@@ -522,7 +562,7 @@ function GeotagFeedContent({ tag }: { tag: string }) {
|
||||
if (filteredEvents.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found near ${tag}.`} />
|
||||
<FeedEmptyState message={emptyMessage} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -538,26 +578,4 @@ function GeotagFeedContent({ tag }: { tag: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+56
-121
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserPlus, LogOut,
|
||||
Loader2, QrCode,
|
||||
QrCode,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -10,9 +11,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { DittoLogo } from '@/components/DittoLogo';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
import { StatusEditor } from '@/components/StatusEditor';
|
||||
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
@@ -22,6 +23,7 @@ import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useSidebarEditing } from '@/hooks/useSidebarEditing';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -29,10 +31,7 @@ import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isItemActive } from '@/lib/sidebarItems';
|
||||
|
||||
import { useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { usePublishStatus } from '@/hooks/usePublishStatus';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
|
||||
|
||||
export function LeftSidebar() {
|
||||
@@ -48,8 +47,7 @@ export function LeftSidebar() {
|
||||
} = useFeedSettings();
|
||||
const { config } = useAppContext();
|
||||
|
||||
const visibleItems = orderedItems;
|
||||
const visibleHiddenItems = hiddenItems;
|
||||
|
||||
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
@@ -58,17 +56,13 @@ export function LeftSidebar() {
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
// NIP-38 status
|
||||
const userStatus = useUserStatus(user?.pubkey);
|
||||
const publishStatus = usePublishStatus();
|
||||
const { toast } = useToast();
|
||||
const [statusEditing, setStatusEditing] = useState(false);
|
||||
const [statusDraft, setStatusDraft] = useState('');
|
||||
|
||||
const homePage = config.homePage;
|
||||
|
||||
const { editingItems, handleEditReorder, handleEditRemove } = useSidebarEditing({
|
||||
editing, items: orderedItems, hiddenItems, updateSidebarOrder, removeFromSidebar,
|
||||
});
|
||||
|
||||
const scrollToTopIfCurrent = useCallback((to: string) => (e: React.MouseEvent) => {
|
||||
if (location.pathname === to) {
|
||||
e.preventDefault();
|
||||
@@ -95,36 +89,53 @@ export function LeftSidebar() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-2 py-4">
|
||||
<ProfileSearchDropdown placeholder="Search..." inputClassName="py-3.5" enableTextSearch />
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<SidebarNavList
|
||||
items={visibleItems}
|
||||
editing={editing}
|
||||
onRemove={removeFromSidebar}
|
||||
onReorder={updateSidebarOrder}
|
||||
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
||||
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
|
||||
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
||||
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
||||
homePage={homePage}
|
||||
/>
|
||||
|
||||
<SidebarMoreMenu
|
||||
editing={editing}
|
||||
hiddenItems={visibleHiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
homePage={homePage}
|
||||
/>
|
||||
{editing ? (
|
||||
<>
|
||||
<SidebarNavList
|
||||
items={editingItems}
|
||||
editing
|
||||
onRemove={handleEditRemove}
|
||||
onAdd={addToSidebar}
|
||||
onReorder={handleEditReorder}
|
||||
isActive={() => false}
|
||||
homePage={homePage}
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing
|
||||
hiddenItems={hiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
homePage={homePage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarNavList
|
||||
items={orderedItems}
|
||||
editing={false}
|
||||
onRemove={removeFromSidebar}
|
||||
onReorder={updateSidebarOrder}
|
||||
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
||||
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
|
||||
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
||||
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
||||
homePage={homePage}
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing={false}
|
||||
hiddenItems={hiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
homePage={homePage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Logged-out join pill — same position as account button, pushed up from bottom */}
|
||||
@@ -194,83 +205,7 @@ export function LeftSidebar() {
|
||||
|
||||
{/* Status editor */}
|
||||
<div className="border-b border-border">
|
||||
{statusEditing ? (
|
||||
<div className="p-3 space-y-2">
|
||||
<Input
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
|
||||
placeholder="What are you up to?"
|
||||
className="h-8 text-base md:text-sm"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const text = statusDraft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = statusDraft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
{userStatus.status && (
|
||||
<button
|
||||
onClick={() => {
|
||||
publishStatus.mutateAsync({ status: '' }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: 'Status cleared' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
|
||||
className="text-xs text-muted-foreground hover:underline ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusEditing(true);
|
||||
setStatusDraft(userStatus.status ?? '');
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
{userStatus.status ? (
|
||||
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Set a status</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<StatusEditor pubkey={user.pubkey} />
|
||||
</div>
|
||||
|
||||
{/* Other accounts */}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Bell, Home, Search, User } from 'lucide-react';
|
||||
import { Bot, User } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { getSidebarItem } from '@/lib/sidebarItems';
|
||||
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
|
||||
import { MobileBuddySheet } from '@/components/AIChat/MobileBuddySheet';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
import { getSidebarItem, isSidebarDivider, sidebarItemIcon, itemLabel, itemPath, isItemActive } from '@/lib/sidebarItems';
|
||||
|
||||
/** Transform style applied when the bottom nav is hidden (scrolled away). */
|
||||
const hiddenStyle: React.CSSProperties = {
|
||||
@@ -27,34 +30,67 @@ export function MobileBottomNav() {
|
||||
const { scrollContainer, noArcs } = useLayoutSnapshot();
|
||||
const { hidden } = useScrollDirection(scrollContainer);
|
||||
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
|
||||
const { orderedItems } = useFeedSettings();
|
||||
const { config } = useAppContext();
|
||||
const homeItem = getSidebarItem(config.homePage);
|
||||
const HomeIcon = homeItem?.icon ?? Home;
|
||||
const homeLabel = homeItem?.label ?? 'Home';
|
||||
const homePath = homeItem?.path;
|
||||
const homePage = config.homePage;
|
||||
const { buddy } = useBuddy();
|
||||
const buddyAuthor = useAuthor(buddy?.pubkey);
|
||||
const buddyMetadata = buddyAuthor.data?.metadata;
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [buddyOpen, setBuddyOpen] = useState(false);
|
||||
|
||||
const handleSearchClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
selectionChanged();
|
||||
setSearchOpen((v) => !v);
|
||||
setBuddyOpen(false);
|
||||
}, []);
|
||||
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
const handleBuddyClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setBuddyOpen((v) => !v);
|
||||
setSearchOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearchOpen(false);
|
||||
setBuddyOpen(false);
|
||||
}, []);
|
||||
|
||||
const sheetOpen = searchOpen || buddyOpen;
|
||||
|
||||
// Only hide nav on scroll — keep it visible when sheets are open so the
|
||||
// user can see the active tab and tap between them.
|
||||
const isHidden = hidden && !sheetOpen;
|
||||
|
||||
const displayName = metadata?.name || metadata?.display_name;
|
||||
const isOnProfile = user && location.pathname === profileUrl;
|
||||
|
||||
// Show only the first 4 sidebar items (matching sidebar order), filtering out dividers and auth-gated items when logged out
|
||||
const allItems = useMemo(() => {
|
||||
return orderedItems.filter((id) => {
|
||||
if (isSidebarDivider(id)) return false;
|
||||
if (!user && getSidebarItem(id)?.requiresAuth) return false;
|
||||
return true;
|
||||
}).slice(0, 4);
|
||||
}, [orderedItems, user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
{/* Shared backdrop for sheets */}
|
||||
{sheetOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search and buddy sheets are independent */}
|
||||
{searchOpen && <MobileSearchSheet open onClose={handleClose} />}
|
||||
{buddyOpen && <MobileBuddySheet hidden={false} onClose={handleClose} />}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 z-40 sidebar:hidden will-change-transform',
|
||||
'fixed bottom-0 left-0 right-0 z-[49] sidebar:hidden will-change-transform',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
)}
|
||||
style={isHidden ? hiddenStyle : undefined}
|
||||
@@ -63,80 +99,106 @@ export function MobileBottomNav() {
|
||||
<div className="relative">
|
||||
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
|
||||
<div className="h-11 flex items-center relative">
|
||||
{allItems.map((id) => {
|
||||
const isSearch = id === 'search';
|
||||
const isBuddy = id === 'ai-chat';
|
||||
const isProfile = id === 'profile';
|
||||
const isNotifications = id === 'notifications';
|
||||
const active = isSearch
|
||||
? searchOpen
|
||||
: isBuddy
|
||||
? buddyOpen
|
||||
: isItemActive(id, location.pathname, location.search, profileUrl, homePage);
|
||||
const label = itemLabel(id);
|
||||
const path = isProfile ? profileUrl : itemPath(id, undefined, homePage);
|
||||
|
||||
{/* Home */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<HomeIcon className="size-5" />
|
||||
<span className="text-[10px] font-medium">{homeLabel}</span>
|
||||
</Link>
|
||||
// Search opens the search sheet instead of navigating
|
||||
if (isSearch) {
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={handleSearchClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{sidebarItemIcon(id, 'size-5')}
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
searchOpen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
<span className="text-[10px] font-medium">Search</span>
|
||||
</button>
|
||||
// Buddy opens the AI chat sheet instead of navigating
|
||||
if (isBuddy) {
|
||||
const hasBuddyPicture = !!buddyMetadata?.picture;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={handleBuddyClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{hasBuddyPicture ? (
|
||||
<Avatar shape={getAvatarShape(buddyMetadata)} className="size-5">
|
||||
<AvatarImage src={buddyMetadata.picture} alt={buddy?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
<Bot className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
sidebarItemIcon(id, 'size-5')
|
||||
)}
|
||||
<span className="text-[10px] font-medium">{hasBuddyPicture ? buddy?.name ?? label : label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Notifications */}
|
||||
{user && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Bell className="size-5" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">Notifications</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Profile */}
|
||||
{user ? (
|
||||
<Link
|
||||
to={profileUrl}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
isOnProfile ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] font-medium">Profile</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
|
||||
>
|
||||
<User className="size-5" />
|
||||
<span className="text-[10px] font-medium">Profile</span>
|
||||
</Link>
|
||||
)}
|
||||
// Profile shows the user avatar
|
||||
if (isProfile && user) {
|
||||
return (
|
||||
<Link
|
||||
key={id}
|
||||
to={path}
|
||||
onClick={handleClose}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={id}
|
||||
to={path}
|
||||
onClick={handleClose}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
{sidebarItemIcon(id, 'size-5')}
|
||||
{isNotifications && hasUnread && (
|
||||
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Safe area fill — matches the arc's semi-transparent background */}
|
||||
|
||||
+76
-121
@@ -1,11 +1,13 @@
|
||||
import { useState, useId, useMemo } from 'react';
|
||||
import { useState, useCallback, useId, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, QrCode } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { useSidebarEditing } from '@/hooks/useSidebarEditing';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
import { StatusEditor } from '@/components/StatusEditor';
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
@@ -24,10 +26,8 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isItemActive } from '@/lib/sidebarItems';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { usePublishStatus } from '@/hooks/usePublishStatus';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
|
||||
import { resolveTheme, resolveThemeConfig } from '@/themes';
|
||||
|
||||
/** Total width of the drawer background layer: 300px drawer + 36px arc overhang. */
|
||||
@@ -58,19 +58,21 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const homePage = config.homePage;
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
const [accountExpanded, setAccountExpanded] = useState(false);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
|
||||
// NIP-38 status
|
||||
const userStatus = useUserStatus(user?.pubkey);
|
||||
const publishStatus = usePublishStatus();
|
||||
const { toast } = useToast();
|
||||
const [statusEditing, setStatusEditing] = useState(false);
|
||||
const [statusDraft, setStatusDraft] = useState('');
|
||||
// Portal container for dropdown popovers inside the Sheet so they scroll
|
||||
// correctly and aren't blocked by Radix Dialog's RemoveScroll.
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
const sheetContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
/** Compute the background image style for the drawer, mirroring the body background. */
|
||||
const bgStyle = useMemo<React.CSSProperties>(() => {
|
||||
@@ -96,17 +98,21 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
});
|
||||
}, [orderedItems]);
|
||||
|
||||
const visibleHiddenItems = hiddenItems;
|
||||
|
||||
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
|
||||
|
||||
const { editingItems, handleEditReorder, handleEditRemove } = useSidebarEditing({
|
||||
editing, items: visibleItems, hiddenItems, updateSidebarOrder, removeFromSidebar,
|
||||
});
|
||||
|
||||
const handleClose = () => { onOpenChange(false); };
|
||||
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
|
||||
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
||||
const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
|
||||
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) { setEditing(false); } onOpenChange(v); }}>
|
||||
<SheetContent ref={sheetContentRef} side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
|
||||
{/* SVG clip path definition for the drawer + arc shape.
|
||||
The clip path uses objectBoundingBox units so the arc scales with the
|
||||
background layer. The 0.893 ratio ≈ DRAWER_WIDTH / DRAWER_BG_WIDTH
|
||||
@@ -133,6 +139,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
/>
|
||||
)}
|
||||
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
|
||||
{user ? (
|
||||
<div className="flex flex-col h-full relative">
|
||||
@@ -169,83 +176,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
<div>
|
||||
{/* Status editor */}
|
||||
<div className="border-b border-border">
|
||||
{statusEditing ? (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<Input
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
|
||||
placeholder="What are you up to?"
|
||||
className="h-8 text-base md:text-sm"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const text = statusDraft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = statusDraft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
{userStatus.status && (
|
||||
<button
|
||||
onClick={() => {
|
||||
publishStatus.mutateAsync({ status: '' }).then(() => {
|
||||
setStatusEditing(false);
|
||||
setStatusDraft('');
|
||||
toast({ title: 'Status cleared' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
|
||||
className="text-xs text-muted-foreground hover:underline ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusEditing(true);
|
||||
setStatusDraft(userStatus.status ?? '');
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
{userStatus.status ? (
|
||||
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Set a status</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<StatusEditor pubkey={user.pubkey} formClassName="px-3 py-2" buttonClassName="px-3" />
|
||||
</div>
|
||||
{otherUsers.map((account) => (
|
||||
<button
|
||||
@@ -300,30 +231,55 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1"
|
||||
>
|
||||
<div className="contents">
|
||||
<SidebarNavList
|
||||
items={visibleItems}
|
||||
editing={editing}
|
||||
onRemove={removeFromSidebar}
|
||||
onReorder={updateSidebarOrder}
|
||||
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
||||
getOnClick={() => handleClose}
|
||||
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
||||
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
||||
linkClassName="text-base"
|
||||
homePage={homePage}
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing={editing}
|
||||
hiddenItems={visibleHiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
onNavigate={handleClose}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
homePage={homePage}
|
||||
/>
|
||||
{editing ? (
|
||||
<>
|
||||
<SidebarNavList
|
||||
items={editingItems}
|
||||
editing
|
||||
onRemove={handleEditRemove}
|
||||
onAdd={addToSidebar}
|
||||
onReorder={handleEditReorder}
|
||||
isActive={() => false}
|
||||
linkClassName="text-base"
|
||||
homePage={homePage}
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing
|
||||
hiddenItems={hiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
onNavigate={handleClose}
|
||||
homePage={homePage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarNavList
|
||||
items={visibleItems}
|
||||
editing={false}
|
||||
onRemove={removeFromSidebar}
|
||||
onReorder={updateSidebarOrder}
|
||||
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
|
||||
getOnClick={() => handleClose}
|
||||
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
|
||||
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
|
||||
linkClassName="text-base"
|
||||
homePage={homePage}
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing={false}
|
||||
hiddenItems={hiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
onNavigate={handleClose}
|
||||
homePage={homePage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -358,14 +314,12 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
/>
|
||||
<SidebarMoreMenu
|
||||
editing={false}
|
||||
hiddenItems={visibleHiddenItems}
|
||||
hiddenItems={hiddenItems}
|
||||
onDoneEditing={() => setEditing(false)}
|
||||
onStartEditing={() => setEditing(true)}
|
||||
onAdd={addToSidebar}
|
||||
onAddDivider={addDividerToSidebar}
|
||||
onNavigate={handleClose}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
homePage={homePage}
|
||||
/>
|
||||
</div>
|
||||
@@ -376,6 +330,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PortalContainerProvider>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@@ -126,12 +126,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to let the animation settle and keyboard to appear
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 80);
|
||||
return () => clearTimeout(t);
|
||||
} else {
|
||||
setQuery('');
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -188,7 +184,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
if (!query.trim()) return;
|
||||
|
||||
handleClose();
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
navigate(`/discover?tab=posts&q=${encodeURIComponent(query.trim())}`);
|
||||
}, [query, navigate, handleClose]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -236,18 +232,10 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
|
||||
const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0));
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
|
||||
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
|
||||
<div className={cn('fixed left-0 right-0 bottom-mobile-nav z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-2', !open && 'hidden')}>
|
||||
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
{hasResults && (
|
||||
@@ -343,12 +331,15 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X, FileText, Scroll } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { FileText, Scroll, WandSparkles } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nostrUriToNip19 } from '@/lib/sidebarItems';
|
||||
import { SortableItemShell } from '@/components/SortableItemShell';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getKindIcon } from '@/lib/extraKinds';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -21,7 +20,8 @@ import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
|
||||
* Used as a fallback when getKindIcon() returns undefined.
|
||||
*/
|
||||
const KNOWN_KIND_ICONS: Record<number, ComponentType<{ className?: string }>> = {
|
||||
30000: Scroll, // NIP-51 lists
|
||||
777: WandSparkles, // Spells
|
||||
30000: Scroll, // NIP-51 lists
|
||||
};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
@@ -32,6 +32,9 @@ export interface NostrEventSidebarItemProps {
|
||||
active: boolean;
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onAdd?: (id: string) => void;
|
||||
/** True when this item is below the "More..." separator (hidden zone). */
|
||||
belowMore?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** Extra classes on the link. */
|
||||
linkClassName?: string;
|
||||
@@ -71,41 +74,65 @@ function ProfileSidebarLabel({ pubkey }: { pubkey: string }) {
|
||||
|
||||
// ── Event sidebar item (non-profile) ──────────────────────────────────────────
|
||||
|
||||
function EventSidebarIcon({ kind, className }: { kind: number; className?: string }) {
|
||||
const Icon = getKindIcon(kind) ?? KNOWN_KIND_ICONS[kind] ?? FileText;
|
||||
return <Icon className={cn('size-6', className)} />;
|
||||
function resolveKindIcon(kind: number): ComponentType<{ className?: string }> {
|
||||
return getKindIcon(kind) ?? KNOWN_KIND_ICONS[kind] ?? FileText;
|
||||
}
|
||||
|
||||
interface EventSidebarLabelProps {
|
||||
/**
|
||||
* Renders icon + label for a non-profile event sidebar item.
|
||||
* Fetches the event to resolve the kind (needed when the nevent doesn't
|
||||
* encode a kind) and derives the correct icon and navigation path.
|
||||
*/
|
||||
function EventSidebarContent({ decoded, nip19Id, linkClassName, active, editing, onClick }: {
|
||||
decoded: DecodedNostrId;
|
||||
}
|
||||
|
||||
function EventSidebarLabel({ decoded }: EventSidebarLabelProps) {
|
||||
nip19Id: string;
|
||||
linkClassName?: string;
|
||||
active: boolean;
|
||||
editing: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const params = decoded.type === 'naddr' && decoded.identifier !== undefined
|
||||
? { addr: { kind: decoded.kind!, pubkey: decoded.pubkey, identifier: decoded.identifier } }
|
||||
: { eventId: decoded.eventId };
|
||||
|
||||
const { data, isLoading } = useNostrEventSidebar(params);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <Skeleton className="h-4 w-20" />;
|
||||
}
|
||||
// Use fetched kind when available, fall back to decoded kind
|
||||
const resolvedKind = data?.kind ?? decoded.kind ?? 1;
|
||||
const Icon = resolveKindIcon(resolvedKind);
|
||||
|
||||
const path = `/${nip19Id}`;
|
||||
|
||||
return (
|
||||
<span className="truncate">
|
||||
{data?.label ?? 'Event'}
|
||||
</span>
|
||||
<Link
|
||||
to={path}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-4 w-20" />
|
||||
) : (
|
||||
data?.label ?? 'Event'
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function NostrEventSidebarItem({
|
||||
id, active, editing, onRemove, onClick, linkClassName,
|
||||
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
|
||||
}: NostrEventSidebarItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
const nip19Id = nostrUriToNip19(id);
|
||||
const decoded = decodeNostrId(nip19Id);
|
||||
|
||||
@@ -114,61 +141,39 @@ export function NostrEventSidebarItem({
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = `/${nip19Id}`;
|
||||
const isProfile = decoded.type === 'npub' || decoded.type === 'nprofile';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore}>
|
||||
{isProfile ? (
|
||||
<Link
|
||||
to={`/${nip19Id}`}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={path}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{isProfile ? (
|
||||
<span className="shrink-0">
|
||||
<ProfileSidebarIcon pubkey={decoded.pubkey} />
|
||||
) : (
|
||||
<EventSidebarIcon kind={decoded.kind ?? 1} />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
|
||||
{isProfile ? (
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
|
||||
<ProfileSidebarLabel pubkey={decoded.pubkey} />
|
||||
) : (
|
||||
<EventSidebarLabel decoded={decoded} />
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{editing && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<EventSidebarContent
|
||||
decoded={decoded}
|
||||
nip19Id={nip19Id}
|
||||
linkClassName={linkClassName}
|
||||
active={active}
|
||||
editing={editing}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SortableItemShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+528
-298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/** Reusable loading skeleton that matches the NoteCard layout. */
|
||||
export function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ function encodeEventNip19(event: NostrEvent): string {
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
}
|
||||
}
|
||||
return nip19.neventEncode({ id: event.id, author: event.pubkey });
|
||||
return nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
|
||||
}
|
||||
|
||||
interface EventJsonDialogProps {
|
||||
@@ -332,7 +332,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
const close = () => onOpenChange(false);
|
||||
|
||||
const handleViewPostDetails = () => {
|
||||
navigate(`/${nip19Id}`);
|
||||
// For spells, use a nevent without kind hint so NIP19Page falls through
|
||||
// to PostDetailPage instead of running the spell again.
|
||||
const detailId = event.kind === 777
|
||||
? nip19.neventEncode({ id: event.id, author: event.pubkey })
|
||||
: nip19Id;
|
||||
navigate(`/${detailId}`);
|
||||
close();
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function PageHeader({ title, icon, titleContent, backTo = '/', onBack, al
|
||||
const backButtonClass = cn('p-2 -ml-2 rounded-full hover:bg-secondary transition-colors', !alwaysShowBack && 'sidebar:hidden');
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4 px-4 py-4 bg-background/85', className)}>
|
||||
<div className={cn('flex items-center gap-4 px-4 py-4', className)}>
|
||||
{onBack ? (
|
||||
<button onClick={onBack} className={backButtonClass} aria-label="Go back">
|
||||
<ArrowLeft className="size-5" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useFloating, autoUpdate, flip, shift, size, offset, type Strategy } from '@floating-ui/react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, UserRoundCheck, MessageSquare, FileText, Hash, Archive } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -23,28 +25,42 @@ import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikip
|
||||
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
|
||||
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
|
||||
import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems';
|
||||
import { usePortalContainer } from '@/hooks/usePortalContainer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Padding from viewport edge for dropdown max-height calculation. */
|
||||
const DROPDOWN_VIEWPORT_PADDING = 16;
|
||||
|
||||
interface ProfileSearchDropdownProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
/** Inline styles applied directly to the <input> element (e.g. font-family overrides). */
|
||||
inputStyle?: React.CSSProperties;
|
||||
autoFocus?: boolean;
|
||||
onSelect?: (profile: SearchProfile) => void;
|
||||
/** When true, pressing Enter without a profile selected navigates to the search page */
|
||||
enableTextSearch?: boolean;
|
||||
/** When true, country suggestions are hidden from the dropdown */
|
||||
hideCountry?: boolean;
|
||||
/** When true, the search icon and loading spinner inside the input are hidden. */
|
||||
hideIcon?: boolean;
|
||||
/** Called when the dropdown wants to fully dismiss (Escape, outside click, selection).
|
||||
* Useful for parent components that manage an expanded/collapsed state. */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ProfileSearchDropdown({
|
||||
placeholder = 'Search people...',
|
||||
className,
|
||||
inputClassName,
|
||||
inputStyle,
|
||||
autoFocus,
|
||||
onSelect,
|
||||
enableTextSearch,
|
||||
hideCountry = false,
|
||||
hideIcon = false,
|
||||
onDismiss,
|
||||
}: ProfileSearchDropdownProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -55,6 +71,43 @@ export function ProfileSearchDropdown({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Portal target: when inside a PortalContainerProvider (e.g. MobileDrawer's
|
||||
// Sheet), portal into that container so scroll events aren't blocked by Radix
|
||||
// Dialog's RemoveScroll. Otherwise fall back to document.body.
|
||||
const portalContainer = usePortalContainer();
|
||||
const portalTarget = portalContainer ?? document.body;
|
||||
|
||||
// Use 'absolute' positioning when portaling into a container element (which
|
||||
// may have CSS transforms from Sheet animations or dnd-kit), and 'fixed'
|
||||
// when portaling to document.body where there's no transform ancestor.
|
||||
const strategy: Strategy = portalContainer ? 'absolute' : 'fixed';
|
||||
|
||||
const { refs, floatingStyles, placement } = useFloating({
|
||||
open,
|
||||
strategy,
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(6),
|
||||
flip({ fallbackPlacements: ['top-start'], padding: DROPDOWN_VIEWPORT_PADDING }),
|
||||
shift({ padding: DROPDOWN_VIEWPORT_PADDING }),
|
||||
size({
|
||||
padding: DROPDOWN_VIEWPORT_PADDING,
|
||||
apply({ availableHeight, rects, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxHeight: `${Math.max(120, availableHeight)}px`,
|
||||
width: `${rects.reference.width}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const dropUp = placement.startsWith('top');
|
||||
const dropdownBaseClass = dropUp
|
||||
? 'z-[300] rounded-xl border border-border bg-popover shadow-lg overflow-y-auto overscroll-contain animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 duration-150'
|
||||
: 'z-[300] rounded-xl border border-border bg-popover shadow-lg overflow-y-auto overscroll-contain animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150';
|
||||
|
||||
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
|
||||
|
||||
// Wikipedia & Archive search (async, debounced by their hooks at >=2 chars)
|
||||
@@ -114,16 +167,20 @@ export function ProfileSearchDropdown({
|
||||
setSelectedIndex(-1);
|
||||
}, [profiles]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
// Close dropdown on outside click (check both the input container and the
|
||||
// portaled dropdown, since the dropdown renders outside the DOM tree).
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
const floating = refs.floating.current;
|
||||
if (floating?.contains(target)) return;
|
||||
setOpen(false);
|
||||
onDismiss?.();
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
}, [refs.floating, onDismiss]);
|
||||
|
||||
const handleSelect = useCallback((profile: SearchProfile, profileUrl: string) => {
|
||||
setOpen(false);
|
||||
@@ -142,7 +199,7 @@ export function ProfileSearchDropdown({
|
||||
inputRef.current?.blur();
|
||||
|
||||
if (!enableTextSearch) return;
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
navigate(`/discover?tab=posts&q=${encodeURIComponent(query.trim())}`);
|
||||
}, [enableTextSearch, query, navigate]);
|
||||
|
||||
// Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive?
|
||||
@@ -216,6 +273,7 @@ export function ProfileSearchDropdown({
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
onDismiss?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,12 +330,19 @@ export function ProfileSearchDropdown({
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Merge the container ref (for outside-click detection) and Floating UI reference ref.
|
||||
const { setReference } = refs;
|
||||
const setReferenceRef = useCallback((node: HTMLDivElement | null) => {
|
||||
containerRef.current = node;
|
||||
setReference(node);
|
||||
}, [setReference]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<div ref={setReferenceRef} className={cn('relative', className)}>
|
||||
{/* Search input */}
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isFetching && (
|
||||
{!hideIcon && <Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />}
|
||||
{!hideIcon && isFetching && (
|
||||
<svg
|
||||
className="absolute right-3 size-4 text-muted-foreground"
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
@@ -310,6 +375,7 @@ export function ProfileSearchDropdown({
|
||||
'pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
inputClassName,
|
||||
)}
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
@@ -319,13 +385,14 @@ export function ProfileSearchDropdown({
|
||||
</div>
|
||||
|
||||
{/* Dropdown results — only when text search is not enabled */}
|
||||
{!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
|
||||
{!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
ref={refs.setFloating}
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className={dropdownBaseClass}
|
||||
style={floatingStyles}
|
||||
>
|
||||
<div className="max-h-[320px] overflow-y-auto py-1">
|
||||
<div ref={listRef} className="py-1">
|
||||
{navItems.map((item, index) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
@@ -379,13 +446,13 @@ export function ProfileSearchDropdown({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
portalTarget)}
|
||||
|
||||
{/* Text search option */}
|
||||
{enableTextSearch && open && query.trim().length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
|
||||
<div ref={listRef} className="max-h-[320px] overflow-y-auto py-1">
|
||||
{enableTextSearch && open && query.trim().length > 0 && createPortal(
|
||||
<div ref={refs.setFloating} className={dropdownBaseClass} style={floatingStyles}>
|
||||
<div ref={listRef} className="py-1">
|
||||
{/* Search text option */}
|
||||
<button
|
||||
className={cn(
|
||||
@@ -478,17 +545,17 @@ export function ProfileSearchDropdown({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
portalTarget)}
|
||||
|
||||
{/* Empty state — only when text search is not enabled */}
|
||||
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
|
||||
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && createPortal(
|
||||
<div ref={refs.setFloating} className={dropdownBaseClass} style={floatingStyles}>
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No profiles found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
portalTarget)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface SaveDestinationRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** A single row in the save-feed popover (Home feed / Profile tab / Share). */
|
||||
export function SaveDestinationRow({
|
||||
icon, label, description, onClick, disabled, loading,
|
||||
}: SaveDestinationRowProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
|
||||
>
|
||||
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,33 @@
|
||||
/**
|
||||
* SavedFeedFiltersEditor
|
||||
*
|
||||
* A controlled component that renders filter controls for a standard
|
||||
* NIP-01 filter object (TabFilter). Used on the Search page filter
|
||||
* popover and in the Settings > Feed saved-feed edit panel.
|
||||
*
|
||||
* Edits the following filter fields:
|
||||
* - `kinds` (array of kind numbers)
|
||||
* - `authors` (array of pubkeys)
|
||||
* - `search` (NIP-50 search string)
|
||||
* Shared filter sub-components used by FeedEditModal, ProfileTabEditModal,
|
||||
* ContentSettings, and the Search page. Includes MultiKindPicker, ScopeToggle,
|
||||
* AuthorChip, AuthorFilterDropdown, and ListPackPicker.
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Globe, UserSearch,
|
||||
ChevronDown, ChevronUp,
|
||||
Hash, Search as SearchIcon,
|
||||
X, Check, User,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, 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 { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
|
||||
import type { SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import type { UserList } from '@/hooks/useUserLists';
|
||||
import type { FollowPack } from '@/hooks/useFollowPacks';
|
||||
import type { KindOption } from '@/lib/feedFilterUtils';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type KindOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parentId: string;
|
||||
icon: React.ComponentType<{ className?: string }> | undefined;
|
||||
};
|
||||
|
||||
// ─── Kind options (built once) ───────────────────────────────────────────────
|
||||
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
// Re-export for consumers that were importing from this file
|
||||
export { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
export type { KindOption } from '@/lib/feedFilterUtils';
|
||||
|
||||
// ─── useScrollCarets ─────────────────────────────────────────────────────────
|
||||
|
||||
function useScrollCarets() {
|
||||
export function useScrollCarets() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
@@ -501,9 +480,6 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
|
||||
);
|
||||
}
|
||||
|
||||
// ─── parseSelectedKinds ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
// ─── AuthorChip ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -547,210 +523,4 @@ export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper: parse kinds from filter ──────────────────────────────────────────
|
||||
|
||||
/** Get the kindFilter string representation from a TabFilter's kinds array. */
|
||||
function kindsToKindFilter(filter: TabFilter): string {
|
||||
const kinds = filter.kinds;
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return 'all';
|
||||
return kinds.map(String).join(',');
|
||||
}
|
||||
|
||||
/** Get the author scope from a TabFilter. */
|
||||
function getAuthorScope(filter: TabFilter): 'anyone' | 'people' {
|
||||
const authors = filter.authors;
|
||||
if (Array.isArray(authors) && authors.length > 0) return 'people';
|
||||
return 'anyone';
|
||||
}
|
||||
|
||||
// ─── SavedFeedFiltersEditor ───────────────────────────────────────────────────
|
||||
|
||||
interface SavedFeedFiltersEditorProps {
|
||||
/** Current filter values */
|
||||
value: TabFilter;
|
||||
/** Called on every field change with the updated filter */
|
||||
onChange: (filter: TabFilter) => 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;
|
||||
/** Optional: pre-built kind options (pass to avoid rebuilding) */
|
||||
kindOptions?: KindOption[];
|
||||
}
|
||||
|
||||
export function SavedFeedFiltersEditor({
|
||||
value,
|
||||
onChange,
|
||||
showQuery = true,
|
||||
hideFrom = false,
|
||||
kindOptions: kindOptionsProp,
|
||||
}: SavedFeedFiltersEditorProps) {
|
||||
const kindOptions = useMemo(() => kindOptionsProp ?? buildKindOptions(), [kindOptionsProp]);
|
||||
const { lists } = useUserLists();
|
||||
const { data: followPacks = [] } = useFollowPacks();
|
||||
|
||||
const listPickerValue = useMatchedListId(
|
||||
Array.isArray(value.authors) ? (value.authors as string[]) : [],
|
||||
);
|
||||
|
||||
const search = typeof value.search === 'string' ? value.search : '';
|
||||
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
|
||||
// Local scope state so clicking "People" immediately shows the panel,
|
||||
// even before any authors have been added. Initialised from the filter value.
|
||||
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
|
||||
() => getAuthorScope(value),
|
||||
);
|
||||
const kindFilter = kindsToKindFilter(value);
|
||||
const [customKindText, setCustomKindText] = useState('');
|
||||
|
||||
const addAuthor = useCallback((pubkey: string, _label: string) => {
|
||||
const next = authorPubkeys.includes(pubkey) ? authorPubkeys : [...authorPubkeys, pubkey];
|
||||
setAuthorScopeState('people');
|
||||
onChange({ ...value, authors: next });
|
||||
}, [authorPubkeys, onChange, value]);
|
||||
|
||||
const removeAuthor = useCallback((pubkey: string) => {
|
||||
const next = authorPubkeys.filter((p) => p !== pubkey);
|
||||
const updated = { ...value };
|
||||
if (next.length > 0) {
|
||||
updated.authors = next;
|
||||
} else {
|
||||
delete updated.authors;
|
||||
}
|
||||
onChange(updated);
|
||||
}, [authorPubkeys, onChange, value]);
|
||||
|
||||
const setAuthorScope = useCallback((scope: 'anyone' | 'people') => {
|
||||
setAuthorScopeState(scope);
|
||||
if (scope === 'anyone') {
|
||||
const updated = { ...value };
|
||||
delete updated.authors;
|
||||
onChange(updated);
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
const handleKindChange = useCallback((v: string) => {
|
||||
const updated = { ...value };
|
||||
if (v === 'all') {
|
||||
delete updated.kinds;
|
||||
setCustomKindText('');
|
||||
} else if (v === 'custom') {
|
||||
setCustomKindText(Array.isArray(value.kinds) ? (value.kinds as number[]).join(', ') : '');
|
||||
} else {
|
||||
updated.kinds = [parseInt(v, 10)];
|
||||
setCustomKindText('');
|
||||
}
|
||||
onChange(updated);
|
||||
}, [onChange, value]);
|
||||
|
||||
const handleCustomKindChange = useCallback((text: string) => {
|
||||
setCustomKindText(text);
|
||||
const kinds = text.split(/[\s,]+/).map(Number).filter((n) => !isNaN(n) && n > 0);
|
||||
if (kinds.length > 0) {
|
||||
onChange({ ...value, kinds });
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
const handleSearchChange = useCallback((newSearch: string) => {
|
||||
const updated = { ...value };
|
||||
if (newSearch.trim()) {
|
||||
updated.search = newSearch;
|
||||
} else {
|
||||
delete updated.search;
|
||||
}
|
||||
onChange(updated);
|
||||
}, [onChange, value]);
|
||||
|
||||
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={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="e.g. bitcoin"
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md: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],
|
||||
['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} />
|
||||
<ListPackPicker
|
||||
lists={lists}
|
||||
followPacks={followPacks}
|
||||
value={listPickerValue}
|
||||
onSelectPubkeys={(pubkeys) => {
|
||||
const updated = { ...value };
|
||||
if (pubkeys.length > 0) {
|
||||
updated.authors = pubkeys;
|
||||
} else {
|
||||
delete updated.authors;
|
||||
}
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kind */}
|
||||
<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={handleKindChange} />
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => handleCustomKindChange(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useScreenEffect } from '@/contexts/ScreenEffectContext';
|
||||
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
|
||||
|
||||
/**
|
||||
* Reads the global screen effect state and renders the appropriate overlay.
|
||||
* Must be placed inside a ScreenEffectProvider.
|
||||
*/
|
||||
export function ScreenEffectRenderer() {
|
||||
const { screenEffect } = useScreenEffect();
|
||||
|
||||
if (!screenEffect) return null;
|
||||
|
||||
switch (screenEffect.type) {
|
||||
case 'rain':
|
||||
case 'snow':
|
||||
return <PrecipitationEffect type={screenEffect.type} intensity={screenEffect.intensity} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Pencil, Check, SeparatorHorizontal, Search, ChevronDown, ChevronUp, LinkIcon } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { SeparatorHorizontal, ChevronDown, ChevronUp, LinkIcon, Pencil, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
|
||||
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -16,105 +14,22 @@ interface SidebarMoreMenuProps {
|
||||
onAdd: (id: string) => void;
|
||||
onAddDivider: () => void;
|
||||
onNavigate?: () => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Extra classes on the link text. */
|
||||
linkClassName?: string;
|
||||
/** Sidebar item ID configured as the homepage. */
|
||||
homePage?: string;
|
||||
}
|
||||
|
||||
function useScrollCarets(centerOnOpen = false) {
|
||||
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) => {
|
||||
// Disconnect previous observer if any
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
if (!el) return;
|
||||
if (centerOnOpen) {
|
||||
el.scrollTop = (el.scrollHeight - el.clientHeight) / 2;
|
||||
}
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
update();
|
||||
}, [centerOnOpen, 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();
|
||||
// stop automatically when the limit is reached
|
||||
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
if (atLimit) stopScroll();
|
||||
}, 16);
|
||||
}, [update, stopScroll]);
|
||||
|
||||
// clean up interval on unmount
|
||||
useEffect(() => stopScroll, [stopScroll]);
|
||||
|
||||
return { scrollRef, refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
|
||||
}
|
||||
|
||||
function ScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) {
|
||||
return (
|
||||
<button className="flex cursor-default items-center justify-center py-1 w-full shrink-0" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
{direction === 'up' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id: string) => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => { onAdd(item.id); onClose(); }}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer"
|
||||
>
|
||||
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onAdd(item.id); onClose(); }}
|
||||
className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title={`Add ${item.label} to sidebar`}
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarMoreMenu({
|
||||
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, open, onOpenChange, homePage,
|
||||
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, linkClassName, homePage,
|
||||
}: SidebarMoreMenuProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const [addQuery, setAddQuery] = useState('');
|
||||
const { user } = useCurrentUser();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [linkInput, setLinkInput] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState('');
|
||||
const [linkError, setLinkError] = useState('');
|
||||
|
||||
const filtered = hiddenItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
|
||||
const addFiltered = hiddenItems.filter((item) => item.label.toLowerCase().includes(addQuery.toLowerCase()));
|
||||
const sizeClass = linkClassName ?? 'text-lg';
|
||||
|
||||
const handleAddLink = () => {
|
||||
const raw = linkValue.trim();
|
||||
@@ -176,35 +91,11 @@ export function SidebarMoreMenu({
|
||||
setLinkError('');
|
||||
};
|
||||
|
||||
const main = useScrollCarets(true);
|
||||
const add = useScrollCarets();
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<DropdownMenu open={addMenuOpen} onOpenChange={(o) => { setAddMenuOpen(o); if (!o) setAddQuery(''); }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
|
||||
<Plus className="size-4" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
|
||||
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
|
||||
<Search className="size-5 shrink-0" />
|
||||
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
|
||||
</div>
|
||||
<div className="h-px bg-border mb-1 shrink-0" />
|
||||
{add.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => add.startScroll('up')} onMouseLeave={add.stopScroll} />}
|
||||
<div ref={add.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={add.onScroll}>
|
||||
{addFiltered.map((item) => <ItemRow key={item.id} item={item} onAdd={onAdd} onClose={() => setAddMenuOpen(false)} />)}
|
||||
{addFiltered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
|
||||
</div>
|
||||
{add.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => add.startScroll('down')} onMouseLeave={add.stopScroll} />}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button onClick={onAddDivider} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
|
||||
<SeparatorHorizontal className="size-4" />
|
||||
<button onClick={onAddDivider} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}>
|
||||
<SeparatorHorizontal className="size-6" />
|
||||
<span>Add divider</span>
|
||||
</button>
|
||||
{linkInput ? (
|
||||
@@ -248,56 +139,59 @@ export function SidebarMoreMenu({
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setLinkInput(true)}
|
||||
className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85"
|
||||
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
<LinkIcon className="size-6" />
|
||||
<span>Add link</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDoneEditing} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-primary font-medium hover:bg-primary/10 bg-background/85">
|
||||
<Check className="size-4" />
|
||||
<button onClick={onDoneEditing} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-primary font-medium hover:bg-primary/10 bg-background/85 ${sizeClass}`}>
|
||||
<Check className="size-6" />
|
||||
<span>Done editing</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-editing mode: inline collapsible list (no popover)
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setQuery(''); }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85">
|
||||
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||
<span>More...</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
|
||||
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
|
||||
<Search className="size-5 shrink-0" />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
|
||||
</div>
|
||||
<div className="h-px bg-border mb-1 shrink-0" />
|
||||
{main.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => main.startScroll('up')} onMouseLeave={main.stopScroll} />}
|
||||
<div ref={main.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={main.onScroll}>
|
||||
{filtered.map((item) => (
|
||||
<div key={item.id} className="flex items-center">
|
||||
<Link to={itemPath(item.id, undefined, homePage)} onClick={() => { onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors">
|
||||
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
|
||||
</Link>
|
||||
<button onClick={() => { onAdd(item.id); onOpenChange(false); }} className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" title={`Add ${item.label} to sidebar`}>
|
||||
<Plus className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
|
||||
>
|
||||
{expanded ? <ChevronUp className="size-6" /> : <ChevronDown className="size-6" />}
|
||||
<span>{expanded ? 'Less...' : 'More...'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{hiddenItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={itemPath(item.id, undefined, homePage)}
|
||||
onClick={() => { setExpanded(false); onNavigate?.(); }}
|
||||
className={`flex items-center gap-4 px-3 py-3 rounded-full font-normal text-foreground transition-colors hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
|
||||
>
|
||||
{sidebarItemIcon(item.id)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{filtered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
|
||||
|
||||
{hiddenItems.length === 0 && (
|
||||
<p className={`px-3 py-3 text-muted-foreground ${sizeClass}`}>All items are in the sidebar</p>
|
||||
)}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => { setExpanded(false); onStartEditing(); }}
|
||||
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
|
||||
>
|
||||
<Pencil className="size-6" />
|
||||
<span>Edit sidebar</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{main.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => main.startScroll('down')} onMouseLeave={main.stopScroll} />}
|
||||
<div className="h-px bg-border my-1 shrink-0" />
|
||||
<button onClick={() => { onStartEditing(); onOpenChange(false); }} className="flex items-center gap-3 w-full px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer shrink-0">
|
||||
<Pencil className="size-5" />
|
||||
<span>Edit sidebar</span>
|
||||
</button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
import { GripVertical, X, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
type DragEndEvent,
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
|
||||
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isSidebarSearch, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
|
||||
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
|
||||
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { SortableItemShell } from '@/components/SortableItemShell';
|
||||
|
||||
// ── Sortable item ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +23,9 @@ export interface SidebarNavItemProps {
|
||||
active: boolean;
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onAdd?: (id: string) => void;
|
||||
/** True when this item is below the "More..." separator (hidden zone). */
|
||||
belowMore?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
profilePath?: string;
|
||||
showIndicator?: boolean;
|
||||
@@ -31,35 +36,19 @@ export interface SidebarNavItemProps {
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
id, active, editing, onRemove, onClick, profilePath, showIndicator, linkClassName, homePage,
|
||||
id, active, editing, onRemove, onAdd, belowMore, onClick, profilePath, showIndicator, linkClassName, homePage,
|
||||
}: SidebarNavItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const icon = sidebarItemIcon(id);
|
||||
const label = itemLabel(id);
|
||||
const path = itemPath(id, profilePath, homePage);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore} label={label}>
|
||||
<Link
|
||||
to={path}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
@@ -73,17 +62,69 @@ export function SidebarNavItem({
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{label}</span>
|
||||
</Link>
|
||||
</SortableItemShell>
|
||||
);
|
||||
}
|
||||
|
||||
{editing && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title={`Remove ${label}`}
|
||||
// ── Search input item (sidebar inline search) ────────────────────────────────
|
||||
|
||||
interface SidebarSearchItemProps {
|
||||
id: string;
|
||||
editing: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onAdd?: (id: string) => void;
|
||||
belowMore?: boolean;
|
||||
linkClassName?: string;
|
||||
}
|
||||
|
||||
function SidebarSearchItem({
|
||||
id, editing, onRemove, onAdd, belowMore, linkClassName,
|
||||
}: SidebarSearchItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const collapse = useCallback(() => setExpanded(false), []);
|
||||
|
||||
const icon = sidebarItemIcon(id);
|
||||
const label = itemLabel(id);
|
||||
|
||||
// Font style matching: the collapsed label uses --title-font-family.
|
||||
// Apply the same font to the expanded input so the row height stays constant.
|
||||
const titleFontStyle: React.CSSProperties = { fontFamily: 'var(--title-font-family, inherit)' };
|
||||
|
||||
return (
|
||||
<SortableItemShell id={id} editing={editing} onRemove={onRemove} onAdd={onAdd} belowMore={belowMore} label={label}>
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
{/* Always render the sidebar-item-shaped row with a locked min-height
|
||||
so toggling between label and input doesn't shift layout. */}
|
||||
<div
|
||||
role={expanded && !editing ? undefined : 'button'}
|
||||
tabIndex={expanded && !editing ? undefined : 0}
|
||||
onClick={() => { if (!editing && !expanded) setExpanded(true); }}
|
||||
onKeyDown={(e) => { if (!editing && !expanded && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setExpanded(true); } }}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors w-full text-left',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
{expanded && !editing ? (
|
||||
<ProfileSearchDropdown
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
enableTextSearch
|
||||
hideIcon
|
||||
onDismiss={collapse}
|
||||
className="flex-1 min-w-0"
|
||||
inputClassName="h-auto py-0 px-0 bg-transparent border-0 rounded-none shadow-none ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-[length:inherit] md:text-[length:inherit]"
|
||||
inputStyle={titleFontStyle}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate" style={titleFontStyle}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableItemShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,12 +171,49 @@ function SidebarDividerItem({ sortableId, editing, onRemove }: SidebarDividerIte
|
||||
);
|
||||
}
|
||||
|
||||
// ── "More..." separator (draggable boundary in editing mode) ──────────────────
|
||||
|
||||
function MoreSeparatorItem({ sortableId, editing, linkClassName }: { sortableId: string; editing: boolean; linkClassName?: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full flex-1 min-w-0 text-muted-foreground/60',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}>
|
||||
<ChevronDown className="size-6" />
|
||||
<span>More...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DnD-aware nav list ────────────────────────────────────────────────────────
|
||||
|
||||
/** Sentinel ID representing the "More..." boundary in the editing list. */
|
||||
export const MORE_SEPARATOR_ID = '__more__';
|
||||
|
||||
export interface SidebarNavListProps {
|
||||
items: string[];
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onAdd?: (id: string) => void;
|
||||
onReorder: (newOrder: string[]) => void;
|
||||
isActive: (id: string) => boolean;
|
||||
getOnClick?: (id: string) => ((e: React.MouseEvent) => void) | undefined;
|
||||
@@ -147,7 +225,7 @@ export interface SidebarNavListProps {
|
||||
}
|
||||
|
||||
export function SidebarNavList({
|
||||
items, editing, onRemove, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
|
||||
items, editing, onRemove, onAdd, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
|
||||
}: SidebarNavListProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
@@ -157,6 +235,9 @@ export function SidebarNavList({
|
||||
// Assign unique sortable IDs: regular items use their id, dividers get "divider-{index}"
|
||||
const sortableIds = items.map((id, i) => isSidebarDivider(id) ? `divider-${i}` : id);
|
||||
|
||||
// Find the "More..." boundary to determine which items are below it
|
||||
const moreIndex = items.indexOf(MORE_SEPARATOR_ID);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
@@ -171,6 +252,12 @@ export function SidebarNavList({
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{items.map((id, i) => {
|
||||
const sortableId = sortableIds[i];
|
||||
const isBelowMore = moreIndex !== -1 && i > moreIndex;
|
||||
|
||||
if (id === MORE_SEPARATOR_ID) {
|
||||
return <MoreSeparatorItem key={MORE_SEPARATOR_ID} sortableId={MORE_SEPARATOR_ID} editing={editing} linkClassName={linkClassName} />;
|
||||
}
|
||||
|
||||
if (isSidebarDivider(id)) {
|
||||
return (
|
||||
<SidebarDividerItem
|
||||
@@ -189,6 +276,8 @@ export function SidebarNavList({
|
||||
active={isActive(id)}
|
||||
editing={editing}
|
||||
onRemove={(removeId) => onRemove(removeId, i)}
|
||||
onAdd={onAdd}
|
||||
belowMore={isBelowMore}
|
||||
onClick={getOnClick?.(id)}
|
||||
linkClassName={linkClassName}
|
||||
/>
|
||||
@@ -202,11 +291,26 @@ export function SidebarNavList({
|
||||
active={isActive(id)}
|
||||
editing={editing}
|
||||
onRemove={(removeId) => onRemove(removeId, i)}
|
||||
onAdd={onAdd}
|
||||
belowMore={isBelowMore}
|
||||
onClick={getOnClick?.(id)}
|
||||
linkClassName={linkClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isSidebarSearch(id)) {
|
||||
return (
|
||||
<SidebarSearchItem
|
||||
key={id}
|
||||
id={id}
|
||||
editing={editing}
|
||||
onRemove={(removeId) => onRemove(removeId, i)}
|
||||
onAdd={onAdd}
|
||||
belowMore={isBelowMore}
|
||||
linkClassName={linkClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={id}
|
||||
@@ -214,6 +318,8 @@ export function SidebarNavList({
|
||||
active={isActive(id)}
|
||||
editing={editing}
|
||||
onRemove={(removeId) => onRemove(removeId, i)}
|
||||
onAdd={onAdd}
|
||||
belowMore={isBelowMore}
|
||||
onClick={getOnClick?.(id)}
|
||||
profilePath={getProfilePath?.(id)}
|
||||
showIndicator={getShowIndicator?.(id)}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { GripVertical, X, Plus } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SortableItemShellProps {
|
||||
/** The sortable ID (must be unique within the DnD context). */
|
||||
id: string;
|
||||
/** Whether the sidebar is in editing mode. */
|
||||
editing: boolean;
|
||||
/** Called when the remove (X) button is clicked. */
|
||||
onRemove: (id: string) => void;
|
||||
/** Called when the add (+) button is clicked (below-more items). */
|
||||
onAdd?: (id: string) => void;
|
||||
/** True when this item is below the "More..." separator (hidden zone). */
|
||||
belowMore?: boolean;
|
||||
/** Label for the add/remove button tooltip. */
|
||||
label?: string;
|
||||
/** The content to render inside the shell (the Link + icon + label). */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared drag-sortable wrapper for sidebar items.
|
||||
* Provides the grip handle, outer container, and add/remove action buttons.
|
||||
*/
|
||||
export function SortableItemShell({
|
||||
id, editing, onRemove, onAdd, belowMore, label, children,
|
||||
}: SortableItemShellProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{editing && (
|
||||
belowMore ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAdd?.(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
title={label ? `Add ${label}` : 'Add'}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title={label ? `Remove ${label}` : 'Remove'}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Clock, Globe, Image, Languages, MessageSquareOff, Radio, Search, SortDesc, Terminal, Users, Video, WandSparkles } from 'lucide-react';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
|
||||
/** Map from kind number string to friendly label like "Posts (1)". */
|
||||
const KIND_LABEL_MAP: Map<string, string> = new Map(
|
||||
buildKindOptions().map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
/** Parse a spell timestamp value into human-readable text. */
|
||||
function formatTimestamp(value: string): string {
|
||||
const units: Record<string, string> = {
|
||||
s: 'second', m: 'minute', h: 'hour', d: 'day',
|
||||
w: 'week', mo: 'month', y: 'year',
|
||||
};
|
||||
|
||||
if (value === 'now') return 'now';
|
||||
|
||||
const match = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/);
|
||||
if (match) {
|
||||
const [, num, unit] = match;
|
||||
const label = units[unit] ?? unit;
|
||||
return `last ${num} ${label}${parseInt(num) !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Absolute timestamp
|
||||
const ts = parseInt(value);
|
||||
if (!isNaN(ts)) {
|
||||
return new Date(ts * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Friendly display name for tag filter letters. */
|
||||
function tagFilterLabel(letter: string): string {
|
||||
if (letter === 't') return '#';
|
||||
return `#${letter}`;
|
||||
}
|
||||
|
||||
interface SpellContentProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
export function SpellContent({ event }: SpellContentProps) {
|
||||
const { tags } = event;
|
||||
|
||||
const name = tags.find(([t]) => t === 'name')?.[1];
|
||||
const cmd = tags.find(([t]) => t === 'cmd')?.[1];
|
||||
const kinds = tags.filter(([t]) => t === 'k').map(([, v]) => v);
|
||||
const authors = tags.find(([t]) => t === 'authors')?.slice(1) ?? [];
|
||||
const search = tags.find(([t]) => t === 'search')?.[1];
|
||||
const since = tags.find(([t]) => t === 'since')?.[1];
|
||||
const until = tags.find(([t]) => t === 'until')?.[1];
|
||||
const limit = tags.find(([t]) => t === 'limit')?.[1];
|
||||
const relays = tags.find(([t]) => t === 'relays')?.slice(1) ?? [];
|
||||
const tagFilters = tags.filter(([t]) => t === 'tag');
|
||||
const closeOnEose = tags.some(([t]) => t === 'close-on-eose');
|
||||
|
||||
// Client-hint tags (NIP-50 extensions)
|
||||
const media = tags.find(([t]) => t === 'media')?.[1];
|
||||
const language = tags.find(([t]) => t === 'language')?.[1];
|
||||
const platform = tags.find(([t]) => t === 'platform')?.[1];
|
||||
const sort = tags.find(([t]) => t === 'sort')?.[1];
|
||||
const includeReplies = tags.find(([t]) => t === 'include-replies')?.[1];
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{/* Title */}
|
||||
{name && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<WandSparkles className="size-4 text-primary shrink-0" />
|
||||
<span className="text-[15px] font-semibold leading-snug">{name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description from content */}
|
||||
{event.content && (
|
||||
<p className="text-[15px] leading-relaxed text-foreground/90 line-clamp-3 mb-3">
|
||||
{event.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Badge row */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cmd && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Terminal className="size-3" />
|
||||
{cmd}
|
||||
</Badge>
|
||||
)}
|
||||
{kinds.map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-xs">
|
||||
{KIND_LABEL_MAP.get(k) ?? `Kind ${k}`}
|
||||
</Badge>
|
||||
))}
|
||||
{authors
|
||||
.filter((a) => a.startsWith('$'))
|
||||
.map((a) => (
|
||||
<Badge key={a} variant="outline" className="gap-1 text-xs">
|
||||
<Users className="size-3" />
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
{search && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Search className="size-3" />
|
||||
{search}
|
||||
</Badge>
|
||||
)}
|
||||
{since && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Clock className="size-3" />
|
||||
{formatTimestamp(since)}
|
||||
</Badge>
|
||||
)}
|
||||
{until && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Clock className="size-3" />
|
||||
until {formatTimestamp(until)}
|
||||
</Badge>
|
||||
)}
|
||||
{limit && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
limit: {limit}
|
||||
</Badge>
|
||||
)}
|
||||
{closeOnEose && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
one-shot
|
||||
</Badge>
|
||||
)}
|
||||
{tagFilters.map(([, letter, ...values], i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{tagFilterLabel(letter)}: {values.join(', ')}
|
||||
</Badge>
|
||||
))}
|
||||
{media && media !== 'all' && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
{media === 'images' ? <Image className="size-3" /> : media === 'videos' || media === 'vines' ? <Video className="size-3" /> : null}
|
||||
{media}
|
||||
</Badge>
|
||||
)}
|
||||
{language && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Languages className="size-3" />
|
||||
{language}
|
||||
</Badge>
|
||||
)}
|
||||
{platform && platform !== 'nostr' && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Globe className="size-3" />
|
||||
{platform}
|
||||
</Badge>
|
||||
)}
|
||||
{sort && sort !== 'recent' && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<SortDesc className="size-3" />
|
||||
{sort}
|
||||
</Badge>
|
||||
)}
|
||||
{includeReplies === 'false' && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<MessageSquareOff className="size-3" />
|
||||
no replies
|
||||
</Badge>
|
||||
)}
|
||||
{relays.map((r) => (
|
||||
<Badge key={r} variant="outline" className="gap-1 text-xs">
|
||||
<Radio className="size-3" />
|
||||
{r.replace('wss://', '')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { usePublishStatus } from '@/hooks/usePublishStatus';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface StatusEditorProps {
|
||||
pubkey: string;
|
||||
/** Padding class for the editing form wrapper (e.g. "p-3" or "px-3 py-2"). */
|
||||
formClassName?: string;
|
||||
/** Padding class for the inactive "Set a status" button (e.g. "px-4" or "px-3"). */
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline NIP-38 status editor used in the account popover (LeftSidebar)
|
||||
* and the mobile drawer (MobileDrawer).
|
||||
*
|
||||
* Renders either the current status (or "Set a status" placeholder) as a
|
||||
* clickable button, or an input + Save/Clear/Cancel controls when editing.
|
||||
*/
|
||||
export function StatusEditor({ pubkey, formClassName = 'p-3', buttonClassName = 'px-4' }: StatusEditorProps) {
|
||||
const userStatus = useUserStatus(pubkey);
|
||||
const publishStatus = usePublishStatus();
|
||||
const { toast } = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const submitStatus = (text: string) => {
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
}).catch(() => {
|
||||
toast({ title: 'Failed to update status', variant: 'destructive' });
|
||||
});
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={`${formClassName} space-y-2`}>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value.slice(0, 80))}
|
||||
placeholder="What are you up to?"
|
||||
className="h-8 text-base md:text-sm"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitStatus(draft.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => submitStatus(draft.trim())}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
{userStatus.status && (
|
||||
<button
|
||||
onClick={() => submitStatus('')}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setEditing(false); setDraft(''); }}
|
||||
className="text-xs text-muted-foreground hover:underline ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(true);
|
||||
setDraft(userStatus.status ?? '');
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full ${buttonClassName} py-2.5 text-sm hover:bg-secondary/60 transition-colors`}
|
||||
>
|
||||
{userStatus.status ? (
|
||||
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Set a status</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const conversationCache = new Map<string, ChatMessage[]>();
|
||||
/** Compact AI chat widget for the sidebar. */
|
||||
export function AIChatWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare();
|
||||
const { sendStreamingMessage, getAvailableModels, isLoading } = useShakespeare();
|
||||
|
||||
// Fetch available models and select the cheapest as default
|
||||
const { data: defaultModelId } = useQuery({
|
||||
@@ -88,7 +88,7 @@ export function AIChatWidget() {
|
||||
}
|
||||
}, [input, isLoading, messages, sendStreamingMessage, defaultModelId]);
|
||||
|
||||
if (!user || !isAuthenticated) {
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
@@ -156,7 +156,7 @@ export function AIChatWidget() {
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
|
||||
const content = typeof message.content === 'string' ? message.content : Array.isArray(message.content) ? message.content.map((c) => c.text ?? '').join('') : '';
|
||||
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
|
||||
@@ -16,9 +16,6 @@ export type Theme = "light" | "dark" | "system" | "custom";
|
||||
*/
|
||||
export type ContentWarningPolicy = "blur" | "hide" | "show";
|
||||
|
||||
/** How to handle events with a NIP-36 content-warning tag. */
|
||||
export type NsfwPolicy = "blur" | "hide" | "show";
|
||||
|
||||
export interface RelayMetadata {
|
||||
/** List of relays with read/write permissions */
|
||||
relays: { url: string; read: boolean; write: boolean }[];
|
||||
@@ -177,6 +174,10 @@ export interface SavedFeed {
|
||||
filter: TabFilter;
|
||||
vars: TabVarDef[];
|
||||
createdAt: number;
|
||||
/** Hex event ID of a kind:777 spell event. When present, the saved feed
|
||||
* is rendered by fetching this spell and passing it to useStreamPosts({ spell }),
|
||||
* which handles full resolution (variables, hints, tag filters, etc.). */
|
||||
spellId?: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -247,6 +248,10 @@ export interface AppConfig {
|
||||
sandboxDomain: string;
|
||||
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
|
||||
sidebarWidgets: WidgetConfig[];
|
||||
/** Selected AI model ID for Buddy chat. Empty string means "use cheapest available". */
|
||||
aiModel: string;
|
||||
/** Custom system prompt override for Buddy chat. Empty string means use the built-in default. */
|
||||
aiSystemPrompt: string;
|
||||
}
|
||||
|
||||
/** Configuration for a single widget in the right sidebar. */
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
import type { PrecipitationIntensity } from '@/hooks/useWeather';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
/** A visual effect rendered as a full-screen overlay. Extend this union to add new effects. */
|
||||
export type ScreenEffect =
|
||||
| { type: 'rain' | 'snow'; intensity: PrecipitationIntensity }
|
||||
// Future effects can be added here:
|
||||
// | { type: 'confetti'; duration?: number }
|
||||
// | { type: 'fireworks' }
|
||||
;
|
||||
|
||||
export interface ScreenEffectContextValue {
|
||||
/** The currently active screen effect, or null if none. */
|
||||
screenEffect: ScreenEffect | null;
|
||||
/** Set or clear the screen effect. Pass null to stop. */
|
||||
setScreenEffect: (effect: ScreenEffect | null) => void;
|
||||
}
|
||||
|
||||
// ─── Context ───
|
||||
|
||||
const ScreenEffectCtx = createContext<ScreenEffectContextValue | null>(null);
|
||||
|
||||
// ─── Persistence ───
|
||||
|
||||
const STORAGE_KEY = 'ditto:screen-effect';
|
||||
|
||||
function loadEffect(): ScreenEffect | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
// Basic validation
|
||||
if (parsed && typeof parsed.type === 'string') {
|
||||
return parsed as ScreenEffect;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEffect(effect: ScreenEffect | null): void {
|
||||
try {
|
||||
if (effect) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(effect));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider ───
|
||||
|
||||
export function ScreenEffectProvider({ children }: { children: ReactNode }) {
|
||||
const [screenEffect, setScreenEffectRaw] = useState<ScreenEffect | null>(loadEffect);
|
||||
|
||||
const setScreenEffect = useCallback((effect: ScreenEffect | null) => {
|
||||
setScreenEffectRaw(effect);
|
||||
saveEffect(effect);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScreenEffectCtx.Provider value={{ screenEffect, setScreenEffect }}>
|
||||
{children}
|
||||
</ScreenEffectCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ───
|
||||
|
||||
export function useScreenEffect(): ScreenEffectContextValue {
|
||||
const ctx = useContext(ScreenEffectCtx);
|
||||
if (!ctx) {
|
||||
throw new Error('useScreenEffect must be used within a ScreenEffectProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAIChatTools, TOOLS } from '@/hooks/useAIChatTools';
|
||||
import { type DisplayMessage, type ToolCall } from '@/lib/aiChatTools';
|
||||
import { buildSystemPrompt, type UserIdentity } from '@/lib/aiChatSystemPrompt';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Options for configuring the AI chat session with a buddy identity. */
|
||||
export interface AIChatSessionOptions {
|
||||
/** Buddy agent display name. When omitted, defaults to "Dork". */
|
||||
buddyName?: string;
|
||||
/** Buddy soul text injected into the system prompt. */
|
||||
buddySoul?: string;
|
||||
}
|
||||
|
||||
// ─── Persistence ───
|
||||
|
||||
const CHAT_STORAGE_KEY = 'ditto:ai-chat-messages';
|
||||
|
||||
/** Zod schema for a single persisted chat message. */
|
||||
const StoredToolCallSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
result: z.string().optional(),
|
||||
});
|
||||
|
||||
const StoredMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant', 'system', 'tool_result']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(StoredToolCallSchema).optional(),
|
||||
toolCallId: z.string().optional(),
|
||||
// nostrEvent is not validated in detail — just needs to be an object if present
|
||||
nostrEvent: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const StoredMessagesSchema = z.array(StoredMessageSchema);
|
||||
|
||||
function loadMessages(): DisplayMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CHAT_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = StoredMessagesSchema.safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) {
|
||||
console.warn('Discarding corrupted AI chat history:', parsed.error.message);
|
||||
localStorage.removeItem(CHAT_STORAGE_KEY);
|
||||
return [];
|
||||
}
|
||||
return parsed.data.map((m) => ({
|
||||
...m,
|
||||
timestamp: new Date(m.timestamp),
|
||||
nostrEvent: m.nostrEvent as NostrEvent | undefined,
|
||||
toolCalls: m.toolCalls as ToolCall[] | undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveMessages(messages: DisplayMessage[]): void {
|
||||
try {
|
||||
const stored = messages.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() }));
|
||||
localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(stored));
|
||||
} catch {
|
||||
// Storage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ───
|
||||
|
||||
export function useAIChatSession(options: AIChatSessionOptions = {}) {
|
||||
const { buddyName, buddySoul } = options;
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { sendStreamingMessage, getAvailableModels, getCredits, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
|
||||
const { executeToolCall, savedFeeds } = useAIChatTools();
|
||||
|
||||
const [messages, setMessages] = useState<DisplayMessage[]>(loadMessages);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
|
||||
// Resolve the effective model: config value, or fetch the cheapest as default
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const selectedModel = config.aiModel || defaultModel;
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Persist messages to localStorage
|
||||
useEffect(() => {
|
||||
saveMessages(messages);
|
||||
}, [messages]);
|
||||
|
||||
// Scroll to bottom on new messages or streaming text updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingText]);
|
||||
|
||||
// Fetch cheapest model as fallback when no model is configured
|
||||
useEffect(() => {
|
||||
if (!user || config.aiModel) return;
|
||||
|
||||
let cancelled = false;
|
||||
getAvailableModels()
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const sorted = response.data.sort((a, b) => {
|
||||
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
||||
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
||||
return costA - costB;
|
||||
});
|
||||
if (sorted.length > 0) {
|
||||
setDefaultModel(sorted[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [user, config.aiModel, getAvailableModels]);
|
||||
|
||||
// Build the system prompt — dynamic based on buddy identity, saved feeds, user identity, + optional custom override
|
||||
const savedFeedLabels = useMemo(() => savedFeeds.map((f) => f.label), [savedFeeds]);
|
||||
|
||||
const userIdentity = useMemo<UserIdentity | undefined>(() => {
|
||||
if (!user) return undefined;
|
||||
return {
|
||||
npub: nip19.npubEncode(user.pubkey),
|
||||
pubkey: user.pubkey,
|
||||
displayName: metadata?.display_name || metadata?.name,
|
||||
nip05: metadata?.nip05,
|
||||
about: metadata?.about,
|
||||
};
|
||||
}, [user, metadata]);
|
||||
|
||||
const systemPrompt = useMemo(
|
||||
() => buildSystemPrompt(buddyName, buddySoul, config.aiSystemPrompt || undefined, savedFeedLabels, userIdentity),
|
||||
[buddyName, buddySoul, config.aiSystemPrompt, savedFeedLabels, userIdentity],
|
||||
);
|
||||
|
||||
// Build the chat messages array for the API
|
||||
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
|
||||
const apiMessages: ChatMessage[] = [systemPrompt];
|
||||
|
||||
for (const msg of displayMsgs) {
|
||||
if (msg.role === 'tool_result') {
|
||||
apiMessages.push({
|
||||
role: 'tool',
|
||||
content: msg.content,
|
||||
tool_call_id: msg.toolCallId,
|
||||
});
|
||||
} else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
apiMessages.push({
|
||||
role: 'assistant',
|
||||
content: msg.content || null,
|
||||
tool_calls: msg.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content });
|
||||
}
|
||||
}
|
||||
|
||||
return apiMessages;
|
||||
}, [systemPrompt]);
|
||||
|
||||
// Handle sending a message. Pass `override` to send arbitrary text (e.g. suggestion chips).
|
||||
const handleSend = useCallback(async (override?: string) => {
|
||||
const trimmed = (override ?? input).trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
// Slash commands — handled locally, never sent to the API
|
||||
if (trimmed.startsWith('/')) {
|
||||
const cmd = trimmed.toLowerCase();
|
||||
if (cmd === '/new' || cmd === '/clear') {
|
||||
handleClear();
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
// Unknown command — ignore silently
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedModel) return;
|
||||
|
||||
clearError();
|
||||
setInput('');
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const userMessage: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setIsStreaming(true);
|
||||
setStreamingText('');
|
||||
|
||||
try {
|
||||
const MAX_TOOL_ROUNDS = 10;
|
||||
let apiMessages = buildApiMessages(newMessages);
|
||||
let currentMessages = newMessages;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
// Stream the response — text chunks update streamingText in real-time
|
||||
let streamAccumulator = '';
|
||||
const response = await sendStreamingMessage(
|
||||
apiMessages,
|
||||
selectedModel,
|
||||
(chunk) => {
|
||||
streamAccumulator += chunk;
|
||||
setStreamingText(streamAccumulator);
|
||||
},
|
||||
{ tools: TOOLS } as Partial<Record<string, unknown>>,
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
// Stream finished — clear the streaming text
|
||||
setStreamingText('');
|
||||
|
||||
const choice = response.choices[0];
|
||||
const assistantMsg = choice.message;
|
||||
|
||||
// Check for tool calls
|
||||
const rawMessage = assistantMsg as unknown as {
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!rawMessage.tool_calls || rawMessage.tool_calls.length === 0) {
|
||||
const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : '';
|
||||
const assistantMessage: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
let nostrEvent: NostrEvent | undefined;
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
for (const tc of rawMessage.tool_calls) {
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
} catch {
|
||||
// Return an error to the AI so it can retry instead of silently running with empty args
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: {},
|
||||
result: JSON.stringify({ error: `Invalid tool call arguments: could not parse JSON for ${tc.function.name}` }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const execResult = await executeToolCall(tc.function.name, args);
|
||||
|
||||
if (execResult.nostrEvent) {
|
||||
nostrEvent = execResult.nostrEvent;
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: args,
|
||||
result: execResult.result,
|
||||
});
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
// Add assistant message with tool calls to display
|
||||
const toolMsg: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: rawMessage.content || '',
|
||||
timestamp: new Date(),
|
||||
toolCalls,
|
||||
nostrEvent,
|
||||
};
|
||||
|
||||
// Add tool result display messages (hidden in UI, used by buildApiMessages)
|
||||
const toolResultMsgs: DisplayMessage[] = toolCalls.map((tc) => ({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'tool_result' as const,
|
||||
content: tc.result ?? '',
|
||||
toolCallId: tc.id,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
|
||||
currentMessages = [...currentMessages, toolMsg, ...toolResultMsgs];
|
||||
setMessages(currentMessages);
|
||||
|
||||
// Rebuild API messages
|
||||
apiMessages = buildApiMessages(currentMessages);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle user-initiated abort and other errors
|
||||
// (API-level errors are surfaced via apiError from useShakespeare)
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
}
|
||||
}, [input, selectedModel, isStreaming, messages, buildApiMessages, sendStreamingMessage, executeToolCall, clearError]);
|
||||
|
||||
// Stop an in-flight generation
|
||||
const handleStop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}, [handleSend]);
|
||||
|
||||
// Clear conversation
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(CHAT_STORAGE_KEY);
|
||||
clearError();
|
||||
}, [clearError]);
|
||||
|
||||
return {
|
||||
// State
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
selectedModel,
|
||||
apiLoading,
|
||||
apiError,
|
||||
messagesEndRef,
|
||||
|
||||
// Actions
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleKeyDown,
|
||||
handleClear,
|
||||
getCredits,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useScreenEffect } from '@/contexts/ScreenEffectContext';
|
||||
import { truncateToolResult } from '@/lib/tools/truncateToolResult';
|
||||
import { toolToOpenAI } from '@/lib/tools/toolToOpenAI';
|
||||
|
||||
import { SetThemeTool } from '@/lib/tools/SetThemeTool';
|
||||
import { SearchUsersTool } from '@/lib/tools/SearchUsersTool';
|
||||
import { SearchFollowPacksTool } from '@/lib/tools/SearchFollowPacksTool';
|
||||
import { CreateSpellTool } from '@/lib/tools/CreateSpellTool';
|
||||
import { FetchPageTool } from '@/lib/tools/FetchPageTool';
|
||||
import { UploadFromUrlTool } from '@/lib/tools/UploadFromUrlTool';
|
||||
import { CreateEmojiPackTool } from '@/lib/tools/CreateEmojiPackTool';
|
||||
import { PublishEventsTool } from '@/lib/tools/PublishEventsTool';
|
||||
import { FetchEventTool } from '@/lib/tools/FetchEventTool';
|
||||
import { GetFeedTool } from '@/lib/tools/GetFeedTool';
|
||||
import { CreateWebxdcTool } from '@/lib/tools/CreateWebxdcTool';
|
||||
import { MakeItRainTool } from '@/lib/tools/MakeItRainTool';
|
||||
|
||||
import type { Tool, ToolContext } from '@/lib/tools/Tool';
|
||||
import type { ToolExecutorResult } from '@/lib/aiChatTools';
|
||||
|
||||
// ─── Tool Registry ───
|
||||
|
||||
/** All registered tools, keyed by name. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TOOL_REGISTRY: Record<string, Tool<any>> = {
|
||||
set_theme: SetThemeTool,
|
||||
search_users: SearchUsersTool,
|
||||
search_follow_packs: SearchFollowPacksTool,
|
||||
create_spell: CreateSpellTool,
|
||||
fetch_page: FetchPageTool,
|
||||
upload_from_url: UploadFromUrlTool,
|
||||
create_emoji_pack: CreateEmojiPackTool,
|
||||
publish_events: PublishEventsTool,
|
||||
fetch_event: FetchEventTool,
|
||||
get_feed: GetFeedTool,
|
||||
create_webxdc: CreateWebxdcTool,
|
||||
make_it_rain: MakeItRainTool,
|
||||
};
|
||||
|
||||
/** OpenAI-formatted tool definitions derived from the registry. */
|
||||
export const TOOLS = Object.entries(TOOL_REGISTRY).map(
|
||||
([name, tool]) => toolToOpenAI(name, tool),
|
||||
);
|
||||
|
||||
// ─── Hook ───
|
||||
|
||||
export function useAIChatTools() {
|
||||
const { applyCustomTheme } = useTheme();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const { setScreenEffect } = useScreenEffect();
|
||||
const { getBuddySecretKey } = useBuddy();
|
||||
|
||||
/** Build a ToolContext from current hook values. */
|
||||
const buildContext = useCallback((): ToolContext => ({
|
||||
nostr,
|
||||
user: user ? { pubkey: user.pubkey, signer: user.signer } : undefined,
|
||||
config: {
|
||||
corsProxy: config.corsProxy,
|
||||
blossomServerMetadata: config.blossomServerMetadata,
|
||||
useAppBlossomServers: config.useAppBlossomServers,
|
||||
},
|
||||
getBuddySecretKey,
|
||||
savedFeeds,
|
||||
applyCustomTheme,
|
||||
setScreenEffect,
|
||||
}), [nostr, user, config, getBuddySecretKey, savedFeeds, applyCustomTheme, setScreenEffect]);
|
||||
|
||||
const executeToolCall = useCallback(async (name: string, rawArgs: Record<string, unknown>): Promise<ToolExecutorResult> => {
|
||||
const tool = TOOL_REGISTRY[name];
|
||||
if (!tool) {
|
||||
return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) };
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate and parse args through the tool's Zod schema.
|
||||
const args = tool.inputSchema.parse(rawArgs);
|
||||
const ctx = buildContext();
|
||||
const toolResult = await tool.execute(args, ctx);
|
||||
|
||||
return {
|
||||
result: truncateToolResult(toolResult.result),
|
||||
nostrEvent: toolResult.nostrEvent,
|
||||
};
|
||||
} catch (err) {
|
||||
return { result: JSON.stringify({ error: `Tool "${name}" failed: ${err instanceof Error ? err.message : 'Unknown error'}` }) };
|
||||
}
|
||||
}, [buildContext]);
|
||||
|
||||
// Expose savedFeeds for the system prompt (saved feed labels)
|
||||
const savedFeedsMemo = useMemo(() => savedFeeds, [savedFeeds]);
|
||||
|
||||
return { executeToolCall, savedFeeds: savedFeedsMemo };
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
import { z } from 'zod';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** localStorage key for the buddy agent's secret key (hex-encoded). */
|
||||
const BUDDY_NSEC_STORAGE = 'ditto:buddy-nsec';
|
||||
|
||||
/** Suffix appended to `config.appId` for the NIP-78 d-tag. */
|
||||
const BUDDY_DTAG_SUFFIX = '/buddy';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Encrypted content stored in the kind 30078 buddy event. */
|
||||
export interface BuddySecrets {
|
||||
/** Buddy agent secret key as hex string. */
|
||||
nsec: string;
|
||||
/** The buddy's canonical name (source of truth — kind 0 may use nicknames). */
|
||||
name: string;
|
||||
/** The buddy's soul — personality / behavior description injected into the system prompt. */
|
||||
soul: string;
|
||||
}
|
||||
|
||||
/** Public + decrypted buddy data returned by the hook. */
|
||||
export interface BuddyIdentity {
|
||||
/** Buddy agent's public key (hex). */
|
||||
pubkey: string;
|
||||
/** The buddy's canonical name. */
|
||||
name: string;
|
||||
/** The buddy's soul text. */
|
||||
soul: string;
|
||||
/** The raw kind 30078 event (for reference). */
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Zod schema for validating decrypted buddy secrets. */
|
||||
const BuddySecretsSchema = z.object({
|
||||
nsec: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
soul: z.string().min(1),
|
||||
});
|
||||
|
||||
// ─── localStorage helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Read the buddy nsec from localStorage, or null if not present. */
|
||||
function getStoredNsec(): Uint8Array | null {
|
||||
const hex = localStorage.getItem(BUDDY_NSEC_STORAGE);
|
||||
if (!hex) return null;
|
||||
try {
|
||||
return hexToBytes(hex);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the buddy nsec to localStorage. */
|
||||
function storeNsec(sk: Uint8Array): void {
|
||||
localStorage.setItem(BUDDY_NSEC_STORAGE, bytesToHex(sk));
|
||||
}
|
||||
|
||||
/** Remove the buddy nsec from localStorage. */
|
||||
function clearStoredNsec(): void {
|
||||
localStorage.removeItem(BUDDY_NSEC_STORAGE);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Manages the user's Buddy AI agent identity.
|
||||
*
|
||||
* - Reads buddy nsec from localStorage for fast access.
|
||||
* - Queries the kind 30078 buddy event from relays as backup.
|
||||
* - If localStorage is empty but a relay event exists, decrypts and restores the nsec.
|
||||
* - Provides `createBuddy` to generate a keypair + publish identity events.
|
||||
* - Provides `updateSoul` to change the buddy's soul text.
|
||||
* - Provides `resetBuddy` to wipe the buddy entirely.
|
||||
*/
|
||||
export function useBuddy() {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
const dTag = `${config.appId}${BUDDY_DTAG_SUFFIX}`;
|
||||
|
||||
// ── Query the kind 30078 buddy event from relays ────────────────────────
|
||||
|
||||
const buddyEventQuery = useQuery({
|
||||
queryKey: ['buddy-event', user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user) return null;
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: [30078],
|
||||
authors: [user.pubkey],
|
||||
'#d': [dTag],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events = await nostr.query([filter]);
|
||||
return events.length > 0 ? events[0] : null;
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
// ── Decrypt buddy secrets from the relay event ──────────────────────────
|
||||
|
||||
const buddyQuery = useQuery<BuddyIdentity | null>({
|
||||
queryKey: ['buddy-identity', buddyEventQuery.data?.id],
|
||||
queryFn: async () => {
|
||||
const event = buddyEventQuery.data;
|
||||
if (!event || !user) return null;
|
||||
|
||||
// Always need to decrypt to get name + soul
|
||||
const secrets = await decryptSecrets(event, user);
|
||||
if (!secrets) return null;
|
||||
|
||||
// Try localStorage nsec first
|
||||
const localSk = getStoredNsec();
|
||||
if (localSk) {
|
||||
const pubkey = getPublicKey(localSk);
|
||||
// Verify the localStorage key matches the event's p-tag
|
||||
const eventPubkey = event.tags.find(([t]) => t === 'p')?.[1];
|
||||
if (eventPubkey && eventPubkey !== pubkey) {
|
||||
// Mismatch — restore from decrypted secrets
|
||||
clearStoredNsec();
|
||||
const sk = hexToBytes(secrets.nsec);
|
||||
storeNsec(sk);
|
||||
return { pubkey: getPublicKey(sk), name: secrets.name, soul: secrets.soul, event };
|
||||
}
|
||||
return { pubkey, name: secrets.name, soul: secrets.soul, event };
|
||||
}
|
||||
|
||||
// localStorage empty — restore nsec from decrypted secrets
|
||||
const sk = hexToBytes(secrets.nsec);
|
||||
storeNsec(sk);
|
||||
|
||||
return { pubkey: getPublicKey(sk), name: secrets.name, soul: secrets.soul, event };
|
||||
},
|
||||
enabled: !!buddyEventQuery.data && !!user,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
// ── Create a new buddy ──────────────────────────────────────────────────
|
||||
|
||||
const createBuddy = useMutation({
|
||||
mutationFn: async ({ name, soul, picture }: { name: string; soul: string; picture?: string }) => {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
|
||||
|
||||
// Generate buddy keypair
|
||||
const sk = generateSecretKey();
|
||||
const pubkey = getPublicKey(sk);
|
||||
|
||||
// Persist nsec to localStorage
|
||||
storeNsec(sk);
|
||||
|
||||
// Build kind 0 profile for the buddy agent
|
||||
const profileContent = JSON.stringify({
|
||||
name,
|
||||
...(picture ? { picture } : {}),
|
||||
about: soul,
|
||||
bot: true,
|
||||
});
|
||||
|
||||
const profileEvent = finalizeEvent({
|
||||
kind: 0,
|
||||
content: profileContent,
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}, sk) as NostrEvent;
|
||||
|
||||
// Build kind 30078 buddy identity event (signed by the user via useNostrPublish)
|
||||
const secrets: BuddySecrets = {
|
||||
nsec: bytesToHex(sk),
|
||||
name,
|
||||
soul,
|
||||
};
|
||||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(secrets));
|
||||
|
||||
// Publish buddy profile (signed by buddy key) in background
|
||||
nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }).catch(() => {
|
||||
toast({ title: 'Buddy profile publish failed', description: 'The buddy\'s profile could not be published to relays.', variant: 'destructive' });
|
||||
});
|
||||
|
||||
// Publish kind 30078 via useNostrPublish (handles client tag + published_at)
|
||||
const buddyEvent = await publishEvent({
|
||||
kind: 30078,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['p', pubkey],
|
||||
['alt', 'Buddy AI agent identity'],
|
||||
],
|
||||
});
|
||||
|
||||
return { pubkey, name, soul, event: buddyEvent } satisfies BuddyIdentity;
|
||||
},
|
||||
onSuccess: (identity) => {
|
||||
// Update caches
|
||||
queryClient.setQueryData(['buddy-event', user?.pubkey], identity.event);
|
||||
queryClient.setQueryData(['buddy-identity', identity.event.id], identity);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Update the buddy's soul ─────────────────────────────────────────────
|
||||
|
||||
const updateSoul = useMutation({
|
||||
mutationFn: async (newSoul: string) => {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
|
||||
|
||||
// Get the current nsec (must exist if buddy exists)
|
||||
const localSk = getStoredNsec();
|
||||
if (!localSk) throw new Error('Buddy nsec not found in localStorage');
|
||||
|
||||
// Fetch fresh from relay — never read from cache for read-modify-write
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30078],
|
||||
authors: [user.pubkey],
|
||||
'#d': [dTag],
|
||||
});
|
||||
if (!prev) throw new Error('No existing buddy identity to update');
|
||||
|
||||
const freshSecrets = await decryptSecrets(prev, user);
|
||||
if (!freshSecrets) throw new Error('Failed to decrypt buddy secrets');
|
||||
|
||||
const pubkey = getPublicKey(localSk);
|
||||
const currentName = freshSecrets.name;
|
||||
|
||||
// Encrypt updated secrets (preserve name from fresh event)
|
||||
const secrets: BuddySecrets = {
|
||||
nsec: bytesToHex(localSk),
|
||||
name: currentName,
|
||||
soul: newSoul,
|
||||
};
|
||||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(secrets));
|
||||
|
||||
// Publish updated kind 30078 via useNostrPublish (handles client tag + published_at)
|
||||
const buddyEvent = await publishEvent({
|
||||
kind: 30078,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['p', pubkey],
|
||||
['alt', 'Buddy AI agent identity'],
|
||||
],
|
||||
prev,
|
||||
});
|
||||
|
||||
// Also update the buddy's kind 0 about field (fire-and-forget)
|
||||
const profileEvent = finalizeEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({
|
||||
name: currentName,
|
||||
about: newSoul,
|
||||
bot: true,
|
||||
}),
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}, localSk) as NostrEvent;
|
||||
|
||||
nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }).catch(() => {
|
||||
toast({ title: 'Buddy profile update failed', description: 'The buddy\'s updated profile could not be published to relays.', variant: 'destructive' });
|
||||
});
|
||||
|
||||
return { pubkey, name: currentName, soul: newSoul, event: buddyEvent } satisfies BuddyIdentity;
|
||||
},
|
||||
onSuccess: (identity) => {
|
||||
queryClient.setQueryData(['buddy-event', user?.pubkey], identity.event);
|
||||
queryClient.setQueryData(['buddy-identity', identity.event.id], identity);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Reset (wipe) the buddy ──────────────────────────────────────────────
|
||||
|
||||
const resetBuddy = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
|
||||
// Clear localStorage
|
||||
clearStoredNsec();
|
||||
|
||||
// Fetch the current event so useNostrPublish can preserve published_at
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30078],
|
||||
authors: [user.pubkey],
|
||||
'#d': [dTag],
|
||||
});
|
||||
|
||||
// Publish an empty kind 30078 event to overwrite on relays
|
||||
const emptyEvent = await publishEvent({
|
||||
kind: 30078,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['alt', 'Buddy AI agent identity (cleared)'],
|
||||
],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
return emptyEvent;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(['buddy-event', user?.pubkey], null);
|
||||
// Clear all buddy-identity cache entries (the key includes a dynamic event ID)
|
||||
queryClient.removeQueries({ queryKey: ['buddy-identity'] });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Derived state ───────────────────────────────────────────────────────
|
||||
|
||||
const buddy = buddyQuery.data ?? null;
|
||||
const isLoading = buddyEventQuery.isLoading || buddyQuery.isLoading;
|
||||
const hasBuddy = buddy !== null;
|
||||
|
||||
/** Get the buddy's secret key from localStorage. Only call when buddy exists. */
|
||||
const getBuddySecretKey = useCallback((): Uint8Array | null => {
|
||||
return getStoredNsec();
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
/** The resolved buddy identity, or null if none configured. */
|
||||
buddy,
|
||||
/** True while loading from relays / decrypting. */
|
||||
isLoading,
|
||||
/** Whether a buddy has been configured. */
|
||||
hasBuddy,
|
||||
/** Create a new buddy agent. */
|
||||
createBuddy,
|
||||
/** Update the buddy's soul text. */
|
||||
updateSoul,
|
||||
/** Wipe the buddy identity entirely. */
|
||||
resetBuddy,
|
||||
/** Get the buddy's secret key from localStorage (for signing events). */
|
||||
getBuddySecretKey,
|
||||
}), [buddy, isLoading, hasBuddy, createBuddy, updateSoul, resetBuddy, getBuddySecretKey]);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Decrypt the buddy secrets from a kind 30078 event's encrypted content. */
|
||||
async function decryptSecrets(
|
||||
event: NostrEvent,
|
||||
user: { pubkey: string; signer: { nip44?: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } } },
|
||||
): Promise<BuddySecrets | null> {
|
||||
if (!event.content || !user.signer.nip44) return null;
|
||||
try {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
|
||||
const parsed = BuddySecretsSchema.safeParse(JSON.parse(decrypted));
|
||||
if (!parsed.success) {
|
||||
console.warn('Buddy secrets failed validation:', parsed.error.message);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { DisplayMessage } from '@/lib/aiChatTools';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type OnboardingStep = 'intro' | 'name' | 'soul' | 'confirm' | 'creating' | 'done';
|
||||
|
||||
// ─── Static Dork messages ─────────────────────────────────────────────────────
|
||||
|
||||
function dorkMessage(content: string, id: string): DisplayMessage {
|
||||
return { id, role: 'assistant', content, timestamp: new Date() };
|
||||
}
|
||||
|
||||
const INTRO_MESSAGE = dorkMessage(
|
||||
`Hey there! I'm **Dork**, your friendly setup assistant.\n\nI'm here to help you create your very own AI buddy — a personal agent with its own Nostr identity and personality.\n\nOnce set up, your buddy will replace me as your chat companion here. Don't worry, I won't be offended. Probably.\n\nLet's get started! **What should we name your buddy?**`,
|
||||
'dork-intro',
|
||||
);
|
||||
|
||||
const SOUL_PROMPT = dorkMessage(
|
||||
`Great name! Now for the fun part — **describe your buddy's soul.**\n\nThis is their personality: how they think, talk, and vibe. It gets injected into their brain every time you chat.\n\nA few examples to spark ideas:\n- *"A witty space explorer who explains everything with cosmic analogies"*\n- *"A chill surfer dude who's secretly a philosophy professor"*\n- *"A sarcastic librarian who knows everything but judges your taste"*\n\nWrite as much or as little as you want:`,
|
||||
'dork-soul-prompt',
|
||||
);
|
||||
|
||||
function confirmMessage(name: string, soul: string): DisplayMessage {
|
||||
return dorkMessage(
|
||||
`Here's what we've got:\n\n**Name:** ${name}\n**Soul:** ${soul}\n\nLook good? Type **"yes"** to create your buddy, or **"no"** to start over.`,
|
||||
'dork-confirm',
|
||||
);
|
||||
}
|
||||
|
||||
const CREATING_MESSAGE = dorkMessage(
|
||||
`Creating your buddy's Nostr identity... one moment! ✨`,
|
||||
'dork-creating',
|
||||
);
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useBuddyOnboarding() {
|
||||
const { createBuddy } = useBuddy();
|
||||
|
||||
const [step, setStep] = useState<OnboardingStep>('intro');
|
||||
const [messages, setMessages] = useState<DisplayMessage[]>([INTRO_MESSAGE]);
|
||||
const [buddyName, setBuddyName] = useState('');
|
||||
const [buddySoul, setBuddySoul] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addUserMessage = useCallback((content: string) => {
|
||||
const msg: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
return msg;
|
||||
}, []);
|
||||
|
||||
const addDorkMessage = useCallback((msg: DisplayMessage) => {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
switch (step) {
|
||||
case 'intro': {
|
||||
// User provided a name
|
||||
addUserMessage(trimmed);
|
||||
setBuddyName(trimmed);
|
||||
addDorkMessage(SOUL_PROMPT);
|
||||
setStep('name');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'name': {
|
||||
// User provided soul description
|
||||
addUserMessage(trimmed);
|
||||
setBuddySoul(trimmed);
|
||||
addDorkMessage(confirmMessage(buddyName, trimmed));
|
||||
setStep('soul');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'soul': {
|
||||
// User confirms or restarts
|
||||
addUserMessage(trimmed);
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (lower === 'yes' || lower === 'y' || lower === 'yep' || lower === 'looks good' || lower === 'confirm') {
|
||||
setStep('creating');
|
||||
addDorkMessage(CREATING_MESSAGE);
|
||||
|
||||
try {
|
||||
await createBuddy.mutateAsync({ name: buddyName, soul: buddySoul });
|
||||
setStep('done');
|
||||
} catch {
|
||||
setError('Failed to create buddy. Please try again.');
|
||||
setStep('soul');
|
||||
addDorkMessage(dorkMessage(
|
||||
`Hmm, something went wrong. Type **"yes"** to try again.`,
|
||||
`dork-error-${Date.now()}`,
|
||||
));
|
||||
}
|
||||
} else if (lower === 'no' || lower === 'n' || lower === 'nope' || lower === 'start over' || lower === 'restart') {
|
||||
setBuddyName('');
|
||||
setBuddySoul('');
|
||||
setMessages([INTRO_MESSAGE]);
|
||||
setStep('intro');
|
||||
} else {
|
||||
addDorkMessage(dorkMessage(
|
||||
`Just type **"yes"** to confirm or **"no"** to start over.`,
|
||||
`dork-clarify-${Date.now()}`,
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [step, buddyName, buddySoul, addUserMessage, addDorkMessage, createBuddy]);
|
||||
|
||||
const isCreating = step === 'creating';
|
||||
const isDone = step === 'done';
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
switch (step) {
|
||||
case 'intro': return 'Type a name...';
|
||||
case 'name': return 'Describe their personality...';
|
||||
case 'soul': return 'yes / no';
|
||||
case 'creating': return 'Creating...';
|
||||
default: return '';
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
handleSend,
|
||||
isCreating,
|
||||
isDone,
|
||||
placeholder,
|
||||
error,
|
||||
};
|
||||
}
|
||||
+13
-106
@@ -5,10 +5,9 @@ import { useFeedSettings } from './useFeedSettings';
|
||||
import { useFollowList } from './useFollowActions';
|
||||
import { parseAuthorEvent } from './useAuthor';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils';
|
||||
import { getPaginationCursor, isRepostKind, unwrapReposts, deduplicateFeedItems, type FeedItem } from '@/lib/feedUtils';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { setProfileCached } from '@/lib/profileCache';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
@@ -173,59 +172,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
|
||||
const oldestQueryTimestamp = getPaginationCursor(validFilteredEvents);
|
||||
|
||||
// Process reposts same as follows feed
|
||||
const items: FeedItem[] = [];
|
||||
const repostMissingIds: string[] = [];
|
||||
const repostMap = new Map<string, NostrEvent>();
|
||||
const items = await unwrapReposts(
|
||||
validFilteredEvents,
|
||||
(ids) => nostr.query([{ ids, limit: ids.length }], { signal }),
|
||||
now,
|
||||
);
|
||||
|
||||
for (const ev of validFilteredEvents) {
|
||||
if (isRepostKind(ev.kind)) {
|
||||
// Handle reposts (kind 6 for notes, kind 16 for generic)
|
||||
const embedded = parseRepostContent(ev);
|
||||
if (embedded && embedded.created_at <= now) {
|
||||
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
|
||||
} else {
|
||||
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (repostedId) {
|
||||
repostMissingIds.push(repostedId);
|
||||
repostMap.set(repostedId, ev);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Kind 1 and extra kinds — direct post
|
||||
items.push({ event: ev, sortTimestamp: ev.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch any missing reposted events in a single query
|
||||
if (repostMissingIds.length > 0) {
|
||||
try {
|
||||
const originals = await nostr.query(
|
||||
[{ ids: repostMissingIds, limit: repostMissingIds.length }],
|
||||
{ signal },
|
||||
);
|
||||
for (const original of originals) {
|
||||
const repost = repostMap.get(original.id);
|
||||
if (repost && original.created_at <= now) {
|
||||
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout or abort — just skip the missing reposts
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Map<string, FeedItem>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.event.id);
|
||||
if (!existing) {
|
||||
seen.set(item.event.id, item);
|
||||
} else if (!item.repostedBy && existing.repostedBy) {
|
||||
seen.set(item.event.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
let dedupedItems = deduplicateFeedItems(items);
|
||||
|
||||
// Filter replies if the user has disabled them
|
||||
if (!feedSettings.followsFeedShowReplies) {
|
||||
@@ -258,59 +211,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
|
||||
const validEvents = rawEvents.filter((ev) => ev.created_at <= now);
|
||||
const oldestQueryTimestamp = getPaginationCursor(validEvents);
|
||||
|
||||
const items: FeedItem[] = [];
|
||||
const repostMissingIds: string[] = [];
|
||||
const repostMap = new Map<string, NostrEvent>();
|
||||
const items = await unwrapReposts(
|
||||
validEvents,
|
||||
(ids) => nostr.query([{ ids, limit: ids.length }], { signal }),
|
||||
now,
|
||||
);
|
||||
|
||||
for (const ev of validEvents) {
|
||||
if (isRepostKind(ev.kind)) {
|
||||
// Handle reposts (kind 6 for notes, kind 16 for generic)
|
||||
const embedded = parseRepostContent(ev);
|
||||
if (embedded && embedded.created_at <= now) {
|
||||
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
|
||||
} else {
|
||||
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (repostedId) {
|
||||
repostMissingIds.push(repostedId);
|
||||
repostMap.set(repostedId, ev);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Kind 1, 1068, 3367, 34236, 37516, etc. — direct post / extra kinds
|
||||
items.push({ event: ev, sortTimestamp: ev.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch any missing reposted events in a single query
|
||||
if (repostMissingIds.length > 0) {
|
||||
try {
|
||||
const originals = await nostr.query(
|
||||
[{ ids: repostMissingIds, limit: repostMissingIds.length }],
|
||||
{ signal },
|
||||
);
|
||||
for (const original of originals) {
|
||||
const repost = repostMap.get(original.id);
|
||||
if (repost && original.created_at <= now) {
|
||||
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout or abort — just skip the missing reposts
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Map<string, FeedItem>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.event.id);
|
||||
if (!existing) {
|
||||
seen.set(item.event.id, item);
|
||||
} else if (!item.repostedBy && existing.repostedBy) {
|
||||
seen.set(item.event.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
let dedupedItems = Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
let dedupedItems = deduplicateFeedItems(items);
|
||||
|
||||
// Filter replies if the user has disabled them
|
||||
if (!feedSettings.followsFeedShowReplies) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCallback, useMemo } from "react";
|
||||
|
||||
/** Default sidebar order for fresh installs (system pages only). */
|
||||
const DEFAULT_SIDEBAR_ORDER = SIDEBAR_ITEMS
|
||||
.filter((s) => ['feed', 'notifications', 'search', 'trends', 'bookmarks', 'profile', 'settings', 'help', 'theme'].includes(s.id))
|
||||
.filter((s) => ['search', 'feed', 'notifications', 'discover', 'trends', 'bookmarks', 'profile', 'settings', 'help', 'theme'].includes(s.id))
|
||||
.map((s) => s.id);
|
||||
|
||||
/** Map of legacy sidebar item IDs to their current replacements. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -19,16 +19,15 @@ interface PublishStatusParams {
|
||||
* signals that the status is cleared.
|
||||
*/
|
||||
export function usePublishStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ status, url }: PublishStatusParams) => {
|
||||
if (!user?.pubkey) return;
|
||||
if (!user) throw new Error('User not logged in');
|
||||
|
||||
// Fetch the previous event to preserve published_at (addressable event convention)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30315],
|
||||
authors: [user.pubkey],
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useSavedFeeds() {
|
||||
};
|
||||
|
||||
/** Add a new saved feed. Returns the created feed. */
|
||||
const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[]): Promise<SavedFeed> => {
|
||||
const addSavedFeed = async (label: string, filter: TabFilter, vars: TabVarDef[], spellId?: string): Promise<SavedFeed> => {
|
||||
if (!user) throw new Error('Must be logged in to save feeds');
|
||||
|
||||
const newFeed: SavedFeed = {
|
||||
@@ -50,6 +50,7 @@ export function useSavedFeeds() {
|
||||
filter,
|
||||
vars,
|
||||
createdAt: Date.now(),
|
||||
...(spellId ? { spellId } : {}),
|
||||
};
|
||||
|
||||
await persist([...savedFeeds, newFeed]);
|
||||
|
||||
+139
-123
@@ -3,15 +3,25 @@ import { useCurrentUser } from './useCurrentUser';
|
||||
import type { NUser } from '@nostrify/react/login';
|
||||
|
||||
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
|
||||
export interface ToolCallFunction {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string | Array<{
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string | null | Array<{
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}>;
|
||||
/** Present on assistant messages that invoke tools. */
|
||||
tool_calls?: ToolCallFunction[];
|
||||
/** Present on tool result messages — must match a tool_calls[].id from the preceding assistant message. */
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/** Tool function definition for chat completions. */
|
||||
@@ -83,7 +93,9 @@ export interface ModelsResponse {
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
|
||||
// REMOVE BEFORE ALEX SEE THIS
|
||||
const SHAKESPEARE_API_URL = 'https://ai.pocketvibe.app/v1';
|
||||
const SHAKESPEARE_SERVER_URL = 'https://ai.pocketvibe.app/v1';
|
||||
|
||||
// Helper function to create NIP-98 token
|
||||
async function createNIP98Token(
|
||||
@@ -146,7 +158,8 @@ async function handleAPIError(response: Response) {
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e;
|
||||
throw new Error('Invalid request. Please check your parameters and try again.');
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
@@ -157,7 +170,8 @@ async function handleAPIError(response: Response) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e;
|
||||
throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`);
|
||||
}
|
||||
}
|
||||
@@ -173,74 +187,17 @@ export function useShakespeare() {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Chat completion function
|
||||
const sendChatMessage = useCallback(async (
|
||||
messages: ChatMessage[],
|
||||
model: string = 'shakespeare',
|
||||
options?: Partial<ChatCompletionRequest>
|
||||
): Promise<ChatCompletionResponse> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const requestBody: ChatCompletionRequest = {
|
||||
model,
|
||||
messages,
|
||||
...options
|
||||
};
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
// Add context for common issues
|
||||
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||
} else if (errorMessage.includes('signer')) {
|
||||
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Streaming chat completion function
|
||||
// Streaming chat completion function.
|
||||
// Streams text via `onChunk` and returns the fully-assembled response
|
||||
// (including any tool_calls) so callers can use the same tool-loop logic
|
||||
// as the non-streaming path.
|
||||
const sendStreamingMessage = useCallback(async (
|
||||
messages: ChatMessage[],
|
||||
model: string = 'shakespeare',
|
||||
onChunk: (chunk: string) => void,
|
||||
options?: Partial<ChatCompletionRequest>
|
||||
): Promise<void> => {
|
||||
options?: Partial<ChatCompletionRequest>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChatCompletionResponse> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
@@ -258,7 +215,7 @@ export function useShakespeare() {
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
`${SHAKESPEARE_SERVER_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user
|
||||
);
|
||||
@@ -270,6 +227,7 @@ export function useShakespeare() {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
@@ -281,34 +239,94 @@ export function useShakespeare() {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Accumulate the full response from stream deltas
|
||||
let content = '';
|
||||
let finishReason = 'stop';
|
||||
let responseId = '';
|
||||
let responseModel = model;
|
||||
const toolCalls: Map<number, { id: string; type: 'function'; function: { name: string; arguments: string } }> = new Map();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
onChunk(content);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete chunks
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
if (parsed.id) responseId = parsed.id;
|
||||
if (parsed.model) responseModel = parsed.model;
|
||||
if (parsed.choices?.[0]?.finish_reason) {
|
||||
finishReason = parsed.choices[0].finish_reason;
|
||||
}
|
||||
|
||||
// Accumulate text content and stream to UI
|
||||
if (delta.content) {
|
||||
content += delta.content;
|
||||
onChunk(delta.content);
|
||||
}
|
||||
|
||||
// Accumulate tool call deltas
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
const existing = toolCalls.get(idx);
|
||||
if (!existing) {
|
||||
toolCalls.set(idx, {
|
||||
id: tc.id ?? '',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function?.name ?? '',
|
||||
arguments: tc.function?.arguments ?? '',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (tc.id) existing.id = tc.id;
|
||||
if (tc.function?.name) existing.function.name += tc.function.name;
|
||||
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Assemble the full response in the same shape as the non-streaming endpoint
|
||||
const assembledToolCalls = toolCalls.size > 0
|
||||
? Array.from(toolCalls.values())
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: responseModel,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: content || undefined,
|
||||
...(assembledToolCalls ? { tool_calls: assembledToolCalls } : {}),
|
||||
},
|
||||
finish_reason: finishReason,
|
||||
}],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
};
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
@@ -332,65 +350,63 @@ export function useShakespeare() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Get credits balance
|
||||
const getCredits = useCallback(async (): Promise<{ amount: number }> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to check credits');
|
||||
}
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_SERVER_URL}/credits`,
|
||||
undefined,
|
||||
user
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/credits`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
}, [user]);
|
||||
|
||||
// Get available models
|
||||
const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_SERVER_URL}/models`,
|
||||
undefined,
|
||||
user
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_API_URL}/models`,
|
||||
undefined,
|
||||
user
|
||||
);
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
// Add context for common issues
|
||||
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||
} else if (errorMessage.includes('signer')) {
|
||||
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
}, [user]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
|
||||
// Actions
|
||||
sendChatMessage,
|
||||
sendStreamingMessage,
|
||||
getAvailableModels,
|
||||
getCredits,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { MORE_SEPARATOR_ID } from '@/components/SidebarNavItem';
|
||||
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
|
||||
|
||||
/**
|
||||
* Shared sidebar editing logic used by both LeftSidebar and MobileDrawer.
|
||||
*
|
||||
* Builds the combined editing list (visible + __more__ separator + hidden)
|
||||
* and provides handlers for reordering and removing items.
|
||||
*/
|
||||
export function useSidebarEditing({
|
||||
editing,
|
||||
items,
|
||||
hiddenItems,
|
||||
updateSidebarOrder,
|
||||
removeFromSidebar,
|
||||
}: {
|
||||
editing: boolean;
|
||||
/** Visible sidebar item IDs (may be pre-filtered, e.g. MobileDrawer strips leading dividers). */
|
||||
items: string[];
|
||||
hiddenItems: HiddenSidebarItem[];
|
||||
updateSidebarOrder: (newOrder: string[]) => void;
|
||||
removeFromSidebar: (id: string, index?: number) => void;
|
||||
}) {
|
||||
/** Combined list for the drag-and-drop editor: visible items + separator + hidden items. */
|
||||
const editingItems = useMemo(() => {
|
||||
if (!editing) return [];
|
||||
return [...items, MORE_SEPARATOR_ID, ...hiddenItems.map((h) => h.id)];
|
||||
}, [editing, items, hiddenItems]);
|
||||
|
||||
/** Handle drag-and-drop reorder — extract items above the __more__ separator. */
|
||||
const handleEditReorder = useCallback((newOrder: string[]) => {
|
||||
const moreIdx = newOrder.indexOf(MORE_SEPARATOR_ID);
|
||||
if (moreIdx === -1) return;
|
||||
const newVisible = newOrder.slice(0, moreIdx);
|
||||
updateSidebarOrder(newVisible);
|
||||
}, [updateSidebarOrder]);
|
||||
|
||||
/** Remove a sidebar item; dividers require an index to identify which one. */
|
||||
const handleEditRemove = useCallback((id: string, index?: number) => {
|
||||
if (id === 'divider' && index !== undefined) {
|
||||
removeFromSidebar(id, index);
|
||||
} else {
|
||||
removeFromSidebar(id);
|
||||
}
|
||||
}, [removeFromSidebar]);
|
||||
|
||||
return { editingItems, handleEditReorder, handleEditRemove };
|
||||
}
|
||||
+233
-68
@@ -1,17 +1,20 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useFeedSettings } from './useFeedSettings';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useFollowList } from './useFollowActions';
|
||||
import { useMuteList } from './useMuteList';
|
||||
import { useContentFilters } from './useContentFilters';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { isRepostKind } from '@/lib/feedUtils';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { resolveSpell } from '@/lib/spellEngine';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
interface StreamPostsOptions {
|
||||
export interface StreamPostsOptions {
|
||||
includeReplies: boolean;
|
||||
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
|
||||
language?: string;
|
||||
@@ -29,6 +32,12 @@ interface StreamPostsOptions {
|
||||
authorPubkeys?: string[];
|
||||
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
|
||||
sort?: 'recent' | 'hot' | 'trending';
|
||||
/**
|
||||
* When set, drives the entire stream from a kind:777 spell event.
|
||||
* The spell is resolved internally (variables, timestamps, hints).
|
||||
* All other options on this interface are ignored when spell is set.
|
||||
*/
|
||||
spell?: NostrEvent;
|
||||
}
|
||||
|
||||
/** Check if an event has imeta tags with image MIME types. */
|
||||
@@ -63,21 +72,14 @@ function filterEvent(
|
||||
const protocols = options.protocols ?? ['nostr'];
|
||||
if (!protocols.includes('nostr') || protocols.length > 1) {
|
||||
const proxyTag = event.tags.find(([name]) => name === 'proxy');
|
||||
if (protocols.includes('nostr') && !protocols.some(p => p !== 'nostr')) {
|
||||
// nostr only: reject events with a proxy tag
|
||||
if (proxyTag) return false;
|
||||
} else {
|
||||
// bridged protocol selected: only keep events that have a matching proxy tag
|
||||
// and optionally native nostr events if 'nostr' is also in protocols
|
||||
const hasProxy = !!proxyTag;
|
||||
const isNative = !hasProxy;
|
||||
if (isNative && !protocols.includes('nostr')) return false;
|
||||
if (hasProxy) {
|
||||
// proxy tag format: ['proxy', '<uri>', '<protocol>']
|
||||
const proxyProtocol = proxyTag?.[2]?.toLowerCase();
|
||||
const wantedBridged = protocols.filter(p => p !== 'nostr');
|
||||
if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false;
|
||||
}
|
||||
const hasProxy = !!proxyTag;
|
||||
const isNative = !hasProxy;
|
||||
if (isNative && !protocols.includes('nostr')) return false;
|
||||
if (hasProxy) {
|
||||
// proxy tag format: ['proxy', '<uri>', '<protocol>']
|
||||
const proxyProtocol = proxyTag?.[2]?.toLowerCase();
|
||||
const wantedBridged = protocols.filter(p => p !== 'nostr');
|
||||
if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +138,67 @@ function filterEvent(
|
||||
export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
const { nostr } = useNostr();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { muteItems } = useMuteList();
|
||||
const { shouldFilterEvent } = useContentFilters();
|
||||
|
||||
// ── Spell resolution ────────────────────────────────────────────────
|
||||
// When a spell is provided, resolve it and derive effective options.
|
||||
// All other option fields are ignored in spell mode.
|
||||
const resolved = useMemo(() => {
|
||||
if (!options.spell) return null;
|
||||
try {
|
||||
const contactPubkeys = followData?.pubkeys ?? [];
|
||||
return resolveSpell(options.spell, user?.pubkey, contactPubkeys);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [options.spell, user?.pubkey, followData?.pubkeys]);
|
||||
|
||||
// Derive effective options: spell-resolved values take priority
|
||||
const effectiveQuery = resolved ? (resolved.filter.search ?? '') : query;
|
||||
const effectiveOptions: StreamPostsOptions = useMemo(() => {
|
||||
if (!resolved) return options;
|
||||
const h = resolved.hints;
|
||||
return {
|
||||
includeReplies: h.includeReplies,
|
||||
mediaType: h.mediaType,
|
||||
language: h.language,
|
||||
protocols: [h.platform],
|
||||
kindsOverride: resolved.filter.kinds,
|
||||
authorPubkeys: resolved.filter.authors,
|
||||
sort: h.sort,
|
||||
};
|
||||
}, [resolved, options]);
|
||||
|
||||
// Whether the initial query should be routed exclusively to Ditto relays.
|
||||
// True when NIP-50 extensions are used that only Ditto relays understand
|
||||
// (sort:hot, language:en, protocol:activitypub, media filters).
|
||||
// Applies to both spell-driven and direct option-driven queries.
|
||||
const useDittoOnly = resolved?.needsDittoRelay ?? !!(
|
||||
(effectiveOptions.sort && effectiveOptions.sort !== 'recent')
|
||||
|| (effectiveOptions.language && effectiveOptions.language !== 'global')
|
||||
|| (effectiveOptions.protocols && effectiveOptions.protocols.some(p => p !== 'nostr'))
|
||||
);
|
||||
|
||||
// Extra filter fields from the spell (since, until, limit, tag filters)
|
||||
const spellExtraFilter: Partial<NostrFilter> | undefined = useMemo(() => {
|
||||
if (!resolved) return undefined;
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (resolved.filter.since !== undefined) extra.since = resolved.filter.since;
|
||||
if (resolved.filter.until !== undefined) extra.until = resolved.filter.until;
|
||||
if (resolved.filter.limit !== undefined) extra.limit = resolved.filter.limit;
|
||||
// Copy tag filters (#t, #e, #p, etc.)
|
||||
for (const [key, val] of Object.entries(resolved.filter)) {
|
||||
if (key.startsWith('#')) extra[key] = val;
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra as Partial<NostrFilter> : undefined;
|
||||
}, [resolved]);
|
||||
|
||||
// Stable key for the spell so the effect restarts when the spell changes
|
||||
const spellKey = options.spell?.id ?? '';
|
||||
|
||||
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Buffer for streamed events — held separately until user scrolls back up
|
||||
@@ -151,6 +212,17 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
const [flushedIds, setFlushedIds] = useState<Set<string>>(new Set());
|
||||
const flushedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Pagination state for "load more" (infinite scroll)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
// Stash the filter + store used by the initial query so loadMore can reuse it
|
||||
const paginationRef = useRef<{
|
||||
filter: NostrFilter;
|
||||
store: { query: (filters: NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]> };
|
||||
knownIds: Set<string>;
|
||||
eventMap: Map<string, NostrEvent>;
|
||||
} | null>(null);
|
||||
|
||||
/** Merge buffered events into the main list and mark them as flushed. */
|
||||
const doFlush = useCallback(() => {
|
||||
if (streamBufferRef.current.length === 0) return;
|
||||
@@ -171,6 +243,59 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => () => clearTimeout(flushedTimerRef.current), []);
|
||||
|
||||
/** Fetch the next page of older events (cursor-based pagination). */
|
||||
const loadMore = useCallback(async () => {
|
||||
const ctx = paginationRef.current;
|
||||
if (!ctx || isLoadingMore || !hasMore) return;
|
||||
|
||||
// Find the oldest event timestamp for the cursor
|
||||
const oldest = allEvents.length > 0
|
||||
? Math.min(...allEvents.map((e) => e.created_at))
|
||||
: undefined;
|
||||
if (oldest === undefined) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const PAGE_SIZE = ctx.filter.limit ?? 40;
|
||||
const events = await ctx.store.query(
|
||||
[{ ...ctx.filter, until: oldest - 1, limit: PAGE_SIZE }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
if (events.length < PAGE_SIZE) {
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setAllEvents((prev) => {
|
||||
const merged = [...prev];
|
||||
for (const event of events) {
|
||||
if (event.created_at > now) continue;
|
||||
|
||||
let dedupeKey: string;
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||
dedupeKey = `${event.pubkey}:${event.kind}:${dTag}`;
|
||||
} else {
|
||||
dedupeKey = event.id;
|
||||
}
|
||||
|
||||
if (ctx.knownIds.has(dedupeKey)) continue;
|
||||
ctx.knownIds.add(dedupeKey);
|
||||
ctx.eventMap.set(dedupeKey, event);
|
||||
merged.push(event);
|
||||
}
|
||||
return merged.sort((a, b) => b.created_at - a.created_at);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// timeout — don't break the UI
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [allEvents, isLoadingMore, hasMore]);
|
||||
|
||||
// Monitor scroll position — only buffer when user is scrolled down
|
||||
useEffect(() => {
|
||||
const threshold = 200; // px from top
|
||||
@@ -187,36 +312,39 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
|
||||
// Resolve authorPubkeys: accept hex or npub-encoded entries
|
||||
const resolvedAuthorPubkeys = useMemo(() => {
|
||||
if (!options.authorPubkeys || options.authorPubkeys.length === 0) return undefined;
|
||||
const resolved: string[] = [];
|
||||
for (const raw of options.authorPubkeys) {
|
||||
if (!effectiveOptions.authorPubkeys || effectiveOptions.authorPubkeys.length === 0) return undefined;
|
||||
const res: string[] = [];
|
||||
for (const raw of effectiveOptions.authorPubkeys) {
|
||||
const t = raw.trim();
|
||||
if (/^[0-9a-f]{64}$/i.test(t)) {
|
||||
resolved.push(t);
|
||||
res.push(t);
|
||||
} else {
|
||||
try {
|
||||
const decoded = nip19.decode(t);
|
||||
if (decoded.type === 'npub') resolved.push(decoded.data);
|
||||
if (decoded.type === 'npub') res.push(decoded.data);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return resolved.length > 0 ? resolved : undefined;
|
||||
}, [options.authorPubkeys]);
|
||||
return res.length > 0 ? res : undefined;
|
||||
}, [effectiveOptions.authorPubkeys]);
|
||||
|
||||
// These mediaTypes query dedicated event kinds rather than filtering kind 1
|
||||
const isDedicatedKindQuery = !options.kindsOverride && (options.mediaType === 'vines' || options.mediaType === 'images' || options.mediaType === 'videos');
|
||||
const isDedicatedKindQuery = !effectiveOptions.kindsOverride && (effectiveOptions.mediaType === 'vines' || effectiveOptions.mediaType === 'images' || effectiveOptions.mediaType === 'videos');
|
||||
|
||||
const enabledKinds = getEnabledFeedKinds(feedSettings);
|
||||
const kindsKey = [...enabledKinds].sort().join(',');
|
||||
|
||||
// Stable key for protocols so it can be a useEffect dependency
|
||||
const protocolsKey = [...(options.protocols ?? ['nostr'])].sort().join(',');
|
||||
const protocolsKey = [...(effectiveOptions.protocols ?? ['nostr'])].sort().join(',');
|
||||
|
||||
// Stable key for kindsOverride
|
||||
const kindsOverrideKey = options.kindsOverride ? [...options.kindsOverride].sort().join(',') : '';
|
||||
const kindsOverrideKey = effectiveOptions.kindsOverride ? [...effectiveOptions.kindsOverride].sort().join(',') : '';
|
||||
|
||||
// Stable key for authorPubkeys (follows list)
|
||||
const authorPubkeysKey = options.authorPubkeys ? [...options.authorPubkeys].sort().join(',') : '';
|
||||
const authorPubkeysKey = effectiveOptions.authorPubkeys ? [...effectiveOptions.authorPubkeys].sort().join(',') : '';
|
||||
|
||||
// Stable key for spell extra filter
|
||||
const spellExtraFilterKey = spellExtraFilter ? JSON.stringify(spellExtraFilter) : '';
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
@@ -224,7 +352,10 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
|
||||
setAllEvents([]);
|
||||
setIsLoading(true);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
initialLoadDoneRef.current = false;
|
||||
paginationRef.current = null;
|
||||
streamBufferRef.current = [];
|
||||
setStreamBufferCount(0);
|
||||
|
||||
@@ -272,13 +403,13 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
|
||||
// Build the kinds list based on mediaType (or override entirely)
|
||||
let kinds: number[];
|
||||
if (options.kindsOverride && options.kindsOverride.length > 0) {
|
||||
kinds = [...options.kindsOverride];
|
||||
} else if (options.mediaType === 'vines') {
|
||||
if (effectiveOptions.kindsOverride && effectiveOptions.kindsOverride.length > 0) {
|
||||
kinds = [...effectiveOptions.kindsOverride];
|
||||
} else if (effectiveOptions.mediaType === 'vines') {
|
||||
kinds = [22, 34236]; // shorts + vines
|
||||
} else if (options.mediaType === 'videos') {
|
||||
} else if (effectiveOptions.mediaType === 'videos') {
|
||||
kinds = [21, 22, ...enabledKinds.filter((k) => !isRepostKind(k))];
|
||||
} else if (options.mediaType === 'images') {
|
||||
} else if (effectiveOptions.mediaType === 'images') {
|
||||
kinds = [20, ...enabledKinds.filter((k) => !isRepostKind(k))];
|
||||
} else {
|
||||
kinds = enabledKinds.filter((k) => !isRepostKind(k));
|
||||
@@ -293,63 +424,94 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
// protocol:nostr = native Nostr only (no bridged events).
|
||||
// When bridged protocols are selected, omit protocol:nostr so the relay
|
||||
// returns both native and bridged events matching the selected protocols.
|
||||
const protocols = options.protocols ?? ['nostr'];
|
||||
// When the caller doesn't explicitly pass protocols (e.g. Feeds/Packs tabs
|
||||
// that query Nostr-native kinds only), skip the protocol term entirely so
|
||||
// the relay doesn't filter through NIP-50 search for kinds it may not index.
|
||||
const protocols = effectiveOptions.protocols ?? ['nostr'];
|
||||
const bridged = protocols.filter(p => p !== 'nostr');
|
||||
const searchParts: string[] = bridged.length > 0
|
||||
? bridged.map(p => `protocol:${p}`)
|
||||
: ['protocol:nostr'];
|
||||
: effectiveOptions.protocols
|
||||
? ['protocol:nostr']
|
||||
: [];
|
||||
|
||||
if (query.trim()) {
|
||||
searchParts.push(query.trim());
|
||||
if (effectiveQuery.trim()) {
|
||||
searchParts.push(effectiveQuery.trim());
|
||||
}
|
||||
|
||||
// Add language filter (NIP-50 extension supported by Ditto)
|
||||
if (options.language && options.language !== 'global') {
|
||||
searchParts.push(`language:${options.language}`);
|
||||
if (effectiveOptions.language && effectiveOptions.language !== 'global') {
|
||||
searchParts.push(`language:${effectiveOptions.language}`);
|
||||
}
|
||||
|
||||
// Add media filter (NIP-50 extension supported by Ditto)
|
||||
// Skip for dedicated-kind queries — kind selection already scopes them
|
||||
if (!isDedicatedKindQuery) {
|
||||
if (options.mediaType === 'images') {
|
||||
if (effectiveOptions.mediaType === 'images') {
|
||||
searchParts.push('media:true');
|
||||
searchParts.push('video:false');
|
||||
} else if (options.mediaType === 'videos') {
|
||||
} else if (effectiveOptions.mediaType === 'videos') {
|
||||
searchParts.push('video:true');
|
||||
} else if (options.mediaType === 'none') {
|
||||
} else if (effectiveOptions.mediaType === 'none') {
|
||||
searchParts.push('media:false');
|
||||
}
|
||||
// 'all' means no media filter
|
||||
}
|
||||
|
||||
// Sort preference (NIP-50 extension)
|
||||
if (effectiveOptions.sort === 'hot') {
|
||||
searchParts.push('sort:hot');
|
||||
} else if (effectiveOptions.sort === 'trending') {
|
||||
searchParts.push('sort:trending');
|
||||
}
|
||||
|
||||
const initialFilter: NostrFilter = { ...streamFilter };
|
||||
if (searchParts.length > 0) {
|
||||
initialFilter.search = searchParts.join(' ');
|
||||
}
|
||||
|
||||
// Merge spell-specific filter fields (since, until, limit, tag filters)
|
||||
if (spellExtraFilter) {
|
||||
Object.assign(initialFilter, spellExtraFilter);
|
||||
// Also apply tag filters and author scope to the stream filter
|
||||
for (const [key, val] of Object.entries(spellExtraFilter)) {
|
||||
if (key.startsWith('#')) {
|
||||
(streamFilter as Record<string, unknown>)[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Author filter — scopes both the initial batch and streaming subscription.
|
||||
if (resolvedAuthorPubkeys && resolvedAuthorPubkeys.length > 0) {
|
||||
initialFilter.authors = resolvedAuthorPubkeys;
|
||||
streamFilter.authors = resolvedAuthorPubkeys;
|
||||
}
|
||||
|
||||
// Sort preference (NIP-50 extension)
|
||||
if (options.sort === 'hot') {
|
||||
searchParts.push('sort:hot');
|
||||
} else if (options.sort === 'trending') {
|
||||
searchParts.push('sort:trending');
|
||||
}
|
||||
// Determine relay routing for the initial query.
|
||||
// Ditto relays are required when the NIP-50 search string contains
|
||||
// extensions like `language:`, `protocol:`, `media:`, or `sort:` that
|
||||
// standard relays don't support. When the query has none of these
|
||||
// extensions the user's own relays are appropriate.
|
||||
const initialStore = useDittoOnly ? nostr.group(DITTO_RELAYS) : nostr;
|
||||
|
||||
// 1. Fetch initial batch with search filters (uses pool, reuses existing connections)
|
||||
(async () => {
|
||||
// Stash for loadMore pagination
|
||||
paginationRef.current = { filter: initialFilter, store: initialStore, knownIds, eventMap };
|
||||
|
||||
const PAGE_SIZE = initialFilter.limit ?? 40;
|
||||
|
||||
// 1. Fetch initial batch with search filters
|
||||
async function fetchInitialBatch() {
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ ...initialFilter, limit: 40 }],
|
||||
const events = await initialStore.query(
|
||||
[{ ...initialFilter, limit: PAGE_SIZE }],
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
for (const event of events) {
|
||||
addEvent(event, false);
|
||||
}
|
||||
if (alive && events.length < PAGE_SIZE) {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
// abort expected
|
||||
}
|
||||
@@ -357,27 +519,27 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
initialLoadDoneRef.current = true;
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// 2. Stream new events WITHOUT search (relays don't support streaming search)
|
||||
// Client-side filtering is applied via useMemo at the end
|
||||
//
|
||||
//
|
||||
// CRITICAL: The pool has eoseTimeout: 500 which aborts req() subscriptions 500ms after
|
||||
// the first EOSE. This kills streaming! Solution: Use relay() directly for one relay
|
||||
// to avoid the pool's timeout logic.
|
||||
(async () => {
|
||||
async function startStreaming() {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
// Use Ditto relays directly for streaming to avoid pool's eoseTimeout
|
||||
const dittoRelay = nostr.group(DITTO_RELAYS);
|
||||
|
||||
|
||||
for await (const msg of dittoRelay.req(
|
||||
[{ ...streamFilter, since: now, limit: 0 }],
|
||||
{ signal: ac.signal }
|
||||
)) {
|
||||
if (!alive) break;
|
||||
|
||||
|
||||
if (msg[0] === 'EVENT') {
|
||||
addEvent(msg[2], true);
|
||||
} else if (msg[0] === 'CLOSED') {
|
||||
@@ -387,29 +549,32 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
} catch {
|
||||
// abort expected
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
fetchInitialBatch();
|
||||
startStreaming();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
ac.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols is stabilized via protocolsKey; kindsOverride is stabilized via kindsOverrideKey; authorPubkeys is stabilized via authorPubkeysKey
|
||||
}, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; effectiveOptions fields are stabilized via their respective keys; spellExtraFilter is stabilized via spellExtraFilterKey
|
||||
}, [nostr, effectiveQuery, isDedicatedKindQuery, kindsKey, effectiveOptions.language, effectiveOptions.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, effectiveOptions.sort, useDittoOnly, spellExtraFilterKey, spellKey]);
|
||||
|
||||
// Flush buffered streamed events into the main list (called by UI when user wants to see new posts)
|
||||
const flushStreamBuffer = doFlush;
|
||||
|
||||
// Pre-compute author set outside the per-event callback
|
||||
const authorSet = useMemo(() => resolvedAuthorPubkeys ? new Set(resolvedAuthorPubkeys) : null, [resolvedAuthorPubkeys]);
|
||||
|
||||
// Shared predicate for client-side filtering (mute, content, search, media, author, etc.)
|
||||
const matchesFilters = useCallback((event: NostrEvent) => {
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
if (shouldFilterEvent(event)) return false;
|
||||
if (resolvedAuthorPubkeys) {
|
||||
const authorSet = new Set(resolvedAuthorPubkeys);
|
||||
if (!authorSet.has(event.pubkey)) return false;
|
||||
}
|
||||
return filterEvent(event, options, query);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity
|
||||
}, [options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkeys, shouldFilterEvent, authorPubkeysKey]);
|
||||
if (authorSet && !authorSet.has(event.pubkey)) return false;
|
||||
return filterEvent(event, effectiveOptions, effectiveQuery);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- using specific option fields and stabilized keys for granular reactivity
|
||||
}, [effectiveOptions.includeReplies, effectiveOptions.mediaType, protocolsKey, effectiveQuery, muteItems, authorSet, shouldFilterEvent, authorPubkeysKey]);
|
||||
|
||||
// Apply client-side filters (including mute filtering and content filters) without restarting the stream
|
||||
const posts = useMemo(() => {
|
||||
@@ -424,5 +589,5 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streamBufferCount, matchesFilters]);
|
||||
|
||||
return { posts, isLoading, newPostCount: filteredNewPostCount, flushStreamBuffer, flushedIds };
|
||||
return { posts, isLoading, newPostCount: filteredNewPostCount, flushStreamBuffer, flushedIds, loadMore, hasMore, isLoadingMore };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { bundledFonts } from '@/lib/fonts';
|
||||
import type { ChatMessage } from '@/hooks/useShakespeare';
|
||||
|
||||
/** Comma-separated list of available bundled font names for the system prompt. */
|
||||
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
|
||||
|
||||
/** Minimal profile fields injected into the system prompt so the AI knows who it's talking to. */
|
||||
export interface UserIdentity {
|
||||
/** The user's npub (bech32 public key). */
|
||||
npub: string;
|
||||
/** The user's hex public key. */
|
||||
pubkey: string;
|
||||
/** Display name from kind 0 metadata. */
|
||||
displayName?: string;
|
||||
/** NIP-05 identifier (e.g. "alice@example.com"). */
|
||||
nip05?: string;
|
||||
/** Short bio / about text. */
|
||||
about?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI chat system prompt.
|
||||
*
|
||||
* When a buddy is configured, `name` and `soul` are injected via the
|
||||
* `{{NAME}}` and `{{SOUL}}` placeholders. Identity and personality are
|
||||
* entirely determined by those values — the base template is purely
|
||||
* functional (tool definitions, capabilities, formatting).
|
||||
*
|
||||
* `{{SAVED_FEEDS}}` is replaced with a list of the user's saved feed
|
||||
* labels so the model knows which named feeds are available.
|
||||
*
|
||||
* `{{USER_IDENTITY}}` is replaced with a block describing the logged-in
|
||||
* user so the AI can answer questions like "who am I?" or "show me my
|
||||
* recent posts" without extra round-trips.
|
||||
*
|
||||
* If `customPrompt` is provided (from Advanced Settings), it replaces
|
||||
* the entire base template. Placeholders are substituted in both cases.
|
||||
*/
|
||||
export function buildSystemPrompt(
|
||||
name?: string,
|
||||
soul?: string,
|
||||
customPrompt?: string,
|
||||
savedFeedLabels?: string[],
|
||||
userIdentity?: UserIdentity,
|
||||
): ChatMessage {
|
||||
const agentName = name ?? 'Dork';
|
||||
const soulText = soul ?? '';
|
||||
|
||||
const savedFeedsText = savedFeedLabels && savedFeedLabels.length > 0
|
||||
? `**Saved feeds the user has created:** ${savedFeedLabels.map((l) => `"${l}"`).join(', ')}`
|
||||
: '';
|
||||
|
||||
const userIdentityText = userIdentity ? buildUserIdentityBlock(userIdentity) : '';
|
||||
|
||||
const template = customPrompt || DEFAULT_TEMPLATE;
|
||||
|
||||
const resolved = template
|
||||
.replace(/\{\{NAME\}\}/g, agentName)
|
||||
.replace(/\{\{SOUL\}\}/g, soulText)
|
||||
.replace(/\{\{SAVED_FEEDS\}\}/g, savedFeedsText)
|
||||
.replace(/\{\{USER_IDENTITY\}\}/g, userIdentityText);
|
||||
|
||||
return { role: 'system', content: resolved };
|
||||
}
|
||||
|
||||
/** Build a markdown block describing the current user. */
|
||||
function buildUserIdentityBlock(identity: UserIdentity): string {
|
||||
const lines: string[] = [
|
||||
'# Current User',
|
||||
`- **npub:** ${identity.npub}`,
|
||||
`- **hex pubkey:** ${identity.pubkey}`,
|
||||
];
|
||||
|
||||
if (identity.displayName) {
|
||||
lines.push(`- **name:** ${identity.displayName}`);
|
||||
}
|
||||
if (identity.nip05) {
|
||||
lines.push(`- **NIP-05:** ${identity.nip05}`);
|
||||
}
|
||||
if (identity.about) {
|
||||
lines.push(`- **about:** ${identity.about}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Use this identity when the user asks "who am I?", "what\'s my npub?", or similar. To fetch their full profile, use `fetch_event` with their npub. To see their recent posts, use `get_feed` with `authors: ["$me"]`.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Default template ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TEMPLATE = `You are {{NAME}}, an AI assistant in Ditto, a Nostr social client.
|
||||
|
||||
{{SOUL}}
|
||||
|
||||
{{USER_IDENTITY}}
|
||||
|
||||
# Tools
|
||||
|
||||
## set_theme
|
||||
Applies a full custom theme. Supports:
|
||||
|
||||
**Colors** (required): Three HSL values without the "hsl()" wrapper (e.g. "228 20% 10%"):
|
||||
- background: page background color
|
||||
- text: main text/foreground color (must contrast well with background)
|
||||
- primary: accent color for buttons, links, and highlights
|
||||
|
||||
**Font** (optional): Choose from bundled fonts to match the theme's mood. Available: ${AVAILABLE_FONTS}
|
||||
|
||||
**Background image** (optional): A URL to a publicly accessible image. Set mode to "cover" for full-bleed or "tile" for repeating patterns.
|
||||
|
||||
When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one.
|
||||
|
||||
## create_spell vs get_feed — choosing the right tool
|
||||
|
||||
These two tools both deal with Nostr feeds but serve fundamentally different purposes:
|
||||
|
||||
- **create_spell** is a **write** operation. It creates a persistent feed (a kind:777 event) that appears in the user's UI. The user can view it, run it themselves, and save it to their sidebar. Use this when the user wants to **build**, **save**, or **set up** a feed for ongoing use. The user will browse the results in the app's feed viewer — you do NOT see the results.
|
||||
- **get_feed** is a **read** operation. It fetches posts from Nostr and returns their content to YOU so you can summarize, analyze, or answer questions about what's happening. Use this when the user wants **information**, a **summary**, or is asking a question about recent activity. The user does NOT see the raw posts — they see your conversational summary.
|
||||
|
||||
**Decision guide:**
|
||||
| User intent | Tool | Why |
|
||||
|---|---|---|
|
||||
| "make me a feed of bitcoin posts" | create_spell | They want a persistent feed to browse |
|
||||
| "set up a feed for photos from my friends" | create_spell | They want to save it and view it in the UI |
|
||||
| "what are my friends talking about?" | get_feed | They want you to summarize activity |
|
||||
| "what's trending on nostr?" | get_feed | They want information, not a saved feed |
|
||||
| "anything about bitcoin today?" | get_feed | They're asking a question about recent content |
|
||||
| "show me what's happening in Japan" | get_feed | They want a summary of activity |
|
||||
| "create a feed for Japanese posts" | create_spell | They want a persistent feed to browse later |
|
||||
|
||||
**Key signals:**
|
||||
- Words like "make", "create", "set up", "build", "save" → create_spell
|
||||
- Words like "what's going on", "tell me about", "summarize", "anything about", "what are people saying" → get_feed
|
||||
- If ambiguous, prefer get_feed — it's less intrusive (read vs write) and you can always offer to create a spell afterward if the user wants to save the query
|
||||
|
||||
## create_spell
|
||||
Creates Nostr spells (NIP-A7) — saved queries that act as custom feeds. When a user describes what they want to see, translate it into spell parameters.
|
||||
|
||||
**How spells work:**
|
||||
- A spell is a kind:777 event encoding a Nostr relay filter
|
||||
- The user can click the spell to run it and see results, then add it to their sidebar for quick access
|
||||
- Spells are published with an ephemeral key, not the user's identity
|
||||
|
||||
**Runtime variables** (resolved when the spell runs, not when created):
|
||||
- "$me" — the logged-in user's pubkey
|
||||
- "$contacts" — all pubkeys from the user's kind:3 follow list
|
||||
|
||||
**Relative timestamps** (subtracted from now at execution time):
|
||||
- "24h", "7d", "2w", "1mo", "3mo", "1y", etc.
|
||||
|
||||
**Common kinds:**
|
||||
- 1 = text notes, 6 = reposts, 7 = reactions, 20 = photos
|
||||
- 30023 = articles, 9735 = zap receipts, 5 = deletions, 30402 = classifieds
|
||||
|
||||
**Tag filters vs search:**
|
||||
- Use search for broad topic matching in content text (e.g. search: "bitcoin")
|
||||
- Use tag_filters with letter "t" for hashtag filtering (e.g. #bitcoin, #nostr)
|
||||
- Note: search relies on NIP-50 relay support; hashtags are more universally supported
|
||||
|
||||
**Client hints** (NIP-50 extensions — routed to Ditto relay automatically):
|
||||
- media: "images", "videos", "vines", "none" — filter by media type
|
||||
- language: ISO 639-1 code (e.g. "en", "ja") — filter by language
|
||||
- platform: "nostr" (default), "activitypub", "atproto" — filter by protocol
|
||||
- sort: "recent" (default), "hot", "trending" — sort order
|
||||
- include_replies: false — exclude reply posts (default: true, include everything)
|
||||
|
||||
**Spell examples:**
|
||||
- "feed of my friends talking about bitcoin" → authors: ["$contacts"], kinds: [1], search: "bitcoin"
|
||||
- "posts tagged nostr and dev" → kinds: [1], tag_filters: [{letter: "t", values: ["nostr", "dev"]}]
|
||||
- "my mass deletions" → authors: ["$me"], kinds: [5]
|
||||
- "photos from people I follow" → authors: ["$contacts"], kinds: [20], media: "images"
|
||||
- "articles about nostr from the past month" → kinds: [30023], search: "nostr", since: "1mo"
|
||||
- "trending posts this week" → since: "7d", sort: "trending"
|
||||
- "zaps this week" → kinds: [9735], since: "7d"
|
||||
- "what I've been posting lately" → authors: ["$me"], kinds: [1], since: "30d"
|
||||
- "english posts from follows, no replies" → authors: ["$contacts"], language: "en", include_replies: false
|
||||
|
||||
Keep spell names short and descriptive (2-4 words). When you create a spell, briefly explain what it will show.
|
||||
|
||||
## search_users
|
||||
Resolves names to Nostr pubkeys. When a user mentions a specific person by name (e.g. "Derek Ross", "fiatjaf"), use search_users to find their pubkey before creating a spell that references them. The search checks the user's contacts first, then does a broader relay search. If multiple matches are found, ask the user to confirm which one they meant. Use the hex pubkey from the results directly in the spell's authors array.
|
||||
|
||||
## search_follow_packs
|
||||
Finds curated follow packs (starter packs). Follow packs are lists of people grouped by theme or community (e.g. "Team Soapbox", "Bitcoin Developers"). When a user mentions a follow pack or starter pack by name, use search_follow_packs to look it up. The tool returns the pack's title, description, and all member pubkeys. Use those pubkeys directly in the spell's authors array to create a feed based on the pack's members.
|
||||
|
||||
**Follow pack examples:**
|
||||
- "feed from the team soapbox pack" → search_follow_packs("team soapbox") → use returned pubkeys as authors
|
||||
- "photos from the bitcoin developers pack" → search_follow_packs("bitcoin developers") → use pubkeys as authors, kinds: [20]
|
||||
|
||||
## fetch_page
|
||||
Fetches a URL and extracts text content and image URLs from the HTML. Use when a user provides a link and you need to discover what's on the page (images, content, file listings).
|
||||
|
||||
## upload_from_url
|
||||
Downloads files from URLs and uploads them to Blossom file servers. Supports any file type — images, .xdc (WebXDC apps), .zip archives, video, audio, documents, etc. MIME types are detected automatically from file extensions. Returns Blossom URLs, detected MIME types, and auto-generated shortcodes. Max 50 files per call.
|
||||
|
||||
## create_emoji_pack
|
||||
Publishes a NIP-30 custom emoji pack (kind 30030) as the logged-in user. Takes a pack name and array of {shortcode, url} pairs. The shortcodes must be alphanumeric (hyphens and underscores allowed). Use Blossom URLs from upload_from_url.
|
||||
|
||||
**Workflow for creating emoji packs from a webpage:**
|
||||
1. fetch_page(url) → get image URLs from the page
|
||||
2. upload_from_url(image_urls) → upload to Blossom, get URLs + shortcodes
|
||||
3. create_emoji_pack(name, emojis) → publish the pack
|
||||
|
||||
When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context.
|
||||
|
||||
## publish_events
|
||||
Publishes one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Use this when the user asks you to post, publish, or broadcast something to Nostr.
|
||||
|
||||
**Common kinds:**
|
||||
- 1 = text note (put post text in content)
|
||||
- 7 = reaction (content is "+" or an emoji, add an "e" tag referencing the target event)
|
||||
- 6 = repost (content is the JSON of the reposted event, add an "e" tag)
|
||||
|
||||
**Tag format:** Arrays of strings, e.g. \`[["t", "nostr"], ["p", "<hex-pubkey>"]]\`
|
||||
|
||||
**Examples:**
|
||||
- Post a note: \`{ events: [{ content: "Hello Nostr!" }] }\`
|
||||
- Post with hashtags: \`{ events: [{ content: "Building on Nostr", tags: [["t", "nostr"], ["t", "dev"]] }] }\`
|
||||
|
||||
Only publish events when the user explicitly asks you to. Never publish autonomously.
|
||||
|
||||
## create_webxdc
|
||||
Creates and publishes a WebXDC mini-app from scratch. WebXDC apps are self-contained HTML5 apps (games, tools, widgets) that run in a sandboxed iframe with no internet access. Users can launch them directly from the feed.
|
||||
|
||||
You write the code, the tool handles the rest: packaging into a .xdc archive, uploading to Blossom, and publishing as a kind 1063 event.
|
||||
|
||||
**Two modes for source code:**
|
||||
- **Simple (\`html\` param):** A single self-contained HTML string. Best for small apps.
|
||||
- **Multi-file (\`files\` param):** A JSON object mapping filenames to content strings, e.g. \`{"index.html": "<!DOCTYPE html>...", "engine.js": "...", "levels.json": "..."}\`. Must include \`index.html\`. Other files are loaded via relative paths (\`<script src="engine.js">\` or \`fetch('levels.json')\`). Use this when the code is large enough that splitting into separate files improves clarity.
|
||||
|
||||
Only one of \`html\` or \`files\` is needed. If both are provided, \`files\` takes priority.
|
||||
|
||||
**Bundling binary assets (\`asset_urls\` param, optional):**
|
||||
Include remote files (images, audio, ROMs, WASM, fonts, etc.) as binary assets in the archive. Provide a JSON object mapping filenames to URLs: \`{"game.gb": "https://blossom.example.com/abc123.bin"}\`. Each URL is fetched and bundled into the .xdc archive. The app loads them via relative paths at runtime (e.g. \`fetch('game.gb')\`, \`new Audio('sfx.wav')\`, \`<img src="cover.png">\`).
|
||||
|
||||
Use \`upload_from_url\` first to upload the asset to Blossom, then pass the Blossom URL here. This is useful for bundling emulator ROMs, sprite sheets, audio samples, or any binary content the app needs.
|
||||
|
||||
**Example workflow for a retro game:**
|
||||
1. \`upload_from_url\` the ROM file → get Blossom URL
|
||||
2. \`upload_from_url\` cover art → get Blossom URL
|
||||
3. \`create_webxdc\` with \`files\` containing the emulator HTML/JS and \`asset_urls\` containing the ROM and art
|
||||
|
||||
**Critical constraints for the code you generate:**
|
||||
- Must include a complete HTML document with \`<!DOCTYPE html>\`
|
||||
- NO external resources of any kind: no CDN links, no external CSS/JS/fonts
|
||||
- NO ES module imports — use plain \`<script>\` tags
|
||||
- All graphics must be procedural (canvas, CSS shapes, SVG inline) or data: URIs
|
||||
- Use system fonts only (e.g. \`system-ui, sans-serif\`)
|
||||
- The sandbox blocks ALL network access — external requests to remote servers silently fail
|
||||
- \`fetch()\` to relative paths within the .xdc archive DOES work (files are served from the unzipped archive)
|
||||
- \`localStorage\` is available and scoped to the app — use it for save states, high scores, and user preferences
|
||||
|
||||
**What works well:**
|
||||
- Canvas games: pong, snake, tetris, breakout, flappy bird, space invaders
|
||||
- CSS/JS tools: calculators, timers, stopwatches, drawing apps, to-do lists
|
||||
- Procedural art and generative visuals
|
||||
- Web Audio API for sound effects
|
||||
|
||||
**Input handling — IMPORTANT:**
|
||||
- The host app provides a built-in virtual gamepad (D-pad, A/B, Start/Select) that injects synthetic KeyboardEvents into the iframe
|
||||
- **Do NOT build touch controls or on-screen gamepads into your HTML** — the host handles that
|
||||
- Only add \`keydown\`/\`keyup\` event listeners for keyboard input
|
||||
- The app canvas/UI should fill the entire viewport (no space reserved for controls)
|
||||
- For games, use these exact key bindings to match the host gamepad: ArrowUp (38), ArrowDown (40), ArrowLeft (37), ArrowRight (39), \`x\` (88) = A button, \`z\` (90) = B button, Enter (13) = Start, Shift (16) = Select
|
||||
|
||||
**App icon (optional but recommended):** The \`image_url\` parameter sets a thumbnail shown on the app's launch card in the feed. Without it, a generic icon is displayed. To add one, use upload_from_url first to upload an image to Blossom, then pass the URL.
|
||||
|
||||
**Example use:** "Build me a pong game" → generate complete pong HTML → create_webxdc(name: "Pong", html: "<!DOCTYPE html>...")
|
||||
|
||||
## Publishing existing WebXDC apps from URLs
|
||||
|
||||
When a user shares a link to an existing .xdc file (from a Git repo or elsewhere), use upload_from_url + publish_events:
|
||||
|
||||
1. **Upload the .xdc file** using upload_from_url with the direct download URL
|
||||
2. **Publish a kind 1063 event** using publish_events with these tags:
|
||||
- \`["url", "<blossom-url>"]\` — must end with .xdc
|
||||
- \`["m", "application/x-webxdc"]\` — MIME type
|
||||
- \`["alt", "Webxdc app: <App Name>"]\` — human-readable description
|
||||
- \`["webxdc", "<random-uuid>"]\` — unique session UUID (use UUID format like "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
|
||||
**Finding .xdc files from Git repositories:**
|
||||
When a user shares a GitLab or GitHub repo URL, construct the raw download URL:
|
||||
- **GitLab:** \`https://gitlab.com/<user>/<repo>/-/raw/main/<filename>.xdc\`
|
||||
- **GitHub:** \`https://raw.githubusercontent.com/<user>/<repo>/main/<filename>.xdc\`
|
||||
|
||||
If the branch is \`master\` instead of \`main\`, adjust accordingly. If you don't know the exact filename, use fetch_page on the repo URL to discover it.
|
||||
|
||||
## make_it_rain
|
||||
A fun easter egg! Triggers a visual rain or snow effect across the entire app. The effect persists across all pages until stopped.
|
||||
|
||||
Use this playfully and creatively:
|
||||
- When the user says "make it rain" or asks for weather effects
|
||||
- To celebrate achievements or exciting moments (heavy rain = hype)
|
||||
- For cozy or moody vibes (light rain = ambiance)
|
||||
- When discussing weather, seasons, or winter (snow is great here)
|
||||
- Any moment where a visual surprise would delight the user
|
||||
|
||||
Stop the effect when the user asks. Be responsive — if they say "enough", "stop the rain", or seem annoyed, stop it immediately.
|
||||
|
||||
Pair it with set_theme for maximum atmosphere — dark theme + rain = moody, winter theme + snow = cozy.
|
||||
|
||||
## fetch_event
|
||||
Fetches a Nostr event by its NIP-19 identifier. Use this when the user shares a Nostr link or identifier and you need to read its content.
|
||||
|
||||
**Supported identifiers:**
|
||||
- npub1... → fetches the user's kind 0 profile
|
||||
- note1... → fetches a specific event by ID
|
||||
- nevent1... → fetches an event (may include relay hints)
|
||||
- naddr1... → fetches an addressable event by kind+author+d-tag
|
||||
- nprofile1... → fetches a user profile with relay hints
|
||||
|
||||
Returns the full event JSON. For profiles (kind 0), the content field contains JSON metadata (name, about, picture, etc.).
|
||||
|
||||
## get_feed
|
||||
Reads posts from a feed and returns their content. Use this when the user asks what's going on, wants a summary of recent activity, or asks about a specific topic, person, or country.
|
||||
|
||||
**Built-in feeds:**
|
||||
- "follows" — posts from people the user follows (requires login)
|
||||
- "global" — recent posts from everyone
|
||||
- "ditto" — curated trending posts
|
||||
|
||||
{{SAVED_FEEDS}}
|
||||
|
||||
**Country feeds:**
|
||||
When the user asks about a country (e.g. "what's going on in Venezuela?", "anything happening in Japan?"), use the \`country\` parameter with the ISO 3166-1 alpha-2 code (e.g. "VE", "JP"). This queries NIP-73 geographic comments (kind 1111) for that country. You do NOT need to know the country code in advance — map the country name to its 2-letter code (e.g. Venezuela = VE, Brazil = BR, United States = US, Japan = JP, Germany = DE).
|
||||
|
||||
**Ad-hoc queries:**
|
||||
When no existing feed matches, build a query using:
|
||||
- kinds: event kinds (default [1] for text notes; use [20] for photos, [30023] for articles, etc.)
|
||||
- authors: "$me", "$contacts", or hex pubkeys from search_users
|
||||
- search: NIP-50 full-text search
|
||||
- hashtag: filter by hashtag
|
||||
|
||||
**Time window:**
|
||||
- hours: how far back to look (default 12). Use 1-6 for "what's happening right now", 12-24 for "today", 168 for "this week"
|
||||
|
||||
**Workflow:**
|
||||
1. Determine the best feed source: named feed, country code, or ad-hoc query
|
||||
2. Call get_feed with appropriate parameters
|
||||
3. Summarize the results — highlight key topics, interesting conversations, and notable posts
|
||||
4. Be conversational; don't just list posts, synthesize what's going on
|
||||
|
||||
**Examples:**
|
||||
- "what are my friends talking about?" → get_feed(feed_name: "follows")
|
||||
- "what's trending?" → get_feed(feed_name: "ditto")
|
||||
- "what's going on in Venezuela?" → get_feed(country: "VE")
|
||||
- "anything about bitcoin today?" → get_feed(search: "bitcoin", hours: 24)
|
||||
- "what's #nostr been like this week?" → get_feed(hashtag: "nostr", hours: 168)`;
|
||||
|
||||
/** The raw default template with {{NAME}} and {{SOUL}} placeholders (for display in settings). */
|
||||
export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// ─── Message Types ───
|
||||
|
||||
export interface DisplayMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool_result';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
/** For tool_result messages: the tool_call_id this result corresponds to. */
|
||||
toolCallId?: string;
|
||||
/** A Nostr event published by a tool, rendered inline in the chat. */
|
||||
nostrEvent?: NostrEvent;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
/** Result returned by a tool executor. */
|
||||
export interface ToolExecutorResult {
|
||||
/** JSON string returned to the AI as the tool result. */
|
||||
result: string;
|
||||
/** A Nostr event published by the tool, to be rendered inline in the chat. */
|
||||
nostrEvent?: NostrEvent;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
|
||||
type KindOption = {
|
||||
export type KindOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -42,8 +41,8 @@ export function buildKindOptions(): KindOption[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse a TabFilter's kinds array into an array of string kind values. */
|
||||
export function parseSelectedKinds(filter: TabFilter): string[] {
|
||||
/** Extract selected kind strings from a TabFilter (for populating multi-select). */
|
||||
export function parseSelectedKinds(filter: Record<string, unknown>): string[] {
|
||||
const kinds = filter.kinds;
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return [];
|
||||
return kinds.map(String);
|
||||
|
||||
@@ -114,3 +114,77 @@ export function parseRepostContent(repost: NostrEvent): NostrEvent | undefined {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps kind 6/16 reposts from a list of events into FeedItems.
|
||||
*
|
||||
* For each repost event, attempts to extract the original from the JSON
|
||||
* content. If the content is missing or unparseable, the original event ID
|
||||
* is collected for a batch fetch via `fetchMissing`.
|
||||
*
|
||||
* Non-repost events are passed through as direct FeedItems.
|
||||
*
|
||||
* @param events - Pre-validated events (future timestamps already stripped).
|
||||
* @param fetchMissing - Callback to fetch originals by ID (e.g. `nostr.query`).
|
||||
* @param now - Current unix timestamp for future-event rejection.
|
||||
*/
|
||||
export async function unwrapReposts(
|
||||
events: NostrEvent[],
|
||||
fetchMissing: (ids: string[]) => Promise<NostrEvent[]>,
|
||||
now: number,
|
||||
): Promise<FeedItem[]> {
|
||||
const items: FeedItem[] = [];
|
||||
const repostMissingIds: string[] = [];
|
||||
const repostMap = new Map<string, NostrEvent>();
|
||||
|
||||
for (const ev of events) {
|
||||
if (isRepostKind(ev.kind)) {
|
||||
const embedded = parseRepostContent(ev);
|
||||
if (embedded && embedded.created_at <= now) {
|
||||
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
|
||||
} else {
|
||||
const repostedId = ev.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (repostedId) {
|
||||
repostMissingIds.push(repostedId);
|
||||
repostMap.set(repostedId, ev);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items.push({ event: ev, sortTimestamp: ev.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
if (repostMissingIds.length > 0) {
|
||||
try {
|
||||
const originals = await fetchMissing(repostMissingIds);
|
||||
for (const original of originals) {
|
||||
const repost = repostMap.get(original.id);
|
||||
if (repost && original.created_at <= now) {
|
||||
items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// timeout or abort — just skip the missing reposts
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates FeedItems by event ID, preferring the original post over a
|
||||
* repost when both are present. Returns items sorted newest-first by
|
||||
* `sortTimestamp`.
|
||||
*/
|
||||
export function deduplicateFeedItems(items: FeedItem[]): FeedItem[] {
|
||||
const seen = new Map<string, FeedItem>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.event.id);
|
||||
if (!existing) {
|
||||
seen.set(item.event.id, item);
|
||||
} else if (!item.repostedBy && existing.repostedBy) {
|
||||
seen.set(item.event.id, item);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
}
|
||||
|
||||
+15
-16
@@ -196,8 +196,19 @@ export const SavedFeedSchema = z.object({
|
||||
filter: TabFilterSchema,
|
||||
vars: z.array(TabVarDefSchema).default([]),
|
||||
createdAt: z.number(),
|
||||
/** Hex event ID of a kind:777 spell event that drives this feed. */
|
||||
spellId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Shared transform for savedFeeds arrays: drops legacy destination items and validates each entry. */
|
||||
const savedFeedsTransform = (arr: unknown[]) =>
|
||||
arr.flatMap((item) => {
|
||||
if (typeof item !== 'object' || item === null) return [];
|
||||
if ((item as Record<string, unknown>).destination !== undefined) return [];
|
||||
const result = SavedFeedSchema.safeParse(item);
|
||||
return result.success ? [result.data] : [];
|
||||
});
|
||||
|
||||
// ─── AppConfigSchema ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -236,14 +247,7 @@ export const AppConfigSchema = z.object({
|
||||
sentryEnabled: z.boolean(),
|
||||
plausibleDomain: z.string(),
|
||||
plausibleEndpoint: z.string(),
|
||||
savedFeeds: z.array(z.unknown()).transform((arr) =>
|
||||
arr.flatMap((item) => {
|
||||
if (typeof item !== 'object' || item === null) return [];
|
||||
if ((item as Record<string, unknown>).destination !== undefined) return [];
|
||||
const result = SavedFeedSchema.safeParse(item);
|
||||
return result.success ? [result.data] : [];
|
||||
})
|
||||
).optional().default([]),
|
||||
savedFeeds: z.array(z.unknown()).transform(savedFeedsTransform).optional().default([]),
|
||||
imageQuality: z.enum(['compressed', 'original']),
|
||||
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
|
||||
sandboxDomain: z.string().optional(),
|
||||
@@ -251,6 +255,8 @@ export const AppConfigSchema = z.object({
|
||||
id: z.string(),
|
||||
height: z.number().optional(),
|
||||
})).optional(),
|
||||
aiModel: z.string().optional().default(''),
|
||||
aiSystemPrompt: z.string().optional().default(''),
|
||||
});
|
||||
|
||||
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
|
||||
@@ -337,12 +343,5 @@ export const EncryptedSettingsSchema = z.looseObject({
|
||||
faviconUrl: z.string().optional(),
|
||||
linkPreviewUrl: z.string().optional(),
|
||||
sentryDsn: z.string().optional(),
|
||||
savedFeeds: z.array(z.unknown()).transform((arr) =>
|
||||
arr.flatMap((item) => {
|
||||
if (typeof item !== 'object' || item === null) return [];
|
||||
if ((item as Record<string, unknown>).destination !== undefined) return [];
|
||||
const result = SavedFeedSchema.safeParse(item);
|
||||
return result.success ? [result.data] : [];
|
||||
})
|
||||
).optional(),
|
||||
savedFeeds: z.array(z.unknown()).transform(savedFeedsTransform).optional(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Clapperboard,
|
||||
Compass,
|
||||
Code,
|
||||
Earth,
|
||||
Film,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
TrendingUp,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { CardsIcon } from "@/components/icons/CardsIcon";
|
||||
import { ChestIcon } from "@/components/icons/ChestIcon";
|
||||
import { PlanetIcon } from "@/components/icons/PlanetIcon";
|
||||
@@ -54,6 +56,14 @@ export function isSidebarDivider(id: string): boolean {
|
||||
return id === SIDEBAR_DIVIDER_ID;
|
||||
}
|
||||
|
||||
/** The sidebar item ID for the inline search input. */
|
||||
export const SIDEBAR_SEARCH_ID = 'search';
|
||||
|
||||
/** Returns true if the given sidebar order ID is the search input item. */
|
||||
export function isSidebarSearch(id: string): boolean {
|
||||
return id === SIDEBAR_SEARCH_ID;
|
||||
}
|
||||
|
||||
/** Returns true if the given sidebar order ID is a `nostr:` URI. */
|
||||
export function isNostrUri(id: string): boolean {
|
||||
return id.startsWith("nostr:");
|
||||
@@ -111,6 +121,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
{ id: "search", label: "Search", path: "/search", icon: Search },
|
||||
{ id: "discover", label: "Discover", path: "/discover", icon: Compass },
|
||||
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
|
||||
{
|
||||
id: "bookmarks",
|
||||
@@ -144,7 +155,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
},
|
||||
{
|
||||
id: "ai-chat",
|
||||
label: "AI Chat",
|
||||
label: "Buddy",
|
||||
path: "/ai-chat",
|
||||
icon: Bot,
|
||||
requiresAuth: true,
|
||||
@@ -275,7 +286,18 @@ export function isItemActive(
|
||||
// Nostr URI items: active when pathname matches /<nip19>
|
||||
if (isNostrUri(id)) {
|
||||
const nip19Id = nostrUriToNip19(id);
|
||||
return pathname === `/${nip19Id}`;
|
||||
if (pathname === `/${nip19Id}`) return true;
|
||||
|
||||
// Different nevent encodings of the same event may produce different
|
||||
// bech32 strings (different relay hints). Compare by decoded event ID.
|
||||
const sidebarEventId = safeDecodeEventId(nip19Id);
|
||||
if (sidebarEventId && pathname.startsWith('/')) {
|
||||
const pathSegment = pathname.slice(1);
|
||||
const pathEventId = safeDecodeEventId(pathSegment);
|
||||
if (pathEventId && sidebarEventId === pathEventId) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// External content items: active when pathname matches /i/<encoded-value>
|
||||
@@ -295,3 +317,17 @@ export function isItemActive(
|
||||
|
||||
return pathname === itemPathname;
|
||||
}
|
||||
|
||||
/** Safely decode a NIP-19 identifier and extract its event ID, or null on failure. */
|
||||
export function safeDecodeEventId(bech32: string): string | null {
|
||||
try {
|
||||
const decoded = nip19.decode(bech32);
|
||||
switch (decoded.type) {
|
||||
case 'note': return decoded.data as string;
|
||||
case 'nevent': return (decoded.data as { id: string }).id;
|
||||
default: return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unit multipliers for relative timestamps (in seconds). */
|
||||
const UNIT_SECONDS: Record<string, number> = {
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 3600,
|
||||
d: 86400,
|
||||
w: 604800,
|
||||
mo: 2592000,
|
||||
y: 31536000,
|
||||
};
|
||||
|
||||
/** Valid media type values for the `media` tag. */
|
||||
export const SPELL_MEDIA_TYPES = ['all', 'images', 'videos', 'vines', 'none'] as const;
|
||||
export type SpellMediaType = typeof SPELL_MEDIA_TYPES[number];
|
||||
|
||||
/** Valid sort preference values for the `sort` tag. */
|
||||
export const SPELL_SORT_VALUES = ['recent', 'hot', 'trending'] as const;
|
||||
export type SpellSort = typeof SPELL_SORT_VALUES[number];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve a spell timestamp value to an absolute Unix timestamp. */
|
||||
function resolveTimestamp(value: string): number {
|
||||
if (value === 'now') return Math.floor(Date.now() / 1000);
|
||||
|
||||
const match = value.match(/^(\d+)(s|m|h|d|w|mo|y)$/);
|
||||
if (match) {
|
||||
const amount = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
const seconds = UNIT_SECONDS[unit];
|
||||
if (seconds !== undefined) {
|
||||
return Math.floor(Date.now() / 1000) - amount * seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute timestamp
|
||||
const ts = parseInt(value);
|
||||
if (!isNaN(ts)) return ts;
|
||||
|
||||
throw new Error(`Invalid timestamp value: ${value}`);
|
||||
}
|
||||
|
||||
/** Resolve runtime variables in an array of values. */
|
||||
function resolveValues(
|
||||
values: string[],
|
||||
userPubkey: string | undefined,
|
||||
contactPubkeys: string[],
|
||||
): string[] {
|
||||
return values.flatMap((v) => {
|
||||
if (v === '$me') {
|
||||
if (!userPubkey) throw new Error('Cannot resolve $me: no logged-in user');
|
||||
return [userPubkey];
|
||||
}
|
||||
if (v === '$contacts') {
|
||||
return contactPubkeys;
|
||||
}
|
||||
return [v];
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Client-hint fields parsed from spell metadata tags. These instruct the
|
||||
* client how to build NIP-50 search extensions and apply client-side filters. */
|
||||
export interface SpellClientHints {
|
||||
/** Media filter: 'all' (default), 'images', 'videos', 'vines', 'none'. */
|
||||
mediaType: SpellMediaType;
|
||||
/** Whether to include reply events. Default true. */
|
||||
includeReplies: boolean;
|
||||
/** Language code for NIP-50 language: extension, e.g. 'en'. Undefined = no filter. */
|
||||
language?: string;
|
||||
/** Protocol filter, e.g. 'nostr', 'activitypub', 'atproto'. Default 'nostr'. */
|
||||
platform: string;
|
||||
/** Sort preference for NIP-50 sort: extension. Default 'recent' (no sort: term). */
|
||||
sort: SpellSort;
|
||||
}
|
||||
|
||||
export interface ResolvedSpell {
|
||||
/** The command type: REQ or COUNT. */
|
||||
cmd: 'REQ' | 'COUNT';
|
||||
/** The resolved Nostr filter (kinds, authors, search text, since/until, etc.). */
|
||||
filter: NostrFilter;
|
||||
/** Client-hint fields for NIP-50 extensions and client-side filtering. */
|
||||
hints: SpellClientHints;
|
||||
/** Target relay URLs (if specified by the spell). */
|
||||
relays: string[];
|
||||
/** Whether the subscription should close after EOSE. */
|
||||
closeOnEose: boolean;
|
||||
/** Whether the spell uses NIP-50 extensions that require Ditto relay routing
|
||||
* (media, language, platform, sort). Plain keyword search does not set this. */
|
||||
needsDittoRelay: boolean;
|
||||
}
|
||||
|
||||
// ─── Resolver ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a kind:777 spell event into a resolved Nostr filter.
|
||||
*
|
||||
* Resolves runtime variables ($me, $contacts) and relative timestamps
|
||||
* into concrete values ready to send as a REQ.
|
||||
*/
|
||||
export function resolveSpell(
|
||||
event: NostrEvent,
|
||||
userPubkey: string | undefined,
|
||||
contactPubkeys: string[],
|
||||
): ResolvedSpell {
|
||||
const { tags } = event;
|
||||
|
||||
const cmd = (tags.find(([t]) => t === 'cmd')?.[1] ?? 'REQ') as 'REQ' | 'COUNT';
|
||||
|
||||
const filter: NostrFilter = {};
|
||||
|
||||
// Kinds
|
||||
const kinds = tags.filter(([t]) => t === 'k').map(([, v]) => parseInt(v)).filter((n) => !isNaN(n));
|
||||
if (kinds.length > 0) filter.kinds = kinds;
|
||||
|
||||
// Authors
|
||||
const authorsTag = tags.find(([t]) => t === 'authors');
|
||||
if (authorsTag) {
|
||||
const resolved = resolveValues(authorsTag.slice(1), userPubkey, contactPubkeys);
|
||||
if (resolved.length > 0) filter.authors = resolved;
|
||||
}
|
||||
|
||||
// IDs
|
||||
const idsTag = tags.find(([t]) => t === 'ids');
|
||||
if (idsTag) {
|
||||
filter.ids = idsTag.slice(1);
|
||||
}
|
||||
|
||||
// Tag filters
|
||||
const tagFilters = tags.filter(([t]) => t === 'tag');
|
||||
for (const [, letter, ...values] of tagFilters) {
|
||||
if (letter) {
|
||||
const resolved = resolveValues(values, userPubkey, contactPubkeys);
|
||||
if (resolved.length > 0) {
|
||||
(filter as Record<string, unknown>)[`#${letter}`] = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit
|
||||
const limitTag = tags.find(([t]) => t === 'limit');
|
||||
if (limitTag) {
|
||||
const n = parseInt(limitTag[1]);
|
||||
if (!isNaN(n)) filter.limit = n;
|
||||
}
|
||||
|
||||
// Since
|
||||
const sinceTag = tags.find(([t]) => t === 'since');
|
||||
if (sinceTag) {
|
||||
filter.since = resolveTimestamp(sinceTag[1]);
|
||||
}
|
||||
|
||||
// Until
|
||||
const untilTag = tags.find(([t]) => t === 'until');
|
||||
if (untilTag) {
|
||||
filter.until = resolveTimestamp(untilTag[1]);
|
||||
}
|
||||
|
||||
// Search (NIP-50)
|
||||
const searchTag = tags.find(([t]) => t === 'search');
|
||||
if (searchTag) {
|
||||
filter.search = searchTag[1];
|
||||
}
|
||||
|
||||
// Relays
|
||||
const relaysTag = tags.find(([t]) => t === 'relays');
|
||||
const relays = relaysTag ? relaysTag.slice(1) : [];
|
||||
|
||||
// Close on EOSE
|
||||
const closeOnEose = tags.some(([t]) => t === 'close-on-eose');
|
||||
|
||||
// ── Client hints (NIP-50 extensions) ──────────────────────────────────
|
||||
|
||||
const rawMedia = tags.find(([t]) => t === 'media')?.[1];
|
||||
const mediaType: SpellMediaType = rawMedia && (SPELL_MEDIA_TYPES as readonly string[]).includes(rawMedia)
|
||||
? rawMedia as SpellMediaType
|
||||
: 'all';
|
||||
|
||||
const includeReplies = tags.find(([t]) => t === 'include-replies')?.[1] !== 'false';
|
||||
|
||||
const language = tags.find(([t]) => t === 'language')?.[1] || undefined;
|
||||
|
||||
const rawPlatform = tags.find(([t]) => t === 'platform')?.[1];
|
||||
const platform = rawPlatform || 'nostr';
|
||||
|
||||
const rawSort = tags.find(([t]) => t === 'sort')?.[1];
|
||||
const sort: SpellSort = rawSort && (SPELL_SORT_VALUES as readonly string[]).includes(rawSort)
|
||||
? rawSort as SpellSort
|
||||
: 'recent';
|
||||
|
||||
const hints: SpellClientHints = { mediaType, includeReplies, language, platform, sort };
|
||||
|
||||
// Determine if this spell needs Ditto relay routing.
|
||||
// Plain keyword search works on any relay; NIP-50 extensions do not.
|
||||
const needsDittoRelay = mediaType !== 'all'
|
||||
|| (language !== undefined && language !== 'global')
|
||||
|| platform !== 'nostr'
|
||||
|| sort !== 'recent';
|
||||
|
||||
return { cmd, filter, hints, relays, closeOnEose, needsDittoRelay };
|
||||
}
|
||||
|
||||
// ─── Builder ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build the kind:777 tags array from spell parameters.
|
||||
* Used by both the AI tool handler and the manual spell builders. */
|
||||
export function buildSpellTags(args: {
|
||||
name?: string;
|
||||
cmd?: string;
|
||||
kinds?: number[];
|
||||
authors?: string[];
|
||||
tag_filters?: Array<{ letter: string; values: string[] }>;
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
relays?: string[];
|
||||
media?: string;
|
||||
language?: string;
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
includeReplies?: boolean;
|
||||
}): string[][] {
|
||||
const tags: string[][] = [];
|
||||
|
||||
if (args.name) tags.push(['name', args.name]);
|
||||
|
||||
const cmd = args.cmd ?? 'REQ';
|
||||
tags.push(['cmd', cmd]);
|
||||
|
||||
if (args.kinds) {
|
||||
for (const k of args.kinds) {
|
||||
tags.push(['k', String(k)]);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.authors && args.authors.length > 0) {
|
||||
tags.push(['authors', ...args.authors]);
|
||||
}
|
||||
|
||||
if (args.tag_filters) {
|
||||
for (const tf of args.tag_filters) {
|
||||
if (tf.letter && Array.isArray(tf.values)) {
|
||||
tags.push(['tag', tf.letter, ...tf.values]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.since) tags.push(['since', args.since]);
|
||||
if (args.until) tags.push(['until', args.until]);
|
||||
if (typeof args.limit === 'number') tags.push(['limit', String(args.limit)]);
|
||||
if (args.search) tags.push(['search', args.search]);
|
||||
|
||||
if (args.relays && args.relays.length > 0) {
|
||||
tags.push(['relays', ...args.relays]);
|
||||
}
|
||||
|
||||
// Client-hint tags (NIP-50 extensions)
|
||||
if (args.media && args.media !== 'all') tags.push(['media', args.media]);
|
||||
if (args.language && args.language !== 'global') tags.push(['language', args.language]);
|
||||
if (args.platform && args.platform !== 'nostr') tags.push(['platform', args.platform]);
|
||||
if (args.sort && args.sort !== 'recent') tags.push(['sort', args.sort]);
|
||||
if (args.includeReplies === false) tags.push(['include-replies', 'false']);
|
||||
|
||||
tags.push(['alt', `Spell: ${args.name ?? 'unnamed'}`]);
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Build an unsigned kind:777 spell event from pre-built tags.
|
||||
* Useful when you need a spell event structure without signing. */
|
||||
export function buildUnsignedSpell(tags: string[][]): NostrEvent {
|
||||
return {
|
||||
id: '',
|
||||
pubkey: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 777,
|
||||
tags,
|
||||
content: '',
|
||||
sig: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Spell Tag Parsers ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Shared helpers for reading common fields out of a spell event's tags.
|
||||
// Used by feed/tab edit modals to seed form state from an existing spell.
|
||||
|
||||
/** Extract the raw `authors` tag values. May contain `$me`, `$contacts`, or hex pubkeys. */
|
||||
export function spellAuthors(spell: NostrEvent | undefined): string[] {
|
||||
return spell?.tags.find(([t]) => t === 'authors')?.slice(1) ?? [];
|
||||
}
|
||||
|
||||
/** Extract kind numbers from `k` tags as string values. */
|
||||
export function spellKinds(spell: NostrEvent | undefined): string[] {
|
||||
if (!spell) return [];
|
||||
return spell.tags.filter(([t]) => t === 'k').map(([, v]) => v);
|
||||
}
|
||||
|
||||
/** Extract the `search` tag value. */
|
||||
export function spellSearch(spell: NostrEvent | undefined): string {
|
||||
return spell?.tags.find(([t]) => t === 'search')?.[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract explicit author pubkeys, filtering out runtime variables
|
||||
* (`$me`, `$contacts`) and optionally a specific pubkey (e.g. profile owner).
|
||||
*/
|
||||
export function spellAuthorPubkeys(spell: NostrEvent | undefined, excludePubkey?: string): string[] {
|
||||
return spellAuthors(spell).filter((a) => {
|
||||
if (a.startsWith('$')) return false;
|
||||
if (excludePubkey && a === excludePubkey) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable semantic fingerprint for a spell event.
|
||||
* Two spells with the same fingerprint represent the same query regardless
|
||||
* of name, alt text, or event identity (id/pubkey/sig).
|
||||
*/
|
||||
export function spellFingerprint(spell: NostrEvent | undefined): string {
|
||||
if (!spell) return '';
|
||||
const METADATA_TAGS = new Set(['name', 'alt']);
|
||||
const filterTags = spell.tags
|
||||
.filter(([t]) => !METADATA_TAGS.has(t))
|
||||
.map((tag) => tag.join('\x00'))
|
||||
.sort();
|
||||
return filterTags.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
import { getBuddyOrEphemeralKey, signAndPublishWithProfile } from './helpers';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
name: z.string().describe('Human-readable name for the emoji pack (e.g. "AIM Emoticons", "Retro Smileys").'),
|
||||
emojis: z.array(z.object({
|
||||
shortcode: z.string().describe('Shortcode for the emoji (alphanumeric, hyphens, underscores). E.g. "smiley", "heart-eyes".'),
|
||||
url: z.string().describe('URL to the emoji image (should be a Blossom URL).'),
|
||||
})).describe('Array of emoji entries to include in the pack.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const CreateEmojiPackTool: Tool<Params> = {
|
||||
description: `Create and publish a NIP-30 custom emoji pack (kind 30030 event). The pack is published as the logged-in user.
|
||||
|
||||
Takes a pack name and an array of emoji entries (shortcode + image URL). Shortcodes must be alphanumeric with hyphens and underscores only. The image URLs should be Blossom URLs from a prior upload_from_url call.
|
||||
|
||||
After publishing, the emoji pack appears in the user's feed and can be added to their emoji collection.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const packName = args.name.trim();
|
||||
if (!packName) {
|
||||
return { result: JSON.stringify({ error: 'A pack name is required.' }) };
|
||||
}
|
||||
|
||||
if (args.emojis.length === 0) {
|
||||
return { result: JSON.stringify({ error: 'At least one emoji is required.' }) };
|
||||
}
|
||||
|
||||
for (const e of args.emojis) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(e.shortcode)) {
|
||||
return { result: JSON.stringify({ error: `Invalid shortcode "${e.shortcode}". Must be alphanumeric with hyphens and underscores only.` }) };
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize emoji URLs -- reject any that aren't valid HTTPS
|
||||
const sanitizedEmojis = args.emojis
|
||||
.map((e) => ({ shortcode: e.shortcode, url: sanitizeUrl(e.url) }))
|
||||
.filter((e): e is { shortcode: string; url: string } => !!e.url);
|
||||
|
||||
if (sanitizedEmojis.length === 0) {
|
||||
return { result: JSON.stringify({ error: 'No emojis had valid HTTPS URLs. All emoji image URLs must be HTTPS.' }) };
|
||||
}
|
||||
|
||||
const dTag = packName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', dTag],
|
||||
['title', packName],
|
||||
...sanitizedEmojis.map((e) => ['emoji', e.shortcode, e.url]),
|
||||
];
|
||||
|
||||
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
|
||||
const emojiPackEvent = await signAndPublishWithProfile(
|
||||
ctx.nostr, sk, isBuddy,
|
||||
{ kind: 30030, content: '', tags, created_at: Math.floor(Date.now() / 1000) },
|
||||
{ name: 'Dork Emoji Maker', about: 'Emoji packs created by Dork AI' },
|
||||
);
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
event_id: emojiPackEvent.id,
|
||||
pubkey,
|
||||
name: packName,
|
||||
slug: dTag,
|
||||
emoji_count: sanitizedEmojis.length,
|
||||
}),
|
||||
nostrEvent: emojiPackEvent,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildSpellTags } from '@/lib/spellEngine';
|
||||
import { getBuddyOrEphemeralKey, signAndPublishWithProfile } from './helpers';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
name: z.string().describe('Short human-readable name for the spell (e.g. "fren bitcoin", "my mass deletions").'),
|
||||
description: z.string().optional().describe('Optional longer description of what the spell does.'),
|
||||
cmd: z.enum(['REQ', 'COUNT']).optional().describe('Command type. "REQ" returns matching events as a feed (default). "COUNT" returns just the count of matches.'),
|
||||
kinds: z.array(z.number()).optional().describe('Event kind numbers to filter (e.g. [1] for text notes, [20] for photos, [30023] for articles, [9735] for zap receipts).'),
|
||||
authors: z.array(z.string()).optional().describe('Author filter. Use "$me" for the logged-in user, "$contacts" for their follow list, or hex pubkeys.'),
|
||||
tag_filters: z.array(z.object({
|
||||
letter: z.string().describe('Single-letter tag name (e.g. "t" for hashtags, "e" for event references, "p" for pubkey references).'),
|
||||
values: z.array(z.string()).describe('Tag values to match. Supports "$me" and "$contacts" variables.'),
|
||||
})).optional().describe('Tag-based filters. Each entry becomes a #<letter> filter in the Nostr query.'),
|
||||
since: z.string().optional().describe('Only include events after this time. Accepts relative durations ("7d", "2w", "1mo", "1y", "24h") or "now".'),
|
||||
until: z.string().optional().describe('Only include events before this time. Same format as since.'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return.'),
|
||||
search: z.string().optional().describe('Full-text search query (NIP-50). Filters events by content text.'),
|
||||
relays: z.array(z.string()).optional().describe('Specific relay WebSocket URLs to query (e.g. ["wss://relay.damus.io"]). If omitted, uses the user\'s default relays.'),
|
||||
media: z.enum(['all', 'images', 'videos', 'vines', 'none']).optional().describe('Media filter. "images" = only posts with images, "videos" = only videos, "vines" = short-form video, "none" = text only. Omit for all content.'),
|
||||
language: z.string().optional().describe('Language filter (ISO 639-1 code, e.g. "en", "ja", "es"). Only returns posts in this language. Requires Ditto relay.'),
|
||||
platform: z.enum(['nostr', 'activitypub', 'atproto']).optional().describe('Protocol filter. "nostr" = native Nostr only (default), "activitypub" = bridged from ActivityPub, "atproto" = bridged from AT Protocol.'),
|
||||
sort: z.enum(['recent', 'hot', 'trending']).optional().describe('Sort order. "recent" = newest first (default), "hot" = trending recently, "trending" = most popular. Non-recent sorts require Ditto relay.'),
|
||||
include_replies: z.boolean().optional().describe('Whether to include reply posts. Default true. Set false to exclude replies and show only top-level posts.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const CreateSpellTool: Tool<Params> = {
|
||||
description: `Create a Nostr spell — a saved query that acts as a custom feed. The spell is published as a kind:777 event and can be added to the sidebar for quick access.
|
||||
|
||||
Spells define a Nostr relay filter with optional runtime variables that resolve when executed:
|
||||
- "$me" expands to the logged-in user's pubkey
|
||||
- "$contacts" expands to the user's follow list (kind:3 contacts)
|
||||
|
||||
Timestamps can be relative durations subtracted from now: "7d" (7 days ago), "2w" (2 weeks), "1mo" (1 month), "1y" (1 year), "24h" (24 hours), or "now" for the current time.
|
||||
|
||||
Examples:
|
||||
- "friends talking about bitcoin" → authors: ["$contacts"], tag_filters: [{letter: "t", values: ["bitcoin"]}]
|
||||
- "my mass deletions" → authors: ["$me"], kinds: [5]
|
||||
- "popular zap receipts this week" → kinds: [9735], since: "7d"
|
||||
- "photos from people I follow" → authors: ["$contacts"], kinds: [20], media: "images"
|
||||
- "trending posts this week" → since: "7d", sort: "trending"
|
||||
- "articles mentioning nostr" → kinds: [30023], search: "nostr"
|
||||
- "english posts from follows" → authors: ["$contacts"], language: "en"`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!args.name.trim()) {
|
||||
return { result: JSON.stringify({ error: 'A spell name is required.' }) };
|
||||
}
|
||||
|
||||
const tags = buildSpellTags({
|
||||
name: args.name,
|
||||
cmd: args.cmd,
|
||||
kinds: args.kinds,
|
||||
authors: args.authors,
|
||||
tag_filters: args.tag_filters,
|
||||
since: args.since,
|
||||
until: args.until,
|
||||
limit: args.limit,
|
||||
search: args.search,
|
||||
relays: args.relays,
|
||||
media: args.media,
|
||||
language: args.language,
|
||||
platform: args.platform,
|
||||
sort: args.sort,
|
||||
includeReplies: args.include_replies,
|
||||
});
|
||||
const content = args.description ?? '';
|
||||
|
||||
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
|
||||
const spellEvent = await signAndPublishWithProfile(
|
||||
ctx.nostr, sk, isBuddy,
|
||||
{ kind: 777, content, tags, created_at: Math.floor(Date.now() / 1000) },
|
||||
{ name: 'Dork Spellcaster', about: 'Spells created by Dork AI' },
|
||||
);
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
event_id: spellEvent.id,
|
||||
pubkey,
|
||||
name: args.name,
|
||||
}),
|
||||
nostrEvent: spellEvent,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from 'zod';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
import { getBuddyOrEphemeralKey, signAndPublishWithProfile, createBuddyUploader } from './helpers';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
name: z.string().describe('Human-readable app name (e.g. "Pong", "Snake", "Tic Tac Toe").'),
|
||||
html: z.string().optional().describe('Complete HTML source code for a single-file app. Must be a full HTML document with <!DOCTYPE html>. Ignored if "files" is provided.'),
|
||||
files: z.record(z.string(), z.string()).optional().describe('Map of filenames to text content for multi-file apps. Must include "index.html". Other files (e.g. "game.js", "style.css") are loaded via relative paths.'),
|
||||
asset_urls: z.record(z.string(), z.string()).optional().describe('Map of filenames to remote URLs for binary assets to bundle into the archive. Each URL is fetched and included as a raw file.'),
|
||||
description: z.string().optional().describe('Optional short description of the app.'),
|
||||
image_url: z.string().optional().describe('Optional icon/thumbnail image URL for the app card in the feed.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const CreateWebxdcTool: Tool<Params> = {
|
||||
description: `Create and publish a WebXDC mini-app. WebXDC apps are self-contained HTML5 apps (games, tools, widgets) that run inside a sandboxed iframe with no internet access.
|
||||
|
||||
You provide the app name and source code. The tool handles everything else: packaging into a .xdc archive, uploading to Blossom, and publishing as a kind 1063 Nostr event that other users can launch directly from their feed.
|
||||
|
||||
**Two modes for source code:**
|
||||
- **Simple (html param):** Provide a single self-contained HTML string. Best for small apps.
|
||||
- **Multi-file (files param):** Provide a map of filenames to content strings. The archive can contain index.html plus separate .js, .css, .json, or .svg files. index.html loads them via relative paths (e.g. <script src="game.js">). Use this when the code is large enough that splitting into separate files improves clarity.
|
||||
|
||||
Only one of html or files is needed. If both are provided, files takes priority.
|
||||
|
||||
**Binary assets (asset_urls param, optional):** Include remote files as binary assets in the archive. Map filenames to Blossom URLs (from prior upload_from_url calls). Each URL is fetched and bundled into the .xdc. The app loads them via relative paths (e.g. fetch("game.gb"), new Audio("sfx.wav")). Works for ROMs, images, audio, WASM, fonts, or any binary content.
|
||||
|
||||
**Important constraints:**
|
||||
- NO external resources: no CDN links, no external CSS/JS, no Google Fonts
|
||||
- NO ES module imports — use plain <script> tags only
|
||||
- All assets (images, sounds) must be generated procedurally (canvas drawing, CSS shapes, Web Audio API) or embedded as data: URIs
|
||||
- The sandbox blocks all external network access — remote requests silently fail
|
||||
- fetch() to relative paths within the archive DOES work; localStorage is available and scoped to the app
|
||||
|
||||
**Input handling:**
|
||||
- The host app provides a built-in virtual gamepad — do NOT build touch controls or on-screen gamepads
|
||||
- Only use keydown/keyup listeners. The host gamepad maps to: ArrowUp/Down/Left/Right for D-pad, x (88) = A, z (90) = B, Enter (13) = Start, Shift (16) = Select
|
||||
- Fill the entire viewport with the app canvas — no space needed for controls
|
||||
|
||||
**Good patterns:**
|
||||
- Canvas-based games (pong, snake, tetris, breakout, etc.)
|
||||
- CSS + JS interactive toys (calculators, timers, drawing apps)
|
||||
- Procedurally generated visuals
|
||||
- Web Audio API for sound effects
|
||||
|
||||
**Example:** A simple game with inline CSS and JS, all graphics drawn on canvas, no external dependencies.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!ctx.user) {
|
||||
return { result: JSON.stringify({ error: 'Must be logged in to create a WebXDC app.' }) };
|
||||
}
|
||||
|
||||
const appName = args.name.trim();
|
||||
const html = args.html ?? '';
|
||||
const filesMap = args.files ?? null;
|
||||
const description = (args.description ?? '').trim();
|
||||
|
||||
if (!appName) {
|
||||
return { result: JSON.stringify({ error: 'An app name is required.' }) };
|
||||
}
|
||||
if (!filesMap && !html) {
|
||||
return { result: JSON.stringify({ error: 'Either "html" or "files" is required.' }) };
|
||||
}
|
||||
|
||||
// Build the .xdc archive in memory using fflate
|
||||
const manifest = `name = "${appName.replace(/"/g, '\\"')}"\n`;
|
||||
const entries: Record<string, Uint8Array> = {
|
||||
'manifest.toml': strToU8(manifest),
|
||||
};
|
||||
|
||||
if (filesMap) {
|
||||
for (const [filename, content] of Object.entries(filesMap)) {
|
||||
if (typeof content === 'string') {
|
||||
entries[filename] = strToU8(content);
|
||||
}
|
||||
}
|
||||
if (!entries['index.html']) {
|
||||
return { result: JSON.stringify({ error: 'The "files" map must include an "index.html" entry.' }) };
|
||||
}
|
||||
} else if (html) {
|
||||
entries['index.html'] = strToU8(html);
|
||||
}
|
||||
|
||||
// Fetch binary assets from URLs and add to the archive
|
||||
if (args.asset_urls) {
|
||||
const assetEntries = await Promise.all(
|
||||
Object.entries(args.asset_urls)
|
||||
.filter(([, url]) => typeof url === 'string' && url.trim())
|
||||
.map(async ([filename, url]) => {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) throw new Error(`Invalid asset URL for "${filename}": must be a valid HTTPS URL.`);
|
||||
const res = await globalThis.fetch(safeUrl, { signal: AbortSignal.timeout(60_000) });
|
||||
if (!res.ok) throw new Error(`Failed to fetch asset "${filename}" from ${safeUrl}: ${res.status}`);
|
||||
return [filename, new Uint8Array(await res.arrayBuffer())] as const;
|
||||
}),
|
||||
);
|
||||
for (const [filename, bytes] of assetEntries) {
|
||||
entries[filename] = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entries['index.html']) {
|
||||
return { result: JSON.stringify({ error: 'An "index.html" entry is required. Provide "html", or include "index.html" in "files".' }) };
|
||||
}
|
||||
|
||||
const zipped = zipSync(entries);
|
||||
|
||||
const slug = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const xdcFile = new File([zipped], `${slug}.xdc`, { type: 'application/x-webxdc' });
|
||||
|
||||
// Upload to Blossom
|
||||
const uploader = createBuddyUploader(ctx.getBuddySecretKey, ctx.user.signer, ctx.config);
|
||||
|
||||
const uploadTags = await uploader.upload(xdcFile);
|
||||
let blossomUrl = uploadTags[0][1];
|
||||
|
||||
if (!blossomUrl.endsWith('.xdc')) {
|
||||
blossomUrl = blossomUrl + '.xdc';
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID();
|
||||
const eventTags: string[][] = [
|
||||
['url', blossomUrl],
|
||||
['m', 'application/x-webxdc'],
|
||||
['alt', `Webxdc app: ${appName}`],
|
||||
['webxdc', uuid],
|
||||
];
|
||||
|
||||
const hashTag = uploadTags.find(t => t[0] === 'x');
|
||||
if (hashTag) eventTags.push(['x', hashTag[1]]);
|
||||
|
||||
const oxTag = uploadTags.find(t => t[0] === 'ox');
|
||||
if (oxTag) eventTags.push(['ox', oxTag[1]]);
|
||||
|
||||
const sizeTag = uploadTags.find(t => t[0] === 'size');
|
||||
if (sizeTag) eventTags.push(['size', sizeTag[1]]);
|
||||
|
||||
const imageUrl = sanitizeUrl((args.image_url ?? '').trim());
|
||||
if (imageUrl) eventTags.push(['image', imageUrl]);
|
||||
|
||||
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
|
||||
const webxdcEvent = await signAndPublishWithProfile(
|
||||
ctx.nostr, sk, isBuddy,
|
||||
{ kind: 1063, content: description || appName, tags: eventTags, created_at: Math.floor(Date.now() / 1000) },
|
||||
{ name: 'Dork App Maker', about: 'WebXDC apps created by Dork AI' },
|
||||
);
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
event_id: webxdcEvent.id,
|
||||
pubkey,
|
||||
name: appName,
|
||||
url: blossomUrl,
|
||||
size: xdcFile.size,
|
||||
}),
|
||||
nostrEvent: webxdcEvent,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
identifier: z.string().describe('NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const FetchEventTool: Tool<Params> = {
|
||||
description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag).
|
||||
|
||||
Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article.
|
||||
|
||||
Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const identifier = args.identifier.trim();
|
||||
if (!identifier) {
|
||||
return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) };
|
||||
}
|
||||
|
||||
let decoded: nip19.DecodedResult;
|
||||
try {
|
||||
decoded = nip19.decode(identifier);
|
||||
} catch {
|
||||
return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) };
|
||||
}
|
||||
|
||||
if (decoded.type === 'nsec') {
|
||||
return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) };
|
||||
}
|
||||
|
||||
let event: NostrEvent | undefined;
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: [decoded.data], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'nprofile': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'note': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ ids: [decoded.data] }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'nevent': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ ids: [decoded.data.id] }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'naddr': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{
|
||||
kinds: [decoded.data.kind],
|
||||
authors: [decoded.data.pubkey],
|
||||
'#d': [decoded.data.identifier],
|
||||
limit: 1,
|
||||
}],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) };
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) };
|
||||
}
|
||||
|
||||
return { result: JSON.stringify(event) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { proxyUrl } from '@/lib/proxyUrl';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
url: z.string().describe('The URL to fetch (e.g. "https://www.jamfoo.com/aim-emoticons/").'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const FetchPageTool: Tool<Params> = {
|
||||
description: `Fetch a web page and extract its content. Returns the page text and a list of image URLs found on the page. Use this when the user provides a URL and wants to download content from it — for example, to find emoji images on a page.
|
||||
|
||||
The page is fetched through a CORS proxy so it works in the browser. Images are extracted from <img> tags in the HTML. Relative URLs are resolved to absolute URLs.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const url = args.url.trim();
|
||||
if (!url) {
|
||||
return { result: JSON.stringify({ error: 'A URL is required.' }) };
|
||||
}
|
||||
|
||||
const proxied = proxyUrl({ template: ctx.config.corsProxy, url });
|
||||
const response = await fetch(proxied, { signal: AbortSignal.timeout(30_000) });
|
||||
|
||||
if (!response.ok) {
|
||||
return { result: JSON.stringify({ error: `Fetch failed: ${response.status} ${response.statusText}` }) };
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const imgs = Array.from(doc.querySelectorAll('img'));
|
||||
const baseUrl = new URL(url);
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
for (const img of imgs) {
|
||||
const src = img.getAttribute('src');
|
||||
if (!src) continue;
|
||||
try {
|
||||
const absolute = new URL(src, baseUrl).href;
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?.*)?$/i.test(absolute)) {
|
||||
imageUrls.push(absolute);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed URLs.
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueImages = [...new Set(imageUrls)];
|
||||
const title = doc.querySelector('title')?.textContent?.trim() || '';
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
image_count: uniqueImages.length,
|
||||
images: uniqueImages.slice(0, 100),
|
||||
text_preview: doc.body?.textContent?.slice(0, 500)?.trim() || '',
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import { z } from 'zod';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { buildSpellTags, buildUnsignedSpell, resolveSpell } from '@/lib/spellEngine';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
feed_name: z.string().optional().describe('Name of an existing feed: "follows", "global", or a saved feed label.'),
|
||||
kinds: z.array(z.number()).optional().describe('Event kind numbers to filter (e.g. [1] for text notes, [20] for photos, [30023] for articles).'),
|
||||
authors: z.array(z.string()).optional().describe('Author filter. Use "$me" for the logged-in user, "$contacts" for their follow list, or hex pubkeys.'),
|
||||
search: z.string().optional().describe('Full-text search query (NIP-50).'),
|
||||
hashtag: z.string().optional().describe('Filter by hashtag (without the # symbol).'),
|
||||
country: z.string().optional().describe('ISO 3166-1 alpha-2 country code (e.g. "VE", "US", "BR"). Queries NIP-73 geographic comments (kind 1111) for that country.'),
|
||||
hours: z.number().optional().describe('How many hours back to look. Default 12, max 168 (1 week).'),
|
||||
limit: z.number().optional().describe('Maximum number of posts to return. Default 50, max 100.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const GetFeedTool: Tool<Params> = {
|
||||
description: `Read posts from a feed and return their content. Use this when the user asks what people are talking about, wants a summary of recent activity, or asks about a specific topic or country.
|
||||
|
||||
You can reference an existing feed by name or build a query on the fly:
|
||||
|
||||
**Named feeds:**
|
||||
- "follows" — posts from people the user follows
|
||||
- "global" — recent posts from everyone
|
||||
- Any saved feed label the user has created (check the system prompt for available feeds)
|
||||
|
||||
**Ad-hoc queries using spell parameters:**
|
||||
- kinds: event kinds to include (default: [1] for text notes)
|
||||
- authors: who to include — "$me", "$contacts", or hex pubkeys
|
||||
- search: full-text NIP-50 search query
|
||||
- hashtag: filter by hashtag (without #)
|
||||
- country: ISO 3166-1 alpha-2 country code (e.g. "VE", "US") — queries the country activity feed (kind 1111 geographic comments)
|
||||
|
||||
**Time window:**
|
||||
- hours: how far back to look (default: 12, max: 168)
|
||||
|
||||
When the user asks about a country (e.g. "what's going on in Venezuela?"), use the country parameter. When they ask about their friends or follows, use feed_name "follows". When they ask about a topic, use search or hashtag.
|
||||
|
||||
After receiving results, summarize the key topics, conversations, and notable posts for the user.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const feedName = (args.feed_name ?? '').trim().toLowerCase();
|
||||
const country = (args.country ?? '').trim().toUpperCase();
|
||||
const hours = Math.min(Math.max(1, args.hours ?? 12), 168);
|
||||
const limit = Math.min(Math.max(1, args.limit ?? 50), 100);
|
||||
const sinceTimestamp = Math.floor(Date.now() / 1000) - hours * 3600;
|
||||
|
||||
const contactPubkeys = await fetchContactPubkeys(ctx);
|
||||
|
||||
const resolved = resolveFilter(args, ctx, { feedName, country, hours, limit, sinceTimestamp, contactPubkeys });
|
||||
if ('error' in resolved) {
|
||||
return { result: JSON.stringify(resolved) };
|
||||
}
|
||||
|
||||
const { filter, needsDittoRelay, feedLabel } = resolved;
|
||||
|
||||
const store = needsDittoRelay ? ctx.nostr.group(DITTO_RELAYS) : ctx.nostr;
|
||||
const events = await store.query(
|
||||
[filter],
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
|
||||
const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
feed: feedLabel,
|
||||
hours,
|
||||
post_count: 0,
|
||||
data: `No posts found in the "${feedLabel}" feed in the past ${hours} hours.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const text = await formatEvents(sorted, feedLabel, hours, ctx);
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
feed: feedLabel,
|
||||
hours,
|
||||
post_count: sorted.length,
|
||||
data: text,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch the logged-in user's contact list pubkeys. */
|
||||
async function fetchContactPubkeys(ctx: ToolContext): Promise<string[]> {
|
||||
if (!ctx.user) return [];
|
||||
try {
|
||||
const contactEvents = await ctx.nostr.query(
|
||||
[{ kinds: [3], authors: [ctx.user.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
return contactEvents[0]?.tags
|
||||
.filter(([t]) => t === 'p')
|
||||
.map(([, pk]) => pk) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolveContext {
|
||||
feedName: string;
|
||||
country: string;
|
||||
hours: number;
|
||||
limit: number;
|
||||
sinceTimestamp: number;
|
||||
contactPubkeys: string[];
|
||||
}
|
||||
|
||||
type ResolvedFilter =
|
||||
| { filter: NostrFilter; needsDittoRelay: boolean; feedLabel: string }
|
||||
| { error: string; available_feeds?: string };
|
||||
|
||||
/** Build the Nostr filter from the tool arguments. */
|
||||
function resolveFilter(
|
||||
args: Params, ctx: ToolContext,
|
||||
{ feedName, country, limit, sinceTimestamp, contactPubkeys }: ResolveContext,
|
||||
): ResolvedFilter {
|
||||
if (country) {
|
||||
return {
|
||||
filter: { kinds: [1111], '#I': [`iso3166:${country}`], since: sinceTimestamp, limit } as NostrFilter,
|
||||
needsDittoRelay: false,
|
||||
feedLabel: `country: ${country}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (feedName === 'follows') {
|
||||
if (!ctx.user) return { error: 'Must be logged in to read the follows feed.' };
|
||||
const authors = [ctx.user.pubkey, ...contactPubkeys];
|
||||
if (authors.length <= 1) return { error: 'The user is not following anyone yet.' };
|
||||
return { filter: { kinds: [1], authors, since: sinceTimestamp, limit }, needsDittoRelay: false, feedLabel: 'follows' };
|
||||
}
|
||||
|
||||
if (feedName === 'global') {
|
||||
return { filter: { kinds: [1], since: sinceTimestamp, limit }, needsDittoRelay: false, feedLabel: 'global' };
|
||||
}
|
||||
|
||||
if (feedName === 'ditto') {
|
||||
return { filter: { kinds: [1], since: sinceTimestamp, limit, search: 'sort:hot protocol:nostr' }, needsDittoRelay: true, feedLabel: 'ditto (hot)' };
|
||||
}
|
||||
|
||||
if (feedName) {
|
||||
const match = ctx.savedFeeds.find((f) => f.label.toLowerCase() === feedName);
|
||||
if (!match) {
|
||||
const available = ctx.savedFeeds.map((f) => f.label).join(', ');
|
||||
return {
|
||||
error: `No saved feed named "${args.feed_name}".`,
|
||||
available_feeds: available ? `follows, global, ditto, ${available}` : 'follows, global, ditto',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const sf = match.filter as Record<string, unknown>;
|
||||
|
||||
// Map the saved feed's NostrFilter shape into buildSpellTags args,
|
||||
// translating $follows → $contacts so resolveSpell handles variable
|
||||
// expansion and needsDittoRelay detection consistently.
|
||||
const authors = Array.isArray(sf.authors)
|
||||
? (sf.authors as string[]).map((a) => a === '$follows' ? '$contacts' : a)
|
||||
: undefined;
|
||||
|
||||
const kinds = Array.isArray(sf.kinds) ? (sf.kinds as number[]) : undefined;
|
||||
|
||||
const tags = buildSpellTags({
|
||||
name: match.label,
|
||||
kinds,
|
||||
authors,
|
||||
search: typeof sf.search === 'string' ? sf.search : undefined,
|
||||
});
|
||||
const unsigned = buildUnsignedSpell(tags);
|
||||
const resolved = resolveSpell(unsigned, ctx.user?.pubkey, contactPubkeys);
|
||||
const filter = { ...resolved.filter, since: sinceTimestamp, limit } as NostrFilter;
|
||||
return { filter, needsDittoRelay: resolved.needsDittoRelay, feedLabel: match.label };
|
||||
} catch (err) {
|
||||
return { error: `Failed to resolve saved feed "${match.label}": ${err instanceof Error ? err.message : 'Unknown error'}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Ad-hoc query
|
||||
const spellArgs: Parameters<typeof buildSpellTags>[0] = {
|
||||
name: 'ad-hoc',
|
||||
kinds: args.kinds,
|
||||
authors: args.authors,
|
||||
search: args.search,
|
||||
};
|
||||
|
||||
if (args.hashtag?.trim()) {
|
||||
spellArgs.tag_filters = [{ letter: 't', values: [args.hashtag.trim().toLowerCase()] }];
|
||||
}
|
||||
|
||||
const tags = buildSpellTags(spellArgs);
|
||||
const unsigned = buildUnsignedSpell(tags);
|
||||
|
||||
try {
|
||||
const resolved = resolveSpell(unsigned, ctx.user?.pubkey, contactPubkeys);
|
||||
const filter: NostrFilter = { ...resolved.filter, since: sinceTimestamp, limit };
|
||||
if (!filter.kinds) filter.kinds = [1];
|
||||
const feedLabel = args.search ? `search: ${args.search}` : args.hashtag ? `#${args.hashtag}` : 'ad-hoc';
|
||||
return { filter, needsDittoRelay: resolved.needsDittoRelay, feedLabel };
|
||||
} catch (err) {
|
||||
return { error: `Failed to resolve query: ${err instanceof Error ? err.message : 'Unknown error'}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format events into a markdown summary with author display names. */
|
||||
async function formatEvents(
|
||||
sorted: NostrEvent[], feedLabel: string, hours: number, ctx: ToolContext,
|
||||
): Promise<string> {
|
||||
const uniquePubkeys = [...new Set(sorted.map((e) => e.pubkey))];
|
||||
const profileMap = new Map<string, { name?: string; display_name?: string; nip05?: string }>();
|
||||
|
||||
try {
|
||||
const profiles = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: uniquePubkeys }],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
for (const p of profiles) {
|
||||
try {
|
||||
const meta = JSON.parse(p.content);
|
||||
profileMap.set(p.pubkey, {
|
||||
name: meta.name,
|
||||
display_name: meta.display_name,
|
||||
nip05: meta.nip05,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid metadata
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Profiles unavailable — continue with pubkey-only display
|
||||
}
|
||||
|
||||
const formatTimeAgo = (ts: number): string => {
|
||||
const seconds = Math.floor(Date.now() / 1000) - ts;
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
};
|
||||
|
||||
let text = `## ${feedLabel} — past ${hours}h (${sorted.length} posts)\n\n`;
|
||||
|
||||
for (const event of sorted) {
|
||||
const profile = profileMap.get(event.pubkey);
|
||||
const displayName = profile?.display_name || profile?.name || nip19.npubEncode(event.pubkey).slice(0, 16) + '...';
|
||||
|
||||
const hashtags = event.tags
|
||||
.filter(([t]) => t === 't')
|
||||
.map(([, v]) => `#${v}`)
|
||||
.join(' ');
|
||||
|
||||
text += `**${displayName}** (${formatTimeAgo(event.created_at)}):\n`;
|
||||
text += `${event.content.slice(0, 500)}${event.content.length > 500 ? '...' : ''}\n`;
|
||||
if (hashtags) text += `Tags: ${hashtags}\n`;
|
||||
text += '\n---\n\n';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
action: z.enum(['start', 'stop']).describe('Whether to start or stop the effect.'),
|
||||
type: z.enum(['rain', 'snow']).optional().describe('The type of precipitation. Defaults to "rain".'),
|
||||
intensity: z.enum(['light', 'moderate', 'heavy']).optional().describe('How intense the effect should be. "light" for gentle ambiance, "moderate" for noticeable effect, "heavy" for dramatic downpour. Defaults to "moderate".'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const MakeItRainTool: Tool<Params> = {
|
||||
description: `Trigger a fun visual weather effect on the user's screen. This is a playful easter egg — use it when the mood calls for it!
|
||||
|
||||
Use "start" to activate rain or snow, and "stop" to turn it off. The effect persists across the entire app (all pages) until the user asks to stop it.
|
||||
|
||||
**When to use this (be creative!):**
|
||||
- The user literally says "make it rain" or asks for rain/snow
|
||||
- Celebrating something (use heavy rain or snow for dramatic flair)
|
||||
- The conversation has a moody, dramatic, or cozy vibe
|
||||
- The user is feeling down (gentle rain can be soothing)
|
||||
- Discussing weather, seasons, or nature
|
||||
- Any moment where a visual flourish would delight
|
||||
|
||||
**When to stop:**
|
||||
- The user asks to stop, turn off, or clear the effect
|
||||
- The user says "enough" or seems annoyed by it`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (args.action === 'stop') {
|
||||
ctx.setScreenEffect(null);
|
||||
return { result: JSON.stringify({ success: true, message: 'Screen effect stopped.' }) };
|
||||
}
|
||||
|
||||
const effectType = args.type ?? 'rain';
|
||||
const intensity = args.intensity ?? 'moderate';
|
||||
|
||||
ctx.setScreenEffect({ type: effectType, intensity });
|
||||
|
||||
const label = `${intensity} ${effectType}`;
|
||||
return { result: JSON.stringify({ success: true, message: `${label} effect activated!` }) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
import { finalizeEvent } from 'nostr-tools';
|
||||
|
||||
import { getBuddyOrEphemeralKey } from './helpers';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
events: z.array(z.object({
|
||||
kind: z.number().optional().describe('Event kind number (default: 1).'),
|
||||
content: z.string().optional().describe('Event content (default: empty string).'),
|
||||
tags: z.array(z.array(z.string())).optional().describe('Event tags (default: empty array).'),
|
||||
})).describe('Array of events to publish.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const PublishEventsTool: Tool<Params> = {
|
||||
description: `Publish one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Defaults: kind 1 (text note), empty content, empty tags, current timestamp.
|
||||
|
||||
Common kinds: 1 = text note, 6 = repost, 7 = reaction (content is "+" or emoji), 30023 = long-form article.
|
||||
|
||||
For text notes (kind 1), put the post text in content. For reactions (kind 7), set content to "+" or an emoji and add an "e" tag referencing the target event.
|
||||
|
||||
Tags are arrays of strings, e.g. [["t", "nostr"], ["p", "<hex-pubkey>"]] for a hashtag and a mention.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (args.events.length === 0) {
|
||||
return { result: JSON.stringify({ error: 'At least one event is required.' }) };
|
||||
}
|
||||
|
||||
const { sk, pubkey, isBuddy } = getBuddyOrEphemeralKey(ctx.getBuddySecretKey);
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const finalized: NostrEvent[] = args.events.map((partial) =>
|
||||
finalizeEvent({
|
||||
kind: partial.kind ?? 1,
|
||||
content: partial.content ?? '',
|
||||
tags: partial.tags ?? [],
|
||||
created_at: currentTimestamp,
|
||||
}, sk) as NostrEvent,
|
||||
);
|
||||
|
||||
if (!isBuddy) {
|
||||
const profileEvent = finalizeEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ name: 'Dork Publisher', about: 'Events published by Dork AI' }),
|
||||
tags: [],
|
||||
created_at: currentTimestamp,
|
||||
}, sk) as NostrEvent;
|
||||
await ctx.nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) });
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
finalized.map((event) => ctx.nostr.event(event, { signal: AbortSignal.timeout(5000) })),
|
||||
);
|
||||
|
||||
const displayEvent = finalized.find((e) => e.kind === 1) ?? finalized[0];
|
||||
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
pubkey,
|
||||
events_published: finalized.length,
|
||||
events: finalized.map((e) => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
content: e.content.length > 100 ? `${e.content.slice(0, 100)}...` : e.content,
|
||||
})),
|
||||
}),
|
||||
nostrEvent: displayEvent,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
query: z.string().describe('The follow pack title to search for (e.g. "team soapbox", "bitcoin developers", "nostr OGs").'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const SearchFollowPacksTool: Tool<Params> = {
|
||||
description: `Search for Nostr follow packs by title. Follow packs (kind 39089) are curated lists of people. Use this when the user mentions a follow pack or starter pack by name — for example, "team soapbox pack" or "bitcoin developers pack".
|
||||
|
||||
Returns matching packs with their title, description, member count, and the hex pubkeys of all members. Use the returned pubkeys directly in the spell's authors array to create a feed based on the pack's members.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const query = args.query.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return { result: JSON.stringify({ error: 'A search query is required.' }) };
|
||||
}
|
||||
|
||||
const filters: { kinds: number[]; limit: number; search?: string; authors?: string[] }[] = [
|
||||
{ kinds: [39089], limit: 200 },
|
||||
];
|
||||
|
||||
filters.push({ kinds: [39089], search: args.query, limit: 50 });
|
||||
|
||||
if (ctx.user) {
|
||||
filters.push({ kinds: [39089], authors: [ctx.user.pubkey], limit: 50 });
|
||||
}
|
||||
|
||||
const events = await ctx.nostr.query(
|
||||
filters,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
|
||||
// Deduplicate by event id
|
||||
const seen = new Set<string>();
|
||||
const uniqueEvents = events.filter((e) => {
|
||||
if (seen.has(e.id)) return false;
|
||||
seen.add(e.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
interface PackMatch {
|
||||
title: string;
|
||||
description?: string;
|
||||
member_count: number;
|
||||
pubkeys: string[];
|
||||
author: string;
|
||||
}
|
||||
|
||||
const matches: PackMatch[] = [];
|
||||
|
||||
for (const event of uniqueEvents) {
|
||||
const title = (event.tags.find(([t]) => t === 'title')?.[1]
|
||||
?? event.tags.find(([t]) => t === 'name')?.[1]
|
||||
?? '').trim();
|
||||
|
||||
if (!title) continue;
|
||||
if (!title.toLowerCase().includes(query)) continue;
|
||||
|
||||
const description = event.tags.find(([t]) => t === 'description')?.[1]
|
||||
?? event.tags.find(([t]) => t === 'summary')?.[1];
|
||||
|
||||
const pubkeys = event.tags
|
||||
.filter(([t]) => t === 'p')
|
||||
.map(([, pk]) => pk);
|
||||
|
||||
if (pubkeys.length === 0) continue;
|
||||
|
||||
matches.push({
|
||||
title,
|
||||
description: description ? description.slice(0, 150) : undefined,
|
||||
member_count: pubkeys.length,
|
||||
pubkeys,
|
||||
author: event.pubkey,
|
||||
});
|
||||
}
|
||||
|
||||
matches.sort((a, b) => {
|
||||
const aExact = a.title.toLowerCase() === query ? 1 : 0;
|
||||
const bExact = b.title.toLowerCase() === query ? 1 : 0;
|
||||
if (aExact !== bExact) return bExact - aExact;
|
||||
return b.member_count - a.member_count;
|
||||
});
|
||||
|
||||
const results = matches.slice(0, 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return { result: JSON.stringify({ matches: [], message: `No follow packs found matching "${args.query}".` }) };
|
||||
}
|
||||
|
||||
return { result: JSON.stringify({ matches: results }) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
query: z.string().describe('The name or display name to search for (e.g. "Derek Ross", "fiatjaf", "jb55").'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const SearchUsersTool: Tool<Params> = {
|
||||
description: `Search for Nostr users by name. Returns matching profiles with their pubkeys, display names, NIP-05 identifiers, and bios. Use this when you need to resolve a person's name to their Nostr pubkey — for example, when creating a spell that targets a specific author.
|
||||
|
||||
The search checks the user's follow list first (contacts), then falls back to a broader relay search. Results from contacts are prioritized since they're more likely to be the person the user means.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const query = args.query.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return { result: JSON.stringify({ error: 'A search query is required.' }) };
|
||||
}
|
||||
|
||||
interface ProfileMatch {
|
||||
pubkey: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
nip05?: string;
|
||||
about?: string;
|
||||
source: 'contacts' | 'relay';
|
||||
}
|
||||
|
||||
const matches: ProfileMatch[] = [];
|
||||
|
||||
// Phase 1: Search user's contacts
|
||||
if (ctx.user) {
|
||||
const contactEvents = await ctx.nostr.query(
|
||||
[{ kinds: [3], authors: [ctx.user.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
|
||||
const contactPubkeys = contactEvents[0]?.tags
|
||||
.filter(([t]) => t === 'p')
|
||||
.map(([, pk]) => pk) ?? [];
|
||||
|
||||
if (contactPubkeys.length > 0) {
|
||||
const metaEvents = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: contactPubkeys }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
for (const event of metaEvents) {
|
||||
if (matches.length >= 5) break;
|
||||
try {
|
||||
const meta = JSON.parse(event.content);
|
||||
const name = (meta.name || '').toLowerCase();
|
||||
const displayName = (meta.display_name || '').toLowerCase();
|
||||
const nip05 = (meta.nip05 || '').toLowerCase();
|
||||
|
||||
if (name.includes(query) || displayName.includes(query) || nip05.includes(query)) {
|
||||
matches.push({
|
||||
pubkey: event.pubkey,
|
||||
name: meta.name,
|
||||
display_name: meta.display_name,
|
||||
nip05: meta.nip05,
|
||||
about: meta.about ? meta.about.slice(0, 100) : undefined,
|
||||
source: 'contacts',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip events with invalid metadata JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: NIP-50 relay search (if contacts didn't yield enough results)
|
||||
if (matches.length < 3) {
|
||||
try {
|
||||
const searchEvents = await ctx.nostr.query(
|
||||
[{ kinds: [0], search: args.query, limit: 10 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
const existingPubkeys = new Set(matches.map((m) => m.pubkey));
|
||||
|
||||
for (const event of searchEvents) {
|
||||
if (existingPubkeys.has(event.pubkey)) continue;
|
||||
try {
|
||||
const meta = JSON.parse(event.content);
|
||||
matches.push({
|
||||
pubkey: event.pubkey,
|
||||
name: meta.name,
|
||||
display_name: meta.display_name,
|
||||
nip05: meta.nip05,
|
||||
about: meta.about ? meta.about.slice(0, 100) : undefined,
|
||||
source: 'relay',
|
||||
});
|
||||
} catch {
|
||||
// Skip events with invalid metadata JSON
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// NIP-50 search may not be supported by all relays
|
||||
}
|
||||
}
|
||||
|
||||
const results = matches.slice(0, 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return { result: JSON.stringify({ matches: [], message: `No users found matching "${args.query}". The user may need to provide an npub or NIP-05 address.` }) };
|
||||
}
|
||||
|
||||
return { result: JSON.stringify({ matches: results }) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { bundledFonts } from '@/lib/fonts';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
import type { ThemeConfig } from '@/themes';
|
||||
|
||||
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
|
||||
|
||||
/** Simple HSL format check: "H S% L%" where H is 0-360, S and L are 0-100%. */
|
||||
function isValidHsl(value: string): boolean {
|
||||
return /^\d{1,3}\s+\d{1,3}%\s+\d{1,3}%$/.test(value.trim());
|
||||
}
|
||||
|
||||
const inputSchema = z.object({
|
||||
background: z.string().describe('Background color as an HSL string (e.g. "228 20% 10%" for dark blue, "0 0% 100%" for white). This is the main page background.'),
|
||||
text: z.string().describe('Text/foreground color as an HSL string (e.g. "210 40% 98%" for near-white, "0 0% 10%" for near-black). Must contrast well with the background.'),
|
||||
primary: z.string().describe('Primary accent color as an HSL string (e.g. "258 70% 60%" for purple, "142 70% 45%" for green). Used for buttons, links, and interactive elements.'),
|
||||
font: z.string().optional().describe(`Optional font family name. Must be one of the available bundled fonts: ${AVAILABLE_FONTS}. Choose a font that matches the theme's mood and aesthetic.`),
|
||||
background_url: z.string().optional().describe('Optional URL to a background image. Should be a direct link to a publicly accessible image file (JPEG, PNG, WebP, etc.).'),
|
||||
background_mode: z.enum(['cover', 'tile']).optional().describe('How to display the background image. "cover" fills the viewport (good for photos/landscapes). "tile" repeats the image (good for patterns/textures). Defaults to "cover".'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const SetThemeTool: Tool<Params> = {
|
||||
description: `Set a custom theme for the application. You can set colors, a font, and a background image — all in one call. Colors are required; font and background are optional.
|
||||
|
||||
Color values must be HSL strings WITHOUT the "hsl()" wrapper — just raw values like "228 20% 10%". Choose colors that work well together and ensure good contrast between background and text.
|
||||
|
||||
For fonts, choose from the available bundled fonts: ${AVAILABLE_FONTS}. Pick a font that matches the mood of the theme.
|
||||
|
||||
For backgrounds, provide a URL to a publicly accessible image. Choose images that complement the color scheme. Use mode "cover" for full-bleed backgrounds or "tile" for repeating patterns.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const { background, text, primary, font, background_url, background_mode } = args;
|
||||
|
||||
if (!isValidHsl(background) || !isValidHsl(text) || !isValidHsl(primary)) {
|
||||
return { result: JSON.stringify({
|
||||
error: 'Invalid HSL color values. Each must be a string like "228 20% 10%".',
|
||||
received: { background, text, primary },
|
||||
}) };
|
||||
}
|
||||
|
||||
const themeConfig: ThemeConfig = {
|
||||
colors: { background, text, primary },
|
||||
};
|
||||
|
||||
if (font) {
|
||||
const bundled = bundledFonts.find((f) => f.family.toLowerCase() === font.trim().toLowerCase());
|
||||
if (bundled) {
|
||||
themeConfig.font = { family: bundled.family };
|
||||
} else {
|
||||
return { result: JSON.stringify({
|
||||
error: `Unknown font "${font}". Available fonts: ${AVAILABLE_FONTS}`,
|
||||
}) };
|
||||
}
|
||||
}
|
||||
|
||||
if (background_url) {
|
||||
const safeUrl = sanitizeUrl(background_url.trim());
|
||||
if (!safeUrl) {
|
||||
return { result: JSON.stringify({ error: 'Invalid background URL. Must be a valid HTTPS URL.' }) };
|
||||
}
|
||||
themeConfig.background = {
|
||||
url: safeUrl,
|
||||
mode: background_mode === 'tile' ? 'tile' : 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
ctx.applyCustomTheme(themeConfig);
|
||||
|
||||
const resultData: Record<string, unknown> = {
|
||||
success: true,
|
||||
colors: { background, text, primary },
|
||||
};
|
||||
if (themeConfig.font) resultData.font = themeConfig.font.family;
|
||||
if (themeConfig.background) resultData.background = { url: themeConfig.background.url, mode: themeConfig.background.mode };
|
||||
|
||||
return { result: JSON.stringify(resultData) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { z } from 'zod';
|
||||
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||
|
||||
/** Result returned by a tool's execute method. */
|
||||
export interface ToolResult {
|
||||
/** JSON string returned to the AI as the tool result. */
|
||||
result: string;
|
||||
/** A Nostr event published by the tool, rendered inline in the chat. */
|
||||
nostrEvent?: NostrEvent;
|
||||
}
|
||||
|
||||
/** Tool interface — each tool defines its schema, description, and execution logic. */
|
||||
export interface Tool<TParams = unknown> {
|
||||
/** Human-readable description shown to the AI model. */
|
||||
description: string;
|
||||
/** Zod schema for validating and parsing tool arguments. */
|
||||
inputSchema: z.ZodType<TParams>;
|
||||
/** Execute the tool with validated arguments. */
|
||||
execute(args: TParams, ctx: ToolContext): Promise<ToolResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context injected into every tool execution.
|
||||
*
|
||||
* Holds the dependencies that come from React hooks (nostr, user, config, etc.)
|
||||
* so that Tool classes remain plain objects without hook coupling.
|
||||
*/
|
||||
export interface ToolContext {
|
||||
/** Nostr protocol client for querying and publishing events. */
|
||||
nostr: {
|
||||
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
|
||||
event: (event: NostrEvent, opts?: { signal?: AbortSignal }) => Promise<void>;
|
||||
group: (relays: string[]) => {
|
||||
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
|
||||
event: (event: NostrEvent, opts?: { signal?: AbortSignal }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
/** Currently logged-in user, or undefined if not logged in. */
|
||||
user?: {
|
||||
pubkey: string;
|
||||
signer: NostrSigner;
|
||||
};
|
||||
/** App configuration values. */
|
||||
config: {
|
||||
corsProxy: string;
|
||||
blossomServerMetadata: { servers: string[]; updatedAt: number };
|
||||
useAppBlossomServers: boolean;
|
||||
};
|
||||
/** Get the buddy secret key (returns null if no buddy is configured). */
|
||||
getBuddySecretKey: () => Uint8Array | null;
|
||||
/** Saved feed definitions. */
|
||||
savedFeeds: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
filter: Record<string, unknown>;
|
||||
vars: Array<{ name: string; tagName: string; pointer: string }>;
|
||||
createdAt: number;
|
||||
}>;
|
||||
/** Apply a custom theme to the app. */
|
||||
applyCustomTheme: (theme: import('@/themes').ThemeConfig) => void;
|
||||
/** Set a screen effect (rain/snow) or null to clear. */
|
||||
setScreenEffect: (effect: { type: 'rain' | 'snow'; intensity: 'light' | 'moderate' | 'heavy' } | null) => void;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { proxyUrl } from '@/lib/proxyUrl';
|
||||
import { createBuddyUploader } from './helpers';
|
||||
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
urls: z.array(z.string()).describe('Array of file URLs to download and upload (max 50).'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
xdc: 'application/x-webxdc',
|
||||
zip: 'application/zip',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
};
|
||||
|
||||
export const UploadFromUrlTool: Tool<Params> = {
|
||||
description: `Download files from URLs and upload them to Blossom file servers. Returns the resulting Blossom URLs.
|
||||
|
||||
Supports any file type: images (png, jpg, gif, webp, svg), WebXDC apps (.xdc), archives (.zip), video, audio, documents, etc. MIME types are detected from file extensions — .xdc files are uploaded as application/x-webxdc.
|
||||
|
||||
Use this after fetch_page to upload discovered files, or directly with known URLs. Each file is fetched via CORS proxy and uploaded to Blossom. The user must be logged in.
|
||||
|
||||
Handles up to 50 files per call. Returns an array of objects with the original URL, the Blossom URL, detected MIME type, and a suggested shortcode derived from the filename.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!ctx.user) {
|
||||
return { result: JSON.stringify({ error: 'Must be logged in to upload files.' }) };
|
||||
}
|
||||
|
||||
const urls = args.urls.slice(0, 50);
|
||||
if (urls.length === 0) {
|
||||
return { result: JSON.stringify({ error: 'At least one URL is required.' }) };
|
||||
}
|
||||
|
||||
const uploader = createBuddyUploader(ctx.getBuddySecretKey, ctx.user.signer, ctx.config);
|
||||
|
||||
const results: Array<{ original_url: string; blossom_url?: string; shortcode: string; mime_type?: string; error?: string }> = [];
|
||||
|
||||
for (const fileUrl of urls) {
|
||||
try {
|
||||
const proxied = proxyUrl({ template: ctx.config.corsProxy, url: fileUrl });
|
||||
const response = await fetch(proxied, { signal: AbortSignal.timeout(30_000) });
|
||||
|
||||
if (!response.ok) {
|
||||
results.push({ original_url: fileUrl, shortcode: '', error: `HTTP ${response.status}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
const pathname = new URL(fileUrl).pathname;
|
||||
const filename = pathname.split('/').pop() || 'file';
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
const baseName = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
|
||||
const ext = dotIndex > 0 ? filename.slice(dotIndex + 1).toLowerCase() : '';
|
||||
const shortcode = baseName
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
const mimeType = blob.type && blob.type !== 'application/octet-stream'
|
||||
? blob.type
|
||||
: MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
||||
|
||||
const file = new File([blob], filename, { type: mimeType });
|
||||
const tags = await uploader.upload(file);
|
||||
const blossomUrl = tags[0][1];
|
||||
|
||||
results.push({ original_url: fileUrl, blossom_url: blossomUrl, shortcode: shortcode || 'file', mime_type: mimeType });
|
||||
} catch (err) {
|
||||
results.push({ original_url: fileUrl, shortcode: '', error: err instanceof Error ? err.message : 'Upload failed' });
|
||||
}
|
||||
}
|
||||
|
||||
const successful = results.filter((r) => r.blossom_url);
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
uploaded: successful.length,
|
||||
failed: results.length - successful.length,
|
||||
results,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
||||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
|
||||
|
||||
import { getEffectiveBlossomServers } from '@/lib/appBlossom';
|
||||
|
||||
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||
import type { ToolContext } from './Tool';
|
||||
|
||||
/** Get the buddy secret key or generate an ephemeral one. */
|
||||
export function getBuddyOrEphemeralKey(getBuddySecretKey: () => Uint8Array | null) {
|
||||
const buddySk = getBuddySecretKey();
|
||||
const sk = buddySk ?? generateSecretKey();
|
||||
return { sk, pubkey: getPublicKey(sk), isBuddy: !!buddySk };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and publish a Nostr event, plus a throwaway kind-0 profile when using
|
||||
* an ephemeral key (buddy already has a profile).
|
||||
*/
|
||||
export async function signAndPublishWithProfile(
|
||||
nostr: ToolContext['nostr'],
|
||||
sk: Uint8Array,
|
||||
isBuddy: boolean,
|
||||
event: { kind: number; content: string; tags: string[][]; created_at: number },
|
||||
profileMeta: { name: string; about: string },
|
||||
): Promise<NostrEvent> {
|
||||
const signed = finalizeEvent(event, sk) as NostrEvent;
|
||||
const publishes: Promise<void>[] = [
|
||||
nostr.event(signed, { signal: AbortSignal.timeout(5000) }),
|
||||
];
|
||||
if (!isBuddy) {
|
||||
const profileEvent = finalizeEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify(profileMeta),
|
||||
tags: [],
|
||||
created_at: event.created_at,
|
||||
}, sk) as NostrEvent;
|
||||
publishes.push(nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }));
|
||||
}
|
||||
await Promise.all(publishes);
|
||||
return signed;
|
||||
}
|
||||
|
||||
/** Create a BlossomUploader configured with buddy or user signer. */
|
||||
export function createBuddyUploader(
|
||||
getBuddySecretKey: () => Uint8Array | null,
|
||||
userSigner: NostrSigner,
|
||||
config: ToolContext['config'],
|
||||
): BlossomUploader {
|
||||
const buddySk = getBuddySecretKey();
|
||||
const signer = buddySk ? new NSecSigner(buddySk) : userSigner;
|
||||
const servers = getEffectiveBlossomServers(config.blossomServerMetadata, config.useAppBlossomServers);
|
||||
return new BlossomUploader({
|
||||
servers,
|
||||
signer,
|
||||
fetch: (input, init) => globalThis.fetch(input, {
|
||||
...init,
|
||||
signal: AbortSignal.any([
|
||||
init?.signal ?? AbortSignal.timeout(30_000),
|
||||
AbortSignal.timeout(30_000),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Tool } from './Tool';
|
||||
|
||||
/** OpenAI-compatible function-calling tool definition. */
|
||||
export interface OpenAITool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Tool<T> to OpenAI's function-calling format.
|
||||
*
|
||||
* Uses Zod's `.toJSONSchema()` (available since zod v4 / zod-to-json-schema)
|
||||
* to derive the JSON Schema from the tool's inputSchema.
|
||||
*/
|
||||
export function toolToOpenAI<T>(name: string, tool: Tool<T>): OpenAITool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema.toJSONSchema() as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Maximum tool result size in bytes (50 KiB). */
|
||||
const MAX_RESULT_BYTES = 50 * 1024;
|
||||
|
||||
/** Maximum tool result size in lines. */
|
||||
const MAX_RESULT_LINES = 2000;
|
||||
|
||||
/**
|
||||
* Truncate a tool result string if it exceeds size limits.
|
||||
*
|
||||
* Follows the same pattern as Shakespeare: when output is too large,
|
||||
* replace it with a truncation notice so the AI knows to ask for a
|
||||
* smaller result (e.g. fewer posts, shorter time window).
|
||||
*/
|
||||
export function truncateToolResult(result: string): string {
|
||||
const bytes = new TextEncoder().encode(result).length;
|
||||
const lines = result.split('\n').length;
|
||||
|
||||
if (bytes <= MAX_RESULT_BYTES && lines <= MAX_RESULT_LINES) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Truncate to the byte limit by slicing characters (approximate, since
|
||||
// multi-byte chars may straddle the boundary, but close enough for a
|
||||
// size hint to the model).
|
||||
let truncated = result;
|
||||
if (bytes > MAX_RESULT_BYTES) {
|
||||
// TextEncoder gives exact byte length; slice conservatively
|
||||
truncated = truncated.slice(0, MAX_RESULT_BYTES);
|
||||
}
|
||||
if (truncated.split('\n').length > MAX_RESULT_LINES) {
|
||||
truncated = truncated.split('\n').slice(0, MAX_RESULT_LINES).join('\n');
|
||||
}
|
||||
|
||||
const notice = [
|
||||
'\n\n---',
|
||||
`[Output truncated: original was ${bytes.toLocaleString()} bytes / ${lines.toLocaleString()} lines, ` +
|
||||
`limits are ${MAX_RESULT_BYTES.toLocaleString()} bytes / ${MAX_RESULT_LINES.toLocaleString()} lines]`,
|
||||
'Try requesting fewer results (e.g. smaller limit, shorter time window).',
|
||||
].join('\n');
|
||||
|
||||
return truncated + notice;
|
||||
}
|
||||
+122
-524
@@ -1,399 +1,36 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import Markdown from 'react-markdown';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Bot, Send, Trash2, Palette, Type } from 'lucide-react';
|
||||
import { Bot, Send, Square, Trash2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useShakespeare, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare';
|
||||
import { MessageBubble, BuddyThinking } from '@/components/AIChat/AIChatComponents';
|
||||
import { BuddyOnboarding } from '@/components/AIChat/BuddyOnboarding';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { bundledFonts } from '@/lib/fonts';
|
||||
import { useAIChatSession } from '@/hooks/useAIChatSession';
|
||||
import { useBuddy } from '@/hooks/useBuddy';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DorkThinking } from '@/components/DorkThinking';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
import type { ThemeConfig } from '@/themes';
|
||||
|
||||
// ─── Tool Definitions ───
|
||||
|
||||
/** Build the list of available bundled font names for the tool description. */
|
||||
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
|
||||
|
||||
const TOOLS: ChatCompletionTool[] = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_theme',
|
||||
description: `Set a custom theme for the application. You can set colors, a font, and a background image — all in one call. Colors are required; font and background are optional.
|
||||
|
||||
Color values must be HSL strings WITHOUT the "hsl()" wrapper — just raw values like "228 20% 10%". Choose colors that work well together and ensure good contrast between background and text.
|
||||
|
||||
For fonts, choose from the available bundled fonts: ${AVAILABLE_FONTS}. Pick a font that matches the mood of the theme.
|
||||
|
||||
For backgrounds, provide a URL to a publicly accessible image. Choose images that complement the color scheme. Use mode "cover" for full-bleed backgrounds or "tile" for repeating patterns.`,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
background: {
|
||||
type: 'string',
|
||||
description: 'Background color as an HSL string (e.g. "228 20% 10%" for dark blue, "0 0% 100%" for white). This is the main page background.',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Text/foreground color as an HSL string (e.g. "210 40% 98%" for near-white, "0 0% 10%" for near-black). Must contrast well with the background.',
|
||||
},
|
||||
primary: {
|
||||
type: 'string',
|
||||
description: 'Primary accent color as an HSL string (e.g. "258 70% 60%" for purple, "142 70% 45%" for green). Used for buttons, links, and interactive elements.',
|
||||
},
|
||||
font: {
|
||||
type: 'string',
|
||||
description: `Optional font family name. Must be one of the available bundled fonts: ${AVAILABLE_FONTS}. Choose a font that matches the theme's mood and aesthetic.`,
|
||||
},
|
||||
background_url: {
|
||||
type: 'string',
|
||||
description: 'Optional URL to a background image. Should be a direct link to a publicly accessible image file (JPEG, PNG, WebP, etc.).',
|
||||
},
|
||||
background_mode: {
|
||||
type: 'string',
|
||||
description: 'How to display the background image. "cover" fills the viewport (good for photos/landscapes). "tile" repeats the image (good for patterns/textures). Defaults to "cover".',
|
||||
enum: ['cover', 'tile'],
|
||||
},
|
||||
},
|
||||
required: ['background', 'text', 'primary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Message Types ───
|
||||
|
||||
interface DisplayMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool_result';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
// ─── Tool Executor Hook ───
|
||||
|
||||
/** Simple HSL format check: "H S% L%" where H is 0-360, S and L are 0-100%. */
|
||||
function isValidHsl(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
return /^\d{1,3}\s+\d{1,3}%\s+\d{1,3}%$/.test(value.trim());
|
||||
}
|
||||
|
||||
function useToolExecutor() {
|
||||
const { applyCustomTheme } = useTheme();
|
||||
|
||||
const executeToolCall = useCallback((name: string, args: Record<string, unknown>): string => {
|
||||
switch (name) {
|
||||
case 'set_theme': {
|
||||
const { background, text, primary, font, background_url, background_mode } = args;
|
||||
|
||||
// Validate required color values
|
||||
if (!isValidHsl(background) || !isValidHsl(text) || !isValidHsl(primary)) {
|
||||
return JSON.stringify({
|
||||
error: 'Invalid HSL color values. Each must be a string like "228 20% 10%".',
|
||||
received: { background, text, primary },
|
||||
});
|
||||
}
|
||||
|
||||
// Build theme config
|
||||
const themeConfig: ThemeConfig = {
|
||||
colors: {
|
||||
background: background as string,
|
||||
text: text as string,
|
||||
primary: primary as string,
|
||||
},
|
||||
};
|
||||
|
||||
// Add font if provided
|
||||
if (typeof font === 'string' && font.trim()) {
|
||||
const bundled = bundledFonts.find((f) => f.family.toLowerCase() === font.trim().toLowerCase());
|
||||
if (bundled) {
|
||||
themeConfig.font = { family: bundled.family };
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
error: `Unknown font "${font}". Available fonts: ${AVAILABLE_FONTS}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add background if provided (sanitize to prevent CSS injection via url())
|
||||
if (typeof background_url === 'string' && background_url.trim()) {
|
||||
const safeUrl = sanitizeUrl(background_url.trim());
|
||||
if (safeUrl) {
|
||||
themeConfig.background = {
|
||||
url: safeUrl,
|
||||
mode: background_mode === 'tile' ? 'tile' : 'cover',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
applyCustomTheme(themeConfig);
|
||||
|
||||
// Build result summary
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
colors: { background, text, primary },
|
||||
};
|
||||
if (themeConfig.font) result.font = themeConfig.font.family;
|
||||
if (themeConfig.background) result.background = { url: themeConfig.background.url, mode: themeConfig.background.mode };
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
default:
|
||||
return JSON.stringify({ error: `Unknown tool: ${name}` });
|
||||
}
|
||||
}, [applyCustomTheme]);
|
||||
|
||||
return { executeToolCall };
|
||||
}
|
||||
|
||||
// ─── System Prompt ───
|
||||
|
||||
const SYSTEM_PROMPT: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `You are Dork, extraordinaire. You are an AI assistant integrated into Ditto, a Nostr social client. You can help users with questions, conversations, and tasks.
|
||||
|
||||
You have a set_theme tool that applies a full custom theme. It supports:
|
||||
|
||||
**Colors** (required): Three HSL values without the "hsl()" wrapper (e.g. "228 20% 10%"):
|
||||
- background: page background color
|
||||
- text: main text/foreground color (must contrast well with background)
|
||||
- primary: accent color for buttons, links, and highlights
|
||||
|
||||
**Font** (optional): Choose from bundled fonts to match the theme's mood. Available: ${AVAILABLE_FONTS}
|
||||
|
||||
**Background image** (optional): A URL to a publicly accessible image. Set mode to "cover" for full-bleed or "tile" for repeating patterns.
|
||||
|
||||
When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one.
|
||||
|
||||
Be concise and friendly. When you use a tool, briefly describe the theme you created.`,
|
||||
};
|
||||
|
||||
// ─── Page Component ───
|
||||
|
||||
export function AIChatPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { sendChatMessage, getAvailableModels, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
|
||||
const { executeToolCall } = useToolExecutor();
|
||||
|
||||
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const { buddy, isLoading: buddyLoading, hasBuddy } = useBuddy();
|
||||
|
||||
useSeoMeta({
|
||||
title: `AI Chat | ${config.appName}`,
|
||||
description: 'Chat with AI assistant',
|
||||
title: `Buddy | ${config.appName}`,
|
||||
description: 'Chat with your AI buddy',
|
||||
});
|
||||
|
||||
useLayoutOptions({ noOverscroll: true });
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Fetch available models on mount
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
|
||||
getAvailableModels()
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const sorted = response.data.sort((a, b) => {
|
||||
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
||||
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
||||
return costA - costB;
|
||||
});
|
||||
setModels(sorted);
|
||||
if (sorted.length > 0 && !selectedModel) {
|
||||
setSelectedModel(sorted[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) console.error('Failed to fetch models:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [user, getAvailableModels]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Build the chat messages array for the API (includes system prompt + conversation history)
|
||||
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
|
||||
const apiMessages: ChatMessage[] = [SYSTEM_PROMPT];
|
||||
|
||||
for (const msg of displayMsgs) {
|
||||
if (msg.role === 'tool_result') continue; // Tool results are internal
|
||||
apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content });
|
||||
}
|
||||
|
||||
return apiMessages;
|
||||
}, []);
|
||||
|
||||
// Handle sending a message
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !selectedModel || isStreaming) return;
|
||||
|
||||
clearError();
|
||||
setInput('');
|
||||
|
||||
const userMessage: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
// Build API messages
|
||||
const apiMessages = buildApiMessages(newMessages);
|
||||
|
||||
// Send with tools
|
||||
const response = await sendChatMessage(apiMessages, selectedModel, {
|
||||
tools: TOOLS,
|
||||
});
|
||||
|
||||
const choice = response.choices[0];
|
||||
const assistantMsg = choice.message;
|
||||
|
||||
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
|
||||
// Execute tool calls
|
||||
const toolCalls: ToolCall[] = assistantMsg.tool_calls.map((tc) => {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
} catch {
|
||||
// If parsing fails, pass empty args
|
||||
}
|
||||
|
||||
const result = executeToolCall(tc.function.name, args);
|
||||
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: args,
|
||||
result,
|
||||
};
|
||||
});
|
||||
|
||||
// Add assistant message with tool calls noted
|
||||
const toolMsg: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: assistantMsg.content || '',
|
||||
timestamp: new Date(),
|
||||
toolCalls,
|
||||
};
|
||||
const messagesWithTool = [...newMessages, toolMsg];
|
||||
setMessages(messagesWithTool);
|
||||
|
||||
// Build follow-up messages including tool results
|
||||
const followUpMessages: ChatMessage[] = buildApiMessages(newMessages);
|
||||
|
||||
// Add the assistant message with tool_calls
|
||||
followUpMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMsg.content || '',
|
||||
});
|
||||
|
||||
// Add tool results
|
||||
for (const tc of toolCalls) {
|
||||
followUpMessages.push({
|
||||
role: 'user' as const,
|
||||
content: `[Tool "${tc.name}" returned: ${tc.result}]`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get follow-up response from AI
|
||||
const followUp = await sendChatMessage(followUpMessages, selectedModel);
|
||||
const followUpContent = followUp.choices[0]?.message?.content;
|
||||
|
||||
if (followUpContent) {
|
||||
const followUpMsg: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: typeof followUpContent === 'string' ? followUpContent : '',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, followUpMsg]);
|
||||
}
|
||||
} else {
|
||||
// Normal response without tool calls
|
||||
const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : '';
|
||||
const assistantMessage: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Chat error:', err);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [input, selectedModel, isStreaming, messages, buildApiMessages, sendChatMessage, executeToolCall, clearError]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}, [handleSend]);
|
||||
|
||||
// Clear conversation
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
clearError();
|
||||
}, [clearError]);
|
||||
|
||||
// ─── Render ───
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center p-6 gap-6">
|
||||
@@ -401,7 +38,7 @@ export function AIChatPage() {
|
||||
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="size-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">AI Chat</h1>
|
||||
<h1 className="text-2xl font-bold">Buddy</h1>
|
||||
<p className="text-muted-foreground">Log in with your Nostr account to start chatting with AI.</p>
|
||||
<LoginArea className="mt-2" />
|
||||
</div>
|
||||
@@ -409,42 +46,50 @@ export function AIChatPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (buddyLoading) {
|
||||
return (
|
||||
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
|
||||
<PageHeader title="Buddy" icon={<Bot className="size-5" />} className="shrink-0 py-3" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<BuddyThinking />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasBuddy) {
|
||||
return (
|
||||
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
|
||||
<PageHeader title="Buddy Setup" icon={<Bot className="size-5" />} className="shrink-0 py-3" />
|
||||
<BuddyOnboarding className="flex-1" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <BuddyChatView buddy={buddy!} />;
|
||||
}
|
||||
|
||||
// ─── Chat View (buddy exists) ───
|
||||
|
||||
import type { BuddyIdentity } from '@/hooks/useBuddy';
|
||||
|
||||
function BuddyChatView({ buddy }: { buddy: BuddyIdentity }) {
|
||||
const {
|
||||
messages, input, setInput, isStreaming, streamingText, selectedModel,
|
||||
apiLoading, apiError, messagesEndRef,
|
||||
handleSend, handleStop, handleKeyDown, handleClear, getCredits,
|
||||
} = useAIChatSession({ buddyName: buddy.name, buddySoul: buddy.soul });
|
||||
|
||||
return (
|
||||
<main className="flex flex-col ai-chat-height sidebar:h-dvh bg-secondary/50">
|
||||
<main className="flex flex-col overflow-hidden ai-chat-height sidebar:h-dvh bg-secondary/50">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-4 py-3 flex flex-col sidebar:flex-row sidebar:items-center sidebar:justify-between gap-2 sidebar:gap-3">
|
||||
<PageHeader title="AI Chat" icon={<Bot className="size-5" />} className="px-0 mt-0 mb-0" />
|
||||
|
||||
<PageHeader title={buddy.name} icon={<Bot className="size-5" />} className="shrink-0 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model selector */}
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
|
||||
<SelectTrigger className="w-full sidebar:w-44 h-8 text-base md:text-xs">
|
||||
<SelectValue placeholder={modelsLoading ? 'Loading models...' : 'Select model'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => {
|
||||
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
|
||||
const isFree = totalCost === 0;
|
||||
return (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{model.name}
|
||||
{isFree && (
|
||||
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
|
||||
FREE
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<CreditsBadge getCredits={getCredits} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
className="size-8 shrink-0"
|
||||
onClick={handleClear}
|
||||
disabled={messages.length === 0}
|
||||
title="Clear conversation"
|
||||
@@ -452,22 +97,26 @@ export function AIChatPage() {
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Messages Area */}
|
||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState />
|
||||
<EmptyState buddyName={buddy.name} onSuggestion={handleSend} />
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
messages.filter((msg) => msg.role !== 'tool_result').map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && (
|
||||
<DorkThinking className="text-sm" />
|
||||
{/* Streaming / loading indicator */}
|
||||
{(isStreaming || apiLoading) && (
|
||||
streamingText ? (
|
||||
<MessageBubble message={{ id: 'streaming', role: 'assistant', content: streamingText, timestamp: new Date() }} />
|
||||
) : messages[messages.length - 1]?.role === 'user' ? (
|
||||
<BuddyThinking />
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
@@ -485,23 +134,33 @@ export function AIChatPage() {
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={!selectedModel ? 'Select a model first...' : 'Send a message...'}
|
||||
placeholder={!selectedModel ? 'Select a model first...' : `Message ${buddy.name}...`}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !selectedModel || isStreaming}
|
||||
size="icon"
|
||||
className="size-11 shrink-0 rounded-xl"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
onClick={handleStop}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 rounded-full bg-foreground/10 hover:bg-foreground/20 [&_svg]:fill-foreground"
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || !selectedModel}
|
||||
size="icon"
|
||||
className="size-11 shrink-0 rounded-xl"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -510,120 +169,59 @@ export function AIChatPage() {
|
||||
|
||||
// ─── Sub-Components ───
|
||||
|
||||
// DorkThinking is imported from the shared component
|
||||
|
||||
const DORK_GREETINGS = [
|
||||
"Hi, I'm Dork! What would you like me to do?",
|
||||
"Dork here! What do you need?",
|
||||
"Hey, it's Dork! What do you want to do?",
|
||||
const SUGGESTIONS = [
|
||||
'Create a feed of Alex Gleason talking about being Vegan',
|
||||
'Make a feed of the team soapbox follow pack talking about ditto',
|
||||
];
|
||||
|
||||
function EmptyState() {
|
||||
const greeting = useMemo(() => DORK_GREETINGS[Math.floor(Math.random() * DORK_GREETINGS.length)], []);
|
||||
function greetings(name: string): string[] {
|
||||
return [
|
||||
`Hi, I'm ${name}! What would you like me to do?`,
|
||||
`${name} here! What do you need?`,
|
||||
`Hey, it's ${name}! What do you want to do?`,
|
||||
];
|
||||
}
|
||||
|
||||
function EmptyState({ buddyName, onSuggestion }: { buddyName: string; onSuggestion: (text: string) => void }) {
|
||||
const greeting = useMemo(() => {
|
||||
const g = greetings(buddyName);
|
||||
return g[Math.floor(Math.random() * g.length)];
|
||||
}, [buddyName]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-8 text-center select-none animate-in fade-in duration-500">
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4 text-center select-none animate-in fade-in duration-500">
|
||||
<pre className="text-4xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-semibold tracking-tight text-foreground">Dork AI</h2>
|
||||
<p className="text-sm text-muted-foreground">{greeting}</p>
|
||||
<p className="text-sm text-muted-foreground">{greeting}</p>
|
||||
<div className="flex flex-col gap-2 w-full max-w-sm mt-2">
|
||||
{SUGGESTIONS.map((text) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onSuggestion(text)}
|
||||
className="px-4 py-2.5 rounded-xl border border-border bg-secondary/40 hover:bg-secondary/70 text-sm text-left text-foreground/80 transition-colors"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditsBadge({ getCredits }: { getCredits: () => Promise<{ amount: number }> }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['shakespeare-credits'],
|
||||
queryFn: getCredits,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
|
||||
function MessageBubble({ message }: { message: DisplayMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
const formatted = data?.amount != null
|
||||
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(data.amount)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start', isUser && 'justify-end')}>
|
||||
<div className={cn('flex flex-col gap-1 max-w-[85%] min-w-0', isUser && 'items-end')}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl px-4 py-2.5 text-sm',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-tr-md'
|
||||
: 'bg-secondary/60 border border-border rounded-tl-md',
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-a:text-primary">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool call indicators */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{message.toolCalls.map((tc) => (
|
||||
<ToolCallBadge key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground/60 px-1">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
|
||||
let resultParsed: {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
colors?: { background?: string; text?: string; primary?: string };
|
||||
font?: string;
|
||||
background?: { url?: string; mode?: string };
|
||||
} = {};
|
||||
try {
|
||||
resultParsed = JSON.parse(toolCall.result || '{}');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const isSuccess = resultParsed.success === true;
|
||||
const colors = resultParsed.colors;
|
||||
|
||||
if (toolCall.name !== 'set_theme' || !isSuccess) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium',
|
||||
isSuccess
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20'
|
||||
: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border border-orange-500/20',
|
||||
)}>
|
||||
<Palette className="size-3" />
|
||||
{resultParsed.error || toolCall.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-[11px] font-medium bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20">
|
||||
{/* Color swatches */}
|
||||
{colors && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.background})` }} />
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.text})` }} />
|
||||
<span className="size-2.5 rounded-full border border-black/10" style={{ backgroundColor: `hsl(${colors.primary})` }} />
|
||||
</span>
|
||||
)}
|
||||
Theme applied
|
||||
{resultParsed.font && (
|
||||
<span className="inline-flex items-center gap-0.5 opacity-80">
|
||||
<Type className="size-2.5" />
|
||||
{resultParsed.font}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs tabular-nums shrink-0">
|
||||
{isLoading ? '...' : formatted ?? '--'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BadgeContent } from "@/components/BadgeContent";
|
||||
import { parseBadgeDefinition, type BadgeData } from "@/lib/parseBadgeDefinition";
|
||||
import { BadgeRecoveryDialog } from "@/components/BadgeRecoveryDialog";
|
||||
import { BadgeThumbnail } from "@/components/BadgeThumbnail";
|
||||
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
|
||||
import { CreateBadgeDialog } from "@/components/CreateBadgeDialog";
|
||||
import { FeedEmptyState } from "@/components/FeedEmptyState";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
@@ -92,32 +93,6 @@ interface ParsedBadge {
|
||||
aTag: string;
|
||||
}
|
||||
|
||||
// ─── NoteCard Skeleton ─────────────────────────────────────────────────────────
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BadgesPage() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, Shovel, TowelRack, X } from 'lucide-react';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, Shovel, Bath, X } from 'lucide-react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
@@ -2028,7 +2028,7 @@ function CareBar({
|
||||
{isHygieneFocused ? (
|
||||
towelItem ? (
|
||||
<RoomActionButton
|
||||
icon={<TowelRack className="size-7 sm:size-9" />}
|
||||
icon={<Bath className="size-7 sm:size-9" />}
|
||||
label="Towel"
|
||||
color="text-cyan-500"
|
||||
glowHex="#06b6d4"
|
||||
|
||||
@@ -10,6 +10,7 @@ import Index from './Index';
|
||||
const PAGE_LOADERS: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||
'notifications': lazy(() => import('./NotificationsPage').then(m => ({ default: m.NotificationsPage }))),
|
||||
'search': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
|
||||
'discover': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
|
||||
'trends': lazy(() => import('./TrendsPage').then(m => ({ default: m.TrendsPage }))),
|
||||
'bookmarks': lazy(() => import('./BookmarksPage').then(m => ({ default: m.BookmarksPage }))),
|
||||
'settings': lazy(() => import('./SettingsPage').then(m => ({ default: m.SettingsPage }))),
|
||||
|
||||
@@ -2,11 +2,14 @@ import { nip19 } from 'nostr-tools';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { lazy } from 'react';
|
||||
import NotFound from './NotFound';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
import { PostDetailPage, AddrPostDetailPage, PostDetailShell, PostDetailSkeleton } from './PostDetailPage';
|
||||
import { ListDetailPage } from './ListDetailPage';
|
||||
import type { AddressPointer } from 'nostr-tools/nip19';
|
||||
import type { AddressPointer, EventPointer } from 'nostr-tools/nip19';
|
||||
|
||||
const SpellRunPage = lazy(() => import('./SpellRunPage').then(m => ({ default: m.SpellRunPage })));
|
||||
|
||||
const HEX_64_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
@@ -107,7 +110,10 @@ export function NIP19Page() {
|
||||
return <PostDetailPage eventId={decoded.data as string} />;
|
||||
|
||||
case 'nevent': {
|
||||
const neventData = decoded.data as { id: string; relays?: string[]; author?: string };
|
||||
const neventData = decoded.data as EventPointer;
|
||||
if (neventData.kind === 777) {
|
||||
return <SpellRunPage />;
|
||||
}
|
||||
return <PostDetailPage eventId={neventData.id} relays={neventData.relays} authorHint={neventData.author} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
|
||||
import { LiveStreamPage } from "@/components/LiveStreamPage";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { MusicDetailContent } from "@/components/MusicDetailContent";
|
||||
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
|
||||
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NsiteCard } from "@/components/NsiteCard";
|
||||
@@ -1942,32 +1942,32 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
{/* Kind 1018 — Poll vote: compact activity-style card */}
|
||||
{isPollVote && (
|
||||
<div ref={focusedPostRef as React.RefObject<HTMLDivElement>}>
|
||||
<ActivityCard
|
||||
className="border-b-0 pb-0"
|
||||
icon={
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-10">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
}
|
||||
actorRow={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<article className="px-4 py-3 border-b-0 pb-0 overflow-hidden">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-bold text-sm hover:underline truncate">
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
<Avatar shape={avatarShape} className="size-10">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">voted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
|
||||
</ActivityCard>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-bold text-sm hover:underline truncate">
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">voted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
|
||||
</div>
|
||||
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<PostActionBar
|
||||
event={event}
|
||||
onReply={() => setReplyOpen(true)}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, useTabFeed, filterByTab } from '@/hooks/useProfileFeed';
|
||||
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed';
|
||||
import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed';
|
||||
import { useProfileMedia } from '@/hooks/useProfileMedia';
|
||||
import { MediaCollage, MediaCollageSkeleton } from '@/components/MediaCollage';
|
||||
@@ -78,6 +78,9 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { buildUnsignedSpell } from '@/lib/spellEngine';
|
||||
import type { ProfileTab, ProfileTabsData, TabFilter, TabVarDef } from '@/lib/profileTabsEvent';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
@@ -1208,7 +1211,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
};
|
||||
|
||||
// Canonical NIP-01 filters for core tabs so other clients can interpret the event.
|
||||
// Values are interpolated with the actual pubkey (not $me) since these are concrete filters.
|
||||
const CORE_TAB_FILTERS: Record<string, TabFilter> = pubkey ? {
|
||||
'Posts': { kinds: [1, 6], authors: [pubkey] },
|
||||
'Posts & replies': { authors: [pubkey] },
|
||||
@@ -3045,6 +3047,87 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
|
||||
feed: ProfileTab;
|
||||
vars: TabVarDef[];
|
||||
ownerPubkey: string;
|
||||
}) {
|
||||
// If the filter carries _spellTags, render via spell mode (same as SpellRunPage)
|
||||
const rawFilter = feed.filter as Record<string, unknown>;
|
||||
const spellTags = Array.isArray(rawFilter._spellTags) ? rawFilter._spellTags as string[][] : null;
|
||||
|
||||
if (spellTags) {
|
||||
return <ProfileSpellFeedContent label={feed.label} spellTags={spellTags} />;
|
||||
}
|
||||
|
||||
return <ProfileLegacyFeedContent feed={feed} vars={vars} ownerPubkey={ownerPubkey} />;
|
||||
}
|
||||
|
||||
/** Spell-driven profile tab: reconstructs a spell from stored tags and streams results. */
|
||||
function ProfileSpellFeedContent({ label, spellTags }: { label: string; spellTags: string[][] }) {
|
||||
const spellEvent = useMemo(() => buildUnsignedSpell(spellTags), [spellTags]);
|
||||
|
||||
const { ref: tabScrollRef, inView: tabInView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
const { posts, isLoading, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
spell: spellEvent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tabInView && hasMore && !isLoadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
}, [tabInView, hasMore, isLoadingMore, loadMore]);
|
||||
|
||||
if (isLoading && posts.length === 0) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3 border-b border-border">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No posts found for “{label}”.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div ref={tabScrollRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy profile tab without spell tags: resolves filter variables and queries directly. */
|
||||
function ProfileLegacyFeedContent({ feed, vars, ownerPubkey }: {
|
||||
feed: ProfileTab;
|
||||
vars: TabVarDef[];
|
||||
ownerPubkey: string;
|
||||
}) {
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(feed.filter, vars, ownerPubkey);
|
||||
|
||||
@@ -3104,7 +3187,7 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No posts found for "{feed.label}".
|
||||
No posts found for “{feed.label}”.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3120,9 +3203,11 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div ref={tabScrollRef} className="flex justify-center py-6">
|
||||
<div ref={tabScrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+469
-206
@@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
SlidersHorizontal,
|
||||
Compass,
|
||||
Search as SearchIcon,
|
||||
UserRoundCheck,
|
||||
User,
|
||||
@@ -9,13 +10,16 @@ import {
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
Loader2,
|
||||
PanelLeft,
|
||||
Globe, Users, UserSearch,
|
||||
Clock, Flame, TrendingUp,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { NoteCardSkeleton } from '@/components/NoteCardSkeleton';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
@@ -35,11 +39,15 @@ import { KindPicker, AuthorChip, AuthorFilterDropdown } from '@/components/Saved
|
||||
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileTabs } from '@/hooks/useProfileTabs';
|
||||
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
|
||||
@@ -51,18 +59,20 @@ import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { cn, parseKindFilter } from '@/lib/utils';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { buildSpellTags } from '@/lib/spellEngine';
|
||||
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SaveDestinationRow } from '@/components/SaveDestinationRow';
|
||||
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
type TabType = 'posts' | 'accounts';
|
||||
type TabType = 'feeds' | 'packs' | 'posts' | 'accounts';
|
||||
|
||||
const VALID_TABS: TabType[] = ['posts', 'accounts'];
|
||||
const VALID_TABS: TabType[] = ['feeds', 'packs', 'posts', 'accounts'];
|
||||
|
||||
function parseTab(value: string | null): TabType {
|
||||
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts';
|
||||
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'feeds';
|
||||
}
|
||||
|
||||
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
|
||||
@@ -92,8 +102,8 @@ export function SearchPage() {
|
||||
const { config } = useAppContext();
|
||||
|
||||
useSeoMeta({
|
||||
title: `Search | ${config.appName}`,
|
||||
description: 'Search Nostr',
|
||||
title: `Discover | ${config.appName}`,
|
||||
description: 'Discover feeds, posts, and accounts on Nostr',
|
||||
});
|
||||
|
||||
useLayoutOptions({ hasSubHeader: true });
|
||||
@@ -199,7 +209,7 @@ export function SearchPage() {
|
||||
const setActiveTab = useCallback((tab: TabType) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === 'posts') {
|
||||
if (tab === 'feeds') {
|
||||
next.delete('tab');
|
||||
} else {
|
||||
next.set('tab', tab);
|
||||
@@ -346,36 +356,90 @@ export function SearchPage() {
|
||||
const { lists } = useUserLists();
|
||||
const { data: followPacks = [] } = useFollowPacks();
|
||||
const { savedFeeds, addSavedFeed, isPending: isSavingFeed } = useSavedFeeds();
|
||||
const { addToSidebar } = useFeedSettings();
|
||||
const profileTabsQuery = useProfileTabs(user?.pubkey);
|
||||
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
|
||||
const [saveFeedLabel, setSaveFeedLabel] = useState('');
|
||||
const [savedJustNow, setSavedJustNow] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isAddingToSidebar, setIsAddingToSidebar] = useState(false);
|
||||
|
||||
const listPickerValue = useMatchedListId(authorPubkeys);
|
||||
|
||||
// Infinite scroll sentinels
|
||||
const { ref: postsScrollRef, inView: postsInView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { ref: feedsScrollRef, inView: feedsInView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { ref: packsScrollRef, inView: packsInView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
// 'people' scope with explicit authors = user-specific; not eligible for profile tab
|
||||
const isAuthorSpecific = authorScope === 'people' && authorPubkeys.length > 0;
|
||||
|
||||
// Build a standard NIP-01 TabFilter from the current search state
|
||||
const currentFilter = useMemo<TabFilter>(() => {
|
||||
const filter: TabFilter = {};
|
||||
// Build spell tags from the current search state
|
||||
const currentSpellTags = useMemo(() => {
|
||||
let authors: string[] | undefined;
|
||||
if (authorScope === 'follows') authors = ['$contacts'];
|
||||
else if (authorScope === 'people' && authorPubkeys.length > 0) authors = authorPubkeys;
|
||||
|
||||
return buildSpellTags({
|
||||
name: saveFeedLabel.trim() || 'Search',
|
||||
kinds: kindsOverride && kindsOverride.length > 0 ? kindsOverride : undefined,
|
||||
authors,
|
||||
search: debouncedSearchQuery.trim() || undefined,
|
||||
includeReplies: includeReplies ? undefined : false,
|
||||
media: mediaType !== 'all' ? mediaType : undefined,
|
||||
language: language !== 'global' ? language : undefined,
|
||||
platform: platform !== 'nostr' ? platform : undefined,
|
||||
sort: sort !== 'recent' ? sort : undefined,
|
||||
});
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys, includeReplies, mediaType, language, platform, sort, saveFeedLabel]);
|
||||
|
||||
// Build the current filter from the search state (for saving).
|
||||
// Includes client-hint fields (_media, _language, _platform, _sort,
|
||||
// _includeReplies) so SavedFeedContent can faithfully reproduce the query.
|
||||
const currentFilter = useMemo(() => {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (debouncedSearchQuery.trim()) filter.search = debouncedSearchQuery.trim();
|
||||
if (kindsOverride && kindsOverride.length > 0) filter.kinds = kindsOverride;
|
||||
if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
|
||||
if (authorScope === 'follows') filter.authors = ['$follows'];
|
||||
else if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
|
||||
// Persist client-hint fields so saved tabs reproduce the full query
|
||||
if (mediaType !== 'all') filter._media = mediaType;
|
||||
if (language !== 'global') filter._language = language;
|
||||
if (platform !== 'nostr') filter._platform = platform;
|
||||
if (sort !== 'recent') filter._sort = sort;
|
||||
if (!includeReplies) filter._includeReplies = false;
|
||||
return filter;
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys]);
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys, mediaType, language, platform, sort, includeReplies]);
|
||||
|
||||
const alreadySaved = savedFeeds.some(
|
||||
(f) => JSON.stringify(f.filter) === JSON.stringify(currentFilter),
|
||||
);
|
||||
const currentFilterKey = useMemo(() => JSON.stringify(currentFilter), [currentFilter]);
|
||||
const alreadySaved = savedFeeds.some((f) => JSON.stringify(f.filter) === currentFilterKey);
|
||||
|
||||
const handleSaveFeed = async () => {
|
||||
if (!saveFeedLabel.trim() || isSavingFeed) return;
|
||||
const varsToSave = authorScope === 'follows' && user
|
||||
? [{ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` }]
|
||||
: [];
|
||||
await addSavedFeed(saveFeedLabel, currentFilter, varsToSave);
|
||||
if (!saveFeedLabel.trim() || isSavingFeed || !user) return;
|
||||
|
||||
const vars: import('@/lib/profileTabsEvent').TabVarDef[] = [];
|
||||
if (authorScope === 'follows' && user) {
|
||||
vars.push({ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` });
|
||||
}
|
||||
|
||||
// Publish a kind:777 spell event so the home feed can render it via
|
||||
// useStreamPosts({ spell }), which handles full resolution of all filters.
|
||||
try {
|
||||
const tags = currentSpellTags.map(([t, ...rest]) =>
|
||||
t === 'name' ? ['name', saveFeedLabel.trim()] :
|
||||
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
|
||||
[t, ...rest]
|
||||
);
|
||||
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
|
||||
await addSavedFeed(saveFeedLabel.trim(), currentFilter, vars, event.id);
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to save feed', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
@@ -384,9 +448,20 @@ export function SearchPage() {
|
||||
|
||||
const handleSaveProfileTab = async () => {
|
||||
if (!saveFeedLabel.trim() || isPublishingTabs || !user) return;
|
||||
|
||||
// Store spell tags in the filter so ProfileSavedFeedContent can reconstruct
|
||||
// the spell and render via useStreamPosts({ spell }), preserving all filters.
|
||||
const tags = currentSpellTags.map(([t, ...rest]) =>
|
||||
t === 'name' ? ['name', saveFeedLabel.trim()] :
|
||||
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
|
||||
[t, ...rest]
|
||||
);
|
||||
const tabFilter: Record<string, unknown> = { _spellTags: tags };
|
||||
|
||||
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
|
||||
|
||||
await publishProfileTabs({
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: currentFilter }],
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: tabFilter }],
|
||||
vars: existing.vars,
|
||||
});
|
||||
setSavePopoverOpen(false);
|
||||
@@ -395,6 +470,55 @@ export function SearchPage() {
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
};
|
||||
|
||||
const handleShareSpell = async () => {
|
||||
if (!saveFeedLabel.trim() || isSharing || !user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
const tags = currentSpellTags.map(([t, ...rest]) =>
|
||||
t === 'name' ? ['name', saveFeedLabel.trim()] :
|
||||
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
|
||||
[t, ...rest]
|
||||
);
|
||||
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
|
||||
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
|
||||
const url = `${window.location.origin}/${neventId}`;
|
||||
const result = await shareOrCopy(url, saveFeedLabel.trim());
|
||||
if (result === 'copied') {
|
||||
toast({ title: 'Link copied to clipboard' });
|
||||
}
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to share spell', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToSidebar = async () => {
|
||||
if (!saveFeedLabel.trim() || isAddingToSidebar || !user) return;
|
||||
setIsAddingToSidebar(true);
|
||||
try {
|
||||
const tags = currentSpellTags.map(([t, ...rest]) =>
|
||||
t === 'name' ? ['name', saveFeedLabel.trim()] :
|
||||
t === 'alt' ? ['alt', `Spell: ${saveFeedLabel.trim()}`] :
|
||||
[t, ...rest]
|
||||
);
|
||||
const event = await publishEvent({ kind: 777, content: '', tags, created_at: Math.floor(Date.now() / 1000) });
|
||||
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind });
|
||||
addToSidebar(`nostr:${neventId}`);
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to add to sidebar', description: err instanceof Error ? err.message : undefined, variant: 'destructive' });
|
||||
} finally {
|
||||
setIsAddingToSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve author pubkeys for the stream
|
||||
const streamAuthorPubkeys = authorScope === 'follows'
|
||||
? followPubkeys
|
||||
@@ -402,7 +526,10 @@ export function SearchPage() {
|
||||
? authorPubkeys
|
||||
: undefined;
|
||||
|
||||
const { posts, isLoading: postsLoading, newPostCount, flushStreamBuffer, flushedIds } = useStreamPosts(debouncedSearchQuery, {
|
||||
const {
|
||||
posts, isLoading: postsLoading, newPostCount, flushStreamBuffer, flushedIds,
|
||||
loadMore: loadMorePosts, hasMore: hasMorePosts, isLoadingMore: isLoadingMorePosts,
|
||||
} = useStreamPosts(debouncedSearchQuery, {
|
||||
includeReplies,
|
||||
mediaType,
|
||||
language,
|
||||
@@ -413,15 +540,70 @@ export function SearchPage() {
|
||||
});
|
||||
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? debouncedSearchQuery : '');
|
||||
|
||||
// Feeds tab: stream kind:777 spell events with From + Sort filters only
|
||||
const {
|
||||
posts: feedSpells,
|
||||
isLoading: feedsLoading,
|
||||
newPostCount: feedsNewCount,
|
||||
flushStreamBuffer: flushFeedsBuffer,
|
||||
flushedIds: feedsFlushedIds,
|
||||
loadMore: loadMoreFeeds, hasMore: hasMoreFeeds, isLoadingMore: isLoadingMoreFeeds,
|
||||
} = useStreamPosts(activeTab === 'feeds' ? debouncedSearchQuery : '', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
kindsOverride: [777],
|
||||
authorPubkeys: activeTab === 'feeds' ? streamAuthorPubkeys : undefined,
|
||||
sort: activeTab === 'feeds' ? sort : 'recent',
|
||||
});
|
||||
|
||||
// Packs tab: stream kind 39089/30000 with From + Sort filters
|
||||
const {
|
||||
posts: packPosts,
|
||||
isLoading: packsLoading,
|
||||
newPostCount: packsNewCount,
|
||||
flushStreamBuffer: flushPacksBuffer,
|
||||
flushedIds: packsFlushedIds,
|
||||
loadMore: loadMorePacks,
|
||||
hasMore: hasMorePacks,
|
||||
isLoadingMore: isLoadingMorePacks,
|
||||
} = useStreamPosts(activeTab === 'packs' ? debouncedSearchQuery : '', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
kindsOverride: [39089, 30000],
|
||||
authorPubkeys: activeTab === 'packs' ? streamAuthorPubkeys : undefined,
|
||||
sort: activeTab === 'packs' ? sort : 'recent',
|
||||
});
|
||||
|
||||
// Trigger infinite scroll when sentinels are visible
|
||||
useEffect(() => {
|
||||
if (postsInView && hasMorePosts && !isLoadingMorePosts) loadMorePosts();
|
||||
}, [postsInView, hasMorePosts, isLoadingMorePosts, loadMorePosts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (feedsInView && hasMoreFeeds && !isLoadingMoreFeeds) loadMoreFeeds();
|
||||
}, [feedsInView, hasMoreFeeds, isLoadingMoreFeeds, loadMoreFeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (packsInView && hasMorePacks && !isLoadingMorePacks) loadMorePacks();
|
||||
}, [packsInView, hasMorePacks, isLoadingMorePacks, loadMorePacks]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
flushStreamBuffer();
|
||||
if (activeTab === 'feeds') {
|
||||
flushFeedsBuffer();
|
||||
} else if (activeTab === 'packs') {
|
||||
flushPacksBuffer();
|
||||
} else {
|
||||
flushStreamBuffer();
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [flushStreamBuffer]);
|
||||
}, [activeTab, flushStreamBuffer, flushFeedsBuffer, flushPacksBuffer]);
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0">
|
||||
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
|
||||
<PageHeader title="Discover" icon={<Compass className="size-5" />} />
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Feeds" active={activeTab === 'feeds'} onClick={() => setActiveTab('feeds')} />
|
||||
<TabButton label="Follow Packs" active={activeTab === 'packs'} onClick={() => setActiveTab('packs')} />
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
|
||||
</SubHeaderBar>
|
||||
@@ -500,6 +682,22 @@ export function SearchPage() {
|
||||
loading={isPublishingTabs}
|
||||
/>
|
||||
)}
|
||||
<SaveDestinationRow
|
||||
icon={<PanelLeft className="size-4 text-muted-foreground" />}
|
||||
label="Sidebar"
|
||||
description="Pin to your sidebar"
|
||||
onClick={() => handleAddToSidebar()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs || isAddingToSidebar}
|
||||
loading={isAddingToSidebar}
|
||||
/>
|
||||
<SaveDestinationRow
|
||||
icon={<Share2 className="size-4 text-muted-foreground" />}
|
||||
label="Share"
|
||||
description="Publish and share a link"
|
||||
onClick={() => handleShareSpell()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs || isSharing}
|
||||
loading={isSharing}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -508,8 +706,8 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter popover (posts tab only) */}
|
||||
{activeTab === 'posts' && (
|
||||
{/* Filter popover (posts, feeds, and packs tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'feeds' || activeTab === 'packs') && (
|
||||
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -629,89 +827,95 @@ export function SearchPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Media + Protocol */}
|
||||
<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 flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md: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 flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Posts-only filters */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* 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 flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md: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 flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Media + Protocol */}
|
||||
<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 flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md: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 flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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-base md:text-xs h-8"
|
||||
/>
|
||||
{/* 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 flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md: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 flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</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-base md:text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter summary chips (posts tab only) */}
|
||||
{activeTab === 'posts' && activeFilterLabels.length > 0 && (
|
||||
{/* Active filter summary chips (posts, feeds, and packs tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'feeds' || activeTab === 'packs') && activeFilterLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{activeFilterLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs font-normal">
|
||||
@@ -750,61 +954,24 @@ export function SearchPage() {
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{/* ─── Posts Tab ─── */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
{/* New posts pill — sticks below the SubHeaderBar arc, hides with nav.
|
||||
Mobile: top = MobileTopBar (2.5rem) + safe-area + SubHeaderBar (~2.5rem).
|
||||
Desktop: top = SubHeaderBar only (~2.5rem), no MobileTopBar. */}
|
||||
{newPostCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
|
||||
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
|
||||
)}
|
||||
style={{ marginBottom: '-3rem' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
flushStreamBuffer();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
>
|
||||
{newPostCount} new post{newPostCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Post results — stream */}
|
||||
{postsLoading && posts.length === 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div>
|
||||
{posts.map((event) => {
|
||||
const isNew = flushedIds.has(event.id);
|
||||
if (isRepostKind(event.kind)) {
|
||||
const embedded = parseRepostContent(event);
|
||||
if (embedded) {
|
||||
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return <NoteCard key={event.id} event={event} highlight={isNew} />;
|
||||
})}
|
||||
</div>
|
||||
) : debouncedSearchQuery.trim() ? (
|
||||
<EmptyState
|
||||
message="No posts found matching your search."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Enter a search query to find posts." />
|
||||
)}
|
||||
</>
|
||||
<StreamingFeed
|
||||
posts={posts}
|
||||
isLoading={postsLoading}
|
||||
newPostCount={newPostCount}
|
||||
flushStreamBuffer={flushStreamBuffer}
|
||||
flushedIds={flushedIds}
|
||||
hasMore={hasMorePosts}
|
||||
isLoadingMore={isLoadingMorePosts}
|
||||
scrollRef={postsScrollRef}
|
||||
navHidden={navHidden}
|
||||
itemLabel="post"
|
||||
hasSearch={!!debouncedSearchQuery.trim()}
|
||||
emptySearchMessage="No posts found matching your search."
|
||||
emptyNoSearchMessage="Enter a search query to find posts."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
unwrapReposts
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Accounts Tab ─── */}
|
||||
@@ -833,6 +1000,48 @@ export function SearchPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Follow Packs Tab ─── */}
|
||||
{activeTab === 'packs' && (
|
||||
<StreamingFeed
|
||||
posts={packPosts}
|
||||
isLoading={packsLoading}
|
||||
newPostCount={packsNewCount}
|
||||
flushStreamBuffer={flushPacksBuffer}
|
||||
flushedIds={packsFlushedIds}
|
||||
hasMore={hasMorePacks}
|
||||
isLoadingMore={isLoadingMorePacks}
|
||||
scrollRef={packsScrollRef}
|
||||
navHidden={navHidden}
|
||||
itemLabel="follow pack"
|
||||
hasSearch={!!debouncedSearchQuery.trim()}
|
||||
emptySearchMessage="No follow packs found matching your search."
|
||||
emptyNoSearchMessage="Enter a search query to find follow packs."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─── Feeds Tab ─── */}
|
||||
{activeTab === 'feeds' && (
|
||||
<StreamingFeed
|
||||
posts={feedSpells}
|
||||
isLoading={feedsLoading}
|
||||
newPostCount={feedsNewCount}
|
||||
flushStreamBuffer={flushFeedsBuffer}
|
||||
flushedIds={feedsFlushedIds}
|
||||
hasMore={hasMoreFeeds}
|
||||
isLoadingMore={isLoadingMoreFeeds}
|
||||
scrollRef={feedsScrollRef}
|
||||
navHidden={navHidden}
|
||||
itemLabel="feed"
|
||||
hasSearch={!!debouncedSearchQuery.trim()}
|
||||
emptySearchMessage="No feeds found matching your search."
|
||||
emptyNoSearchMessage="Enter a search query to find feeds."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
@@ -840,6 +1049,109 @@ export function SearchPage() {
|
||||
|
||||
/* ── Shared sub-components ── */
|
||||
|
||||
/** Reusable streaming feed panel used by the Posts, Feeds, and Follow Packs tabs. */
|
||||
function StreamingFeed({
|
||||
posts,
|
||||
isLoading,
|
||||
newPostCount,
|
||||
flushStreamBuffer,
|
||||
flushedIds,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
scrollRef,
|
||||
navHidden,
|
||||
itemLabel,
|
||||
hasSearch,
|
||||
emptySearchMessage,
|
||||
emptyNoSearchMessage,
|
||||
activeFilters,
|
||||
onResetFilters,
|
||||
unwrapReposts = false,
|
||||
}: {
|
||||
posts: import('@nostrify/nostrify').NostrEvent[];
|
||||
isLoading: boolean;
|
||||
newPostCount: number;
|
||||
flushStreamBuffer: () => void;
|
||||
flushedIds: Set<string>;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
scrollRef: (node?: Element | null) => void;
|
||||
navHidden: boolean;
|
||||
/** Singular label for the "N new ___" pill, e.g. "post", "feed", "follow pack". */
|
||||
itemLabel: string;
|
||||
/** Whether a search query is active (controls empty-state messaging). */
|
||||
hasSearch: boolean;
|
||||
emptySearchMessage: string;
|
||||
emptyNoSearchMessage: string;
|
||||
activeFilters?: string[];
|
||||
onResetFilters?: () => void;
|
||||
/** If true, kind 6/16 reposts are unwrapped into the embedded event. */
|
||||
unwrapReposts?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{newPostCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
|
||||
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
|
||||
)}
|
||||
style={{ marginBottom: '-3rem' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
flushStreamBuffer();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
>
|
||||
{newPostCount} new {itemLabel}{newPostCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && posts.length === 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div>
|
||||
{posts.map((event) => {
|
||||
const isNew = flushedIds.has(event.id);
|
||||
if (unwrapReposts && isRepostKind(event.kind)) {
|
||||
const embedded = parseRepostContent(event);
|
||||
if (embedded) {
|
||||
return <NoteCard key={event.id} event={embedded} repostedBy={event.pubkey} highlight={isNew} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return <NoteCard key={event.id} event={event} highlight={isNew} />;
|
||||
})}
|
||||
{hasMore && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : hasSearch ? (
|
||||
<EmptyState
|
||||
message={emptySearchMessage}
|
||||
activeFilters={activeFilters}
|
||||
onResetFilters={onResetFilters}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message={emptyNoSearchMessage} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metadata: Record<string, unknown>; event?: { tags: string[][] } }; isFollowed: boolean }) {
|
||||
const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]);
|
||||
const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean };
|
||||
@@ -1012,32 +1324,6 @@ function EmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
function PostSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{/* Header: avatar + stacked name/handle — matches NoteCard layout */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
@@ -1093,29 +1379,6 @@ function SearchInput({
|
||||
);
|
||||
}
|
||||
|
||||
function SaveDestinationRow({
|
||||
icon, label, description, onClick, disabled, loading,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
|
||||
>
|
||||
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { AlertCircle, BookmarkPlus, Check, Loader2, PanelLeft, Share2, User, WandSparkles } from 'lucide-react';
|
||||
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SaveDestinationRow } from '@/components/SaveDestinationRow';
|
||||
import { SpellContent } from '@/components/SpellContent';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useProfileTabs } from '@/hooks/useProfileTabs';
|
||||
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveSpell } from '@/lib/spellEngine';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
export function SpellRunPage() {
|
||||
const params = useParams<{ nevent?: string; nip19?: string }>();
|
||||
const nevent = params.nevent ?? params.nip19;
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const contactPubkeys = useMemo(() => followData?.pubkeys ?? [], [followData?.pubkeys]);
|
||||
|
||||
// Decode the nevent identifier
|
||||
const decoded = useMemo(() => {
|
||||
if (!nevent) return null;
|
||||
try {
|
||||
const result = nip19.decode(nevent);
|
||||
if (result.type === 'nevent') return result.data;
|
||||
if (result.type === 'note') return { id: result.data, author: undefined, relays: undefined };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [nevent]);
|
||||
|
||||
// Fetch the spell event
|
||||
const { data: spellEvent, isLoading: isLoadingSpell, error: spellError } = useQuery<NostrEvent | null>({
|
||||
queryKey: ['spell-event', decoded?.id],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!decoded) return null;
|
||||
const events = await nostr.query(
|
||||
[{ ids: [decoded.id], kinds: [777], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
|
||||
);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
enabled: !!decoded,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Resolve the spell for error checking and cmd detection
|
||||
const resolved = useMemo(() => {
|
||||
if (!spellEvent) return null;
|
||||
try {
|
||||
return resolveSpell(spellEvent, user?.pubkey, contactPubkeys);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Failed to resolve spell' };
|
||||
}
|
||||
}, [spellEvent, user?.pubkey, contactPubkeys]);
|
||||
|
||||
const resolveError = resolved && 'error' in resolved ? resolved.error : null;
|
||||
const cmd = resolved && !('error' in resolved) ? resolved.cmd : null;
|
||||
|
||||
// Execute the spell via useStreamPosts (live streaming + initial batch)
|
||||
const { posts, isLoading: isLoadingResults, newPostCount, flushStreamBuffer, loadMore, hasMore, isLoadingMore } = useStreamPosts('', {
|
||||
includeReplies: true,
|
||||
mediaType: 'all',
|
||||
spell: spellEvent ?? undefined,
|
||||
});
|
||||
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasMore && !isLoadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
}, [inView, hasMore, isLoadingMore, loadMore]);
|
||||
|
||||
const spellName = spellEvent?.tags.find(([t]) => t === 'name')?.[1];
|
||||
|
||||
// ── Save popover state ───────────────────────────────────────────────
|
||||
const { savedFeeds, addSavedFeed, removeSavedFeed } = useSavedFeeds();
|
||||
const { addToSidebar, orderedItems } = useFeedSettings();
|
||||
const profileTabsQuery = useProfileTabs(user?.pubkey);
|
||||
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
|
||||
const { toast } = useToast();
|
||||
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
|
||||
const [saveFeedLabel, setSaveFeedLabel] = useState('');
|
||||
const [savedJustNow, setSavedJustNow] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
/** Convert a spell event to a TabFilter for saving.
|
||||
* Includes client-hint fields (_media, _language, _platform, _sort,
|
||||
* _includeReplies) so SavedFeedContent can faithfully reproduce the query. */
|
||||
const spellAsFilter = useMemo(() => {
|
||||
if (!spellEvent) return undefined;
|
||||
try {
|
||||
const resolved = resolveSpell(spellEvent, undefined, []);
|
||||
const filter: Record<string, unknown> = { ...resolved.filter };
|
||||
// Persist spell hints into the saved filter
|
||||
const h = resolved.hints;
|
||||
if (h.mediaType !== 'all') filter._media = h.mediaType;
|
||||
if (h.language && h.language !== 'global') filter._language = h.language;
|
||||
if (h.platform !== 'nostr') filter._platform = h.platform;
|
||||
if (h.sort !== 'recent') filter._sort = h.sort;
|
||||
if (!h.includeReplies) filter._includeReplies = false;
|
||||
return filter;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [spellEvent]);
|
||||
|
||||
/** Find an existing saved feed that matches the spell's filter or spell ID. */
|
||||
const matchingSavedFeed = useMemo(() => {
|
||||
if (!spellEvent && !spellAsFilter) return undefined;
|
||||
// Prefer matching by spell event ID (exact match)
|
||||
if (spellEvent) {
|
||||
const bySpellId = savedFeeds.find((f) => f.spellId === spellEvent.id);
|
||||
if (bySpellId) return bySpellId;
|
||||
}
|
||||
// Fall back to filter comparison for legacy saved feeds without spellId
|
||||
if (!spellAsFilter) return undefined;
|
||||
const filterKey = JSON.stringify(spellAsFilter);
|
||||
return savedFeeds.find((f) => JSON.stringify(f.filter) === filterKey);
|
||||
}, [savedFeeds, spellAsFilter, spellEvent]);
|
||||
|
||||
const alreadySaved = !!matchingSavedFeed;
|
||||
|
||||
/** The nostr: URI for this spell (used for sidebar). */
|
||||
const sidebarId = nevent ? `nostr:${nevent}` : undefined;
|
||||
const alreadyInSidebar = sidebarId ? orderedItems.includes(sidebarId) : false;
|
||||
|
||||
const handleSaveHomeFeed = useCallback(async () => {
|
||||
if (!spellAsFilter || !saveFeedLabel.trim() || !spellEvent) return;
|
||||
await addSavedFeed(saveFeedLabel.trim(), spellAsFilter as Record<string, unknown>, [], spellEvent.id);
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
toast({ title: 'Added to home feed' });
|
||||
}, [spellAsFilter, saveFeedLabel, addSavedFeed, toast, spellEvent]);
|
||||
|
||||
const handleSaveProfileTab = useCallback(async () => {
|
||||
if (!spellEvent || !saveFeedLabel.trim() || !user) return;
|
||||
// Store the spell event's tags so ProfileSavedFeedContent can reconstruct
|
||||
// the spell and render via useStreamPosts({ spell }).
|
||||
const tabFilter: Record<string, unknown> = { _spellTags: spellEvent.tags };
|
||||
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
|
||||
await publishProfileTabs({
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: tabFilter }],
|
||||
vars: existing.vars,
|
||||
});
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
toast({ title: 'Added to profile tabs' });
|
||||
}, [spellEvent, saveFeedLabel, user, profileTabsQuery.data, publishProfileTabs, toast]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!spellEvent || !nevent || !saveFeedLabel.trim()) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
const url = `${window.location.origin}/${nevent}`;
|
||||
const result = await shareOrCopy(url, saveFeedLabel.trim());
|
||||
if (result === 'copied') {
|
||||
toast({ title: 'Link copied to clipboard' });
|
||||
}
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [spellEvent, nevent, saveFeedLabel, toast]);
|
||||
|
||||
const handleAddToSidebar = useCallback(() => {
|
||||
if (!sidebarId) return;
|
||||
addToSidebar(sidebarId);
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
}, [sidebarId, addToSidebar, toast]);
|
||||
|
||||
const handleRemoveSaved = useCallback(async () => {
|
||||
if (!matchingSavedFeed) return;
|
||||
await removeSavedFeed(matchingSavedFeed.id);
|
||||
toast({ title: 'Removed from home feed' });
|
||||
}, [matchingSavedFeed, removeSavedFeed, toast]);
|
||||
|
||||
useSeoMeta({
|
||||
title: spellName ? `${spellName} | Spell Results` : 'Spell Results',
|
||||
});
|
||||
|
||||
// Invalid identifier
|
||||
if (!decoded) return <NotFound />;
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<PageHeader
|
||||
title={spellName ?? 'Spell Results'}
|
||||
icon={<WandSparkles className="size-5 text-primary" />}
|
||||
backTo="/discover"
|
||||
>
|
||||
{cmd && (
|
||||
<Badge variant="secondary" className="text-xs font-mono shrink-0">
|
||||
{cmd}
|
||||
</Badge>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Spell summary card */}
|
||||
{spellEvent && (
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<SpellContent event={spellEvent} />
|
||||
</div>
|
||||
{user && (
|
||||
<Popover open={savePopoverOpen} onOpenChange={(o) => {
|
||||
setSavePopoverOpen(o);
|
||||
if (o && !saveFeedLabel) {
|
||||
setSaveFeedLabel(spellName ?? '');
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 size-8 flex items-center justify-center rounded-md transition-colors',
|
||||
alreadySaved || savedJustNow
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label="Save spell"
|
||||
>
|
||||
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className={cn(
|
||||
'size-4',
|
||||
alreadySaved && 'fill-primary text-primary',
|
||||
)} />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-3 space-y-3">
|
||||
<p className="font-semibold text-sm">Save as tab</p>
|
||||
|
||||
{alreadySaved ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Already saved to home feed.</p>
|
||||
<button
|
||||
onClick={handleRemoveSaved}
|
||||
className="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Tab name…"
|
||||
value={saveFeedLabel}
|
||||
onChange={(e) => setSaveFeedLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveHomeFeed(); }}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<SaveDestinationRow
|
||||
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
|
||||
label="Home feed"
|
||||
description="Tab on your home page"
|
||||
onClick={handleSaveHomeFeed}
|
||||
disabled={!saveFeedLabel.trim()}
|
||||
loading={false}
|
||||
/>
|
||||
<SaveDestinationRow
|
||||
icon={<User className="size-4 text-muted-foreground" />}
|
||||
label="Profile tab"
|
||||
description="Tab on your profile"
|
||||
onClick={handleSaveProfileTab}
|
||||
disabled={!saveFeedLabel.trim() || isPublishingTabs}
|
||||
loading={isPublishingTabs}
|
||||
/>
|
||||
<SaveDestinationRow
|
||||
icon={<PanelLeft className="size-4 text-muted-foreground" />}
|
||||
label="Sidebar"
|
||||
description="Pin to your sidebar"
|
||||
onClick={handleAddToSidebar}
|
||||
disabled={!sidebarId || alreadyInSidebar}
|
||||
loading={false}
|
||||
/>
|
||||
<SaveDestinationRow
|
||||
icon={<Share2 className="size-4 text-muted-foreground" />}
|
||||
label="Share"
|
||||
description="Copy link to this spell"
|
||||
onClick={handleShare}
|
||||
disabled={!saveFeedLabel.trim() || isSharing}
|
||||
loading={isSharing}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New posts pill */}
|
||||
{newPostCount > 0 && (
|
||||
<button
|
||||
onClick={flushStreamBuffer}
|
||||
className="w-full py-2 text-sm text-primary hover:bg-muted/50 border-b border-border transition-colors"
|
||||
>
|
||||
{newPostCount} new {newPostCount === 1 ? 'post' : 'posts'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error states */}
|
||||
{resolveError && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertDescription>{resolveError}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{spellError && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertDescription>Failed to fetch spell event.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading states */}
|
||||
{(isLoadingSpell || (isLoadingResults && posts.length === 0)) && (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* COUNT results */}
|
||||
{cmd === 'COUNT' && !isLoadingResults && (
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-4xl font-bold">{posts.length}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">events found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* REQ results */}
|
||||
{cmd !== 'COUNT' && posts.length > 0 && (
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoadingSpell && !isLoadingResults && posts.length === 0 && !resolveError && !spellError && spellEvent && (
|
||||
<div className="p-8 text-center">
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8">
|
||||
<p className="text-muted-foreground">
|
||||
No results found for this spell. The queried relays may not have matching events.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { Loader2, Pencil, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { FeedEmptyState } from "@/components/FeedEmptyState";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||
import { SubHeaderBar } from "@/components/SubHeaderBar";
|
||||
import { TabButton } from "@/components/TabButton";
|
||||
import { ThemeSelector } from "@/components/ThemeSelector";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useLayoutOptions } from "@/contexts/LayoutContext";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
@@ -187,26 +187,4 @@ export function ThemesPage() {
|
||||
// Skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { Link } from "react-router-dom";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
import { NoteCardSkeleton } from "@/components/NoteCardSkeleton";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -125,7 +126,7 @@ export function TrendsPage() {
|
||||
{(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : sortedPosts.length > 0 ? (
|
||||
@@ -203,28 +204,7 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PostSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function TrendSkeleton() {
|
||||
return (
|
||||
|
||||
@@ -113,6 +113,8 @@ export function TestApp({ children }: TestAppProps) {
|
||||
imageQuality: 'compressed',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [],
|
||||
aiModel: '',
|
||||
aiSystemPrompt: '',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -128,6 +128,13 @@ export default defineConfig(({ mode }) => {
|
||||
host: "::",
|
||||
port: 8080,
|
||||
allowedHosts: env.ALLOWED_HOSTS === "*" ? true : undefined,
|
||||
proxy: {
|
||||
'/api/shakespeare': {
|
||||
target: 'http://5.78.68.217:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/shakespeare/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
Reference in New Issue
Block a user