Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4830443c26 | |||
| 98c2d69c02 | |||
| cedc5db249 | |||
| f043d45331 | |||
| 12f1bbd00d | |||
| aa962386c6 | |||
| 5cc1428c06 | |||
| cb35176f60 | |||
| 0ea17672c7 | |||
| 6f888b8d36 | |||
| d5dff04056 | |||
| 8c6be4c57d | |||
| 064e0832df | |||
| 891cf72af8 | |||
| a3563305c4 | |||
| 14733b3b5c | |||
| 4a8fd245a1 | |||
| a159f97a43 | |||
| eb03f3fcc0 | |||
| 003e7d3624 | |||
| 28043378c3 | |||
| 432eae4f79 | |||
| e0a52a5c32 | |||
| 725d6970c5 | |||
| 558b666220 | |||
| 72d7962632 | |||
| 5e91f1d328 | |||
| efe5d3db1c | |||
| 9ac379b259 | |||
| c8b3961da6 | |||
| 259c657c33 | |||
| 2f6aeb05e4 | |||
| 5cea93de34 |
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { Tool, ToolResult, ToolContext } from './Tool';
|
||||
|
||||
const inputSchema = z.object({
|
||||
identifier: z.string().describe('NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof inputSchema>;
|
||||
|
||||
export const FetchEventTool: Tool<Params> = {
|
||||
description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag).
|
||||
|
||||
Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article.
|
||||
|
||||
Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`,
|
||||
|
||||
inputSchema,
|
||||
|
||||
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
|
||||
const identifier = args.identifier.trim();
|
||||
if (!identifier) {
|
||||
return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) };
|
||||
}
|
||||
|
||||
let decoded: nip19.DecodedResult;
|
||||
try {
|
||||
decoded = nip19.decode(identifier);
|
||||
} catch {
|
||||
return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) };
|
||||
}
|
||||
|
||||
if (decoded.type === 'nsec') {
|
||||
return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) };
|
||||
}
|
||||
|
||||
let event: NostrEvent | undefined;
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: [decoded.data], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'nprofile': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'note': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ ids: [decoded.data] }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'nevent': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{ ids: [decoded.data.id] }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
case 'naddr': {
|
||||
const events = await ctx.nostr.query(
|
||||
[{
|
||||
kinds: [decoded.data.kind],
|
||||
authors: [decoded.data.pubkey],
|
||||
'#d': [decoded.data.identifier],
|
||||
limit: 1,
|
||||
}],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
event = events[0];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) };
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) };
|
||||
}
|
||||
|
||||
return { result: JSON.stringify(event) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,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() || '',
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) };
|
||||
},
|
||||
};
|
||||
@@ -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 }) };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Tool } from './Tool';
|
||||
|
||||
/** OpenAI-compatible function-calling tool definition. */
|
||||
export interface OpenAITool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Tool<T> to OpenAI's function-calling format.
|
||||
*
|
||||
* Uses Zod's `.toJSONSchema()` (available since zod v4 / zod-to-json-schema)
|
||||
* to derive the JSON Schema from the tool's inputSchema.
|
||||
*/
|
||||
export function toolToOpenAI<T>(name: string, tool: Tool<T>): OpenAITool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema.toJSONSchema() as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user