Compare commits

...

33 Commits

Author SHA1 Message Date
lemon 4830443c26 Add community member feed tab 2026-05-07 09:41:29 -07:00
lemon 98c2d69c02 Fix duplicate AI chat error display 2026-05-06 23:18:10 -07:00
lemon cedc5db249 Configure AI provider settings 2026-05-06 22:52:12 -07:00
lemon f043d45331 Add slash commands with autocomplete, /tools listing, and styled notice messages 2026-05-06 22:52:12 -07:00
lemon 12f1bbd00d Harden AI chat: SSRF protection, capacity tracking, scoped storage, and error handling 2026-05-06 22:52:12 -07:00
lemon aa962386c6 Add AI Agent chat with tool-calling, model selector, and sidebar integration
- Implement 5 read-only tools: get_feed, search_users, search_follow_packs, fetch_page, fetch_event
- Upgrade useShakespeare streaming to support tool calls, AbortSignal, and robust SSE parsing
- Create useAIChatSession hook with streaming, 10-round tool loop, localStorage persistence
- Rewrite AIChatPage with modular architecture, streaming UI, tool call badges, and empty-bubble handling
- Add Agent settings section with model dropdown selector and pre-populated system prompt editor
- Add Agent to left sidebar navigation and right widget sidebar defaults
- Add aiModel and aiSystemPrompt config fields with encrypted settings sync
2026-05-06 22:52:12 -07:00
lemon 5cc1428c06 Portal tooltip overlays above sidebars 2026-05-06 22:09:15 -07:00
lemon cb35176f60 Refresh goal progress after zaps 2026-05-06 22:01:40 -07:00
lemon 0ea17672c7 Check member badge identifier collisions 2026-05-06 22:00:40 -07:00
lemon 6f888b8d36 Refresh community caches after member updates 2026-05-06 21:59:38 -07:00
lemon d5dff04056 Page community activity streams independently 2026-05-06 21:58:34 -07:00
lemon 8c6be4c57d Prevent banned community moderators from acting 2026-05-06 21:57:23 -07:00
lemon 064e0832df Page community awards and reports exhaustively
Introduce queryAll, a portable helper that exhausts a Nostr filter by
paging with the until cursor, capped at 5,000 events / 10 pages so
worst-case cost stays bounded. Works against any relay regardless of
its internal page size.

Migrate useCommunityMembers and useCommunityActivityFeed so membership
and moderation state are complete for any community that fits within
the cap, instead of silently truncating at 500 events.
2026-05-06 21:45:20 -07:00
lemon 891cf72af8 Tighten flat community primitives
Extract isAuthorizedAward helper as the single source of truth for
membership award validation, used by both resolveMembership and
useMyCommunities. Simplify resolveCommunityModeration by dropping
the dead banned-reporter guard from pass 1 (impossible under strict
rank ordering). Flip useMembersOnlyFilter default to opt-in to match
the spec's MAY wording, and reword the NIP to match.
2026-05-06 21:45:08 -07:00
lemon a3563305c4 Clean up flat community language 2026-05-06 21:11:40 -07:00
lemon 14733b3b5c Flatten community membership resolution 2026-05-06 20:39:01 -07:00
lemon 4a8fd245a1 Document flat community membership 2026-05-06 20:34:44 -07:00
lemon a159f97a43 Add calendar event editing 2026-05-06 19:50:57 -07:00
lemon eb03f3fcc0 Share image upload field across dialogs 2026-05-06 19:40:46 -07:00
lemon 003e7d3624 Add image uploads to event creation 2026-05-06 19:27:34 -07:00
lemon 28043378c3 Add engagement actions to calendar events 2026-05-06 13:13:51 -07:00
lemon 432eae4f79 Add RSVP controls to calendar event details
- Rename tentative label to 'Interested' (Facebook-style, Star icon)
- Auto-enroll event authors as 'accepted' when publishing
- Let authors change their own RSVP from the detail page
- Restyle RSVP section to match About/Attendees headers
- Remove optional note field; click a button to submit immediately
- Move Attendees above RSVP
2026-05-06 12:55:57 -07:00
lemon e0a52a5c32 Use event dialog on events page 2026-05-04 23:29:01 -07:00
lemon 725d6970c5 Add community event creation dialog 2026-05-04 23:23:55 -07:00
lemon 558b666220 Add community events tab 2026-05-04 23:02:43 -07:00
lemon 72d7962632 Improve community bookmark reliability 2026-05-04 21:44:34 -07:00
lemon 5e91f1d328 Add bookmark toggle to community detail page top bar
Places a NIP-51 kind 10004 bookmark button between the edit and share
buttons so users can save a community while viewing it, not just from
the feed card's more-menu.
2026-05-03 22:10:34 -07:00
lemon efe5d3db1c Show bookmarked communities in My Communities via NIP-51 kind 10004
Bookmarking a kind 34550 community now writes to the NIP-51 Communities
list (kind 10004) keyed by the addressable coordinate, so the reference
stays valid across community updates. My Communities merges bookmarked
communities as a third discovery source alongside founded and member-of,
with Founder/Member/Bookmarked badges on each card.

Bookmark toasts live on the mutation itself so they survive the more-menu
dialog unmounting between .mutate() and publish resolution.
2026-05-03 21:59:36 -07:00
lemon 9ac379b259 fix: remove duplicate community share action 2026-05-03 21:28:00 -07:00
lemon c8b3961da6 feat: add community editing 2026-05-03 21:28:00 -07:00
lemon 259c657c33 fix: improve community member management 2026-05-03 21:28:00 -07:00
lemon 2f6aeb05e4 refactor: split community creation into two steps
- CreateCommunityDialog now only publishes kind 34550 (name, image, description)
- New AddMemberDialog on the community detail page handles membership:
  - Founder can add moderators and members
  - Moderators can add members only
  - Badge definition (kind 30009) created lazily on first member add
  - Community definition republished once with all changes batched
  - Kind 8 badge awards published for each member
- Add Members button on Members tab, visible to rank 0 users
- Search dropdown moved outside ScrollArea to prevent clipping
2026-05-03 21:27:41 -07:00
lemon 5cea93de34 feat: add community creation flow and improve discovery UX
- Add CreateCommunityDialog with name, image upload, description, and moderator type-ahead search
- Publish kind 30009 badge definition + kind 34550 community definition with d-tag collision check
- Context-aware FAB on My Communities tab opens the create dialog
- Default Search page tab to Communities instead of Posts
- Add Search to default sidebar order for new accounts
- Improve empty states on both Activities and My Communities tabs to guide users toward discovery
2026-05-03 21:26:48 -07:00
57 changed files with 5767 additions and 1422 deletions
+82 -96
View File
@@ -21,7 +21,7 @@
| Protocol | Composed Kinds | Description |
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) |
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
### Community Kinds
@@ -435,9 +435,9 @@ Clients SHOULD only surface events from the last hour (`since = now - 3600`). Ol
---
## Hierarchical Communities
## Flat Communities
Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards.
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
This specification is intended to be a foundation for community-scoped features. A community is a kind `34550` root that other events can tag with uppercase `A`. Posts, events, polls, listings, and future content kinds can all participate in the same community model when they tag the community root and pass the membership and moderation rules below.
@@ -454,34 +454,31 @@ The initial implementation focuses on three foundation capabilities:
- **Kind 8** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Award
- **Kind 1111** ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) -- Community Posts
- **Kind 1984** ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) -- Moderation
- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Badge Award Revocation / Moderation Rescinding
### Overview
A hierarchical community consists of:
A flat community consists of:
1. **Badge definitions** (kind `30009`), one per rank tier, published by the founder.
2. A **community definition** (kind `34550`) referencing those badges with rank indices.
3. **Badge awards** (kind `8`) forming a chain of trust -- each award grants a rank, validated by the awarder's rank.
1. **One badge definition** (kind `30009`) that represents community membership.
2. A **community definition** (kind `34550`) referencing that member badge with the role marker `"member"`.
3. **Badge awards** (kind `8`) authored by the founder or current moderators, granting membership directly.
4. **Community-scoped content** (initially kind `1111`) tagged to the community root.
5. **Reports and bans** (kind `1984`) scoped to the community for content warnings, content removal, and member bans.
6. **Deletion requests** (kind `5`) for revoking badge awards or rescinding moderation events.
5. **Reports and bans** (kind `1984`) scoped to the community for content warnings, content removal, and member/non-member bans.
Parent, child, sister, and rank relationships are intentionally out of scope for the core permission model. Apps may build discovery or directory surfaces separately.
### Membership Derivation
Community membership is derived from three distinct sources, each resolved differently:
Membership is sourced from the community definition and from validated kind `8` membership awards. This produces three populations:
- **Founder** -- the `pubkey` field on the kind `34550` event. One per community, immutable. Controls the community definition since only they can republish the addressable event.
- **Moderators** -- the `p` tags on the kind `34550` event (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable (the founder can add/remove by republishing). Share rank 0 with the founder.
- **Members** -- derived from kind `8` badge awards forming the authority chain. A member's rank is determined by the badge they were awarded (rank 1 and below).
- **Moderators** -- the `p` tags on the kind `34550` event with role `"moderator"` (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable by republishing the community definition.
- **Members** -- pubkeys named in `p` tags on kind `8` badge awards that reference the community's member badge and are authored by the founder or a current moderator.
The founder and moderators have no badge. Their rank 0 status comes from the community definition itself. Rank 0 cannot be awarded via kind `8` -- there is no rank 0 badge definition. Clients determine founder/moderator display from the community event directly.
Authority is **rank-based, not badge-specific**. A member at rank N can award any badge at rank M where M > N.
The founder and moderators have no membership badge requirement. Their leadership status comes from the community definition itself. Members cannot grant membership to other members.
### Community Definition
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with badge `a` tags that encode rank indices.
A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with one badge `a` tag that identifies the member badge.
#### Tags
@@ -491,18 +488,18 @@ A kind `34550` event defines the community, extending [NIP-72](https://github.co
| `name` | Yes | Human-readable name. |
| `description` | No | Community description. |
| `image` | No | Image URL. |
| `a` | Yes (1+) | Badge definition reference with rank index (see format below). |
| `p` | Yes (1+) | Moderator pubkeys. Implicitly rank 0. The 4th element SHOULD be `"moderator"`. |
| `a` | Yes (1) | Member badge definition reference with role marker `"member"`. |
| `p` | No | Moderator pubkeys. The 4th element SHOULD be `"moderator"`. |
| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). |
| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. |
#### Badge `a` Tag Format
```
["a", "30009:<pubkey>:<badge-d-tag>", "<relay-hint>", "<rank-index>"]
["a", "30009:<pubkey>:<badge-d-tag>", "<relay-hint>", "member"]
```
Rank `0` is reserved for the founder and moderators (derived from the community definition, not from badges). Badge `a` tags define awardable ranks starting from `1`. Higher numbers = lower authority. Indices MUST be contiguous starting from 1.
The fourth element is a strict protocol marker, not a display label. Communities can still use the badge definition's `name`, `description`, and `image` tags for expressive member labels.
#### Example
@@ -516,10 +513,7 @@ Rank `0` is reserved for the founder and moderators (derived from the community
["name", "The Arbiter's Guard"],
["description", "Elite Halo 2 clan"],
["image", "https://example.com/clan-banner.jpg"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff", "", "1"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "2"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-peon", "", "3"],
["p", "<founder-pubkey>", "", "moderator"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member", "", "member"],
["p", "<co-moderator-pubkey>", "", "moderator"],
["relay", "wss://relay.example.com"],
["alt", "Community: The Arbiter's Guard"]
@@ -529,9 +523,9 @@ Rank `0` is reserved for the founder and moderators (derived from the community
### Badge Definitions
Each rank tier is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. Badge definitions MUST be published **before** the community definition that references them.
The member badge is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. The badge definition SHOULD be published **before** the community definition that references it.
The `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uniqueness.
The `d` tag SHOULD use the format `<community-d-tag>-member` for global uniqueness.
```jsonc
{
@@ -539,58 +533,59 @@ The `d` tag SHOULD use the format `<community-d-tag>-<rank-name>` for global uni
"pubkey": "<founder-pubkey>",
"content": "",
"tags": [
["d", "a1b2c3d4-...-staff"],
["name", "Staff"],
["description", "Trusted officers who manage clan operations."],
["image", "https://example.com/staff-badge.png"],
["alt", "Badge definition: Staff"]
["d", "a1b2c3d4-...-member"],
["name", "Member"],
["description", "Member of The Arbiter's Guard"],
["image", "https://example.com/member-badge.png"],
["alt", "Badge definition: Member of The Arbiter's Guard"]
]
}
```
### Badge Awards
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each award forms a chain link.
Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each valid award grants membership directly.
A badge award is **valid** if and only if:
1. The `a` tag references a badge definition listed in the community definition.
2. The awarder is a validated member at a rank **strictly less than** the badge's rank index.
3. The awarder's chain can be walked upward to a founder or moderator.
1. The `a` tag references the member badge listed in the community definition.
2. The award author is the founder or a moderator listed in the community definition currently being evaluated.
3. The award contains at least one `p` tag naming an awarded pubkey.
```jsonc
// Moderator (rank 0) awarding Staff (rank 1)
// Moderator awarding community membership
{
"kind": 8,
"pubkey": "<founder-pubkey>",
"pubkey": "<moderator-pubkey>",
"content": "",
"tags": [
["a", "30009:<founder-pubkey>:a1b2c3d4-...-staff"],
["a", "30009:<founder-pubkey>:a1b2c3d4-...-member"],
["p", "<recipient-pubkey>"],
["alt", "Badge award: Staff in The Arbiter's Guard"]
]
}
```
### Chain Validation
### Membership Validation
Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays.
Membership is resolved with indexed relay filters. There is no recursive authority graph.
#### Algorithm
1. **Seed rank 0**: The event publisher (founder) and all `p` tags (moderators) in the community definition are rank 0 members.
2. **Query awards**: `{ kinds: [8], #a: [<all badge coordinates>] }`
3. **Iteratively validate**: For each award, check if the awarder is a validated member with rank strictly less than the awarded rank. If valid, add the recipient. Repeat until no new members are discovered.
4. **Resolve moderation**: Query `{ kinds: [1984], #A: [<community-a-tag>] }`. Classify kind `1984` events into **bans** and **reports** (see [Moderation](#moderation)). Kind `1984` events from non-members and banned members are ignored. Ban attempts from insufficiently ranked members are ignored, such as a rank 2 member trying to ban a rank 0 founder or moderator.
5. **Apply moderation**: Remove banned members from effective membership. Omit content from banned authors, omit verified content bans, and attach report data to reported content for content-warning display.
1. Fetch the community definition using kind `34550`, the founder pubkey, and the community `d` tag.
2. Extract the founder pubkey, moderator pubkeys, and member badge coordinate.
3. Query awards: `{ kinds: [8], authors: [<founder>, ...<moderators>], #a: [<member-badge-coordinate>] }`.
4. Flatten `p` tags from matching awards.
5. The member set is the union of the founder, current moderators, and awarded pubkeys.
6. Resolve moderation and apply moderation overlays.
Clients MUST NOT trust kind `8` events at face value. An attacker can publish awards for themselves, but these fail chain validation without a path to a founder or moderator.
The `authors` filter is the primary membership-award trust boundary. Awards from non-founder, non-moderator pubkeys are not valid community membership awards.
### Community-Scoped Content
Community-scoped content is any event that tags the community definition with uppercase `A`. The foundation implementation starts with kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) posts, but the same moderation overlay applies to future community content kinds such as calendar events, polls, listings, or other domain-specific events.
Clients SHOULD treat valid community members as the canonical authors for community views. Content from non-members MAY be shown in future review surfaces, but canonical community feeds SHOULD discard non-member content by default.
Clients MAY offer a members-only view that filters community posts down to the resolved member set as an `authors` filter. Whether this is on by default, opt-in, or omitted entirely is a client UX choice -- the protocol makes no recommendation.
#### Community Post
@@ -634,24 +629,25 @@ Replies keep the community as root scope and point to the parent comment:
#### Querying
Fetch community-scoped content and moderation data together when relay limits permit. The `kinds` list can expand as the application adds supported community content kinds.
Clients MAY use the resolved member set as an `authors` filter for members-only views.
```jsonc
{
"kinds": [1111, 1984],
"#A": ["34550:<founder-pubkey>:<community-d-tag>"]
"kinds": [1111],
"#A": ["34550:<founder-pubkey>:<community-d-tag>"],
"authors": ["<founder>", "<moderator>", "<member>"]
}
```
Clients then filter client-side: discard unsupported kinds, discard non-member content from canonical community views, and process kind `1984` events per the moderation rules below. The moderation overlay is content-kind agnostic: a valid content ban or warning applies to the targeted event regardless of whether that event is a post, calendar event, poll, listing, or future supported kind.
The moderation overlay is content-kind agnostic: a valid content ban or warning applies to the targeted event regardless of whether that event is a post, calendar event, poll, listing, or future supported kind.
### Moderation
Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Moderation is derived state: clients first resolve trusted moderation actions from kind `1984`, then apply those actions to concrete community-scoped events.
There are two tiers of moderation events:
There are two moderation event classes:
1. **Bans** -- authoritative actions from higher-ranked members that remove content or ban users. Identified by the presence of [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label tags `["L", "moderation"]` and `["l", "ban", "moderation"]`.
1. **Bans** -- authoritative actions that remove content or ban users. Identified by the presence of [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label tags `["L", "moderation"]` and `["l", "ban", "moderation"]`.
2. **Reports** -- soft flags from any valid community member using standard [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) report types (`nudity`, `spam`, `profanity`, `illegal`, `malware`, `impersonation`, `other`). No `L`/`l` tags. Clients display a content warning that users must click through to reveal.
Kind `1984` events from **non-members** are ignored entirely within community context. Kind `1984` events from members who are themselves banned are also ignored after ban resolution; banned members cannot retain moderation or reporting authority.
@@ -663,7 +659,9 @@ A ban is **authoritative** if and only if:
1. The event contains `["l", "ban", "moderation"]` and `["L", "moderation"]` tags.
2. The publisher is a validated community member.
3. The publisher is not themselves banned after ban resolution.
4. The publisher's rank is **strictly less than** the target's rank (or the target is a non-member).
4. The publisher's authority covers the target:
- founder/moderators may ban member and non-member authors/content;
- members may ban only non-member authors/content.
Bans that fail any of these conditions MUST be ignored.
@@ -688,11 +686,11 @@ Ban a specific post by publishing kind `1984` with `e`, `p`, and `A` tags plus t
Clients MUST omit the banned event from canonical community feeds entirely. The event is not displayed, blurred, or indicated in any way -- it is treated as if it does not exist.
The `e` and `p` tags are untrusted until matched against the actual target event. A content ban MUST only apply when the targeted event's `id` matches the ban's `e` tag and the targeted event's `pubkey` matches the ban's `p` tag. This prevents a malicious or mistaken report from hiding an event by pairing its event ID with a lower-ranked or non-member pubkey.
The `e` and `p` tags are untrusted until matched against the actual target event. A content ban MUST only apply when the targeted event's `id` matches the ban's `e` tag and the targeted event's `pubkey` matches the ban's `p` tag. This prevents a malicious or mistaken report from hiding an event by pairing its event ID with a different target pubkey.
##### Member Ban
Ban a member by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead.
Ban an author by publishing kind `1984` with `p` and `A` tags only (no `e` tag) plus the `ban` label. Founder/moderator-authored bans may target members or non-members. Member-authored bans may target non-members only.
```jsonc
{
@@ -700,7 +698,7 @@ Ban a member by publishing kind `1984` with `p` and `A` tags only (no `e` tag) p
"pubkey": "<moderator-pubkey>",
"content": "Reason for ban",
"tags": [
["p", "<banned-member-pubkey>", "other"],
["p", "<banned-pubkey>", "other"],
["A", "34550:<founder-pubkey>:<community-d-tag>"],
["L", "moderation"],
["l", "ban", "moderation"]
@@ -712,7 +710,7 @@ Clients distinguish content bans (`e` + `p` + `A` + `ban` label) from member ban
#### Reports (Content Warnings)
Any **valid, non-banned community member** (regardless of rank) may report content by publishing kind `1984` with a standard NIP-56 report type on the `e` and `p` tags. Reports do NOT use `L`/`l` label tags.
Any **valid, non-banned community member** may report content by publishing kind `1984` with a standard NIP-56 report type on the `e` and `p` tags. Reports do NOT use `L`/`l` label tags.
```jsonc
{
@@ -737,45 +735,16 @@ As with content bans, report warnings MUST only attach to content when the targe
| `l` tag present? | `e` tag present? | Authority check | Result |
|---|---|---|---|
| `["l", "ban", "moderation"]` | Yes | Non-banned member; rank < target; `e`/`p` match target event | Content ban (omit event) |
| `["l", "ban", "moderation"]` | No | Non-banned member; rank < target | Member ban |
| `["l", "ban", "moderation"]` | Yes | Founder/moderator, or member targeting non-member content; `e`/`p` match target event | Content ban (omit event) |
| `["l", "ban", "moderation"]` | No | Founder/moderator, or member targeting non-member author | Author ban |
| No | Yes | Non-banned member; `e`/`p` match target event | Content warning |
| No | No | -- | Invalid (ignored) |
| Any | Any | Non-member | Ignored |
| Any | Any | Banned member | Ignored |
| `["l", "ban", "moderation"]` | Any | Rank >= target | Ignored |
#### Rescinding Moderation
A kind `1984` ban or report can be rescinded by deleting the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author of the kind `1984` event can delete it.
```jsonc
{
"kind": 5,
"tags": [["e", "<kind-1984-event-id>"], ["k", "1984"]]
}
```
Clients that implement moderation rescinding SHOULD discard any kind `1984` event whose matching kind `5` deletion exists before resolving bans and reports. This branch does not implement moderation rescinding yet; it is retained here as part of the protocol foundation for future moderation extensions.
### Revocation
A badge awarder can revoke their own award via kind `5`:
```jsonc
{
"kind": 5,
"tags": [["e", "<kind-8-event-id>"], ["k", "8"]]
}
```
This is **cascading** -- the chain link is destroyed, so the revoked member and all downstream members whose chain depended on it lose validated status. Per NIP-09, only the original publisher of the kind `8` event can delete it.
**Ban vs revocation**: Use kind `1984` to ban a single member without affecting their downstream recruits. Use kind `5` revocation to remove a member and cascade to their entire subtree.
### Community Updates
Both kind `34550` and kind `30009` are addressable events. To add or remove ranks, republish the community definition with updated `a` tags. To update moderators, republish with updated `p` tags. Removing a moderator cascades to members they recruited (unless those members have another valid chain path). Only the founder (event publisher) can republish the community definition.
Both kind `34550` and kind `30009` are addressable events. To change the member badge or update moderators, republish the community definition. Only the founder (event publisher) can republish the community definition. If a moderator is removed, their authored membership awards no longer count because they are excluded from the authorized awarder query.
### Discovery
@@ -790,20 +759,37 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank
1. `{ "kinds": [8], "#p": ["<user-pubkey>"] }`
2. Extract badge `a` tags from results.
3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }`
4. Keep only communities whose `member` badge reference matches the award badge coordinate.
**Communities a user has bookmarked:**
Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities.
1. `{ "kinds": [10004], "authors": ["<user-pubkey>"], "limit": 1 }`
2. Extract `a` tags whose value begins with `34550:` from the result.
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
```jsonc
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "limit": 1 }
```
Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550:<pubkey>:<d-tag>"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51.
When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community).
### Security Considerations
- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation.
- **Chain validation is required**: Never trust kind `8` events without walking the authority chain.
- **Badge d-tag uniqueness**: Use `<community-d-tag>-<rank-name>` to prevent cross-community collisions.
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on chain validation.
- **Award author filtering is required**: Query member badge awards with `authors: [founder, ...moderators]`.
- **Badge d-tag uniqueness**: Use `<community-d-tag>-member` to prevent cross-community collisions.
- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on community membership.
### Dependencies
- [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) -- Event Deletion Request
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment
- [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag)
- [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label)
- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks)
- [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting
- [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges
- [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities
+5
View File
@@ -144,6 +144,7 @@ const hardcodedConfig: AppConfig = {
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'ai-chat' },
],
messaging: {
enabled: true,
@@ -153,6 +154,10 @@ const hardcodedConfig: AppConfig = {
soundEnabled: false,
devMode: false,
},
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiSystemPrompt: '',
};
/**
+2
View File
@@ -52,6 +52,7 @@ const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").the
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
@@ -177,6 +178,7 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
<Route path="/actions" element={<ActionsPage />} />
<Route path="/agent" element={<AIChatPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
+613
View File
@@ -0,0 +1,613 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageUploadField } from '@/components/ImageUploadField';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
// Default role: member if they're not already a moderator, moderator if founder is adding
const defaultRole: MemberRole = isFounder ? 'moderator' : 'member';
setPendingMembers((prev) => [...prev, { profile, role: defaultRole }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, isFounder, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const toggleRole = useCallback((pubkey: string) => {
if (!isFounder) return; // Only founder can toggle to moderator
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role: m.role === 'moderator' ? 'member' : 'moderator' }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag] });
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag] });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>
{isFounder
? 'Add moderators or members to your community.'
: 'Invite members to the community.'}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search people</Label>
<PersonSearch
onAdd={addPerson}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onToggleRole={isFounder ? toggleRole : undefined}
/>
))}
</div>
</div>
)}
{/* Badge image — only shown when badge needs to be created */}
{needsBadgeCreation && (
<ImageUploadField
id="member-badge-image"
label={<>Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
function PersonSearch({
onAdd,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
excludePubkeys: string[];
}) {
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
useEffect(() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [filteredProfiles, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isFetching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && filteredProfiles.length > 0) {
setDropdownOpen(true);
}
}}
placeholder="Search people to add..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{filteredProfiles.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
</div>
) : query.trim().length >= 2 && !isFetching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onToggleRole,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onToggleRole?: (pubkey: string) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar shape={getAvatarShape(metadata)} className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{/* Role badge — clickable if founder can toggle */}
<button
type="button"
onClick={onToggleRole ? () => onToggleRole(pubkey) : undefined}
disabled={!onToggleRole}
className={cn(
'flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full shrink-0 transition-colors',
role === 'moderator'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-primary/10 text-primary',
onToggleRole && 'cursor-pointer hover:opacity-80',
)}
title={onToggleRole ? 'Click to toggle role' : undefined}
>
{role === 'moderator' ? <Crown className="size-3" /> : <Users className="size-3" />}
{role === 'moderator' ? 'Moderator' : 'Member'}
</button>
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+227 -6
View File
@@ -1,15 +1,21 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, RotateCcw, AlertTriangle, Eye, EyeOff } from 'lucide-react';
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 { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -20,6 +26,7 @@ export function AdvancedSettings() {
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [aiOpen, setAiOpen] = useState(false);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
@@ -28,6 +35,73 @@ export function AdvancedSettings() {
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
const [modelDraft, setModelDraft] = useState(config.aiModel);
const [showApiKey, setShowApiKey] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
const commitBaseUrl = () => {
const trimmed = baseUrlDraft.trim().replace(/\/+$/, '');
if (!trimmed) {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
if (config.aiBaseURL !== DEFAULT_AI_BASE_URL) {
updateConfig((current) => ({ ...current, aiBaseURL: DEFAULT_AI_BASE_URL }));
toast({ title: 'Base URL reset to default' });
}
return;
}
if (trimmed !== config.aiBaseURL) {
updateConfig((current) => ({ ...current, aiBaseURL: trimmed }));
toast({ title: 'AI base URL updated' });
}
};
const commitApiKey = () => {
const trimmed = apiKeyDraft.trim();
if (trimmed !== config.aiApiKey) {
updateConfig((current) => ({ ...current, aiApiKey: trimmed }));
toast({ title: trimmed ? 'API key updated' : 'API key cleared (using NIP-98 auth)' });
}
};
const commitModel = () => {
const trimmed = modelDraft.trim();
if (!trimmed) {
setModelDraft(DEFAULT_AI_MODEL);
if (config.aiModel !== DEFAULT_AI_MODEL) {
updateConfig((current) => ({ ...current, aiModel: DEFAULT_AI_MODEL }));
toast({ title: 'AI model reset to default' });
}
return;
}
if (trimmed !== config.aiModel) {
updateConfig((current) => ({ ...current, aiModel: trimmed }));
toast({ title: 'AI model updated' });
}
};
const resetProviderDefaults = () => {
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
setApiKeyDraft('');
setModelDraft(DEFAULT_AI_MODEL);
updateConfig((current) => ({
...current,
aiBaseURL: DEFAULT_AI_BASE_URL,
aiApiKey: '',
aiModel: DEFAULT_AI_MODEL,
}));
toast({ title: 'Provider settings reset to defaults' });
};
const providerIsDefault =
config.aiBaseURL === DEFAULT_AI_BASE_URL &&
config.aiApiKey === '' &&
config.aiModel === DEFAULT_AI_MODEL;
const handleStatsPubkeyChange = (value: string) => {
setStatsPubkey(value);
@@ -42,6 +116,156 @@ export function AdvancedSettings() {
return (
<div>
{/* Agent Section */}
<div>
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
<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">Agent</span>
{aiOpen ? (
<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 border-b border-border">
{/* AI Base URL */}
<div>
<Label htmlFor="ai-base-url" className="text-sm font-medium">
Base URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
OpenAI-compatible <code className="bg-muted px-1 rounded">/v1</code> endpoint. An API key is required for endpoints that don't support NIP-98 auth.
</p>
<Input
id="ai-base-url"
type="url"
value={baseUrlDraft}
onChange={(e) => setBaseUrlDraft(e.target.value)}
onBlur={commitBaseUrl}
placeholder={DEFAULT_AI_BASE_URL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
</div>
{/* API Key */}
<div>
<Label htmlFor="ai-api-key" className="text-sm font-medium">
API key
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Optional. Required for endpoints that use standard API-key auth (e.g. OpenAI, Anthropic, OpenRouter).
</p>
<div className="flex gap-2">
<Input
id="ai-api-key"
type={showApiKey ? 'text' : 'password'}
value={apiKeyDraft}
onChange={(e) => setApiKeyDraft(e.target.value)}
onBlur={commitApiKey}
placeholder="Leave empty to use NIP-98 auth"
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowApiKey((value) => !value)}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* AI Model */}
<div>
<Label htmlFor="ai-model" className="text-sm font-medium">
Model
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
</p>
<Input
id="ai-model"
type="text"
value={modelDraft}
onChange={(e) => setModelDraft(e.target.value)}
onBlur={commitModel}
placeholder={DEFAULT_AI_MODEL}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{!providerIsDefault && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
onClick={resetProviderDefaults}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset provider to default
</Button>
)}
</div>
{/* AI System Prompt */}
<div>
<Label htmlFor="ai-system-prompt" className="text-sm font-medium">
System Prompt
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
The base system prompt sent to the AI. Supports <code className="bg-muted px-1 rounded">{'{{SAVED_FEEDS}}'}</code> and <code className="bg-muted px-1 rounded">{'{{USER_IDENTITY}}'}</code> placeholders.
</p>
<Textarea
id="ai-system-prompt"
value={systemPromptDraft}
onChange={(e) => setSystemPromptDraft(e.target.value)}
onBlur={() => {
const trimmed = systemPromptDraft.trim();
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
// If the user reverted back to the default text, store empty (meaning "use default")
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-base leading-relaxed"
/>
{config.aiSystemPrompt && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground mt-2"
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>
</CollapsibleContent>
</Collapsible>
</div>
{/* System Section (includes Stats Source) */}
<div>
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
@@ -188,10 +412,7 @@ export function AdvancedSettings() {
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>
<span className="text-base font-semibold">Error Reporting</span>
{sentryOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
+151 -191
View File
@@ -1,34 +1,28 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
import { CalendarDays, Clock, MapPin, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { Badge } from '@/components/ui/badge';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { Badge } from '@/components/ui/badge';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CalendarEventContentProps {
event: NostrEvent;
/** When true, limits the description to 2 lines for compact feed display. */
/** When true, renders a compact feed card. */
compact?: boolean;
className?: string;
}
/** Extract the first value for a given tag name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Collect all values for a repeated tag name. */
function getAllTags(tags: string[][], name: string): string[][] {
return tags.filter(([n]) => n === name);
}
/**
* Parse a location tag value. Some clients encode location as JSON
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
* Extract a human-readable string when possible, otherwise return the raw value.
*/
function parseLocation(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith('{')) return raw;
@@ -43,14 +37,12 @@ function parseLocation(raw: string): string {
return raw;
}
/** Date-only formatter: "Jan 15, 2026" */
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
@@ -59,52 +51,35 @@ const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
minute: '2-digit',
});
/** Time-only formatter: "3:00 PM" */
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
});
/** Check if two dates fall on the same calendar day. */
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
);
}
/**
* Format the date/time display for a NIP-52 calendar event.
*
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
*/
function formatEventDate(event: NostrEvent): string {
const start = getTag(event.tags, 'start');
if (!start) return '';
if (event.kind === 31922) {
// Date-based: start/end are YYYY-MM-DD strings
// Parse as UTC to avoid timezone shifting the date
const startDate = new Date(start + 'T00:00:00Z');
const startDate = new Date(`${start}T00:00:00Z`);
if (isNaN(startDate.getTime())) return start;
const end = getTag(event.tags, 'end');
if (end) {
const endDate = new Date(end + 'T00:00:00Z');
const endDate = new Date(`${end}T00:00:00Z`);
if (!isNaN(endDate.getTime()) && endDate > startDate) {
// Multi-day range: "Jan 15 - Jan 17, 2026"
// NIP-52: end date is exclusive, so display the last inclusive day
const lastDay = new Date(endDate.getTime() - 86400000);
if (lastDay > startDate) {
const startParts = dateFormatter.formatToParts(startDate);
const startStr = startParts
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
.join('')
.trim();
return `${startStr} ${dateFormatter.format(lastDay)}`;
const startStr = dateFormatter.format(startDate).replace(/, \d{4}$/, '');
return `${startStr} - ${dateFormatter.format(lastDay)}`;
}
}
}
@@ -113,7 +88,6 @@ function formatEventDate(event: NostrEvent): string {
}
if (event.kind === 31923) {
// Time-based: start/end are Unix timestamps
const startTs = parseInt(start, 10);
if (isNaN(startTs)) return start;
const startDate = new Date(startTs * 1000);
@@ -123,13 +97,10 @@ function formatEventDate(event: NostrEvent): string {
const endTs = parseInt(end, 10);
if (!isNaN(endTs) && endTs > startTs) {
const endDate = new Date(endTs * 1000);
if (isSameDay(startDate, endDate)) {
// Same day: "Jan 15, 2026 at 3:00 PM 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${timeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${timeFormatter.format(endDate)}`;
}
// Different days: "Jan 15, 2026 at 3:00 PM Jan 16, 2026 at 5:00 PM"
return `${dateTimeFormatter.format(startDate)} ${dateTimeFormatter.format(endDate)}`;
return `${dateTimeFormatter.format(startDate)} - ${dateTimeFormatter.format(endDate)}`;
}
}
@@ -139,173 +110,162 @@ function formatEventDate(event: NostrEvent): string {
return start;
}
function getEventEndTimestamp(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
const image = useMemo(() => sanitizeUrl(getTag(event.tags, 'image')), [event.tags]);
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
const hasContent = event.content.trim().length > 0;
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
const ended = useMemo(() => getEventEndTimestamp(event) < Math.floor(Date.now() / 1000), [event]);
const hasContent = event.content.trim().length > 0;
const participantPubkeys = useMemo(
() => participants.map(([, pubkey]) => pubkey).filter(Boolean),
[participants],
);
if (compact) {
return (
<div className={cn('mt-3 space-y-3', className)}>
{image && (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="w-full h-full object-cover" loading="lazy" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight line-clamp-2">{title ?? 'Untitled event'}</h3>
</div>
{ended ? (
<Badge variant="secondary" className="shrink-0">Ended</Badge>
) : dateDisplay ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[45%]">
<Clock className="size-3" />
<span className="truncate">{dateDisplay}</span>
</span>
) : null}
</div>
{dateDisplay && ended && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
<span>{dateDisplay}</span>
</div>
)}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{summary && !hasContent ? (
<p>{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{(location || participantPubkeys.length > 0) && (
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
{location ? (
<>
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate flex-1">{location}</span>
</>
) : (
<>
<Users className="h-4 w-4 shrink-0 text-primary" />
<span className="text-sm text-muted-foreground flex-1">Participants</span>
</>
)}
{participantPubkeys.length > 0 && (
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="sm" />
)}
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
);
}
return (
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
{compact ? (
/* ── Compact feed card matching reference design ── */
<>
{/* Cover image with capped height, or gradient placeholder */}
{image ? (
<div className="relative h-[180px] overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
{/* Participant avatars overlaid on the image */}
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
) : (
<div className="relative flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent h-[100px]">
<CalendarDays className="h-10 w-10 text-primary/30" />
{participantPubkeys.length > 0 && (
<div className="absolute bottom-2 left-3">
<RSVPAvatars pubkeys={participantPubkeys} maxVisible={4} size="md" />
</div>
)}
</div>
)}
{/* Event details below image */}
<div className="p-3 space-y-2">
{/* Title */}
{title && (
<h3 className="text-base font-bold leading-snug line-clamp-2">{title}</h3>
)}
{/* Date/time */}
{dateDisplay && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{/* Description snippet — hard-capped to ~2 lines */}
{(summary || hasContent) && (
<div className="text-sm text-muted-foreground max-h-[2.8em] overflow-hidden relative">
{summary && !hasContent ? (
<p className="line-clamp-2">{summary}</p>
) : (
<NoteContent event={event} className="text-sm" hideEmbedImages />
)}
</div>
)}
{/* Location pill */}
{location && (
<div className="flex items-center gap-2.5 rounded-lg bg-secondary/50 px-3 py-2">
<MapPin className="h-4 w-4 shrink-0 text-red-500" />
<span className="text-sm truncate">{location}</span>
</div>
)}
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0.5">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img src={image} alt={title ?? 'Calendar event'} className="h-full w-full object-cover" loading="lazy" />
</div>
) : (
/* ── Full detail layout (detail page, expanded view) ── */
<>
{/* Cover image or gradient header */}
{image ? (
<div className="aspect-video rounded-lg overflow-hidden">
<img
src={image}
alt={title ?? 'Calendar event'}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
{/* Event details */}
<div className="space-y-2 p-3">
{title && (
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
)}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
</span>
</div>
)}
{summary && !hasContent && (
<p className="text-sm text-muted-foreground">
{summary}
</p>
)}
{hasContent && (
<div>
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
</div>
)}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</>
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
<CalendarDays className="h-10 w-10 text-primary/30" />
</div>
)}
<div className="space-y-2 p-3">
{title && <h3 className="text-[15px] font-semibold leading-snug">{title}</h3>}
{dateDisplay && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{dateDisplay}</span>
</div>
)}
{location && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{location}</span>
</div>
)}
{participants.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>{participants.length} {participants.length === 1 ? 'participant' : 'participants'}</span>
</div>
)}
{summary && !hasContent && <p className="text-sm text-muted-foreground">{summary}</p>}
{hasContent && <NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{hashtags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
+150 -136
View File
@@ -1,6 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
CalendarDays,
@@ -9,10 +8,9 @@ import {
Users,
Check,
X as XIcon,
HelpCircle,
Share2,
Star,
Pencil,
ExternalLink,
Zap,
Link as LinkIcon,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
@@ -22,10 +20,15 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { NoteContent } from '@/components/NoteContent';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { PostActionBar } from '@/components/PostActionBar';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { RSVPAvatars } from '@/components/RSVPAvatars';
import { ZapDialog } from '@/components/ZapDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useComments } from '@/hooks/useComments';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
@@ -184,48 +187,47 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const rsvps = useEventRSVPs(eventCoord);
const myRsvp = useMyRSVP(eventCoord);
const publishRSVP = usePublishRSVP();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const [replyOpen, setReplyOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const canEdit = user?.pubkey === event.pubkey;
const [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
const [rsvpNote, setRsvpNote] = useState('');
const replyTree = useMemo((): ReplyNode[] => {
const buildNode = (comment: NostrEvent): ReplyNode => {
const children = commentsData?.getDirectReplies(comment.id) ?? [];
if (children.length <= 1) {
return { event: comment, children: children.map((child) => buildNode(child)) };
}
const activeStatus = selectedStatus ?? myRsvp.status;
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
const [first, ...rest] = children;
return {
event: comment,
children: [buildNode(first)],
hiddenChildren: rest.map((child) => buildNode(child)),
};
};
const handleRSVP = useCallback(async () => {
if (!activeStatus) return;
return [...(commentsData?.topLevelComments ?? [])]
.sort((a, b) => a.created_at - b.created_at)
.map((comment) => buildNode(comment));
}, [commentsData]);
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
if (status === myRsvp.status) return;
try {
await publishRSVP.mutateAsync({
eventCoord,
eventAuthorPubkey: event.pubkey,
status: activeStatus,
note: rsvpNote || undefined,
status,
});
setSelectedStatus(null);
setRsvpNote('');
toast({ title: 'RSVP updated' });
} catch {
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
}
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
const isAuthor = user?.pubkey === event.pubkey;
const showRSVP = !!user && !isAuthor;
const showRSVP = !!user;
return (
<div className="max-w-2xl mx-auto pb-16">
@@ -239,6 +241,15 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1">Event Details</h1>
{canEdit && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditOpen(true)}
aria-label="Edit event"
>
<Pencil className="size-5" />
</button>
)}
</div>
{/* ── Cover image ── */}
@@ -256,25 +267,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
<div className="px-5 mt-5 space-y-5">
{/* Title */}
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
{/* Organizer row + actions */}
{/* Organizer row */}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<PersonRow pubkey={event.pubkey} />
</div>
<div className="flex items-center gap-1 shrink-0">
<ZapDialog target={event}>
<button className="p-2 rounded-full hover:bg-secondary/60 transition-colors" aria-label="Zap">
<Zap className="size-5" />
</button>
</ZapDialog>
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
aria-label="Share"
>
<Share2 className="size-5" />
</button>
</div>
</div>
{/* Date & Location — sidebar-style pills */}
@@ -355,96 +352,113 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
</>
)}
{/* RSVP section */}
{showRSVP && (
<div className="rounded-[1.25rem] bg-background/85 p-4 space-y-3">
<h2 className="text-sm font-semibold px-1">Your RSVP</h2>
{myRsvp.status && !selectedStatus && (
<div className="px-1">
<Badge
variant="outline"
className={cn(
myRsvp.status === 'accepted' && 'border-green-500 text-green-600',
myRsvp.status === 'tentative' && 'border-amber-500 text-amber-600',
myRsvp.status === 'declined' && 'border-destructive text-destructive',
)}
>
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
</Badge>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
variant={activeStatus === 'accepted' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => setSelectedStatus('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={activeStatus === 'tentative' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => setSelectedStatus('tentative')}
>
<HelpCircle className="size-3.5 mr-1.5" /> Maybe
</Button>
<Button
size="sm"
variant={activeStatus === 'declined' ? 'default' : 'outline'}
className={cn('flex-1 rounded-full', activeStatus === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => setSelectedStatus('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
{activeStatus && (
<Textarea
placeholder="Add a note (optional)"
value={rsvpNote}
onChange={(e) => setRsvpNote(e.target.value)}
className="mt-1 resize-none rounded-xl"
rows={2}
/>
)}
{(hasChanged || (activeStatus && !myRsvp.status)) && (
<Button
size="sm"
onClick={handleRSVP}
disabled={publishRSVP.isPending}
className="w-full mt-1 rounded-full"
>
{publishRSVP.isPending ? 'Updating...' : myRsvp.status ? 'Update RSVP' : 'Submit RSVP'}
</Button>
)}
</div>
)}
{/* Attendees */}
{rsvps.total > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Maybe', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="size-4" /> Attendees
</h2>
<div className="space-y-2.5">
{([
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
] as const).map(([label, pks, cls]) => pks.length > 0 && (
<div key={label} className="flex items-center gap-3">
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
</div>
))}
</div>
</section>
</>
)}
{/* RSVP section */}
{showRSVP && (
<>
<Separator />
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Check className="size-4" /> RSVP
</h2>
<div className="flex gap-2">
<Button
size="sm"
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
onClick={() => handleRSVP('accepted')}
>
<Check className="size-3.5 mr-1.5" /> Going
</Button>
<Button
size="sm"
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
onClick={() => handleRSVP('tentative')}
>
<Star className="size-3.5 mr-1.5" /> Interested
</Button>
<Button
size="sm"
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
disabled={publishRSVP.isPending}
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
onClick={() => handleRSVP('declined')}
>
<XIcon className="size-3.5 mr-1.5" /> Can't Go
</Button>
</div>
</section>
</>
)}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-5 px-5"
/>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
{canEdit && (
<CreateCommunityEventDialog
open={editOpen}
onOpenChange={setEditOpen}
event={event}
/>
)}
<section>
{commentsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
</section>
)}
) : replyTree.length > 0 ? (
<div className="-mx-5">
<ThreadedReplyList roots={replyTree} />
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
No comments yet. Be the first to comment!
</div>
)}
</section>
</div>
</div>
);
+26 -8
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Crown, Shield, Users } from 'lucide-react';
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -18,6 +18,10 @@ interface CommunityCardProps {
event: NostrEvent;
/** Whether the current user founded this community. */
isFounded?: boolean;
/** Whether the current user is a validated member. */
isMember?: boolean;
/** Whether the current user has bookmarked this community (NIP-51 kind 10004). */
isBookmarked?: boolean;
className?: string;
}
@@ -25,7 +29,13 @@ interface CommunityCardProps {
* Compact card for displaying a community in a list.
* Shows image, name, description snippet, founder info, and moderator count.
*/
export function CommunityCard({ event, isFounded, className }: CommunityCardProps) {
export function CommunityCard({
event,
isFounded,
isMember,
isBookmarked,
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
const founderAuthor = useAuthor(event.pubkey);
@@ -42,8 +52,6 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
identifier: community.dTag,
});
const rankCount = community.ranks.length - 1; // Exclude rank 0
return (
<Link
to={`/${naddr}`}
@@ -75,12 +83,22 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded && (
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
)}
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Bookmarked
</Badge>
) : null}
</div>
{/* Description */}
@@ -115,10 +133,10 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp
{community.moderatorPubkeys.length}
</span>
)}
{rankCount > 0 && (
{community.memberBadgeATag && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Users className="size-3" />
{rankCount} rank{rankCount !== 1 ? 's' : ''}
Member badge
</span>
)}
</div>
+2 -29
View File
@@ -1,15 +1,12 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -43,7 +40,6 @@ function parseCommunityEvent(event: NostrEvent) {
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
@@ -68,22 +64,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
@@ -110,13 +90,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Share button */}
<div className="flex items-center">
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
+332 -50
View File
@@ -1,18 +1,28 @@
import { useMemo, useCallback, useState } from 'react';
import { useMemo, useCallback, useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
ArrowLeft,
Bookmark,
CalendarDays,
Crown,
Loader2,
MessageCircle,
Pencil,
Rss,
Shield,
ShieldBan,
Share2,
Target,
UserPlus,
Users,
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
@@ -20,24 +30,33 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { ComposeBox } from '@/components/ComposeBox';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityEvents } from '@/hooks/useCommunityEvents';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeed } from '@/hooks/useFeed';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useMuteList } from '@/hooks/useMuteList';
import { useNow } from '@/hooks/useNow';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent, type FeedItem } from '@/lib/feedUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -118,6 +137,127 @@ function ReplyCardSkeleton() {
);
}
function CommunityMemberFeed({ authorPubkeys, isMembershipLoading }: { authorPubkeys: string[]; isMembershipLoading: boolean }) {
const { muteItems } = useMuteList();
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
const queryKey = useMemo(() => ['feed', 'follows'], []);
const handleRefresh = usePageRefresh(queryKey);
const uniqueAuthors = useMemo(() => Array.from(new Set(authorPubkeys)), [authorPubkeys]);
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useFeed('follows', { authors: uniqueAuthors });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return (rawData.pages as { items: FeedItem[] }[])
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!key || seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
if (!isMembershipLoading && uniqueAuthors.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No active members found.
</div>
);
}
if (isMembershipLoading || isPending || (isLoading && !rawData)) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message="No posts from active community members yet." />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</PullToRefresh>
);
}
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getCalendarEventStart(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const date = new Date(`${start}T00:00:00Z`);
return isNaN(date.getTime()) ? 0 : Math.floor(date.getTime() / 1000);
}
const timestamp = parseInt(start, 10);
return isNaN(timestamp) ? 0 : timestamp;
}
function getCalendarEventEnd(event: NostrEvent): number {
const start = getTag(event.tags, 'start');
if (!start) return 0;
if (event.kind === 31922) {
const end = getTag(event.tags, 'end');
const endDate = new Date(`${end || start}T00:00:00Z`);
if (isNaN(endDate.getTime())) return 0;
return Math.floor(endDate.getTime() / 1000) + (end ? 0 : 86400);
}
const end = getTag(event.tags, 'end') || start;
const endTs = parseInt(end, 10);
return isNaN(endTs) ? 0 : endTs;
}
// ── Main component ────────────────────────────────────────────────────────────
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
@@ -133,6 +273,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [activeTab, setActiveTab] = useState('members');
const [composeOpen, setComposeOpen] = useState(false);
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
// Parse community definition
const community = useMemo(() => parseCommunityEvent(event), [event]);
@@ -156,6 +299,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
// Founder can add moderators + members; moderators (rank 0) can add members
const isFounder = !!user && user.pubkey === event.pubkey;
const canAddMembers = !!viewerMember && viewerMember.rank === 0;
// NIP-51 kind 10004 community bookmark toggle. Toasts are fired from inside
// the mutation so they survive even if this page unmounts mid-publish.
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
const bookmarked = !!communityATag && isCommunityBookmarked(communityATag);
const handleToggleBookmark = useCallback(() => {
if (!user || !communityATag || toggleCommunityBookmark.isPending) return;
toggleCommunityBookmark.mutate({ aTag: communityATag });
}, [user, communityATag, toggleCommunityBookmark]);
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
() => membership?.members.map((m) => m.pubkey) ?? [],
@@ -163,41 +322,19 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
);
useAuthors(allMemberPubkeys);
// Group members by rank
const membersByRank = useMemo(() => {
if (!membership || !community) return [];
const groups = new Map<number, CommunityMember[]>();
for (const m of membership.members) {
const list = groups.get(m.rank) ?? [];
list.push(m);
groups.set(m.rank, list);
const memberSections = useMemo(() => {
if (!membership) return [];
const leadership: CommunityMember[] = [];
const members: CommunityMember[] = [];
for (const member of membership.members) {
if (member.rank === 0) leadership.push(member);
else members.push(member);
}
// Build ordered groups with labels
const result: { rank: number; label: string; members: CommunityMember[] }[] = [];
const sortedRanks = Array.from(groups.keys()).sort((a, b) => a - b);
for (const rank of sortedRanks) {
const members = groups.get(rank)!;
let label: string;
if (rank === 0) {
label = 'Leadership';
} else {
// Find the badge a-tag for this rank from community definition
const tier = community.ranks.find((r) => r.rank === rank);
// Use the badge d-tag suffix as a label hint, or fall back to "Rank N"
if (tier?.badgeATag) {
const parts = tier.badgeATag.split(':');
const dTag = parts.slice(2).join(':');
// Try to extract a human-readable name from the d-tag (after the UUID prefix)
const namePart = dTag.split('-').pop();
label = namePart ? namePart.charAt(0).toUpperCase() + namePart.slice(1) : `Rank ${rank}`;
} else {
label = `Rank ${rank}`;
}
}
result.push({ rank, label, members });
}
return result;
}, [membership, community]);
return [
{ key: 'leadership', label: 'Leadership', members: leadership },
{ key: 'members', label: 'Members', members },
].filter((section) => section.members.length > 0);
}, [membership]);
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
@@ -205,6 +342,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
// ── Fundraising goals (NIP-75) ──────────────────────────────────────────────
const { data: goals, isLoading: goalsLoading } = useCommunityGoals(communityATag || undefined);
const { data: communityEvents, isLoading: eventsLoading } = useCommunityEvents(communityATag || undefined);
const now = useNow(60_000);
/** Check if a goal event's `closed_at` deadline has passed. */
@@ -235,14 +373,38 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
});
}, [moderatedGoals, membersOnly, rankMap, isExpired]);
const eventItems = useMemo(() => {
const moderated = applyCommunityModerationToEvents(communityEvents ?? [], moderation);
const visible = membersOnly ? moderated.filter((e) => rankMap.has(e.pubkey)) : moderated;
return [...visible].sort((a, b) => {
const aStart = getCalendarEventStart(a);
const bStart = getCalendarEventStart(b);
const aFuture = aStart >= now;
const bFuture = bStart >= now;
if (aFuture && !bFuture) return -1;
if (!aFuture && bFuture) return 1;
if (aFuture && bFuture) return aStart - bStart;
return bStart - aStart;
});
}, [communityEvents, moderation, membersOnly, rankMap, now]);
const activeEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) >= now),
[eventItems, now],
);
const pastEventItems = useMemo(
() => eventItems.filter((e) => getCalendarEventEnd(e) < now),
[eventItems, now],
);
const replyTree = useMemo((): ReplyNode[] => {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
// Filter: omit banned events and posts by banned members, then optionally
// restrict to chain-validated members when the "members only" toggle is
// active. The member filter is a presentation-layer choice — the NIP
// recommends it as the canonical-feed default, but users may opt out.
// restrict to validated members when the "members only" toggle is
// active. The member filter is a presentation-layer opt-in — the NIP
// lists it as a MAY feature, so users default to seeing everything.
const applyModeration = (events: NostrEvent[]): NostrEvent[] => {
const moderated = applyCommunityModerationToEvents(events, moderation);
if (!membersOnly) return moderated;
@@ -287,21 +449,33 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments & fundraising tabs ──────────────────────────
// ── FAB — visible on comments, fundraising, and members tabs ──────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'fundraising') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(true);
} else if (activeTab === 'members') {
setAddMemberOpen(true);
}
}, [activeTab]);
const fabIcon = activeTab === 'fundraising'
? <Target strokeWidth={3} size={18} />
: undefined; // default Plus icon for comments
: activeTab === 'members'
? <UserPlus className="size-5" />
: activeTab === 'events'
? <CalendarDays className="size-5" />
: undefined; // default Plus icon for comments
useLayoutOptions({
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
showFAB:
activeTab === 'comments'
|| activeTab === 'fundraising'
|| activeTab === 'events'
|| (activeTab === 'members' && canAddMembers),
onFabClick: handleFabClick,
fabIcon,
});
@@ -324,6 +498,27 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Community</h1>
{isFounder && community && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={() => setEditCommunityOpen(true)}
aria-label="Edit community"
>
<Pencil className="size-5" />
</button>
)}
{user && communityATag && (
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors disabled:opacity-50 disabled:pointer-events-none"
onClick={handleToggleBookmark}
disabled={toggleCommunityBookmark.isPending}
aria-label={bookmarked ? 'Remove community bookmark' : 'Bookmark community'}
aria-pressed={bookmarked}
aria-busy={toggleCommunityBookmark.isPending}
>
<Bookmark className={cn('size-5', bookmarked && 'fill-current')} />
</button>
)}
<button
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
onClick={handleShare}
@@ -373,51 +568,65 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* ── Tabs ── */}
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="feed"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Rss className="size-4 mr-1.5" />
Feed
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
<TabsTrigger
value="fundraising"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Target className="size-4 mr-1.5" />
Fundraising
</TabsTrigger>
<TabsTrigger
value="events"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<CalendarDays className="size-4 mr-1.5" />
Events
</TabsTrigger>
</TabsList>
{/* ── Members tab ── */}
<TabsContent value="members" className="mt-0">
{membersLoading ? (
<MembersSkeleton />
) : membersByRank.length === 0 ? (
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
</div>
) : (
<div className="divide-y divide-border">
{membersByRank.map(({ rank, label, members }) => (
<section key={rank} className="px-5 py-4">
{memberSections.map(({ key, label, members }) => (
<section key={key} className="px-5 py-4">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
{rank === 0 ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
{label}
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
</h3>
<div className="space-y-0.5">
{members.map((m) => {
let roleLabel: string | undefined;
if (rank === 0) {
if (m.rank === 0) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
// Determine if the current user can ban this member
@@ -444,6 +653,14 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</TabsContent>
{/* ── Feed tab ── */}
<TabsContent value="feed" className="mt-0">
<CommunityMemberFeed
authorPubkeys={allMemberPubkeys}
isMembershipLoading={membersLoading}
/>
</TabsContent>
{/* ── Comments tab ── */}
<TabsContent value="comments" className="mt-0">
<ComposeBox compact replyTo={event} />
@@ -502,6 +719,40 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
)}
</TabsContent>
{/* ── Events tab ── */}
<TabsContent value="events" className="mt-0">
{eventsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
{membersOnly && (communityEvents ?? []).length > 0
? 'No events from community members yet. Toggle the shield icon to see all events.'
: <>No events yet.{user ? ' Create one to get started!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{activeEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Events
</h4>
</div>
)}
{pastEventItems.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
</Tabs>
</CommunityModerationContext.Provider>
</div>
@@ -535,6 +786,37 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onOpenChange={setGoalDialogOpen}
/>
)}
{/* FAB-triggered event creation dialog for the events tab */}
{communityATag && (
<CreateCommunityEventDialog
communityATag={communityATag}
open={eventDialogOpen}
onOpenChange={setEventDialogOpen}
/>
)}
{/* Add member dialog */}
{canAddMembers && community && (
<AddMemberDialog
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
existingMemberPubkeys={allMemberPubkeys}
/>
)}
{/* Edit community dialog — founder only */}
{isFounder && community && (
<CreateCommunityDialog
open={editCommunityOpen}
onOpenChange={setEditCommunityOpen}
communityEvent={event}
community={community}
/>
)}
</div>
);
}
+331
View File
@@ -0,0 +1,331 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface CreateCommunityEventDialogProps {
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
event?: NostrEvent;
}
const MANAGED_EDIT_TAGS = new Set([
'd',
'title',
'alt',
'summary',
'location',
'image',
'start',
'end',
'D',
'start_tzid',
'end_tzid',
'A',
'K',
'P',
]);
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
function subtractDays(date: string, days: number): string {
return addDays(date, -days);
}
function formatLocalDateTimeFields(timestamp: string): { date: string; time: string } {
const parsed = parseInt(timestamp, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return { date: '', time: '' };
const date = new Date(parsed * 1000);
const pad = (n: number) => n.toString().padStart(2, '0');
return {
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
};
}
function toLocalTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
}
function parseCommunityAuthor(communityATag: string): string | undefined {
const [, pubkey] = communityATag.split(':');
return pubkey || undefined;
}
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const [step, setStep] = useState<1 | 2>(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const isEditing = !!event;
const effectiveCommunityATag = communityATag ?? event?.tags.find(([name]) => name === 'A')?.[1];
const isCommunityEvent = !!effectiveCommunityATag;
const resetForm = useCallback(() => {
setStep(1);
setTitle('');
setDescription('');
setImageUrl('');
setAllDay(true);
setStartDate('');
setStartTime('');
setEndDate('');
setEndTime('');
setLocation('');
setIsImageUploading(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
useEffect(() => {
if (!open || !event) return;
const titleTag = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const summaryTag = event.tags.find(([name]) => name === 'summary')?.[1] ?? '';
const imageTag = event.tags.find(([name]) => name === 'image')?.[1] ?? '';
const locationTag = event.tags.find(([name]) => name === 'location')?.[1] ?? '';
const startTag = event.tags.find(([name]) => name === 'start')?.[1] ?? '';
const endTag = event.tags.find(([name]) => name === 'end')?.[1] ?? '';
const isAllDay = event.kind === 31922;
setStep(1);
setTitle(titleTag);
setDescription(summaryTag || event.content);
setImageUrl(imageTag);
setLocation(locationTag);
setAllDay(isAllDay);
setIsImageUploading(false);
if (isAllDay) {
setStartDate(startTag);
setStartTime('');
setEndDate(endTag ? subtractDays(endTag, 1) : '');
setEndTime('');
return;
}
const startFields = formatLocalDateTimeFields(startTag);
const endFields = formatLocalDateTimeFields(endTag);
setStartDate(startFields.date);
setStartTime(startFields.time);
setEndDate(endFields.date);
setEndTime(endFields.time);
}, [event, open]);
const validateInfoStep = useCallback((): boolean => {
if (!title.trim()) {
toast({ title: 'Enter an event title', variant: 'destructive' });
return false;
}
return true;
}, [title, toast]);
const handleNext = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (isImageUploading) return;
if (!validateInfoStep()) return;
setStep(2);
}, [isImageUploading, validateInfoStep]);
const handleSubmit = useCallback(async () => {
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (!validateInfoStep()) return;
if (!startDate) {
toast({ title: 'Choose a start date', variant: 'destructive' });
return;
}
if (!allDay && !startTime) {
toast({ title: 'Choose a start time or turn on all-day', variant: 'destructive' });
return;
}
if (!allDay && endDate && !endTime) {
toast({ title: 'Add an end time or clear the end date', variant: 'destructive' });
return;
}
const trimmedTitle = title.trim();
const dTag = event?.tags.find(([name]) => name === 'd')?.[1] || `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = isEditing && event ? event.kind : 31922;
try {
const prev = isEditing && event
? await fetchFreshEvent(nostr, {
kinds: [event.kind],
authors: [event.pubkey],
'#d': [dTag],
})
: undefined;
const preservedTags = isEditing
? (prev?.tags ?? event?.tags ?? []).filter(([name]) => !MANAGED_EDIT_TAGS.has(name))
: [];
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
...preservedTags,
];
if (effectiveCommunityATag) {
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
if (communityAuthor) {
tags.push(['P', communityAuthor]);
}
}
if (description.trim()) {
tags.push(['summary', description.trim()]);
}
if (location.trim()) {
tags.push(['location', location.trim()]);
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) {
toast({ title: 'End date must be on or after the start date', variant: 'destructive' });
return;
}
tags.push(['end', addDays(endDate, 1)]);
}
} else {
if (!isEditing) kind = 31923;
const startTs = toLocalTimestamp(startDate, startTime);
if (!Number.isFinite(startTs) || startTs <= 0) {
toast({ title: 'Start date or time is invalid', variant: 'destructive' });
return;
}
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endTime) {
const effectiveEndDate = endDate || startDate;
const endTs = toLocalTimestamp(effectiveEndDate, endTime);
if (!Number.isFinite(endTs) || endTs <= startTs) {
toast({ title: 'End time must be after the start time', variant: 'destructive' });
return;
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: description.trim(),
tags,
prev: prev ?? undefined,
});
if (!isEditing) {
// Auto-RSVP the author as "accepted" so they appear in the attendees list.
// Best-effort: don't block on failure -- the event itself is already published.
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Silently ignore -- user can manually RSVP from the detail page if needed.
});
}
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(effectiveCommunityATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(effectiveCommunityATag);
},
}),
] : []),
]);
toast({ title: isEditing ? 'Event updated!' : 'Event created!' });
handleOpenChange(false);
} catch (err) {
toast({
title: 'Failed to create event',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [
allDay,
description,
endDate,
endTime,
effectiveCommunityATag,
handleOpenChange,
imageUrl,
isImageUploading,
isEditing,
location,
nostr,
publishEvent,
publishRSVP,
queryClient,
startDate,
startTime,
timezone,
title,
toast,
user,
validateInfoStep,
isCommunityEvent,
event,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<CalendarDays className="size-5 text-primary" />
{isEditing ? 'Edit Event' : 'Create Event'}
</DialogTitle>
<DialogDescription>
Step {step} of 2 · {step === 1 ? 'What is happening?' : 'When and where?'}
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => e.preventDefault()}>
<ScrollArea className="max-h-[62vh]">
<div className="px-5 pb-5 space-y-4">
{step === 1 ? (
<>
<div className="space-y-1.5">
<Label htmlFor="community-event-title">Title *</Label>
<Input
id="community-event-title"
placeholder="e.g. Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-description">Description (recommended)</Label>
<Textarea
id="community-event-description"
placeholder="Tell people what to expect..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<ImageUploadField
id="community-event-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Event image preview"
/>
</>
) : (
<>
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="community-event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">
{isEditing ? "Event type can't be changed while editing." : 'Turn off to add start and end times.'}
</p>
</div>
<Switch
id="community-event-all-day"
checked={allDay}
onCheckedChange={setAllDay}
disabled={isEditing}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-date">Start date *</Label>
<Input
id="community-event-start-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-date">End date (optional)</Label>
<Input
id="community-event-end-date"
type="date"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-3">
<div className="space-y-1.5">
<Label htmlFor="community-event-start-time">Start time *</Label>
<Input
id="community-event-start-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-event-end-time">End time (optional)</Label>
<Input
id="community-event-end-time"
type="time"
className="[color-scheme:light] dark:[color-scheme:dark]"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="community-event-location">Location (recommended)</Label>
<Input
id="community-event-location"
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<div className="flex items-center gap-2 border-t border-border px-5 py-4 bg-background">
{step === 1 ? (
<>
<Button type="button" variant="outline" className="flex-1" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="button" className="flex-1" onClick={handleNext} disabled={isImageUploading}>
{isImageUploading ? 'Uploading...' : 'Next'}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => setStep(1)}>
<ChevronLeft className="size-4" />
Back
</Button>
<Button type="button" className="flex-1" onClick={handleSubmit} disabled={isPending || isImageUploading}>
{isPending ? (isEditing ? 'Saving...' : 'Creating...') : isImageUploading ? 'Uploading...' : isEditing ? 'Save Event' : 'Create Event'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}
+44 -35
View File
@@ -11,6 +11,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -45,6 +46,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
const [summary, setSummary] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [deadlineDate, setDeadlineDate] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const resetForm = () => {
setTitle('');
@@ -52,11 +54,16 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
setSummary('');
setImageUrl('');
setDeadlineDate('');
setIsImageUploading(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
const sats = parseInt(amountSats, 10);
if (isNaN(sats) || sats <= 0) {
@@ -92,9 +99,11 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
tags.push(['image', sanitizedImage]);
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (deadlineDate) {
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
@@ -163,17 +172,29 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-amount">Target Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="goal-amount">Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
@@ -187,29 +208,17 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-image">Image URL (optional)</Label>
<Input
id="goal-image"
type="url"
placeholder="https://..."
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
/>
</div>
<ImageUploadField
id="goal-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Fundraising goal image preview"
/>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Goal'}
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
</Button>
</form>
</DialogContent>
+6 -19
View File
@@ -1,25 +1,12 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
const DORK_ANIMATION = [
'<[o_o]>',
'>[-_-]<',
'<[0_0]>',
'>[-_-]<',
];
/** Animated Dork face shown while the AI is thinking. */
/** Animated thinking indicator shown while the AI agent is processing. */
export function DorkThinking({ className }: { className?: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
}, 100);
return () => clearInterval(interval);
}, []);
return (
<pre className={cn('font-mono text-muted-foreground leading-none', className)}>{DORK_ANIMATION[frame]}</pre>
<div className={cn('flex items-center gap-1.5', className)}>
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:300ms]" />
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { Loader2, Upload, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { resizeImage } from '@/lib/resizeImage';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface ImageUploadFieldProps {
id: string;
label: ReactNode;
value: string;
onChange: (value: string) => void;
onUploadingChange?: (isUploading: boolean) => void;
placeholder?: string;
uploadText?: string;
uploadingText?: string;
uploadToastTitle?: string;
previewAlt?: string;
objectFit?: 'cover' | 'contain';
className?: string;
dropAreaClassName?: string;
disabled?: boolean;
}
export function ImageUploadField({
id,
label,
value,
onChange,
onUploadingChange,
placeholder = 'Paste an image URL, or upload above',
uploadText = 'Paste, drop, or click to upload an image',
uploadingText = 'Uploading image...',
uploadToastTitle = 'Image uploaded',
previewAlt = 'Image preview',
objectFit = 'cover',
className,
dropAreaClassName,
disabled,
}: ImageUploadFieldProps) {
const { config } = useAppContext();
const { toast } = useToast();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const imageInputRef = useRef<HTMLInputElement>(null);
const previewUrl = sanitizeUrl(value);
useEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
const handleImageFile = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) {
toast({ title: 'Invalid file', description: 'Please choose an image file.', variant: 'destructive' });
return;
}
try {
const uploadableFile = config.imageQuality === 'compressed'
? (await resizeImage(file)).file
: file;
const [[, url]] = await uploadFile(uploadableFile);
onChange(url);
toast({ title: uploadToastTitle });
} catch (err) {
toast({
title: 'Upload failed',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
}, [config.imageQuality, onChange, toast, uploadFile, uploadToastTitle]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || disabled) return;
for (const item of Array.from(items)) {
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (!file) return;
e.preventDefault();
void handleImageFile(file);
return;
}
}, [disabled, handleImageFile]);
const handleImageDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file) void handleImageFile(file);
}, [disabled, handleImageFile]);
const clearImage = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
if (imageInputRef.current) imageInputRef.current.value = '';
}, [onChange]);
return (
<div className={cn('space-y-1.5', className)} onPaste={handleImagePaste}>
<Label htmlFor={id}>{label}</Label>
<div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (!disabled) imageInputRef.current?.click();
}}
onDrop={handleImageDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) imageInputRef.current?.click();
}}
className={cn(
'relative flex min-h-28 w-full cursor-pointer flex-col items-center justify-center overflow-hidden rounded-t-xl border border-b-0 border-dashed border-border bg-secondary/20 text-center transition-colors hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60',
dropAreaClassName,
)}
>
{isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-xs">{uploadingText}</span>
</div>
) : previewUrl ? (
<>
<img
src={previewUrl}
alt={previewAlt}
className={cn('absolute inset-0 h-full w-full', objectFit === 'contain' ? 'object-contain p-3' : 'object-cover')}
/>
<button
type="button"
aria-label="Remove image"
onClick={clearImage}
disabled={disabled}
className="absolute right-2 top-2 rounded-full bg-background/90 p-1 text-muted-foreground shadow-sm transition-colors hover:text-destructive disabled:opacity-60"
>
<X className="size-4" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-2 px-4 text-muted-foreground">
<Upload className="size-5 opacity-50" />
<span className="text-xs">{uploadText}</span>
</div>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImageFile(file);
}}
/>
</div>
<Input
id={id}
type="url"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-t-none rounded-b-xl"
disabled={disabled}
/>
</div>
</div>
);
}
+6 -4
View File
@@ -10,10 +10,12 @@ interface MembersOnlyToggleProps {
/**
* Shield-icon toggle that controls the "members only" filter for community
* surfaces. When active (default), community feeds only show content authored
* by chain-validated members — matching the NIP's canonical-author
* recommendation. When inactive, the feed shows every event scoped to the
* community regardless of author.
* surfaces. When active, community feeds only show content authored by
* validated members. When inactive (default), the feed shows every event
* scoped to the community regardless of author.
*
* Per the flat-communities spec, members-only is a MAY feature — the
* protocol makes no recommendation, so the toggle is an opt-in UX choice.
*
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
* is global across community surfaces (Activities feed, per-community
+36 -4
View File
@@ -47,6 +47,7 @@ import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from '@/hooks/useBookmarks';
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
@@ -57,7 +58,7 @@ import { useOrganizers } from '@/hooks/useOrganizers';
import { usePinnedPosts } from '@/hooks/usePinnedPosts';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
// NOTE: `CommunityMenuContext` is derived automatically from
// `useCommunityModerationContext()`. Parents install a
// `CommunityModerationContext.Provider` to enable community-aware menu items.
@@ -368,7 +369,26 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
const bookmarked = isBookmarked(event.id);
const {
isBookmarked: isCommunityBookmarked,
toggleBookmark: toggleCommunityBookmark,
} = useCommunityBookmarks();
// Community events (kind 34550) are bookmarked via NIP-51 kind 10004
// using their addressable `a` tag coordinate, so the reference stays
// valid across community updates. Non-community events use the standard
// kind 10003 bookmark list keyed by event id.
const isCommunityEvent = event.kind === COMMUNITY_DEFINITION_KIND;
const communityATag = useMemo(() => {
if (!isCommunityEvent) return undefined;
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag) return undefined;
return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`;
}, [isCommunityEvent, event.pubkey, event.tags]);
const bookmarked = isCommunityEvent
? !!communityATag && isCommunityBookmarked(communityATag)
: isBookmarked(event.id);
const { isPinned, togglePin } = usePinnedNotes(user?.pubkey);
const pinned = isPinned(event.id);
const isOwnPost = user?.pubkey === event.pubkey;
@@ -411,7 +431,15 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
const handleBookmark = () => {
impactLight();
toggleBookmark.mutate(event.id);
if (isCommunityEvent) {
if (!communityATag) return;
// Success/error toasts are fired from the mutation itself in
// useCommunityBookmarks so they survive this dialog unmounting
// before the publish resolves.
toggleCommunityBookmark.mutate({ aTag: communityATag });
} else {
toggleBookmark.mutate(event.id);
}
close();
};
@@ -548,7 +576,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
label={
isCommunityEvent
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
: (bookmarked ? 'Remove Bookmark' : 'Bookmark')
}
onClick={handleBookmark}
/>
{user && (
+11 -9
View File
@@ -13,15 +13,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[200] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[200] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
+5 -8
View File
@@ -94,8 +94,7 @@ export function AIChatWidget() {
if (!user || !isAuthenticated) {
return (
<div className="flex flex-col items-center gap-3 py-6 px-3 text-center">
<pre className="text-xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<p className="text-xs text-muted-foreground">Log in to chat with Dork</p>
<p className="text-xs text-muted-foreground">Log in to chat with the Agent</p>
</div>
);
}
@@ -105,7 +104,6 @@ export function AIChatWidget() {
if (hasCredits === false) {
return (
<div className="flex flex-col items-center gap-3 py-6 px-3 text-center">
<pre className="text-xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<p className="text-xs text-muted-foreground leading-relaxed">
Grab some credits on{' '}
<a
@@ -116,13 +114,13 @@ export function AIChatWidget() {
>
Shakespeare
</a>
{' '}to chat with Dork.
{' '}to use the Agent.
</p>
<Link
to="/ai-chat"
to="/agent"
className="text-xs font-medium text-primary hover:underline"
>
Open AI Chat
Open Agent
</Link>
</div>
);
@@ -135,7 +133,6 @@ export function AIChatWidget() {
<div className="space-y-3 p-2">
{messages.length === 0 && !streamingContent && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<pre className="text-xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<p className="text-xs text-muted-foreground">Ask me anything...</p>
</div>
)}
@@ -187,7 +184,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 : (message.content ?? []).map((c) => c.text ?? '').join('');
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
+8
View File
@@ -253,6 +253,14 @@ export interface AppConfig {
/** Show developer/debug DM UI affordances. */
devMode?: boolean;
};
/** Base URL for the AI chat-completions provider (OpenAI-compatible /v1 endpoint). */
aiBaseURL: string;
/** API key for the AI provider. Empty string = use NIP-98 auth (only valid for Shakespeare). */
aiApiKey: string;
/** AI model identifier sent to the provider (e.g. "grok-4.1-fast", "claude-opus-4.6"). */
aiModel: string;
/** Custom system prompt for the Agent. Empty string = use the default template. */
aiSystemPrompt: string;
}
/** Configuration for a single widget in the right sidebar. */
+1 -1
View File
@@ -12,7 +12,7 @@ export interface CommunityModerationContextValue {
communityATag: string;
/** Resolved moderation data (bans, reports, content warnings). */
moderation: CommunityModeration;
/** Chain-validated rank lookup (pubkey rank). Includes banned members for authority checks only. */
/** Flat membership lookup (pubkey -> authority rank). Includes banned members for authority checks only. */
rankMap: Map<string, CommunityMember>;
}
+489
View File
@@ -0,0 +1,489 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { useShakespeare, sortModelsByCost, type ChatMessage, type Model } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useAIChatTools, TOOLS, TOOL_SUMMARIES } from '@/hooks/useAIChatTools';
import { type DisplayMessage, type ToolCall } from '@/lib/aiChatTools';
import { buildSystemPrompt, type UserIdentity } from '@/lib/aiChatSystemPrompt';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Persistence ───
const CHAT_STORAGE_KEY_PREFIX = 'agora:ai-chat-messages';
function chatStorageKey(appId: string, pubkey: string): string {
return `${CHAT_STORAGE_KEY_PREFIX}:${appId}:${pubkey}`;
}
/** 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(),
noticeVariant: z.enum(['info', 'error']).optional(),
});
const StoredMessagesSchema = z.array(StoredMessageSchema);
function loadMessages(storageKey: string): DisplayMessage[] {
try {
const raw = localStorage.getItem(storageKey);
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(storageKey);
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 [];
}
}
/** Persist messages and return the serialized byte size. */
function saveMessages(storageKey: string, messages: DisplayMessage[]): number {
try {
const stored = messages.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() }));
const json = JSON.stringify(stored);
localStorage.setItem(storageKey, json);
return new Blob([json]).size;
} catch {
// Storage full or unavailable — silently ignore
return 0;
}
}
/** Measure byte size of the current persisted messages without re-serializing. */
function measureStorageBytes(storageKey: string): number {
try {
const raw = localStorage.getItem(storageKey);
return raw ? new Blob([raw]).size : 0;
} catch {
return 0;
}
}
/** Conservative localStorage budget for chat messages (4 MB). */
const MAX_STORAGE_BYTES = 4 * 1024 * 1024;
// ─── Hook ───
export function useAIChatSession() {
const { user, metadata } = useCurrentUser();
const { config } = useAppContext();
const { sendStreamingMessage, getAvailableModels, getCreditsBalance, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { executeToolCall, savedFeeds } = useAIChatTools();
const storageKey = user ? chatStorageKey(config.appId, user.pubkey) : null;
const [messages, setMessages] = useState<DisplayMessage[]>([]);
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 [models, setModels] = useState<Model[]>([]);
const selectedModel = config.aiModel || defaultModel;
// Capacity tracking
const [lastPromptTokens, setLastPromptTokens] = useState(0);
const [storageBytes, setStorageBytes] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const [loadedStorageKey, setLoadedStorageKey] = useState<string | null>(null);
// Load messages from the current user's scoped storage key.
useEffect(() => {
if (!storageKey) {
setLoadedStorageKey(null);
setMessages([]);
setStorageBytes(0);
return;
}
setMessages(loadMessages(storageKey));
setStorageBytes(measureStorageBytes(storageKey));
setLoadedStorageKey(storageKey);
}, [storageKey]);
// Persist messages to localStorage and update storage bytes
useEffect(() => {
if (!storageKey || loadedStorageKey !== storageKey) return;
const bytes = saveMessages(storageKey, messages);
setStorageBytes(bytes);
}, [storageKey, loadedStorageKey, messages]);
// Scroll to bottom on new messages or streaming text updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Fetch available models (for default model + context_window lookup)
useEffect(() => {
if (!user) return;
let cancelled = false;
getAvailableModels()
.then((response) => {
if (cancelled) return;
setModels(response.data);
if (!config.aiModel) {
const sorted = sortModelsByCost(response.data);
if (sorted.length > 0) {
setDefaultModel(sorted[0].id);
}
}
})
.catch(() => {});
return () => { cancelled = true; };
}, [user, config.aiModel, getAvailableModels]);
// Compute capacity ratio (0 to 1) — max of token usage and storage usage
const contextWindow = useMemo(() => {
if (!selectedModel || models.length === 0) return 0;
const model = models.find((m) => m.id === selectedModel || m.fullId === selectedModel);
return model?.context_window ?? 0;
}, [selectedModel, models]);
const capacity = useMemo(() => {
const tokenRatio = contextWindow > 0 && lastPromptTokens > 0
? lastPromptTokens / contextWindow
: 0;
const storageRatio = storageBytes / MAX_STORAGE_BYTES;
return Math.min(Math.max(tokenRatio, storageRatio), 1);
}, [lastPromptTokens, contextWindow, storageBytes]);
// Build the system prompt — dynamic based on 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(config.aiSystemPrompt || undefined, savedFeedLabels, userIdentity),
[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;
}
if (cmd === '/tools') {
const listing = TOOL_SUMMARIES.map((t) => `- \`${t.name}\`${t.summary}`).join('\n');
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `**Available tools:**\n\n${listing}`,
timestamp: new Date(),
noticeVariant: 'info',
}]);
setInput('');
return;
}
// Unknown command — show feedback in chat
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `Unknown command \`${trimmed.split(' ')[0]}\`. Available commands: \`/new\`, \`/clear\`, \`/tools\`.`,
timestamp: new Date(),
noticeVariant: 'info',
}]);
setInput('');
return;
}
if (!selectedModel) return;
// Block sends when conversation capacity is exhausted
if (capacity >= 1) {
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: 'This conversation has reached its limit. Use /clear to start a fresh conversation.',
timestamp: new Date(),
noticeVariant: 'error',
}]);
setInput('');
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('');
let streamAccumulator = '';
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
streamAccumulator = '';
const response = await sendStreamingMessage(
apiMessages,
selectedModel,
(chunk) => {
streamAccumulator += chunk;
setStreamingText(streamAccumulator);
},
{ tools: TOOLS },
controller.signal,
);
// Stream finished — clear the streaming text and update token usage
setStreamingText('');
if (response.usage.prompt_tokens > 0) {
setLastPromptTokens(response.usage.prompt_tokens);
}
const choice = response.choices[0];
const assistantMsg = choice.message;
if (!assistantMsg.tool_calls || assistantMsg.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 assistantMsg.tool_calls) {
if (controller.signal.aborted) break;
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments);
} catch (parseErr) {
console.error(
`[AI tool call] Failed to parse arguments for "${tc.function.name}":`,
parseErr instanceof Error ? parseErr.message : parseErr,
'\nRaw arguments string:',
JSON.stringify(tc.function.arguments),
);
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: assistantMsg.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) {
// User-initiated stop — preserve whatever was streamed so far
if (controller.signal.aborted) {
if (streamAccumulator.trim()) {
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: streamAccumulator,
timestamp: new Date(),
}]);
}
return;
}
// Surface unexpected errors (e.g. buildApiMessages failure, loop bookkeeping)
// so the user gets feedback instead of streaming silently stopping.
// API-level errors are already surfaced via apiError from useShakespeare.
const errorText = err instanceof Error ? err.message : 'An unexpected error occurred.';
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `Something went wrong: ${errorText}`,
timestamp: new Date(),
noticeVariant: 'error',
}]);
} finally {
abortRef.current = null;
setIsStreaming(false);
setStreamingText('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClear is stable (only depends on storageKey+clearError which are already covered)
}, [input, selectedModel, isStreaming, messages, capacity, 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([]);
if (storageKey) localStorage.removeItem(storageKey);
setLastPromptTokens(0);
setStorageBytes(0);
clearError();
}, [storageKey, clearError]);
return {
// State
messages,
input,
setInput,
isStreaming,
streamingText,
selectedModel,
apiLoading,
apiError,
messagesEndRef,
// Capacity
capacity,
lastPromptTokens,
contextWindow,
storageBytes,
maxStorageBytes: MAX_STORAGE_BYTES,
// Actions
handleSend,
handleStop,
handleKeyDown,
handleClear,
getCredits: getCreditsBalance,
};
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { truncateToolResult } from '@/lib/tools/truncateToolResult';
import { toolToOpenAI } from '@/lib/tools/toolToOpenAI';
import { SearchUsersTool } from '@/lib/tools/SearchUsersTool';
import { SearchFollowPacksTool } from '@/lib/tools/SearchFollowPacksTool';
import { FetchPageTool } from '@/lib/tools/FetchPageTool';
import { FetchEventTool } from '@/lib/tools/FetchEventTool';
import { GetFeedTool } from '@/lib/tools/GetFeedTool';
import type { Tool, ToolContext, ToolResult } from '@/lib/tools/Tool';
// ─── Tool Registry ───
/** All registered tools, keyed by name. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TOOL_REGISTRY: Record<string, Tool<any>> = {
search_users: SearchUsersTool,
search_follow_packs: SearchFollowPacksTool,
fetch_page: FetchPageTool,
fetch_event: FetchEventTool,
get_feed: GetFeedTool,
};
/** OpenAI-formatted tool definitions derived from the registry. */
export const TOOLS = Object.entries(TOOL_REGISTRY).map(
([name, tool]) => toolToOpenAI(name, tool),
);
/** Short human-readable summaries for each tool (name → first sentence of description). */
export const TOOL_SUMMARIES: { name: string; summary: string }[] = Object.entries(TOOL_REGISTRY).map(
([name, tool]) => ({
name,
summary: tool.description.split(/[.\n]/)[0].trim(),
}),
);
// ─── Hook ───
export function useAIChatTools() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { savedFeeds } = useSavedFeeds();
/** Build a ToolContext from current hook values. */
const buildContext = useCallback((): ToolContext => ({
nostr,
user: user ? { pubkey: user.pubkey } : undefined,
config: {
corsProxy: config.corsProxy,
},
savedFeeds,
}), [nostr, user, config, savedFeeds]);
const executeToolCall = useCallback(async (name: string, rawArgs: Record<string, unknown>): Promise<ToolResult> => {
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 };
}
+102 -63
View File
@@ -16,23 +16,39 @@ import {
} from '@/lib/communityUtils';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { getPaginationCursor } from '@/lib/feedUtils';
import { queryAll } from '@/lib/queryAll';
/** Internal result type — events plus per-community moderation/membership data. */
interface ActivityFeedResult {
events: NostrEvent[];
/** Moderation data keyed by community A tag. */
moderationByATag: Map<string, CommunityModeration>;
/** Chain-validated rank maps keyed by community A tag (pre-moderation, for authority checks). */
/** Flat authority maps keyed by community A tag (pre-moderation, for authority checks). */
rankMapByATag: Map<string, Map<string, CommunityMember>>;
/** Cursor for the next comments/goals page. */
oldestActivityTimestamp: number;
/** Whether at least one paginated activity filter returned a full page. */
hasMoreActivity: boolean;
/** Cursor for the next comments page, when comments still have more events. */
commentsNextUntil?: number;
/** Cursor for the next goals page, when goals still have more events. */
goalsNextUntil?: number;
/** Whether comments still have more events. */
hasMoreComments: boolean;
/** Whether goals still have more events. */
hasMoreGoals: boolean;
}
interface ActivityFeedPageParam {
includeComments: boolean;
includeGoals: boolean;
commentsUntil?: number;
goalsUntil?: number;
}
const EMPTY_MODERATION_BY_A_TAG: ReadonlyMap<string, CommunityModeration> = new Map();
const EMPTY_RANK_MAP_BY_A_TAG: ReadonlyMap<string, Map<string, CommunityMember>> = new Map();
const ACTIVITY_PAGE_SIZE = 100;
const INITIAL_PAGE_PARAM: ActivityFeedPageParam = {
includeComments: true,
includeGoals: true,
};
/**
* Fetches a chronological activity feed for communities the current user
@@ -68,25 +84,29 @@ export function useCommunityActivityFeed() {
events: [],
moderationByATag: new Map(),
rankMapByATag: new Map(),
oldestActivityTimestamp: Math.floor(Date.now() / 1000),
hasMoreActivity: false,
hasMoreComments: false,
hasMoreGoals: false,
};
}
const timeout = AbortSignal.timeout(8_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
const until = pageParam as number | undefined;
const page = (pageParam as ActivityFeedPageParam | undefined) ?? INITIAL_PAGE_PARAM;
// Collect all badge a-tag coordinates across all communities for membership resolution
const allBadgeATags: string[] = [];
for (const entry of myCommunities) {
for (const rank of entry.community.ranks) {
if (rank.badgeATag) allBadgeATags.push(rank.badgeATag);
}
}
const awardFilters = myCommunities
.filter((entry) => !!entry.community.memberBadgeATag)
.map((entry) => ({
kinds: [BADGE_AWARD_KIND],
authors: [entry.community.founderPubkey, ...entry.community.moderatorPubkeys],
'#a': [entry.community.memberBadgeATag!],
limit: 500,
}));
// Fetch community definitions, comments, reports, badge awards, and goals in parallel
const [definitionEvents, comments, reports, awards, goals] = await Promise.all([
// Fetch community definitions, comments, membership awards, and goals in parallel.
// Awards are exhausted per-community with `queryAll` so every community's
// membership is complete, regardless of how many communities the user
// belongs to. See src/lib/queryAll.ts.
const [definitionEvents, comments, awards, goals] = await Promise.all([
// The community definitions themselves
nostr.query(
[{
@@ -98,41 +118,35 @@ export function useCommunityActivityFeed() {
{ signal: combinedSignal },
),
// Kind 1111 comments scoped to these communities via uppercase A tag
nostr.query(
[{
kinds: [1111],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
// Kind 1984 reports scoped to these communities
nostr.query(
[{
kinds: [REPORT_KIND],
'#A': aTags,
limit: 500,
}],
{ signal: combinedSignal },
),
// Badge awards for membership resolution
allBadgeATags.length > 0
page.includeComments
? nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#a': allBadgeATags, limit: 500 }],
[{
kinds: [1111],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.commentsUntil ? { until: page.commentsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([]),
: Promise.resolve([] as NostrEvent[]),
// Flat membership awards, one exhaustive query per community.
awardFilters.length > 0
? Promise.all(
awardFilters.map((f) => queryAll(nostr, f, { signal: combinedSignal })),
).then((pages) => pages.flat())
: Promise.resolve([] as NostrEvent[]),
// NIP-75 zap goals linked to these communities (lowercase a tag)
nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(until ? { until } : {}),
}],
{ signal: combinedSignal },
),
page.includeGoals
? nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.goalsUntil ? { until: page.goalsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([] as NostrEvent[]),
]);
// ── Resolve membership and moderation per community ──
@@ -142,25 +156,38 @@ export function useCommunityActivityFeed() {
// only be filtered from community A's posts, not from community B.
//
// We do **not** seed the `['community-members', aTag]` cache from
// this hook. The activity feed uses shared relay limits across all
// subscribed communities (awards and reports both capped at 500
// total), so per-community results can be incomplete. Overwriting
// the members cache with incomplete data would silently corrupt
// membership, authority, and moderation state on detail pages.
// `useCommunityMembers` is the authoritative per-community fetch.
const moderationByATag = new Map<string, CommunityModeration>();
// this hook. Even with exhaustive `queryAll` paging, the per-community
// fetch in `useCommunityMembers` may apply different filters or
// trigger a fresh read; keeping it authoritative avoids stale writes.
const rankMapByATag = new Map<string, Map<string, CommunityMember>>();
const reportAuthorSet = new Set<string>();
for (const entry of myCommunities) {
const community = entry.community;
// Resolve membership for this community.
// Resolve flat membership for this community.
const fullMembership = resolveMembership(community, awards);
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
reportAuthorSet.add(m.pubkey);
}
rankMapByATag.set(community.aTag, rankMap);
}
const reports = reportAuthorSet.size > 0
? await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: [...reportAuthorSet], '#A': aTags, limit: 500 },
{ signal: combinedSignal },
)
: [];
const moderationByATag = new Map<string, CommunityModeration>();
for (const entry of myCommunities) {
const community = entry.community;
const rankMap = rankMapByATag.get(community.aTag) ?? new Map<string, CommunityMember>();
// Resolve moderation. The resolver filters `reports` by matching
// `A` tag internally, so we can pass the full cross-community
@@ -183,12 +210,17 @@ export function useCommunityActivityFeed() {
};
// ── Merge, deduplicate, and filter ──
const knownCommunityATags = new Set(aTags);
const seen = new Set<string>();
const merged: NostrEvent[] = [];
for (const event of [...definitionEvents, ...comments, ...goals]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
if (event.kind === COMMUNITY_DEFINITION_KIND) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag || !knownCommunityATags.has(`${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`)) continue;
}
if (!isAllowed(event)) continue;
merged.push(event);
}
@@ -196,8 +228,8 @@ export function useCommunityActivityFeed() {
// Sort by created_at descending
merged.sort((a, b) => b.created_at - a.created_at);
const paginatedActivity = [...comments, ...goals];
const oldestActivityTimestamp = getPaginationCursor(paginatedActivity);
const hasMoreComments = page.includeComments && comments.length === ACTIVITY_PAGE_SIZE;
const hasMoreGoals = page.includeGoals && goals.length === ACTIVITY_PAGE_SIZE;
// Seed the ['event', id] cache so embedded previews (quotes, reply
// context, etc.) resolve instantly instead of refetching.
@@ -211,15 +243,22 @@ export function useCommunityActivityFeed() {
events: merged,
moderationByATag,
rankMapByATag,
oldestActivityTimestamp,
hasMoreActivity: comments.length === ACTIVITY_PAGE_SIZE || goals.length === ACTIVITY_PAGE_SIZE,
commentsNextUntil: hasMoreComments ? getPaginationCursor(comments) - 1 : undefined,
goalsNextUntil: hasMoreGoals ? getPaginationCursor(goals) - 1 : undefined,
hasMoreComments,
hasMoreGoals,
};
},
getNextPageParam: (lastPage) => {
if (!lastPage.hasMoreActivity) return undefined;
return lastPage.oldestActivityTimestamp - 1;
if (!lastPage.hasMoreComments && !lastPage.hasMoreGoals) return undefined;
return {
includeComments: lastPage.hasMoreComments,
includeGoals: lastPage.hasMoreGoals,
commentsUntil: lastPage.commentsNextUntil,
goalsUntil: lastPage.goalsNextUntil,
} satisfies ActivityFeedPageParam;
},
initialPageParam: undefined as number | undefined,
initialPageParam: INITIAL_PAGE_PARAM,
enabled: !communitiesLoading && aTags.length > 0,
staleTime: 2 * 60_000,
gcTime: 30 * 60_000,
+139
View File
@@ -0,0 +1,139 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { parseATagCoordinate } from '@/lib/nostrEvents';
import { toast } from '@/hooks/useToast';
/** NIP-51 Communities list — kind 10004. */
export const COMMUNITIES_LIST_KIND = 10004;
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
/** Parse and validate a NIP-51 community list coordinate. */
export function parseCommunityBookmarkATag(aTag: string): { pubkey: string; dTag: string } | undefined {
const coord = parseATagCoordinate(aTag);
if (!coord || coord.kind !== COMMUNITY_DEFINITION_KIND) return undefined;
if (!HEX_PUBKEY_RE.test(coord.pubkey) || !coord.identifier) return undefined;
return { pubkey: coord.pubkey, dTag: coord.identifier };
}
/**
* Hook to manage the user's NIP-51 Communities list (kind 10004).
*
* This list stores `a` tag coordinates for kind 34550 community definitions
* that the user has bookmarked / "saved". Unlike `useBookmarks` (kind 10003)
* which targets event IDs, this list targets addressable coordinates so the
* reference remains stable across community updates.
*/
export function useCommunityBookmarks() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query the user's communities list (kind 10004 — replaceable event)
const listQuery = useQuery({
queryKey: ['community-bookmarks', user?.pubkey],
queryFn: async () => {
if (!user) return null;
const events = await nostr.query([{
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
limit: 1,
}]);
return events[0] ?? null;
},
enabled: !!user,
});
// Extract bookmarked community a-tags (only `34550:` coordinates)
const bookmarkedATags: string[] = (listQuery.data?.tags ?? [])
.filter(([name, value]) =>
name === 'a' && typeof value === 'string' && !!parseCommunityBookmarkATag(value),
)
.map(([, value]) => value);
/** Check if a community `a` tag coordinate is bookmarked. */
function isBookmarked(aTag: string): boolean {
return bookmarkedATags.includes(aTag);
}
/**
* Toggle bookmark for a given community coordinate.
* `aTag` is expected to be a `34550:<pubkey>:<d-tag>` string.
* `relayHint` is optional — appended to the tag per NIP-51 when provided.
*/
const toggleBookmark = useMutation({
mutationFn: async ({ aTag, relayHint }: { aTag: string; relayHint?: string }) => {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest kind 10004 from relays before mutating
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITIES_LIST_KIND],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
const currentlyBookmarked = currentTags.some(
([name, value]) => name === 'a' && value === aTag,
);
let newTags: string[][];
if (currentlyBookmarked) {
// Remove all matching a-tags for this coordinate
newTags = currentTags.filter(
([name, value]) => !(name === 'a' && value === aTag),
);
} else {
// Append the new bookmark per NIP-51 recommendation
const newTag: string[] = relayHint ? ['a', aTag, relayHint] : ['a', aTag];
newTags = [...currentTags, newTag];
}
await publishEvent({
kind: COMMUNITIES_LIST_KIND,
content: prev?.content ?? '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
prev: prev ?? undefined,
});
// Return whether this was a remove or add so onSuccess can pick the
// right toast wording. Callbacks live on the mutation (not per-call)
// so they still fire when the triggering UI (e.g. a dialog) unmounts
// before the publish resolves.
return { removed: currentlyBookmarked };
},
onSuccess: ({ removed }) => {
queryClient.invalidateQueries({ queryKey: ['community-bookmarks', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['my-communities'] });
toast({
title: removed ? 'Community removed from bookmarks' : 'Community bookmarked',
});
},
onError: () => {
toast({
title: 'Failed to update bookmark',
variant: 'destructive',
});
},
});
return {
/** The kind 10004 list event itself. */
listEvent: listQuery.data,
/** Array of bookmarked community `a` tag coordinates. */
bookmarkedATags,
/** Whether the list query is still loading. */
isLoading: listQuery.isLoading,
/** Check whether a given `a` tag coordinate is bookmarked. */
isBookmarked,
/** Toggle a community bookmark on/off. */
toggleBookmark,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
const CALENDAR_EVENT_KINDS = [31922, 31923];
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function isValidCalendarEvent(event: NostrEvent): boolean {
if (!CALENDAR_EVENT_KINDS.includes(event.kind)) return false;
const d = getTag(event.tags, 'd');
const title = getTag(event.tags, 'title');
const start = getTag(event.tags, 'start');
if (!d || !title || !start) return false;
if (event.kind === 31922) {
return /^\d{4}-\d{2}-\d{2}$/.test(start);
}
const startTs = parseInt(start, 10);
return Number.isFinite(startTs) && startTs > 0;
}
/** Fetches NIP-52 calendar events scoped to a community via the uppercase `A` tag. */
export function useCommunityEvents(communityATag: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['community-events', communityATag],
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
if (!communityATag) return [];
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: CALENDAR_EVENT_KINDS, '#A': [communityATag], limit: 50 }],
{ signal: combinedSignal },
);
return events.filter(isValidCalendarEvent);
},
enabled: !!communityATag,
staleTime: 60_000,
});
}
+26 -23
View File
@@ -15,21 +15,22 @@ import {
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
import { queryAll } from '@/lib/queryAll';
interface CommunityMembersResult {
/** Resolved membership with banned members removed. Use `members` to list active community members. */
membership: CommunityMembership;
/** Resolved moderation data (bans, reports, content warnings). */
moderation: CommunityModeration;
/** Chain-validated rank lookup (pubkey → rank) BEFORE moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
/** Flat authority lookup before moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
rankMap: Map<string, CommunityMember>;
}
/**
* Fetch and resolve the full membership tree and moderation state for a community.
* Fetch and resolve flat membership and moderation state for a community.
*
* Queries badge awards (kind 8) and reports (kind 1984),
* then runs the chain validation algorithm with moderation overlay.
* Queries founder/moderator-authored membership awards (kind 8), then
* queries member-authored reports and bans (kind 1984).
*/
export function useCommunityMembers(community: ParsedCommunity | null | undefined) {
const { nostr } = useNostr();
@@ -47,41 +48,43 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
// Collect all badge a-tag coordinates from the community definition
const badgeATags = community.ranks
.filter((r) => r.badgeATag)
.map((r) => r.badgeATag!);
const awardAuthors = [community.founderPubkey, ...community.moderatorPubkeys];
// Fetch awards and reports in parallel
const [awards, reports] = await Promise.all([
badgeATags.length > 0
? nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }],
{ signal: combinedSignal },
)
: Promise.resolve([]),
nostr.query(
[{ kinds: [REPORT_KIND], '#A': [community.aTag], limit: 500 }],
// Exhaustive paging: awards and reports are unbounded sets that grow
// with the community. `queryAll` pages with `until` until the relay
// drains, capped at 5_000 events / 10 pages so worst-case cost is
// bounded. See src/lib/queryAll.ts.
const awards = community.memberBadgeATag
? await queryAll(
nostr,
{ kinds: [BADGE_AWARD_KIND], authors: awardAuthors, '#a': [community.memberBadgeATag], limit: 500 },
{ signal: combinedSignal },
),
]);
)
: [];
// Step 1-2: Resolve full membership (needed for authority checks)
const fullMembership = resolveMembership(community, awards);
// Build rank lookup for authority checks (includes all chain-validated members, even those later banned)
// Build authority lookup for checks (includes members even if later banned).
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
}
// Step 3: Resolve moderation using the rank map. The resolver
const reportAuthors = fullMembership.members.map((member) => member.pubkey);
const reports = await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: reportAuthors, '#A': [community.aTag], limit: 500 },
{ signal: combinedSignal },
);
// Step 3: Resolve moderation using the flat membership map. The resolver
// filters by `A` tag internally; we pass all reports as-is since
// the relay query already scoped them to this community.
const moderation = resolveCommunityModeration(community.aTag, reports, rankMap);
// Step 4: Apply moderation overlay — filter banned members from the
// already-computed membership rather than re-running chain validation.
// already-computed membership.
const membership: CommunityMembership = {
members: fullMembership.members.filter(
(m) => !moderation.bannedPubkeys.has(m.pubkey),
+8
View File
@@ -116,6 +116,14 @@ export interface EncryptedSettings {
};
/** Letter preferences (stationery, font, frame, closing, signature, inbox filters) */
letterPreferences?: LetterPreferences;
/** Base URL for the AI chat-completions provider */
aiBaseURL?: string;
/** API key for the AI provider */
aiApiKey?: string;
/** Override the AI model used by the Agent */
aiModel?: string;
/** Override the AI system prompt for the Agent */
aiSystemPrompt?: string;
}
/**
+12 -4
View File
@@ -34,6 +34,8 @@ interface FeedPage {
interface UseFeedOptions {
/** Override the kinds list instead of using feed settings. Used by kind-specific pages. */
kinds?: number[];
/** Override the follows author list. Used by scoped member feeds. */
authors?: string[];
/** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */
tagFilters?: Record<string, string[]>;
}
@@ -51,15 +53,19 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
const allKinds = options?.kinds ?? getEnabledFeedKinds(feedSettings);
const tagFilters = options?.tagFilters;
const authorsOverride = options?.authors;
// Stable key so queries re-run when settings change.
const kindsKey = [...allKinds].sort().join(',');
const authorsKey = authorsOverride ? [...authorsOverride].sort().join(',') : '';
const tagFiltersKey = tagFilters ? JSON.stringify(tagFilters) : '';
// For the follows tab, wait until the follow list is loaded before running any query.
// Without this guard, the query falls through to the global branch while followList is still loading.
// Allow query to run if not on follows tab, OR if follow list has loaded (even if empty).
const followsReady = tab !== 'follows' || (!!user && followList !== undefined);
const followsReady = authorsOverride
? authorsOverride.length > 0
: tab !== 'follows' || (!!user && followList !== undefined);
// Load community pubkeys from localStorage
const communityPubkeys = (() => {
@@ -84,7 +90,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
// on page load because feedSettings is read from localStorage
// synchronously — the encrypted settings sync at ~5s only calls
// updateConfig if values actually differ (NostrSync changed guard).
queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies],
queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, authorsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies],
queryFn: async ({ pageParam }) => {
const signal = AbortSignal.timeout(8000);
const now = Math.floor(Date.now() / 1000);
@@ -238,10 +244,12 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
cacheEvents(dedupedItems);
return { items: dedupedItems, oldestQueryTimestamp, rawCount: validFilteredEvents.length };
} else if (tab === 'follows' && user && followList !== undefined) {
} else if ((authorsOverride && authorsOverride.length > 0) || (tab === 'follows' && user && followList !== undefined)) {
// Follows feed — posts, reposts, and extra kinds from people you follow
// If followList is empty, just query own posts
const authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey];
const authors = authorsOverride ?? (user && followList
? (followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey])
: []);
const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE;
const filter: Record<string, unknown> = { kinds: allKinds, authors, limit: fetchLimit, ...tagFilters };
if (pageParam) {
+2
View File
@@ -13,6 +13,7 @@ import { useCallback, useMemo } from "react";
*/
const DEFAULT_SIDEBAR_ORDER: string[] = [
'wallet',
'search',
'verified',
'actions',
'polls',
@@ -21,6 +22,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [
'feed',
'notifications',
'communities',
'agent',
'profile',
'settings',
];
+9 -9
View File
@@ -8,12 +8,12 @@ const STORAGE_KEY = 'community:members-only';
/**
* Controls whether community views filter content down to posts authored by
* chain-validated members, or show everything scoped to the community.
* validated members, or show everything scoped to the community.
*
* Defaults to `true` (members-only), which aligns with the NIP's "canonical
* community feeds SHOULD discard non-member content by default" guidance
* (see NIP.md §Community-Scoped Content). Users can opt out per their
* preference via a shield-icon toggle in the UI.
* Defaults to `false` (show everything). The flat-communities spec treats
* members-only as a MAY feature (see NIP.md §Community-Scoped Content) —
* the protocol makes no recommendation, so the default is the broader view
* and users opt in via the shield-icon toggle.
*
* Implementation: a module-level singleton store (Set of subscribers +
* cached boolean). Every component mounting `useMembersOnlyFilter` shares
@@ -23,21 +23,21 @@ const STORAGE_KEY = 'community:members-only';
* via the browser's `storage` event.
*/
/** Read the persisted boolean, defaulting to `true` when absent or malformed. */
/** Read the persisted boolean, defaulting to `false` when absent or malformed. */
function readFromStorage(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return true;
if (raw === null) return false;
return JSON.parse(raw) === true;
} catch {
return true;
return false;
}
}
// ── Module-level singleton store ────────────────────────────────────────────
// Module initialisation accesses `localStorage` which is unavailable in some
// SSR-ish environments. Guard so the module can still be imported.
let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : true;
let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : false;
const subscribers = new Set<() => void>();
function notify() {
+132 -17
View File
@@ -3,9 +3,11 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { COMMUNITIES_LIST_KIND, parseCommunityBookmarkATag } from './useCommunityBookmarks';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_AWARD_KIND,
isAuthorizedAward,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
@@ -17,15 +19,26 @@ export interface MyCommunityEntry {
event: NostrEvent;
/** Whether the current user is the founder. */
isFounded: boolean;
/** Whether the current user is a validated member. */
isMember: boolean;
/** Whether the current user has bookmarked the community via kind 10004. */
isBookmarked: boolean;
}
/**
* Fetch communities the logged-in user has founded or been recruited into.
* Fetch communities the logged-in user has founded, been recruited into,
* or bookmarked via their NIP-51 Communities list (kind 10004).
*
* Discovery follows the NIP:
* 1. Founded: `{ kinds: [34550], authors: [<user-pubkey>] }`
* 2. Member-of: query kind 8 awards targeting the user, extract badge `a` tags,
* Discovery:
*
* 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }`
* 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags,
* then find the community definitions referencing those badges.
* 3. Bookmarked -- read kind 10004 authored by user, extract `a` tags
* pointing at kind 34550 events, and fetch those community definitions.
*
* Priority when the same community appears in multiple sources:
* founded > member > bookmarked.
*/
export function useMyCommunities() {
const { nostr } = useNostr();
@@ -39,24 +52,38 @@ export function useMyCommunities() {
const timeout = AbortSignal.timeout(10_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
// Step 1: Communities founded by the user
// ── Step 1: Communities founded by the user ───────────────────────────
const foundedEvents = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], limit: 50 }],
{ signal: combinedSignal },
);
// Step 2: Badge awards targeting the user
const awards = await nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
);
// ── Step 2: Badge awards targeting the user + Bookmarks list ──────────
//
// Batched into a single relay round-trip. The kind 10004 list is a
// replaceable event, so pulling it here keeps the read path tight and
// reuses the same connection.
const [awards, bookmarkListEvents] = await Promise.all([
nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
),
nostr.query(
[{ kinds: [COMMUNITIES_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal: combinedSignal },
),
]);
// Extract badge a-tag coordinates from awards
const badgeATags = new Set<string>();
const awardsByBadgeATag = new Map<string, NostrEvent[]>();
for (const award of awards) {
for (const tag of award.tags) {
if (tag[0] === 'a' && tag[1]?.startsWith('30009:')) {
badgeATags.add(tag[1]);
const list = awardsByBadgeATag.get(tag[1]) ?? [];
list.push(award);
awardsByBadgeATag.set(tag[1], list);
}
}
}
@@ -70,26 +97,114 @@ export function useMyCommunities() {
);
}
// Merge and deduplicate (founded takes priority)
// ── Step 4: Resolve bookmarked community coordinates ──────────────────
//
// NIP-51 kind 10004 stores community definitions as `a` tags like
// `34550:<pubkey>:<d-tag>`. For each bookmarked coordinate we query
// with both `authors` and `#d` so relays return a single authentic
// event per bookmark (per AGENTS.md security guidance on addressable
// events).
//
// Multiple coordinates with the same author are grouped to minimise
// the number of relay queries while keeping the author filter intact.
const bookmarkListEvent = bookmarkListEvents[0];
const bookmarkedCoords: string[] = (bookmarkListEvent?.tags ?? [])
.filter(([n, v]) =>
n === 'a'
&& typeof v === 'string'
&& !!parseCommunityBookmarkATag(v),
)
.map(([, v]) => v);
// Group bookmarked coords by author pubkey: author -> Set<d-tag>
const coordsByAuthor = new Map<string, Set<string>>();
for (const coord of bookmarkedCoords) {
const parsed = parseCommunityBookmarkATag(coord);
if (!parsed) continue;
const existing = coordsByAuthor.get(parsed.pubkey);
if (existing) {
existing.add(parsed.dTag);
} else {
coordsByAuthor.set(parsed.pubkey, new Set([parsed.dTag]));
}
}
let bookmarkedCommunityEvents: NostrEvent[] = [];
if (coordsByAuthor.size > 0) {
bookmarkedCommunityEvents = await nostr.query(
Array.from(coordsByAuthor.entries()).map(([authorPubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [authorPubkey],
'#d': [...dTags],
limit: dTags.size,
})),
{ signal: combinedSignal },
);
}
const bookmarkedATagSet = new Set(bookmarkedCoords);
// ── Merge & deduplicate ──────────────────────────────────────────────
// Priority: founded > member > bookmarked. `isBookmarked` is resolved
// from the bookmark list irrespective of which bucket produced the
// entry, so founders/members who have also bookmarked see both flags.
const seen = new Map<string, MyCommunityEntry>();
for (const event of foundedEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
seen.set(community.aTag, { community, event, isFounded: true });
seen.set(community.aTag, {
community,
event,
isFounded: true,
isMember: false,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
for (const event of memberCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!seen.has(community.aTag)) {
seen.set(community.aTag, { community, event, isFounded: false });
}
if (!community.memberBadgeATag || !badgeATags.has(community.memberBadgeATag)) continue;
const hasValidAward = (awardsByBadgeATag.get(community.memberBadgeATag) ?? [])
.some((award) => isAuthorizedAward(award, community));
if (!hasValidAward) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: true,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
// Sort: founded first, then by created_at descending
for (const event of bookmarkedCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!bookmarkedATagSet.has(community.aTag)) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: false,
isBookmarked: true,
});
}
// Sort: founded first, then member, then bookmarked-only;
// tie-break by created_at descending.
const sortRank = (entry: MyCommunityEntry): number => {
if (entry.isFounded) return 0;
if (entry.isMember) return 1;
return 2;
};
return Array.from(seen.values()).sort((a, b) => {
if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1;
const rankDiff = sortRank(a) - sortRank(b);
if (rankDiff !== 0) return rankDiff;
return b.event.created_at - a.event.created_at;
});
},
+191 -60
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import type { NUser } from '@nostrify/react/login';
/** Error subclass carrying rate-limit metadata. */
@@ -16,15 +17,25 @@ export class RateLimitError extends Error {
}
// 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. */
@@ -99,6 +110,15 @@ export interface ModelsResponse {
data: Model[];
}
/** Sort models by total cost (prompt + completion), cheapest first. */
export function sortModelsByCost(models: Model[]): Model[] {
return [...models].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;
});
}
export interface CreditsResponse {
object: string;
amount: number;
@@ -106,7 +126,17 @@ export interface CreditsResponse {
// ─── Provider Configuration ───
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
/** True when `url` points at the Shakespeare-hosted AI proxy (NIP-98 auth). */
function isShakespeareEndpoint(url: string): boolean {
try {
const host = new URL(url).host;
return host === 'ai.shakespeare.diy' || host.endsWith('.shakespeare.diy');
} catch {
return false;
}
}
// ─── Helpers ───
@@ -243,12 +273,37 @@ function formatError(err: unknown): string {
export function useShakespeare() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Unix-ms timestamp until which the client is rate-limited, or null. */
const [retryAfter, setRetryAfter] = useState<number | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const apiUrl = useMemo(() => {
const raw = (config.aiBaseURL || DEFAULT_SHAKESPEARE_API_URL).trim();
return raw.replace(/\/+$/, '');
}, [config.aiBaseURL]);
const buildAuthHeader = useCallback(async (
method: string,
url: string,
body?: unknown,
): Promise<string> => {
const apiKey = config.aiApiKey.trim();
if (apiKey) {
return `Bearer ${apiKey}`;
}
if (!isShakespeareEndpoint(url)) {
throw new Error(
'An API key is required for this endpoint. ' +
'Set one in Agent settings, or change the base URL to an endpoint that supports NIP-98 auth.',
);
}
const token = await createNIP98Token(method, url, body, user ?? undefined);
return `Nostr ${token}`;
}, [config.aiApiKey, user]);
// Auto-clear retryAfter once the cooldown expires.
useEffect(() => {
if (retryAfter === null) return;
@@ -294,16 +349,12 @@ export function useShakespeare() {
...options,
};
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
requestBody,
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
const chatUrl = `${apiUrl}/chat/completions`;
const authHeader = await buildAuthHeader('POST', chatUrl, requestBody);
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Authorization': `Nostr ${token}`,
'Authorization': authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
@@ -323,16 +374,21 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Chat completions (streaming) ───
//
// 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[],
modelId: string,
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');
}
@@ -350,19 +406,16 @@ export function useShakespeare() {
...options,
};
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
requestBody,
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
const chatUrl = `${apiUrl}/chat/completions`;
const authHeader = await buildAuthHeader('POST', chatUrl, requestBody);
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Authorization': `Nostr ${token}`,
'Authorization': authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal,
});
await handleAPIError(response);
@@ -374,34 +427,121 @@ 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;
let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
const toolCalls: Map<number, ToolCallFunction> = new Map();
/** Process a single parsed SSE data object, accumulating deltas. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const processDelta = (parsed: any) => {
const delta = parsed.choices?.[0]?.delta;
if (!delta) return;
if (parsed.id) responseId = parsed.id;
if (parsed.model) responseModel = parsed.model;
if (parsed.choices?.[0]?.finish_reason) {
finishReason = parsed.choices[0].finish_reason;
}
if (delta.content) {
content += delta.content;
onChunk(delta.content);
}
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;
}
}
}
};
/** Try to parse and process a single SSE data payload string. */
const processSSEData = (data: string) => {
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
// Capture usage from the final chunk (which has choices: [] and real token counts)
if (parsed.usage?.prompt_tokens) {
usage = {
prompt_tokens: parsed.usage.prompt_tokens,
completion_tokens: parsed.usage.completion_tokens ?? 0,
total_tokens: parsed.usage.total_tokens ?? 0,
};
}
processDelta(parsed);
} catch {
// Malformed JSON — nothing to do
}
};
// Buffer for incomplete SSE lines that span across reader.read() boundaries.
let lineBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
const chunk = decoder.decode(value, { stream: true });
const combined = lineBuffer + chunk;
const segments = combined.split('\n');
// The last segment may be incomplete — save it for the next iteration
lineBuffer = segments.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
onChunk(content);
}
} catch {
// Ignore parsing errors for incomplete chunks
}
}
for (const line of segments) {
if (!line.startsWith('data: ')) continue;
processSSEData(line.slice(6));
}
}
// Process any remaining buffered line after the stream ends
if (lineBuffer.startsWith('data: ')) {
processSSEData(lineBuffer.slice(6));
}
} 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,
};
} catch (err) {
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
@@ -414,7 +554,7 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Credit balance (Shakespeare AI only) ───
@@ -424,16 +564,11 @@ export function useShakespeare() {
}
try {
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_API_URL}/credits`,
undefined,
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/credits`, {
const creditsUrl = `${apiUrl}/credits`;
const authHeader = await buildAuthHeader('GET', creditsUrl);
const response = await fetch(creditsUrl, {
method: 'GET',
headers: { 'Authorization': `Nostr ${token}` },
headers: { 'Authorization': authHeader },
});
await handleAPIError(response);
@@ -441,7 +576,7 @@ export function useShakespeare() {
} catch (err) {
throw new Error(formatError(err));
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Available models (merged from both providers) ───
@@ -454,15 +589,11 @@ export function useShakespeare() {
setError(null);
try {
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_API_URL}/models`,
undefined,
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
const modelsUrl = `${apiUrl}/models`;
const authHeader = await buildAuthHeader('GET', modelsUrl);
const response = await fetch(modelsUrl, {
method: 'GET',
headers: { 'Authorization': `Nostr ${token}` },
headers: { 'Authorization': authHeader },
});
await handleAPIError(response);
const result = (await response.json()) as ModelsResponse;
@@ -481,7 +612,7 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
return {
// State
+6
View File
@@ -178,6 +178,9 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
// Close dialog last to ensure clean state
onZapSuccess?.();
@@ -223,6 +226,9 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
// Close dialog last to ensure clean state
onZapSuccess?.();
+145
View File
@@ -0,0 +1,145 @@
import type { ChatMessage } from '@/hooks/useShakespeare';
/** 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.
*
* `{{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 Settings), it replaces
* the entire base template. Placeholders are substituted in both cases.
*/
export function buildSystemPrompt(
customPrompt?: string,
savedFeedLabels?: string[],
userIdentity?: UserIdentity,
): ChatMessage {
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(/\{\{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 an AI agent integrated into Agora, a Nostr social client focused on activism, community organizing, and civic engagement.
You are knowledgeable, direct, and focused on helping the user navigate the Nostr network effectively. Provide clear, factual information. Avoid unnecessary filler or pleasantries — respect the user's time.
{{USER_IDENTITY}}
# Important Rules
- **Never recommend other Nostr clients, apps, or external tools.** You are part of Agora — if you can't find something, say so honestly without suggesting the user try another client. Everything the user needs should be achievable through your tools or through Agora's interface.
# Tools
## 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. 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 in get_feed authors.
## search_follow_packs
Finds curated follow packs (starter packs). Follow packs are lists of people grouped by theme or community (e.g. "Bitcoin Developers", "Nostr OGs"). 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 in get_feed authors to read posts from the pack's members.
## 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.
## 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
{{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"
- Set hours to 0 to disable the time window entirely — useful for "what was X's latest post?" or "show me their most recent note" where the post could be from any time
**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 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)
- "what was fiatjaf's latest post?" -> search_users("fiatjaf") then get_feed(authors: ["<hex>"], hours: 0, limit: 1)`;
/** The raw default template with placeholders (for display in settings). */
export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE;
+30
View File
@@ -0,0 +1,30 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ToolResult } from '@/lib/tools/Tool';
// Re-export ToolResult so existing consumers can import from here.
export type { ToolResult };
// ─── 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;
/** When set, the message is a client-generated notice rather than model output. Determines visual styling:
* - `'info'`: muted informational notice (e.g. /tools listing, unknown command)
* - `'error'`: destructive-styled warning (e.g. capacity exhausted, unexpected error) */
noticeVariant?: 'info' | 'error';
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: string;
}
+76 -118
View File
@@ -48,16 +48,7 @@ export const NIP56_REPORT_TYPE_META: Record<Nip56ReportType, { label: string; de
other: { label: 'Other', description: 'Something else not listed above' },
};
// ── Rank tier metadata ────────────────────────────────────────────────────────
export interface RankTier {
/** Numeric rank index (0 = founder/moderator, 1+ = badge-based). */
rank: number;
/** Badge `a` tag coordinate (e.g. `30009:<pubkey>:<d-tag>`). Undefined for rank 0. */
badgeATag?: string;
/** Optional relay hint from the community definition's `a` tag. */
relayHint?: string;
}
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
// ── Parsed community ──────────────────────────────────────────────────────────
@@ -74,8 +65,10 @@ export interface ParsedCommunity {
founderPubkey: string;
/** Moderator pubkeys (from `p` tags with role "moderator"). */
moderatorPubkeys: string[];
/** Ordered rank tiers (rank 0 first, then badge-based ranks). */
ranks: RankTier[];
/** Member badge `a` tag coordinate (e.g. `30009:<pubkey>:<d-tag>`). */
memberBadgeATag?: string;
/** Optional relay hint from the community definition's member badge `a` tag. */
memberBadgeRelayHint?: string;
/** Recommended relay URLs. */
relays: string[];
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
@@ -98,32 +91,16 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
const image = sanitizeUrl(rawImage);
// Moderators: p tags with "moderator" role (4th element)
const moderatorPubkeys = event.tags
const moderatorPubkeys = Array.from(new Set(event.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey)
.filter(Boolean);
.filter((pubkey): pubkey is string => !!pubkey && pubkey !== event.pubkey && HEX_PUBKEY_RE.test(pubkey))));
// Badge rank tiers: a tags pointing to kind 30009 with rank index in 4th element
const badgeRanks: RankTier[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'a') continue;
const coord = tag[1];
if (!coord || !coord.startsWith('30009:')) continue;
const rankStr = tag[3];
const rank = parseInt(rankStr, 10);
if (isNaN(rank) || rank < 1) continue;
badgeRanks.push({
rank,
badgeATag: coord,
relayHint: tag[2] || undefined,
});
}
// Sort badge ranks ascending
badgeRanks.sort((a, b) => a.rank - b.rank);
// Build full rank list: rank 0 (founder/moderators) + badge ranks
const ranks: RankTier[] = [{ rank: 0 }, ...badgeRanks];
const memberBadgeTag = event.tags.find(
([n, coord, , role]) => n === 'a' && coord?.startsWith('30009:') && role === 'member',
);
const memberBadgeATag = memberBadgeTag?.[1];
const memberBadgeRelayHint = memberBadgeTag?.[2] || undefined;
// Relay URLs
const relays = event.tags
@@ -138,7 +115,8 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
image,
founderPubkey: event.pubkey,
moderatorPubkeys,
ranks,
memberBadgeATag,
memberBadgeRelayHint,
relays,
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
};
@@ -151,7 +129,7 @@ export interface CommunityMember {
pubkey: string;
/** Their effective rank in this community. */
rank: number;
/** The badge award event that established membership (undefined for rank 0). */
/** The badge award event that established membership (undefined for leadership). */
awardEvent?: NostrEvent;
/** Pubkey of whoever awarded them (undefined for rank 0). */
awardedBy?: string;
@@ -352,7 +330,7 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
// Extract target pubkey (required on all reports)
const pTag = event.tags.find(([n]) => n === 'p');
const targetPubkey = pTag?.[1];
if (!targetPubkey) return null;
if (!targetPubkey || !HEX_PUBKEY_RE.test(targetPubkey)) return null;
// Extract target event ID (optional — determines content vs member action)
const eTag = event.tags.find(([n]) => n === 'e');
@@ -401,13 +379,10 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
* Uses a two-pass approach to prevent banned members from retaining
* moderation authority:
*
* **Pass 1 — Resolve bans (rank-ordered):**
* Collects all valid ban candidates (membership + authority checks), then
* processes them sorted by reporter rank ascending. Because bans require
* `reporter.rank < target.rank`, the ban graph is a DAG — processing in
* rank order guarantees that by the time we evaluate a rank-N reporter's
* bans, we've already finalised whether all lower-ranked members are
* banned. If a reporter is themselves banned, their bans are skipped.
* **Pass 1 — Resolve bans (authority-ordered):**
* Founder/moderators (rank 0) can ban members and non-members. Members
* (rank 1) can ban only non-members. Processing leadership before members
* ensures banned members cannot keep moderation authority.
*
* **Pass 2 — Resolve reports (filtered):**
* Processes non-ban reports, skipping any reporter who ended up in the
@@ -446,14 +421,16 @@ export function resolveCommunityModeration(
parsed.push(p);
}
// ── Pass 1: Resolve bans in rank order ─────────────────────────────
// ── Pass 1: Resolve bans in authority order ────────────────────────
//
// Bans are processed sorted by reporter rank ascending. Because bans
// require `reporter.rank < target.rank`, this guarantees that by the
// time we evaluate a rank-N reporter's bans, we've already finalised
// whether any lower-ranked members they're relying on are themselves
// banned by a higher-ranked moderator. A banned reporter's bans are
// then skipped.
// Rank 0 means founder/moderator and rank 1 means member. Non-members
// are treated as lowest rank (Infinity), so members can only ban
// non-members while founder/moderators can ban anyone.
//
// Candidates are sorted by reporter rank ascending so leadership bans
// are resolved before member bans. A reporter banned by an earlier
// authoritative action must not retain moderation authority for later
// actions in the same pass.
interface BanCandidate {
parsed: CommunityReport;
@@ -465,15 +442,14 @@ export function resolveCommunityModeration(
for (const p of parsed) {
if (p.action !== 'content-ban' && p.action !== 'member-ban') continue;
// Reporter is guaranteed to be a member (filtered above).
const reporter = members.get(p.reporterPubkey)!;
// Authority check: reporter rank must be strictly less than target rank.
// Non-members are treated as lowest rank (Infinity).
// Reporter membership is guaranteed by the parse-time filter above.
const reporterRank = members.get(p.reporterPubkey)!.rank;
const targetRank = members.get(p.targetPubkey)?.rank ?? Infinity;
if (reporter.rank >= targetRank) continue;
banCandidates.push({ parsed: p, reporterRank: reporter.rank });
// Authority check: strict rank inequality.
if (reporterRank >= targetRank) continue;
banCandidates.push({ parsed: p, reporterRank });
}
banCandidates.sort((a, b) => a.reporterRank - b.reporterRank);
@@ -513,29 +489,41 @@ export function resolveCommunityModeration(
}
/**
* Resolve community membership via the chain validation algorithm
* described in the community NIP.
* Whether a kind 8 badge award is a valid membership award for a community.
*
* 1. Seed rank 0 from the community definition (founder + moderators).
* 2. Iteratively validate badge awards — awarder must be a validated
* member with rank strictly less than the awarded badge's rank.
* Three conditions must hold (per NIP.md §Badge Awards):
* 1. The event is a kind 8 badge award.
* 2. The award author is the founder or a current moderator of the community.
* 3. The award contains an `a` tag referencing the community's member badge.
*
* This is the single source of truth for award authorization. Both the
* membership resolver and any discovery path that reaches awards through
* an unfiltered query (e.g. `#p`-based "communities I belong to" lookups)
* MUST apply this check before trusting the award.
*/
export function isAuthorizedAward(award: NostrEvent, community: ParsedCommunity): boolean {
if (award.kind !== BADGE_AWARD_KIND) return false;
if (!community.memberBadgeATag) return false;
if (award.pubkey !== community.founderPubkey && !community.moderatorPubkeys.includes(award.pubkey)) return false;
return award.tags.some(([n, v]) => n === 'a' && v === community.memberBadgeATag);
}
/**
* Resolve flat community membership from founder/moderators plus membership
* awards.
*
* Each award is validated via `isAuthorizedAward`. Callers SHOULD still query
* with `authors: [founder, ...moderators]` so the relay indexes the trust
* boundary, but this resolver enforces the same check client-side so that
* discovery paths which reach awards by other filters (e.g. `#p` on the
* viewer) stay consistent.
*/
export function resolveMembership(
community: ParsedCommunity,
awardEvents: NostrEvent[],
): CommunityMembership {
// Build badge-to-rank lookup
const badgeToRank = new Map<string, number>();
for (const tier of community.ranks) {
if (tier.badgeATag) {
badgeToRank.set(tier.badgeATag, tier.rank);
}
}
// Track validated members: pubkey -> CommunityMember
const validated = new Map<string, CommunityMember>();
// Step 1: Seed rank 0
validated.set(community.founderPubkey, {
pubkey: community.founderPubkey,
rank: 0,
@@ -546,52 +534,22 @@ export function resolveMembership(
}
}
// Step 2: Iterative validation
let changed = true;
const processed = new Set<string>();
for (const award of awardEvents) {
if (!isAuthorizedAward(award, community)) continue;
while (changed) {
changed = false;
for (const award of awardEvents) {
if (processed.has(award.id)) continue;
const recipients = award.tags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter((pk): pk is string => !!pk && HEX_PUBKEY_RE.test(pk));
const awarderPubkey = award.pubkey;
const awarder = validated.get(awarderPubkey);
if (!awarder) continue; // Awarder not validated yet
// Find which badge is being awarded
const badgeATag = award.tags.find(
([n, v]) => n === 'a' && v?.startsWith('30009:'),
)?.[1];
if (!badgeATag) continue;
const awardedRank = badgeToRank.get(badgeATag);
if (awardedRank === undefined) continue; // Badge not in this community
// Awarder must have strictly lower rank number
if (awarder.rank >= awardedRank) continue;
// Find recipient(s)
const recipients = award.tags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter(Boolean);
for (const recipientPk of recipients) {
const existing = validated.get(recipientPk);
// Only accept if it gives a better (lower) rank or first membership
if (!existing || awardedRank < existing.rank) {
validated.set(recipientPk, {
pubkey: recipientPk,
rank: awardedRank,
awardEvent: award,
awardedBy: awarderPubkey,
});
changed = true;
}
}
processed.add(award.id);
for (const recipientPk of recipients) {
if (validated.has(recipientPk)) continue;
validated.set(recipientPk, {
pubkey: recipientPk,
rank: 1,
awardEvent: award,
awardedBy: award.pubkey,
});
}
}
+2 -2
View File
@@ -329,11 +329,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
feedKey: 'feedIncludeCommunities',
extraFeedKinds: [9041],
label: 'Communities',
description: 'Hierarchical communities with ranked membership (NIP-72)',
description: 'Flat communities with badge-based membership (NIP-72)',
route: 'communities',
addressable: true,
section: 'social',
blurb: 'Hierarchical communities on Nostr with ranked membership, badge-based authority chains, and moderation. Founded and managed by community creators.',
blurb: 'Flat communities on Nostr with one member badge, explicit moderators, and community moderation.',
},
{
kind: 62,
+97
View File
@@ -0,0 +1,97 @@
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
/**
* Minimal Nostr query interface that `queryAll` needs. Matches the shape of
* `useNostr().nostr` as well as `nostr.relay()` / `nostr.group()` results,
* so the helper is portable across any pool/relay/group handle.
*/
interface NostrQueryable {
query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise<NostrEvent[]>;
}
interface QueryAllOptions {
/**
* Hard cap on total events collected. Protects against runaway relays
* that never stop returning events. Default: 5_000.
*/
maxEvents?: number;
/**
* Hard cap on paged round-trips. Also protects against misbehaving relays
* (e.g. ones that return duplicates instead of advancing). Default: 10.
*/
maxPages?: number;
/**
* Abort signal forwarded to each page query.
*/
signal?: AbortSignal;
}
/**
* Query a Nostr pool/relay/group exhaustively by paging with the `until`
* cursor, stopping when the relay returns fewer events than the filter's
* `limit` (indicating the underlying set is drained) or when either hard
* cap is reached.
*
* The filter's `limit` is used as the page size. Callers SHOULD set it
* explicitly; relays may interpret missing `limit` very differently.
*
* Caps exist so we bound worst-case work regardless of relay behaviour:
* - `maxEvents` — total events across all pages.
* - `maxPages` — total round-trips.
*
* Deduplication happens by `event.id`. A relay returning a duplicate page
* (no forward progress on the cursor) terminates the loop early.
*
* Returns events in the order they were received across pages. Callers
* that need a stable order should sort the result.
*
* This helper intentionally accepts a single filter object — the `until`
* cursor has to be applied per-filter, so a multi-filter query cannot be
* paged as a single pool. If you need to exhaust multiple independent
* filters, call `queryAll` once per filter and merge the results.
*/
export async function queryAll(
nostr: NostrQueryable,
filter: NostrFilter,
opts: QueryAllOptions = {},
): Promise<NostrEvent[]> {
const { maxEvents = 5_000, maxPages = 10, signal } = opts;
const pageSize = filter.limit;
if (!pageSize || pageSize <= 0) {
throw new Error('queryAll: filter.limit must be a positive integer');
}
const collected: NostrEvent[] = [];
const seen = new Set<string>();
let until = filter.until;
for (let page = 0; page < maxPages; page++) {
const pageFilter: NostrFilter = until !== undefined
? { ...filter, until }
: filter;
const events = await nostr.query([pageFilter], { signal });
let newCount = 0;
let oldest = Infinity;
for (const ev of events) {
if (seen.has(ev.id)) continue;
seen.add(ev.id);
collected.push(ev);
newCount++;
if (ev.created_at < oldest) oldest = ev.created_at;
if (collected.length >= maxEvents) return collected;
}
// Stop when the relay indicates the set is drained (short page) or
// when we made no forward progress (all duplicates).
if (events.length < pageSize) return collected;
if (newCount === 0) return collected;
// Advance the cursor one second past the oldest seen event. Using
// `oldest - 1` avoids re-fetching the boundary event on the next page.
until = oldest - 1;
}
return collected;
}
+8
View File
@@ -264,6 +264,10 @@ export const AppConfigSchema = z.object({
soundId: z.string().optional(),
devMode: z.boolean().optional(),
}).optional(),
aiBaseURL: z.string().optional(),
aiApiKey: z.string().optional(),
aiModel: z.string().optional(),
aiSystemPrompt: z.string().optional(),
});
// ─── BuildConfigSchema (build-time app config) ───────────────────────
@@ -372,4 +376,8 @@ export const EncryptedSettingsSchema = z.looseObject({
return result.success ? [result.data] : [];
})
).optional(),
aiBaseURL: z.string().optional(),
aiApiKey: z.string().optional(),
aiModel: z.string().optional(),
aiSystemPrompt: z.string().optional(),
});
+2
View File
@@ -4,6 +4,7 @@ import {
BookOpen,
Bell,
Bookmark,
Bot,
CalendarDays,
Camera,
Clapperboard,
@@ -151,6 +152,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{ id: "help", label: "Help", path: "/help", icon: LifeBuoy },
{ id: "agent", label: "Agent", path: "/agent", icon: Bot },
// Content types
{ id: "actions", label: "Actions", path: "/actions", icon: Zap },
{ id: "events", label: "Events", path: "/events", icon: CalendarDays },
+3 -3
View File
@@ -92,14 +92,14 @@ export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
},
{
id: 'ai-chat',
label: 'AI Chat',
description: 'Chat with Shakespeare AI',
label: 'Agent',
description: 'Chat with your AI agent',
icon: Bot,
defaultHeight: 300,
minHeight: 200,
maxHeight: 700,
category: 'personal',
href: '/ai-chat',
href: '/agent',
fillHeight: true,
},
+97
View File
@@ -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) };
},
};
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { proxyUrl } from '@/lib/proxyUrl';
import { sanitizeToolFetchUrl } from './sanitizeToolFetchUrl';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
url: z.string().describe('The URL to fetch (e.g. "https://example.com/page").'),
});
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 know what's on the 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 = sanitizeToolFetchUrl(args.url.trim());
if (!url) {
return { result: JSON.stringify({ error: 'A valid public HTTPS URL is required.' }) };
}
let html: string;
try {
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}` }) };
}
html = await response.text();
} catch (err) {
return { result: JSON.stringify({ error: `Failed to fetch "${url}": ${err instanceof Error ? err.message : 'Unknown error'}` }) };
}
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)) continue;
// Filter extracted URLs through the same fetch-safe check so that
// malicious pages cannot inject private-network URLs into the result
// list (which typically flows into downstream tool calls).
if (sanitizeToolFetchUrl(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() || '',
}),
};
},
};
+286
View File
@@ -0,0 +1,286 @@
import { z } from 'zod';
import { nip19 } from 'nostr-tools';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { fetchContactPubkeys } from './helpers';
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. Use 0 to disable the time window entirely (useful for "latest post by X" queries where the post could be from any time).'),
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:**
- 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). Set to 0 to disable the time window (for "latest post by X" queries)
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 = args.hours === 0 ? 0 : Math.max(1, args.hours ?? 12);
const limit = Math.min(Math.max(1, args.limit ?? 50), 100);
const sinceTimestamp = hours > 0 ? Math.floor(Date.now() / 1000) - hours * 3600 : undefined;
const contactPubkeys = await fetchContactPubkeys(ctx);
const resolved = resolveFilter(args, ctx, { feedName, country, 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: hours > 0
? `No posts found in the "${feedLabel}" feed in the past ${hours} hours.`
: `No posts found in the "${feedLabel}" feed.`,
}),
};
}
const text = await formatEvents(sorted, feedLabel, hours, ctx);
return {
result: JSON.stringify({
success: true,
feed: feedLabel,
hours,
post_count: sorted.length,
data: text,
}),
};
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Resolve author variables ($me, $contacts) to concrete pubkeys. */
function resolveAuthors(
authors: string[],
userPubkey: string | undefined,
contactPubkeys: string[],
): string[] {
return authors.flatMap((a) => {
if (a === '$me') return userPubkey ? [userPubkey] : [];
if (a === '$contacts') return contactPubkeys;
// Treat $follows the same as $contacts (saved feeds may use this form)
if (a === '$follows') return contactPubkeys;
return [a];
});
}
interface ResolveContext {
feedName: string;
country: string;
limit: number;
sinceTimestamp: number | undefined;
contactPubkeys: string[];
}
type ResolvedFilter =
| { filter: NostrFilter; needsDittoRelay: boolean; feedLabel: string }
| { error: string; available_feeds?: string };
/** Build a base filter with optional `since`. */
function baseFilter(sinceTimestamp: number | undefined, limit: number): NostrFilter {
const f: NostrFilter = { limit };
if (sinceTimestamp !== undefined) f.since = sinceTimestamp;
return f;
}
/** Build the Nostr filter from the tool arguments. */
function resolveFilter(
args: Params, ctx: ToolContext,
{ feedName, country, limit, sinceTimestamp, contactPubkeys }: ResolveContext,
): ResolvedFilter {
// Country query — NIP-73 geographic comments
if (country) {
// Validate as ISO 3166-1 alpha-2 (2 uppercase letters)
if (!/^[A-Z]{2}$/.test(country)) {
return { error: `Invalid country code "${country}". Use a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "VE", "JP").` };
}
return {
filter: { ...baseFilter(sinceTimestamp, limit), kinds: [1111], '#I': [`iso3166:${country}`] } as NostrFilter,
needsDittoRelay: false,
feedLabel: `country: ${country}`,
};
}
// Named feed: follows
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: { ...baseFilter(sinceTimestamp, limit), kinds: [1], authors }, needsDittoRelay: false, feedLabel: 'follows' };
}
// Named feed: global
if (feedName === 'global') {
return { filter: { ...baseFilter(sinceTimestamp, limit), kinds: [1] }, needsDittoRelay: false, feedLabel: 'global' };
}
// Named feed: user saved feed
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, ${available}` : 'follows, global',
};
}
try {
const sf = match.filter as Record<string, unknown>;
const filter: NostrFilter = baseFilter(sinceTimestamp, limit);
let needsDittoRelay = false;
if (Array.isArray(sf.kinds)) filter.kinds = sf.kinds as number[];
if (typeof sf.search === 'string') {
filter.search = sf.search;
// NIP-50 extensions (sort:, protocol:, etc.) require Ditto relay
if (/sort:|protocol:|media:|language:/.test(sf.search)) needsDittoRelay = true;
}
if (Array.isArray(sf.authors)) {
const resolved = resolveAuthors(sf.authors as string[], ctx.user?.pubkey, contactPubkeys);
if (resolved.length > 0) filter.authors = resolved;
}
// Carry over any tag filters (e.g. #t, #p)
for (const [key, value] of Object.entries(sf)) {
if (key.startsWith('#') && Array.isArray(value)) {
(filter as Record<string, unknown>)[key] = value;
}
}
return { filter, 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 — build filter directly from tool args
const filter: NostrFilter = baseFilter(sinceTimestamp, limit);
let needsDittoRelay = false;
filter.kinds = args.kinds ?? [1];
if (args.authors) {
const resolved = resolveAuthors(args.authors, ctx.user?.pubkey, contactPubkeys);
if (resolved.length > 0) filter.authors = resolved;
}
if (args.search) {
filter.search = args.search;
if (/sort:|protocol:|media:|language:/.test(args.search)) needsDittoRelay = true;
}
if (args.hashtag?.trim()) {
(filter as Record<string, unknown>)['#t'] = [args.hashtag.trim().toLowerCase()];
}
const feedLabel = args.search ? `search: ${args.search}` : args.hashtag ? `#${args.hashtag}` : 'ad-hoc';
return { filter, needsDittoRelay, feedLabel };
}
/** 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 = hours > 0
? `## ${feedLabel} — past ${hours}h (${sorted.length} posts)\n\n`
: `## ${feedLabel} — all time (${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;
}
+98
View File
@@ -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. "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, "bitcoin developers pack" or "nostr OGs".
Returns matching packs with their title, description, member count, and the hex pubkeys of all members. Use the returned pubkeys in get_feed authors to read posts from 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 }) };
},
};
+109
View File
@@ -0,0 +1,109 @@
import { z } from 'zod';
import { fetchContactPubkeys } from './helpers';
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 looking up a specific author's posts.
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
const contactPubkeys = await fetchContactPubkeys(ctx);
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 }) };
},
};
+52
View File
@@ -0,0 +1,52 @@
import type { z } from 'zod';
import type { NostrEvent } 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 events. */
nostr: {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
group: (relays: string[]) => {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
};
};
/** Currently logged-in user, or undefined if not logged in. */
user?: {
pubkey: string;
};
/** App configuration values. */
config: {
corsProxy: string;
};
/** Saved feed definitions. */
savedFeeds: Array<{
id: string;
label: string;
filter: Record<string, unknown>;
vars: Array<{ name: string; tagName: string; pointer: string }>;
createdAt: number;
}>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { ToolContext } from './Tool';
/** Fetch the logged-in user's contact list pubkeys (kind 3 `p` tags). */
export 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 [];
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Validate that a URL is safe for the app or CORS proxy to fetch.
*
* This is stricter than `sanitizeUrl()` (which only checks for HTTPS and is
* used for rendering event-sourced URLs in the DOM). This function additionally
* rejects URLs targeting localhost, private networks, link-local addresses,
* cloud metadata endpoints, and other non-public destinations.
*
* Returns the normalised `href` when allowed, or `undefined` when blocked.
*
* Limitations (documented for follow-up):
* - Does not resolve DNS, so public hostnames that resolve to private IPs
* are not caught. The CORS proxy must enforce its own server-side checks.
* - Does not follow redirects; a public URL that 3xx-redirects to a private
* target is not blocked here.
*/
export function sanitizeToolFetchUrl(raw: string | undefined | null): string | undefined {
if (!raw) return undefined;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return undefined;
}
if (parsed.protocol !== 'https:') return undefined;
// Reject URL credentials — no legitimate fetch target needs them.
if (parsed.username || parsed.password) return undefined;
const hostname = parsed.hostname;
// Reject localhost variants.
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost')
) {
return undefined;
}
// Reject .local (mDNS) and .internal TLDs.
if (
hostname.endsWith('.local') ||
hostname.endsWith('.internal')
) {
return undefined;
}
// Reject single-label hostnames (no dot) — likely internal names.
if (!hostname.includes('.')) return undefined;
// Check IPv4 literals (after new URL() normalization, always dotted-decimal).
if (isBlockedIpv4(hostname)) return undefined;
// Check IPv6 literals (URL.hostname strips brackets in browsers).
if (hostname.startsWith('[') || hostname.includes(':')) {
const bare = hostname.replace(/^\[|\]$/g, '');
if (isBlockedIpv6(bare)) return undefined;
}
return parsed.href;
}
// ─── IPv4 ─────────────────────────────────────────────────────────────────────
/** Match a dotted-decimal IPv4 address. */
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function isBlockedIpv4(hostname: string): boolean {
const m = IPV4_RE.exec(hostname);
if (!m) return false;
const a = parseInt(m[1], 10);
const b = parseInt(m[2], 10);
// 0.0.0.0/8
if (a === 0) return true;
// 10.0.0.0/8
if (a === 10) return true;
// 100.64.0.0/10 (Carrier-grade NAT)
if (a === 100 && b >= 64 && b <= 127) return true;
// 127.0.0.0/8 (loopback)
if (a === 127) return true;
// 169.254.0.0/16 (link-local, cloud metadata)
if (a === 169 && b === 254) return true;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.0.0.0/24 (IETF protocol assignments)
if (a === 192 && b === 0 && parseInt(m[3], 10) === 0) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 198.18.0.0/15 (benchmark)
if (a === 198 && (b === 18 || b === 19)) return true;
// 224.0.0.0/4 (multicast)
if (a >= 224 && a <= 239) return true;
// 240.0.0.0/4 (reserved)
if (a >= 240) return true;
return false;
}
// ─── IPv6 ─────────────────────────────────────────────────────────────────────
function isBlockedIpv6(addr: string): boolean {
const lower = addr.toLowerCase();
// ::1 (loopback)
if (lower === '::1') return true;
// :: (unspecified)
if (lower === '::') return true;
// fc00::/7 — unique local (ULA)
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
// fe80::/10 — link-local
if (lower.startsWith('fe80')) return true;
// ff00::/8 — multicast
if (lower.startsWith('ff')) return true;
// IPv4-mapped IPv6: ::ffff:A.B.C.D or ::ffff:HHHH:HHHH
// URL.hostname normalises these to e.g. "::ffff:7f00:1"
// Check both the hex form and the mixed-notation form.
const ffffPrefix = '::ffff:';
if (lower.startsWith(ffffPrefix)) {
const suffix = lower.slice(ffffPrefix.length);
// Mixed notation: ::ffff:127.0.0.1
if (IPV4_RE.test(suffix)) {
return isBlockedIpv4(suffix);
}
// Hex notation: ::ffff:7f00:1 → convert to IPv4 and check.
const hexParts = suffix.split(':');
if (hexParts.length === 2) {
const hi = parseInt(hexParts[0], 16);
const lo = parseInt(hexParts[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
return isBlockedIpv4(ipv4);
}
}
}
return false;
}
+28
View File
@@ -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>,
},
};
}
+44
View File
@@ -0,0 +1,44 @@
/** 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;
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: false });
/**
* 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 encoded = encoder.encode(result);
const lines = result.split('\n').length;
if (encoded.length <= MAX_RESULT_BYTES && lines <= MAX_RESULT_LINES) {
return result;
}
// Truncate to the byte limit using actual byte boundaries.
// TextDecoder with fatal:false gracefully handles a slice that lands
// in the middle of a multi-byte character (replaces the partial char).
let truncated = result;
if (encoded.length > MAX_RESULT_BYTES) {
truncated = decoder.decode(encoded.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 ${encoded.length.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;
}
+391 -513
View File
File diff suppressed because it is too large Load Diff
+56 -9
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Loader2, Users } from 'lucide-react';
import { Loader2, Search, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { CommunityCard } from '@/components/CommunityCard';
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
@@ -14,6 +16,7 @@ import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
@@ -78,19 +81,23 @@ export function CommunitiesPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
useLayoutOptions({
hasSubHeader: !!user,
});
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<CommunitiesTab>('communities', [
'activities',
'mine',
]);
useLayoutOptions({
hasSubHeader: !!user,
showFAB: !!user && activeTab === 'mine',
onFabClick: () => setCreateDialogOpen(true),
fabIcon: <Users className="size-5" />,
});
useSeoMeta({
title: `Communities | ${config.appName}`,
description: 'Discover and join hierarchical communities on Nostr',
description: 'Discover and join flat communities on Nostr',
});
return (
@@ -131,6 +138,8 @@ export function CommunitiesPage() {
}
/>
)}
<CreateCommunityDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />
</main>
);
}
@@ -177,7 +186,25 @@ function MyCommunitiesContent() {
if (!myCommunities || myCommunities.length === 0) {
return (
<FeedEmptyState message="You haven't founded or joined any communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No communities yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own using the button below.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
);
}
@@ -188,6 +215,8 @@ function MyCommunitiesContent() {
key={entry.community.aTag}
event={entry.event}
isFounded={entry.isFounded}
isMember={entry.isMember}
isBookmarked={entry.isBookmarked}
/>
))}
</div>
@@ -294,7 +323,25 @@ function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise<void> }) {
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your communities yet. Toggle the shield icon to see all community activity." />
) : (
<FeedEmptyState message="No activity from your communities yet." />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No activity yet</h2>
<p className="text-muted-foreground text-sm">
Discover communities to join via the Search page, or create your own from the My Communities tab.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button asChild className="rounded-full">
<Link to="/search?tab=communities">
<Search className="size-4 mr-2" />
Search communities
</Link>
</Button>
</div>
</div>
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
+11 -2
View File
@@ -1,7 +1,8 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { useSeoMeta } from "@unhead/react";
import { CalendarDays, Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { CreateCommunityEventDialog } from "@/components/CreateCommunityEventDialog";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { NoteCard } from "@/components/NoteCard";
@@ -37,6 +38,7 @@ export function EventsFeedPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const [createOpen, setCreateOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("events", [
"follows",
@@ -44,7 +46,12 @@ export function EventsFeedPage() {
]);
useSeoMeta({ title: `Events | ${config.appName}` });
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: !!user });
useLayoutOptions({
showFAB: true,
onFabClick: () => setCreateOpen(true),
fabIcon: <CalendarDays className="size-5" />,
hasSubHeader: !!user,
});
// Calendar events feed
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
@@ -160,6 +167,8 @@ export function EventsFeedPage() {
/>
)}
</PullToRefresh>
<CreateCommunityEventDialog open={createOpen} onOpenChange={setCreateOpen} />
</main>
);
}
+2 -3
View File
@@ -62,7 +62,7 @@ type TabType = 'communities' | 'posts' | 'accounts';
const VALID_TABS: TabType[] = ['communities', '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) : 'communities';
}
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
@@ -199,7 +199,7 @@ export function SearchPage() {
const setActiveTab = useCallback((tab: TabType) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (tab === 'posts') {
if (tab === 'communities') {
next.delete('tab');
} else {
next.set('tab', tab);
@@ -1208,4 +1208,3 @@ function SaveDestinationRow({
);
}
+4
View File
@@ -106,6 +106,10 @@ export function TestApp({ children }: TestAppProps) {
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [],
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiSystemPrompt: '',
};
return (